본문 바로가기

HTTP API 레퍼런스

팝업·배너의 v2 결정 조회와 수정안·예약·일시 중지 API는 팝업·배너와 예약 표시를 참고하세요.

베이스 URL: https://api.roottale.com 인증: 모든 요청에 Authorization: Bearer rtlk_cust_... 헤더.

공개 콘텐츠 조회를 JS/TS로 연동할 때는 raw 호출 대신 @roottale/cms-client를 사용하세요. 전체 CMS 관리는 아래 HTTP API, MCP tool 또는 공개 CLI를 사용합니다.

글쓰기·미디어 자동화는 read_write API 키가 필요합니다. 모델·필드·노출까지 관리하려면 full_management 키를 권장합니다. read 키는 공개 콘텐츠뿐 아니라 관리 API의 초안·예약·비공개 글과 미디어 목록도 조회할 수 있습니다. 다만 완전한 읽기 전용은 아닙니다 — 상담 게시판 글 작성 (POST /v1/cms/public/inquiries)과 폼별 문의 접수 (POST /v1/public/inquiry-forms/:formId/submissions)가 cms:read로 허용됩니다. 기존 글·미디어· 설정을 고치거나 지우려면 read_write 이상이 필요합니다.

사업장 정보·상단 메뉴를 바꾸는 일(아래 "설정 쓰기 API")은 한 단계 위인 read_write_settings 키가 따로 필요합니다 — 글쓰기 키로는 열리지 않습니다.

관리 API 빠른 흐름

관리 API는 초안 작성 → 미디어 연결 → 발행을 분리합니다. 자동화가 실수로 미완성 글을 공개하지 않도록 글 생성의 기본 상태는 draft입니다.

전체 리소스 경로와 권한은 콘텐츠 모델과 노출 관리에 정리되어 있습니다.

POST /v1/cms/posts

Tiptap JSON 본문으로 글 또는 페이지를 만듭니다.

curl https://api.roottale.com/v1/cms/posts \
  -H "Authorization: Bearer $ROOTTALE_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: post-2026-07-17-001" \
  -d '{
    "type": "post",
    "title": "자동화로 작성한 글",
    "slug": "automated-post",
    "body_json": {"type":"doc","content":[]},
    "author_profile_id": "site-author-id",
    "featured_media_id": null,
    "category_ids": [],
    "tag_ids": []
  }'

주요 필드:

필드설명
body_jsonTiptap 문서 JSON 객체
author_profile_id사이트 공통 공개 작성자 ID. 생략한 사용자 키는 계정별 기본값, null은 미지정
featured_media_id미디어 업로드 완료 응답의 id; null이면 썸네일 없음
publishtrue면 즉시 발행. 생략하면 초안
scheduled_at미래 ISO 8601 시각. publish와 동시 사용 불가
category_ids, tag_ids생성과 동시에 연결할 term ID 배열
model_key페이지·글·정보 모델의 안정 key. 모델이 여러 개면 필수
field_values모델 필드 정의로 검증할 편집 원문 객체

PATCH /v1/cms/posts/{post_id}

보낸 필드만 수정합니다. featured_media_id: null이면 썸네일을 해제하고, scheduled_at: null이면 예약을 해제합니다. author_profile_id를 생략하면 기존 공개 작성자를 유지하고, null이면 초안의 공개 작성자를 비웁니다.

POST /v1/cms/posts/{post_id}/publish

초안 또는 예약 글을 즉시 발행하고 등록된 revalidate 웹훅을 전송합니다. 본문은 {} 또는 {"site_id":"..."}입니다. 글(type: "post")은 현재 사이트의 활성 공통 작성자가 지정돼 있어야 발행·예약할 수 있습니다.

POST /v1/cms/posts/{post_id}/unpublish

발행 글을 초안으로 되돌립니다.

PATCH /v1/cms/posts/{post_id}/terms

특정 taxonomy의 연결을 전체 교체합니다.

{"taxonomy":"category","term_ids":["term-id-1","term-id-2"]}

PATCH /v1/cms/posts/{post_id}/related

글의 관련 콘텐츠(어드민 편집기 "관련 콘텐츠" 패널과 같은 원장)를 전체 교체합니다. 유형(모델)과 상관없이 같은 사이트의 글을 순서대로 최대 10개. 자기 자신·중복은 무시되고, 다른 사이트 글은 400. 발행 글이면 post.updated 웹훅이 나갑니다.

{"related_post_ids":["post-id-1","post-id-2"],
 "reserved_keys":["faq.headache.migraine.aura-symptoms"]}

reserved_keys = 아직 없는 콘텐츠 예약(선택). 공개 주소 조각을 . 로 이은 키를 적어 두면 대상이 그 주소로 발행되는 순간 공개 related_posts 뒤쪽에 자동 합류합니다. 미전달이면 예약을 손대지 않고, 빈 배열이면 전부 해제합니다(최대 10개).

응답은 related_posts(발행 여부 무관, 순서대로, status 포함)입니다. 공개 응답에는 그중 발행 글만 related_posts 로 나갑니다(아래 글 목록·상세 참고).

