본문 바로가기

HTTP API 레퍼런스

공개 API raw 엔드포인트 — JS 외 스택이나 저수준 연동용

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

공개 콘텐츠 조회를 JS/TS로 연동할 때는 raw 호출 대신 @roottale/cms-client 사용하세요. 글쓰기·미디어 자동화는 아래 HTTP API, MCP tool 또는 공개 CLI를 사용합니다.

글쓰기·미디어 자동화는 read_write API 키가 필요합니다. read 키는 공개 콘텐츠뿐 아니라 관리 API의 초안·예약·비공개 글과 미디어 목록도 조회할 수 있습니다. 다만 완전한 읽기 전용은 아닙니다 — 상담 게시판 글 작성 (POST /v1/cms/public/inquiries)이 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":[]},
    "featured_media_id": null,
    "category_ids": [],
    "tag_ids": []
  }'

주요 필드:

필드 설명
body_json Tiptap 문서 JSON 객체
featured_media_id 미디어 업로드 완료 응답의 id; null이면 썸네일 없음
publish true면 즉시 발행. 생략하면 초안
scheduled_at 미래 ISO 8601 시각. publish 동시 사용 불가
category_ids, tag_ids 생성과 동시에 연결할 term ID 배열

PATCH /v1/cms/posts/{post_id}

보낸 필드만 수정합니다. featured_media_id: null이면 썸네일을 해제하고, scheduled_at: null이면 예약을 해제합니다.

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

초안 또는 예약 글을 즉시 발행하고 등록된 revalidate 웹훅을 전송합니다. 본문은 {} 또는 {"site_id":"..."}입니다.

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"]}

GET /v1/cms/posts

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

미디어 업로드 API

파일 바이트를 API Worker에 직접 통과시키지 않고, 5분 유효한 R2 서명 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 경로, 크기, 형식을 검증한 뒤 미디어를 등록합니다. 응답의 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 객체와 미디어 행 삭제

미디어 삭제 전 해당 URL을 쓰는 본문과 featured_media_id 연결을 먼저 교체하세요. 삭제 후 기존 공개 URL은 더 이상 유효하지 않습니다.

설정 쓰기 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_served 10개 / 각 40자
services 12개 / 각 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):// 허용합니다. 그룹에는 hrefcolumns 중 하나가 반드시 있어야 합니다.

상단 메뉴는 한 번 만들면 빈 값으로 되돌릴 수 없습니다(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
type post | page
site_id 멀티 사이트 키일 때만 — site-scoped 키면 생략
{
  "items": [ { "id": "…", "slug": "…", "title": "…", "body_json": {…},
               "collection_key": "blog",
               "terms": [{ "taxonomy": "category", "name": "…", "slug": "…" }],
               "published_at": "…" } ],
  "has_more": false,
  "next_cursor": null
}

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

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

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

GET /v1/cms/public/search

발행된 글 키워드 검색 (사이트 내 검색, WP ?s= 패리티). title·excerpt·본문 텍스트의 case-insensitive 부분일치, 최신 발행순. 응답은 카드 렌더용 슬림 hit — 본문(body_json)은 미포함이므로 상세는 slug 로 글 1개 API를 호출하세요.

쿼리 설명
q 검색 키워드 (필수, 1~100자)
limit 결과 수 1~50, 기본 10
type post(기본) | page
site_id 멀티 사이트 키일 때만
{ "tenant_id": "…", "site_id": "…", "query": "세무",
  "items": [ { "id": "…", "type": "post", "title": "…", "slug": "…",
               "excerpt": "…", "featured_media_url": "…",
               "published_at": "…" } ] }

JS/TS 는 @roottale/cms-client/server searchPosts({ apiKey, query }) 사용하세요 — 구 서버(라우트 미배포)⁠의 404 를 빈 배열로 처리합니다.

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

어드민 "설정 > 주소 이동"에서 정의한 활성 커스텀 리다이렉트 규칙 전체. 사이트 미들웨어가 요청 경로를 매칭해 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_code 301(영구) 또는 302(임시).

GET /v1/cms/public/theme

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

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

GET /v1/cms/public/blog-settings

블로그 표시 설정 (TOC·작성자·발행일·작성자 카드, 저자 프로필, 글 하단 CTA). post_cta admin 에서 활성화하고 버튼 문구·링크를 채웠을 때만 객체이며, 그 외에는 null. RootTaleBlogPost 본문 끝에 자동으로 렌더하므로 별도 연동 코드는 필요 없다. 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, "logo_url": null,
    "favicon_url": null, "default_og_image_url": null },
  "post_cta": { "title": "상담이 필요하신가요?", "description": "첫 상담은 무료입니다.",
    "button_label": "상담 문의하기", "button_href": "/contact" },
  "updated_at": null }

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> 적용합니다.

GET /v1/cms/public/categories

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

쿼리(선택):

이름 설명
exclude_collections true 어느 콘텐츠 유형에도 속하지 않은 글만 셉니다 — /blog 계열 모음 페이지가 다루는 범위와 같습니다.
collection_key 유형 key 그 유형에 속한 글만 셉니다. exclude_collections 동시 지정 시 400.
locale BCP-47 미지정이면 사이트 기본 로케일(글 목록과 같은 기본값).
site_id 사이트 id 보통 생략.
{ "tenant_id": "…", "site_id": "…",
  "categories": [
    { "slug": "tax", "name": "세무", "published_post_count": 412,
      "collection_key": null, "hub_promoted": true },
    { "slug": "law", "name": "법률", "published_post_count": 0,
      "collection_key": null, "hub_promoted": false }
  ] }
  • 글이 0건인 분류도 그대로 내려옵니다(published_post_count: 0) — "그런 분류가 없다"와 "분류는 있는데 이 범위에 글이 없다"를 구분할 수 있게.
  • 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_name alternateName — 띄어쓰기 변형 등 같은 사업장의 다른 표기
fax_number faxNumber
services hasOfferCatalog Offer.itemOffered 목록 (최대 12개)
profiles.kakao_map hasMapnaver_place 있으면 그쪽이 우선. sameAs 에는 들어가지 않는다(프로필이 아니라 위치 링크)

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

GET /v1/cms/public/analytics

분석 태그 설정.

{ "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.

POST /v1/public/inquiries

상담문의(리드) 접수. multipart/form-data.

필드 필수 비고
vertical consulting | medical | tax | legal
contact_name
business_name
email .+@.+\..+
phone
privacy_consent 체크박스 (on)
overseas_transfer_consent medical 시 ✅
message, consultation_field, current_site_url
lead_kind patient(기본) | sales
_redirect_url 완료 후 redirect base (allowlist 검증)
cf-turnstile-response Cloudflare 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 + 발행 웹훅 조합을 사용하세요.