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 입니다.
세 가지를 먼저 알아 두세요.
- 두 엔드포인트 모두 "그 설정 블록 전체 교체"입니다. 메서드는
PATCH지만 부분 병합이 아닙니다 — 보낸 값이 그 설정의 전부가 되고, 빠뜨린 필드는 지워집니다. 한 칸만 고치고 싶으면 공개 조회로 현재 값을 받아 그 값을 고쳐서 통째로 보내세요. - 대상 사이트는
settings바깥(envelope)에 적습니다. 사이트가 둘 이상인 테넌트가site_id를 생략하면400 site_id_required입니다 — 임의의 사이트를 고르지 않습니다. 사이트에 묶인 키(site-scoped)는 생략하고, 다른 사이트를 지목하면404입니다. - 저장 뒤 연결된 사이트에
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)://만 허용합니다.
그룹에는 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 |
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 |
hasMap — naver_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 + 발행 웹훅 조합을 사용하세요.