GET /v1/cms/posts

초안·예약·비공개·발행 글을 조회합니다. 쿼리는 limit, cursor, type, status, site_id, model_key를 지원합니다.

관리 응답은 owner_user_id(편집 소유 계정)와 author_profile_id(사이트 공통 공개 작성자)를 별도로 반환합니다. 기존 author_id는 하위 호환용이며 새 연동에서는 두 분리 필드를 사용하세요.

미디어 업로드 API

API가 발급한 5분 유효 업로드 URL로 파일을 전송한 뒤 CMS에 등록합니다. 허용 형식은 JPEG, PNG, WebP, GIF, PDF이며 최대 10MB입니다.

1. POST /v1/cms/media/uploads

{
  "original_name": "hero.webp",
  "content_type": "image/webp",
  "size_bytes": 482031
}

응답의 upload_url, r2_key, expires_in을 보관합니다.

2. 서명 URL에 PUT

curl -X PUT "$UPLOAD_URL" \
  -H "Content-Type: image/webp" \
  --data-binary @hero.webp

1단계에서 선언한 Content-Type과 바이트 크기가 정확히 같아야 합니다.

3. POST /v1/cms/media/uploads/complete

{
  "r2_key": "tenants/<tenant-id>/sites/<site-id>/media/...",
  "original_name": "hero.webp",
  "content_type": "image/webp",
  "size_bytes": 482031,
  "alt": "서비스 소개 대표 이미지",
  "caption": null
}

r2_key는 1단계 응답값을 바꾸지 말고 그대로 보냅니다. 서버가 R2 객체의 tenant/site 경로, 크기, 형식을 검증한 뒤 미디어를 등록합니다. 파일 보존이 활성화된 환경에서는 원본의 별도 보존까지 확인한 뒤 등록합니다. 1단계의 r2_key는 업로드용 임시 경로이며, 완료 응답의 r2_key가 최종 원본 경로입니다. 공개 주소를 임시 경로로 직접 만들지 마세요. 완료 응답을 받지 못했거나 503을 받으면 같은 r2_key와 요청 본문으로 완료 요청을 재시도하세요. 새 등록은 201, 이미 완료된 업로드의 재시도는 같은 미디어 ID와 200을 반환합니다. 삭제된 업로드는 다시 완료할 수 없습니다. 응답의 id는 글의 featured_media_id에, url은 Tiptap image 노드의 attrs.src에 사용합니다.

미디어 관리

메서드·경로설명
GET /v1/cms/media최신순 목록. limit, offset, site_id 지원
PATCH /v1/cms/media/{media_id}alt, caption 수정
DELETE /v1/cms/media/{media_id}미디어 행·R2 원본·변형 이미지·CDN 캐시 삭제

미디어 삭제 전 해당 URL을 쓰는 본문과 featured_media_id 연결을 먼저 교체하세요. 삭제 후 기존 공개 URL은 더 이상 유효하지 않습니다. 파일 보존이 활성화된 환경에서 원본 보존 또는 삭제 기록 저장에 실패하면 503을 반환하며 삭제를 완료하지 않습니다. 외부 파일이나 CDN 정리가 지연되면 목록에서 삭제된 뒤에도 503을 받을 수 있습니다. 이때 오류 메시지에 정리 대기와 자동 재시도를 알리며, 서버가 남은 작업을 계속 처리합니다. 200 { deleted: true }는 공개 파일 정리까지 완료된 경우입니다. 미디어 목록의 404만으로 CDN 정리 완료를 판단하지 마세요. 이미 방문자의 브라우저에 저장된 파일은 원격으로 지울 수 없습니다.

설정 쓰기 API

사업장 정보와 상단 메뉴를 어드민 화면 대신 API로 바꿉니다. read_write_settings 권한으로 발급한 키가 필요합니다 (settings:write). 글쓰기 키(read_write)로 호출하면 403 insufficient_scope 입니다.

페이지·글·정보 모델과 배너·팝업 슬롯 연동은 콘텐츠 모델과 노출 슬롯을 참고하세요.

세 가지를 먼저 알아 두세요.

  1. 두 엔드포인트 모두 "그 설정 블록 전체 교체"입니다. 메서드는 PATCH 지만 부분 병합이 아닙니다 — 보낸 값이 그 설정의 전부가 되고, 빠뜨린 필드는 지워집니다. 한 칸만 고치고 싶으면 공개 조회로 현재 값을 받아 그 값을 고쳐서 통째로 보내세요.
  2. 대상 사이트는 settings 바깥(envelope)에 적습니다. 사이트가 둘 이상인 테넌트가 site_id 를 생략하면 400 site_id_required 입니다 — 임의의 사이트를 고르지 않습니다. 사이트에 묶인 키(site-scoped)는 생략하고, 다른 사이트를 지목하면 404 입니다.
  3. 저장 뒤 연결된 사이트에 theme.updated 알림을 보냅니다. 알림이 실패해도 저장은 되돌아가지 않습니다 — 결과는 응답의 revalidate 로 확인합니다.

PATCH /v1/cms/settings/business-profile

{
  "site_id": "019eb70c-…",
  "settings": {
    "name": "길동세무회계",
    "business_type": "AccountingService",
    "telephone": "02-1234-5678",
    "address": { "street_address": "테헤란로 123", "address_locality": "강남구" },
    "opening_hours": [ { "days": ["Mo","Tu","We","Th","Fr"], "opens": "09:00", "closes": "18:00" } ],
    "area_served": ["서울 강남구"],
    "services": ["기장·신고대리", "세무고문"],
    "profiles": { "naver_place": "https://map.naver.com/p/entry/place/1" }
  }
}

settings 안쪽 필드는 GET /v1/cms/public/business-profile 응답과 같은 이름입니다. 서버가 정하는 값(tenant_id·site_id·configured·updated_at)은 넣을 수 없습니다 — 넣으면 400 입니다.

저장 규칙:

항목규칙
profiles.*https 절대 주소만. http:// 와 로컬 주소는 거부
opening_hours최대 7행. 시각은 HH:MM 24시간 표기(09:00)
area_served10개 / 각 40자
services12개 / 각 80자 (중복은 자동 제거)
alternate_name·fax_number각 120자 / 40자
모르는 필드거부 (400)

PATCH /v1/cms/settings/site-nav

{
  "settings": {
    "navGroups": [
      { "label": "사무소 소개", "href": "/about" },
      { "label": "소식",
        "columns": [ { "title": "알림",
                       "links": [ { "label": "공지", "href": "/notice" } ] } ] }
    ],
    "cta": { "label": "상담 문의", "href": "/contact" }
  }
}

settings 는 GET /v1/cms/public/theme 의 siteNav 와 같은 모양입니다.

저장 규칙: 메뉴 그룹 7개 / 그룹당 열 3개 / 열당 링크 6개 / 보조 링크 (utilityLinks) 4개. 주소는 #앵커·/경로·http(s):// 만 허용합니다. 그룹에는 href 나 columns 중 하나가 반드시 있어야 합니다.

상단 메뉴는 한 번 만들면 빈 값으로 되돌릴 수 없습니다(navGroups 는 최소 1개). 메뉴를 없애려면 어드민 화면에서 지우세요.

응답

두 엔드포인트가 같은 모양으로 답합니다.

{
  "tenant_id": "…", "site_id": "…",
  "updated_at": "2026-07-28T04:20:00.000Z",
  "settings": { "…": "저장된 값 그대로" },
  "revalidate": { "configured": 1, "delivered": 1, "failed": 0, "degraded": false }
}

settings 는 저장된 값입니다 — 보낸 값과 다를 수 있습니다(요일 순서 정렬, 목록 중복 제거, business_type 기본값 LocalBusiness 등). 다음 조회에서 무엇을 보게 될지는 이 값이 정답이고, 공개 조회로 확인하려 하지 마세요(공개 응답에는 최대 10초 캐시가 걸립니다).

revalidate 는 저장 뒤 보낸 갱신 알림의 결과입니다.

필드뜻
configured등록되어 있고 켜져 있는 알림 목적지 수
delivered성공
failed목적지가 오류를 돌려줌
degraded보내지도 못한 목적지가 있음 — 설정을 점검해야 합니다

configured: 0, degraded: false 는 정상입니다(알림을 등록하지 않은 사이트). degraded: true 면 사이트에 반영되지 않았을 수 있으니 어드민의 발행 알림 설정을 확인하세요.

GET /v1/cms/public/posts

발행된 글 목록 (커서 페이지네이션).

쿼리설명
limit페이지 크기 (서버에서 상한 clamp)
cursor이전 응답의 next_cursor
typepost | page
site_id멀티 사이트 키일 때만 — site-scoped 키면 생략
{
  "items": [ { "id": "…", "slug": "…", "title": "…", "body_json": {…},
               "collection_key": "blog",
               "author_profile_id": "site-author-id",
               "author_name": "공통 작성자", "author_slug": "writer",
               "author_image_position_x": 50, "author_image_position_y": 50,
               "terms": [{ "taxonomy": "category", "name": "…", "slug": "…" }],
               "pattern_slots": { "post_footer": "clinic-guide" },
               "published_at": "…" } ],
  "has_more": false,
  "next_cursor": null
}

collection_key = 글이 속한 섹션(콘텐츠 유형). 섹션 라우팅(/notice·/blog)의 근거이며, 미설정이면 null. 카테고리(terms)는 섹션 안의 주제로 아카이브에만 쓰입니다 → 콘텐츠 유형 (Collections).

pattern_slots = 공통 블록 자리별 블록 key(자리 key → GET /patterns 의 key 또는 null). 어드민 배치 규칙을 서버가 이 글의 model_key(콘텐츠 모델)에 맞춰 계산한 결과이며, 선언된 자리(현재 post_footer = 글 하단)는 항상 키로 존재합니다. 글 상세·미리보기 응답에도 같은 필드가 붙습니다 → 아래 GET /v1/cms/public/patterns 참고.

related_posts = 편집자가 어드민 "관련 콘텐츠"에서 고른 글(유형 무관, 고른 순서) + 발행된 예약 키 대상(그 뒤, 적은 순서), 발행 글만·중복 제거. 각 항목은 { id, title, slug, path, type, model_key, collection_key, excerpt, featured_media_url, published_at } 로 카드 하나를 그릴 만큼만 담습니다. 목록·상세·미리보기 응답에 모두 붙습니다 (구 서버는 미포함 → 빈 배열로 취급). 비어 있으면 사이트가 "같은 카테고리 최신 글" 같은 자동 추천을 채우면 됩니다 — @roottale/cms-renderer-next 의 RootTaleBlogPost 는 편집자 선택이 있으면 그것을, 없으면 relatedPostsCount 만큼 자동 추천을 그립니다.

{ "related_posts": [ { "id": "…", "title": "…", "slug": "…", "path": "/faq/headache/migraine/…",
                       "type": "post", "model_key": "faq", "collection_key": "faq",
                       "excerpt": "…", "featured_media_url": null, "published_at": "…" } ] }

author_profile_id는 사이트 공통 공개 작성자 ID입니다. 이름·사진·소개·작가 주소는 같은 원장의 author_name·author_image_url·author_bio·author_slug로 제공됩니다. 사진 초점은 author_image_position_x/y(0~100, 구 서버는 null)이며 이미지 모양은 사이트의 시멘틱 토큰/CSS가 정합니다. 기존 author_id는 하위 호환용이므로 새 연동에서는 공개 작성자 식별자로 사용하지 마세요.

GET /v1/cms/public/posts/{identifier}

글 1개 — identifier는 slug 또는 UUID. 미발행/없는 글은 404.

GET /v1/cms/public/posts/preview

관리자 편집기가 발급한 미리보기 토큰으로 초안(미발행 포함)을 발행 글과 같은 모양으로 받습니다. 사이트가 자기 글 템플릿으로 미리보기를 그리게 하는 용도입니다 — 관리자 미리보기와 발행 결과가 다르던 문제의 해법(blog.md "미리보기 페이지" 참고).

쿼리설명
token편집기 '사이트 화면 미리보기'가 URL ?token=으로 넘긴 값 (글 1건 전용, 1시간)
site_id멀티 사이트 키일 때만
  • 응답은 GET /posts/{identifier}와 같은 형식이며 preview 블록이 추가됩니다: { "expires_at": "…", "source_status": "draft" }. status는 템플릿 재사용을 위해 항상 published, title·slug·excerpt·body_json은 토큰 발급 시점의 편집기 내용, body_html은 항상 null, 발행 전이면 published_at은 토큰 발급 시각.
  • 토큰이 없거나 다른 사이트의 것이면 404, 만료면 410 preview_expired.
  • 응답 헤더 cache-control: no-store — 사이트도 절대 캐시하지 마세요.

JS/TS 는 @roottale/cms-client/server 의 fetchPostPreview({ apiKey, token }) 와 isPreviewExpiredError(error) 를 사용하세요.

GET /v1/cms/public/search

발행된 콘텐츠 키워드 검색입니다. 제목 완전일치, 제목 부분일치, 요약, 본문 순으로 관련도를 계산하고 같은 점수에서는 최신 발행순으로 정렬합니다. 응답은 카드 렌더용 슬림 hit이며 본문(body_json)은 포함하지 않습니다.

쿼리설명
q검색 키워드 (필수, 1~100자)
limit결과 수 1~50, 기본 10
typepost(기본, 하위 호환) | page | all(글+페이지 통합)
localeBCP-47 언어 코드. 생략하면 사이트 기본 언어
site_id멀티 사이트 키일 때만
{ "tenant_id": "…", "site_id": "…", "query": "세무",
  "items": [ { "id": "…", "type": "post", "collection_key": "notice",
               "locale": "ko", "title": "…", "slug": "…",
               "excerpt": "…", "featured_media_url": "…",
               "published_at": "…" } ] }

JS/TS 는 @roottale/cms-client/server 의 searchPosts({ apiKey, query }) 를 사용하세요. 사이트 전체 검색은 type: "all"을 지정합니다. 결과 링크는 resolveSearchHitPath(hit, collections, locale?)로 계산해야 콘텐츠 유형의 basePath와 다국어 경로를 그대로 따릅니다. ROOTTALE_API_KEY는 브라우저에 노출하지 말고 Server Component·Route Handler에서만 사용하세요.

GET /v1/cms/public/menus

네비게이션 메뉴 전체 — 어드민 "디자인 > 메뉴" 저장값. 항목은 깊이 2 트리.

{ "tenant_id": "…", "site_id": "…",
  "items": [ { "id": "…", "name": "헤더 메뉴", "slug": "primary",
               "items": [ { "id": "…", "label": "회사 소개", "url": "/about",
                            "children": [ { "id": "…", "label": "오시는 길",
                                            "url": "/about/location" } ] },
                          { "id": "…", "label": "블로그", "url": "/blog" } ],
               "updated_at": "…" } ] }

GET /v1/cms/public/menus/{slug}

위치 핸들(primary, footer 등)로 메뉴 1개. 없으면 404 — 사이트는 자체 fallback 네비를 렌더하세요 (menus.md 참고).

GET /v1/cms/public/redirects

어드민 "설정 > 주소 이동"에서 정의한 활성 커스텀 리다이렉트 규칙 전체. 사이트 Proxy가 요청 경로를 매칭해 301/302 처리합니다. 비활성 규칙과 운영자 메모는 응답에 포함되지 않습니다. 라우트 미배포(구 서버)는 404 — 빈 목록으로 처리하세요. 연동은 custom-redirects.md 참고.

{ "tenant_id": "…", "site_id": "…",
  "items": [ { "id": "…", "from_path": "/old-event",
               "to_target": "/promo", "status_code": 301 } ] }
필드설명
from_path출발 경로 — 정규화된 사이트 내부 절대 경로(앞 슬래시, 쿼리 제외).
to_target도착지 — 내부 경로(/promo) 또는 절대 URL(https://…).
status_code301(영구) 또는 302(임시).

GET /v1/cms/public/theme

어드민에서 설정한 디자인 토큰. 설정된 그룹만 포함됩니다.

{ "tenant_id": "…", "site_id": "…",
  "colors": {…}, "fonts": {…}, "radius": {…}, "updated_at": "…" }

GET /v1/cms/public/blog-settings

블로그 표시 설정 (TOC·작성자·발행일·작성자 카드 표시 정책). post_cta는 공통 글 하단 블록이 없는 기존 글에서 RootTaleBlogPost가 적용하고, 공통 블록이 있으면 중복을 막기 위해 생략한다. 작성자 이미지 모양과 과거 전역 초점 필드는 호환용이며, 모양은 사이트 시멘틱 토큰/CSS, 초점은 글 응답의 작성자별 값이 소유한다. toc_position은 목차 배치("inline"=본문 위 접이식, "sidebar"=넓은 화면에서 본문 오른쪽 sticky, 좁은 화면은 자동 인라인)로, 렌더러가 알아서 반영하므로 연동 코드 변경은 불필요하다.

{ "show_table_of_contents": false, "show_author": true, "show_date": true,
  "show_author_card": true, "toc_title": null, "toc_position": "inline",
  "author_profile_name": null, "author_profile_bio": null,
  "author_profile_image_url": null, "author_profile_image_radius": "circle",
  "site_profile": { "site_description": null, "home_title": null,
    "home_description": null, "logo_url": null, "favicon_url": null,
    "default_og_image_url": null },
  "post_cta": { "title": "상담이 필요하신가요?", "description": "첫 상담은 무료입니다.",
    "button_label": "상담 문의하기", "button_href": "/contact" },
  "updated_at": null }

author_profile_name·author_profile_bio·author_profile_image_url· author_card_description은 이전 연동을 깨지 않기 위해 응답 모양에만 남아 있고 항상 null입니다. 작성자 이름·사진·소개는 글 응답의 author_name· author_image_url·author_bio를 사용하세요. 이 값은 글에 지정된 사이트별 작성자 프로필에서 옵니다. 글에 작성자가 없으면 작성자 메타와 카드를 표시하지 않습니다.

site_profile 은 사이트 공통 SEO 값. default_og_image_url(1200×630 권장)은 글에 대표/OG 이미지가 없을 때 SNS 공유 썸네일 폴백으로 쓰세요 — generateMetadata 에서 post.seo?.ogImage ?? post.featured_media_url ?? settings.siteProfile.defaultOgImageUrl 순으로 우선합니다. logo_url· favicon_url·site_description 은 사이트 <head> 에 적용합니다. home_title·home_description 은 메인 홈(/) 전용 검색 제목·설명(어드민 "검색·공유 표시 > 메인 홈 검색 노출")으로, null 이면 사이트 이름(theme site_name)·site_description 으로 폴백하세요(seo.md "메인 홈 메타데이터").

GET /v1/cms/public/patterns

공통 블록(여러 글의 같은 자리에 붙는 재사용 본문) 중 발행 상태 목록. 어느 글의 어느 자리에 놓을지는 글 응답의 pattern_slots가 이미 말해 주므로, 사이트는 pattern_slots[slot] 값으로 이 목록에서 key를 찾아 body_json(Tiptap 문서)을 글 본문과 같은 렌더러·정화 경로로 그리면 됩니다. 목록이 비었거나 요청이 실패해도 글은 정상 열려야 합니다(블록만 생략).

{ "tenant_id": "…", "site_id": "…",
  "patterns": [ { "id": "…", "key": "clinic-guide", "name": "병원 안내",
                  "description": "글 하단 공통 안내", "body_json": { "type": "doc", "content": [ … ] },
                  "presentation": { "layout": "card", "background": "#f8f9fa",
                                    "link_style": "buttons", "button_colors": ["#03c75a", "#1a1a1a", "#666666"] },
                  "status": "published", "updated_at": "…" } ] }
  • 자리 key: post_footer(글 하단, 본문 바로 아래). 새 자리는 문서와 함께 추가됩니다.
  • presentation = 어드민이 정한 표시 형태(layout plain|card, background hex, link_style text|buttons, button_colors hex 순서 — 마지막 문단의 링크에 차례로, 모자라면 마지막 색 반복). 사이트는 이 값을 래퍼의 data 속성·CSS 변수로 옮겨 그리고, 색·모양을 따로 고정하지 않습니다.
  • 캐시: Cache-Control: private, max-age=10. 어드민에서 블록·배치 규칙을 저장하면 theme.updated 웹훅이 오므로 설정류 캐시와 함께 지우세요(revalidation-webhooks.md).

GET /v1/cms/public/categories

분류(카테고리)와 분류별 발행 글 수. 글 수는 서버가 SQL로 세므로 글이 몇 건이든 정확합니다 — 분류 모음 페이지의 "글 0건이면 404" 판정과 사이트맵 포함 여부(색인 위생)를 계산하는 데 씁니다. @roottale/cms-renderer-next 의 fetchBlogArchiveCategories 가 이 API를 자동으로 씁니다(직접 부를 일은 드뭅니다).

쿼리(선택):

이름값설명
exclude_collectionstrue어느 콘텐츠 유형에도 속하지 않은 글만 셉니다 — /blog 계열 모음 페이지가 다루는 범위와 같습니다.
collection_key유형 key그 유형에 속한 글만 셉니다. exclude_collections 와 동시 지정 시 400.
localeBCP-47미지정이면 사이트 기본 로케일(글 목록과 같은 기본값).
site_id사이트 id보통 생략.
{ "tenant_id": "…", "site_id": "…",
  "categories": [
    { "slug": "tax", "name": "세무", "published_post_count": 412,
      "collection_key": "column", "hub_promoted": true,
      "description": "세무 칼럼", "seo_title": "세무 칼럼 모음",
      "seo_description": "세무 칼럼을 주제별로 확인하세요.",
      "image_media_id": "019…", "image_url": "https://imagedelivery.net/…/md",
      "image_alt": "세무 자료와 계산기",
      "path": "/column/tax" },
    { "slug": "law", "name": "법률", "published_post_count": 0,
      "collection_key": null, "hub_promoted": false }
  ] }
  • 글이 0건인 분류도 그대로 내려옵니다(published_post_count: 0) — "그런 분류가 없다"와 "분류는 있는데 이 범위에 글이 없다"를 구분할 수 있게.
  • description·seo_title·seo_description은 ROOT-ADMIN 카테고리 편집값입니다. 검색 화면에서는 seo_title → "{name} 글 모음", seo_description → description → 사이트별 기본 소개문 순으로 대체값을 적용하세요.
  • image_media_id는 대표 이미지의 안정 ID, image_url은 바로 표시할 수 있는 URL, image_alt는 대체 텍스트입니다. 세 값은 이미지 미설정 또는 구버전 API에서 null이거나 생략될 수 있습니다. path는 콘텐츠 유형의 category_path 정책까지 적용한 정규 상대 경로이며, 해당 유형에 카테고리 아카이브가 없으면 null입니다.
  • hub_promoted 는 어드민의 "이 카테고리 모음 페이지를 검색에 공개" 값이지만, 검색 노출 판정에는 쓰지 마세요. 그 판정의 단일 소스는 /blog-settings 의 sitemap.promoted_category_slugs 입니다(두 소스를 섞으면 사이트맵과 페이지가 서로 다른 시점의 값을 볼 수 있습니다). 여기 값은 진단·표시용입니다.
  • 분류가 서버 상한(500개)을 넘으면 응답에 "truncated": true 가 붙습니다.

@roottale/cms-client 로 직접 부를 때:

import { fetchCategoryCounts } from "@roottale/cms-client/server";

const { categories, truncated } = await fetchCategoryCounts({
  apiKey: process.env.ROOTTALE_API_KEY!,
  excludeCollections: true,
});

GET /v1/cms/public/site-knowledge

사이트 지식 — 브랜드 보이스(어조·톤·화자) + 용어 규칙(금지어·교정어). AI 에이전트가 이 사이트에 맞는 글을 쓸 때 참고. 운영 메모·내부 SEO 임계값·내부 출처는 노출 안 함 (theme-and-settings.md 참고).

{ "tenant_id": "…", "site_id": "…",
  "brand": { "voice": "", "tone": "", "persona": "" },
  "lexicon": { "forbidden_words": [], "preferred_terms": [{ "from": "유저", "to": "사용자" }] },
  "updated_at": null }

GET /v1/cms/public/business-profile

비즈니스 프로필 (로컬 SEO) — 어드민 "운영 > 비즈니스 프로필" 저장값. 미설정이면 configured: false + 필드 null.

{ "tenant_id": "…", "site_id": "…", "configured": true,
  "name": "길동세무회계", "legal_name": null, "alternate_name": "길동 세무회계",
  "business_type": "AccountingService",
  "telephone": "02-1234-5678", "fax_number": "02-1234-5679", "email": null,
  "address": { "street_address": "테헤란로 123", "address_locality": "강남구",
               "address_region": "서울특별시", "postal_code": "06234" },
  "geo": { "latitude": 37.5006, "longitude": 127.0364 },
  "opening_hours": [ { "days": ["Mo","Tu","We","Th","Fr"],
                       "opens": "09:00", "closes": "18:00" } ],
  "price_range": "₩₩", "area_served": ["서울 강남구"],
  "services": ["기장·신고대리", "세무고문"],
  "profiles": { "naver_place": "https://…", "google_business": "https://…",
                "kakao_channel": null, "kakao_map": "https://…",
                "instagram": null, "naver_blog": null },
  "updated_at": "…" }

business_type: LocalBusiness | ProfessionalService | AccountingService | LegalService | MedicalClinic | Dentist | RealEstateAgent | Restaurant | BeautySalon.

필드JSON-LD 매핑
alternate_namealternateName — 띄어쓰기 변형 등 같은 사업장의 다른 표기
fax_numberfaxNumber
serviceshasOfferCatalog 의 Offer.itemOffered 목록 (최대 12개)
profiles.kakao_maphasMap — naver_place 가 있으면 그쪽이 우선. sameAs 에는 들어가지 않는다(프로필이 아니라 위치 링크)

주의: name(사업장 이름)이 비어 있으면 configured: false 로 내려가고 @roottale/cms-client 의 fetchBusinessProfile 은 null 을 반환한다 — 전화·주소만 채우고 이름을 비우면 나머지 입력이 전부 무시된다.

GET /v1/cms/public/analytics

ROOT-ANALYTICS 사이트 ID와 외부 태그 설정.

{ "tags": [ { "provider": "ga4", "id": "G-XXXXXXX", "enabled": true } ] }

provider: ga4 | clarity | meta_pixel | naver.

GET /v1/cms/public/jwks

웹훅 서명 검증용 site-scoped JWKS 공개키. site-scoped 키 필수 — 테넌트 전체 키로 호출하면 400 site_scope_required.

GET /v1/public/inquiry-forms/:formId

사이트에 등록한 활성 문의 폼의 현재 발행 정의를 읽습니다. cms:read 고객 키를 사이트 서버에서 사용하며, 고객사 전체 키에는 site_id 쿼리가 필수입니다. 사이트 한정 키는 해당 사이트에 고정됩니다. 다른 고객사·사이트의 폼은 조회하거나 접수할 수 없습니다. 비활성·미등록·범위 밖 폼은 404 not_found입니다.

{
  "form_id": "01993841-7930-7000-8000-000000000001",
  "site_id": "01993841-7930-7000-8000-000000000002",
  "form_key": "quote",
  "version": 2,
  "definition": {
    "title": "견적 문의",
    "kind": "sales",
    "fields": [
      { "key": "phone", "label": "전화번호", "type": "tel", "role": "phone", "required": true },
      { "key": "message", "label": "문의 내용", "type": "textarea", "role": "message", "required": true }
    ],
    "requireOneOf": [],
    "privacyConsentText": "견적 상담을 위한 연락처 수집에 동의합니다.",
    "successMessage": "문의가 접수되었습니다."
  }
}

한 사이트에 견적·AS 등 폼 ID를 여러 개 등록할 수 있습니다. 각 폼의 버전과 입력 항목은 독립적입니다. 정의 계약은 공개 패키지 @roottale/inquiry-forms, SDK는 fetchInquiryForm입니다. 응답은 no-store로 다룹니다.

POST /v1/public/inquiry-forms/:formId/submissions

폼별 CRM 접수. application/json, cms:read 고객 키를 사용합니다. 사이트 범위는 위 GET과 같고 고객사 전체 키에는 body의 site_id가 필수입니다.

{
  "site_id": "01993841-7930-7000-8000-000000000002",
  "version": 2,
  "answers": { "phone": "010-1234-5678", "message": "옥상 방수 견적을 요청합니다." },
  "privacy_consent": true,
  "idempotency_key": "01993841-7930-7000-8000-000000000005",
  "placement": "contact/quote"
}
필드필수설명
site_id고객사 전체 키 사용 시폼이 속한 사이트 ID
version✅방문자가 보고 동의한 발행 버전, 양의 정수
answers✅정의의 항목 키에 해당하는 답변. 문자열·문자열 배열·숫자·boolean 타입 보존
privacy_consent✅사용자 명시 동의 true
idempotency_key✅같은 제출의 재시도에 재사용. 1~200자 [A-Za-z0-9_-]
placement폼 배치 식별자
turnstile_token사이트의 보안 확인 토큰
attribution유입 객체. 기존 multipart의 attr_ 접두 없이 landing_path, utm_source 등 사용
journey방문 여정 배열, 최대 30개. 기존 multipart의 JSON 문자열과 구분

이름·이메일·사업체명을 일괄 필수로 요구하지 않습니다. 서버가 폼의 항목·선택지· 필수 조건을 검증하고 전화 또는 이메일의 연락 수단을 확인합니다. 등록되지 않은 항목·선택지는 거부합니다. 폼의 업무 유형·항목 라벨은 서버 정의를 사용합니다.

새 저장은 201, 저장된 동일 키·동일 내용의 재시도는 200과 접수증을 반환합니다.

{
  "id": "01993841-7930-7000-8000-000000000003",
  "inquiry_no": 17,
  "received_at": "2026-09-12T08:30:00.000Z",
  "replayed": false
}

inquiry_no는 정수입니다. HTTP 2xx만으로 성공을 판단하지 말고 접수증을 확인하세요. 응답 유실·서버 오류에는 동일한 키와 제출 내용 전체로 다시 요청합니다. 서버는 문의·접수번호·알림 예약을 함께 저장하며 재시도 시 알림을 다시 만들지 않습니다. 이미 저장된 동일 제출은 폼의 변경·중지 후에도 같은 접수증을 반환합니다.

주요 오류는 400 validation_error, 409 form_version_conflict, 409 idempotency_key_conflict, 404 not_found, 429 rate_limited입니다. 필드 검증 오류는 다음 구조를 사용합니다.

{
  "code": "validation_error",
  "message": "Request validation failed",
  "hint": null,
  "retry_after": null,
  "details": {
    "field_errors": { "phone": ["연락처를 확인해주세요."] },
    "form_errors": []
  }
}

form_version_conflict는 새 정의와 동의 문구를 다시 확인한 뒤 제출해야 합니다. idempotency_key_conflict에서는 자동으로 새 키를 만들어 중복 접수하지 마세요. SDK submitFormInquiry가 접수증 검증과 구조화 오류 반환을 제공합니다. 문의 폼 연동에 같은 사이트의 두 폼을 연결하는 예제가 있습니다.

POST /v1/public/inquiries

기존 고정 필드 상담문의(리드) 접수. multipart/form-data. 새 사이트의 여러 폼은 위의 폼별 JSON 접수를 사용하세요.

필드필수비고
vertical✅consulting | medical | tax | legal
contact_name✅
business_name✅
email✅.+@.+\..+
phone✅
privacy_consent✅체크박스 (on)
overseas_transfer_consentmedical 시 ✅
message, consultation_field, current_site_url
lead_kindpatient(기본) | sales
_redirect_url완료 후 redirect base (allowlist 검증)
cf-turnstile-responseCloudflare Turnstile 토큰. tenant-api에 LEAD_INTAKE_TURNSTILE_SECRET_KEY가 설정된 경우 필수
attr_landing_path, attr_rt_src, attr_utm_source, attr_utm_medium, attr_utm_campaign, attr_utm_term, attr_utm_content, attr_ad_click, attr_referrer, attr_first_touch_at, attr_last_touch_at, attr_last_touch_channel, attr_last_touch_source, attr_last_touch_medium, attr_last_touch_campaign, attr_last_touch_referrer유입 어트리뷰션 — CRM에 유입 경로 표시 (inquiries.md 참고). 그 외 attr_* 키는 무시
attr_journey방문 여정 JSON 원문(서버 zod 검증, 최대 30항목) — CRM "방문 여정" 패널에 표시 (inquiries.md 참고)
기타 임의 필드최대 50개, 암호화 보관, CRM 상세 노출

응답: 302 redirect — 성공 ?ok=1, 실패 ?err=<code> (consent_privacy, consent_overseas, invalid_vertical, missing_fields, invalid_email, turnstile_failed, internal). 잘못된 키는 401. IP/tenant rate limit 초과 시 429 rate_limited를 반환합니다.

서버-서버 호출 시 redirect를 따라가지 말고(redirect: "manual") Location 헤더를 파싱하세요 — @roottale/cms-client의 submitInquiry가 이를 대신합니다.

POST /v1/cms/revalidate

수동 캐시 갱신 트리거 (등록된 웹훅으로 재발송).

{ "event": "post.updated", "paths": ["/blog", "/blog/my-post"], "slug": "my-post" }

event는 post.published · post.updated · post.deleted · theme.updated 중 하나이고, 생략하면 post.updated입니다. 앞의 세 값은 read_write 권한 (cms:write)으로 보냅니다.

theme.updated(설정 저장 신호)만 read_write_settings 권한이 필요합니다 ("읽기 + 쓰기 + 설정 변경", scope settings:write). 글쓰기 키로 보내면 403 insufficient_scope입니다. 이 신호는 경로 몇 개가 아니라 수신 측의 설정 캐시 이름표 전체와 루트 레이아웃(그 아래 모든 페이지) 을 다시 만들게 하므로, 글을 쓰라고 내준 키에는 열지 않습니다.

{ "event": "theme.updated" }

위 "설정 쓰기 API"로 사업장 정보·상단 메뉴를 바꾸면 이 신호는 저장과 함께 자동으로 나갑니다 — 직접 보낼 필요는 없습니다. 자세한 동작은 재검증 웹훅 문서에 있습니다.

에러 형식

비 2xx 응답은 JSON 에러 바디(code, message)를 가집니다. 주요 코드: invalid_key(401), insufficient_scope(403), rate_limited(429), not_found(404).

캐시 헤더

공개 콘텐츠 응답은 private 캐시 헤더로 내려갑니다 — CDN 공유 캐시에 의존하지 말고 사이트 측 ISR + 발행 웹훅 조합을 사용하세요.