API 레퍼런스
외부 도구가 개인 액세스 토큰으로 페이지·데이터베이스·속성·검색을 읽고 쓰고, 변경 알림을 웹훅으로 받는 방법을 정리한 문서입니다.
한눈에 보기
| 항목 | 값 |
|---|---|
| 기준 주소 | https://<서비스 주소>/api/v1 |
| 인증 | Authorization: Bearer estn_<토큰 ID>_<비밀값> 또는 연결된 앱의 OAuth 액세스 토큰(12장) |
| 요청·응답 형식 | application/json (내보내기·첨부 다운로드는 파일, 첨부 업로드는 원본 바이트) |
| 요청 본문 한도 | 4 MiB (페이지 첨부 업로드는 10 MiB, 피드백 등록은 20 MiB) |
| 토큰 범위 | 토큰을 만든 한 Space, 고른 권한 범위 안. 연결된 앱의 토큰은 동의한 여러 Space |
| 쿠키 | 읽지 않습니다. 세션 쿠키만으로는 401입니다 |
| 시간 표기 | 모든 시각은 epoch 밀리초 정수 |
API는 앱 화면과 같은 서비스 계층을 호출합니다. 페이지 권한·세대(generation)·개정(revision) 검사가 사람이 쓸 때와 똑같이 적용되므로, 토큰이 토큰 주인보다 더 많이 보거나 쓸 수 있는 경로는 없습니다.
폴링하지 마세요. 변경은 웹훅으로 받으세요. 데이터베이스나 페이지를 몇십 초마다 다시 읽어 바뀐 것을 찾는 방식은 서버를 계속 묶어 두고, 사용자가 늘면 그대로 늘어납니다. 바뀐 것을 알아야 하면 웹훅을 구독하고, 알림을 받은 페이지만 읽으세요. 웹훅을 받을 주소를 열 수 없는 환경이면 변경 목록(GET /changes)을 몇 분에 한 번 부르세요. 같은 대상을 다시 읽어야 할 때는If-None-Match를 보내 바뀌지 않았으면 304를 받으세요(3장).
1. 토큰 만들기
토큰 발급은 로그인한 앱 화면에서만 합니다. API로는 토큰을 만들 수 없습니다.
- 개발 도구 페이지의 API 도구(
/dev#api)를 열고, 토큰을 쓸 Space을 고릅니다. 로그인한 사용자라면 누구나 열 수 있습니다. - API 토큰에서 이름(1~100자)을 적고 필요한 권한 범위를 고른 뒤 토큰 만들기를 누릅니다.
- 토큰 값은 이때 한 번만 보입니다. 서버는 SHA-256 해시만 보관하므로 다시 볼 수 없고, 잃어버리면 새로 만들어야 합니다.
export ESTNOTE_TOKEN='estn_…'
curl -sS https://estnote.example.com/api/v1/workspace \
-H "Authorization: Bearer $ESTNOTE_TOKEN"
{ "workspaceId": "b9f1…", "scopes": ["pages:read", "pages:write"] }
토큰 형식은 estn_<32자 16진수 ID>_<43자 base64url 비밀값>입니다. 비교는 상수 시간 비교를 씁니다.
2. 권한 범위
범위를 벗어난 호출은 403 API_SCOPE_REQUIRED이며, 어떤 범위가 필요한지 메시지에 담깁니다.
| 범위 | 화면 이름 | 쓰는 곳 |
|---|---|---|
pages:read | 페이지 읽기 | GET /workspace, GET /pages*, 본문·내보내기·첨부·댓글 조회 |
pages:write | 페이지 쓰기 | POST /pages, PATCH /pages/:id, PUT /pages/:id/content, POST /pages/:id/assets |
databases:read | 데이터베이스 읽기 | 스키마·속성 조회, POST /databases/:id/query |
databases:write | 데이터베이스 쓰기 | 데이터베이스·항목 만들기, 스키마·속성 변경 |
search:read | 검색 | GET /search |
comments:write | 댓글 쓰기 | POST /pages/:id/comments, POST /pages/:id/threads/:threadId/replies, MCP의 add_comment 도구(MCP 참조) |
webhooks:read | 웹훅 읽기 | 웹훅 목록·배달 기록 |
webhooks:write | 웹훅 관리 | 웹훅 등록·상태 변경·삭제 |
feedback:create | 버그 신고·개선 제안 등록 | POST /feedback/:kind. 검토자가 아니어도 자기 이름으로 등록 |
feedback:read | 피드백 읽기 (검토자) | 버그 신고·개선 제안 목록과 한 건 조회. 토큰 주인이 피드백 검토자일 때만 (4.6) |
feedback:write | 피드백 답변·완료 표시 (검토자) | 답글 달기, 완료 표시·다시 열기. 토큰 주인이 피드백 검토자일 때만 |
workspaces:read | 내 Space 목록 | GET /workspaces (12.1) |
inbox:read | 받은함 읽기 | GET /inbox* (12.4) |
inbox:write | 받은함 읽음·보관 | 받은함 읽음·보관 처리 |
assignments:read | 담당 작업 읽기 | GET /me/assignments (12.5) |
session:embed | 문서 패널 세션 | POST /embed/code (12.6), 연결된 앱만 |
chat:act | Chat 카드 동작 | POST /chat/actions (12.7) |
토큰은 만들 때 지정한 Space에만 묶입니다. 다른 workspaceId를 쿼리로 넘기면 403 API_WORKSPACE_MISMATCH입니다. 페이지·데이터베이스·웹훅을 ID로 가리킬 때도 같습니다. 토큰 주인이 다른 Space의 구성원이어도 그 Space의 대상은 이 토큰에게 없는 것으로 취급해 404(PAGE_NOT_FOUND, WEBHOOK_NOT_FOUND)입니다.
만료는 /api/v2/tokens로 만들 때 1~365일 사이로 지정할 수 있습니다(앱 화면에는 만료 입력이 없어 기본은 만료 없음). 철회·만료된 토큰과 그 Space를 떠난 사용자의 토큰은 다음 호출부터 401입니다.
3. 공통 규칙
- 오류 형식: 어떤 실패든
{ "error": { "code": "…", "message": "…" } }입니다. 상태 코드와code로 분기하세요. - 모르는 필드 거절: 본문·쿼리에 문서에 없는 키가 있으면 400입니다. 관용적으로 무시하지 않습니다.
- ID 형식: 경로의 ID는
[A-Za-z0-9_-]{1,256}만 받습니다. 어기면 400INVALID_IDENTIFIER입니다. - 빈 응답: 반환값이 없는 호출은 204입니다.
- 캐시: 모든 응답에
Cache-Control: no-store가 붙습니다. - 조건부 요청(2026-11-01부터): 본문(
GET /pages/:id/content)과 데이터베이스 조회(POST /databases/:id/query)는 응답에ETag를 줍니다. 같은 요청에If-None-Match: <그 값>을 보내면, 그사이 답이 바뀌지 않았을 때 본문 없이 304로 답합니다. 서버는 저장된 버전만 보고 판단하므로 다시 계산하지 않습니다. 권한은 매번 확인하므로, 읽을 수 없게 된 대상에는 304가 아니라 원래의 오류가 옵니다. 데이터베이스 조회는 그 데이터베이스나 관계로 닿는 데이터베이스에now()·today()수식이 있으면 시계에 따라 답이 바뀌므로 304를 주지 않습니다. 관계로 닿는 데이터베이스가 50개를 넘어도 마찬가지입니다. 또 다음 페이지 커서가 살아 있도록 10분마다 한 번은 새로 답합니다. - 목록 커서:
limit(1~100, 기본 50)과cursor를 씁니다. 응답의nextCursor를 그대로 다시 넘기세요. 커서는 요청한 계정·범위에 묶이고, 목록의 앞쪽이 바뀌면 409(PAGE_LIST_STALE,QUERY_STALE)로 알립니다. 데이터베이스 조회 커서는 15분 뒤 만료됩니다. - 서버 상태: v2 저장 구조가 활성이 아니면 404
V2_NOT_ACTIVE, 종료 중이면 503SERVER_SHUTTING_DOWN입니다. - 공개 API는 콘텐츠 초기화 epoch 헤더를 요구하지 않습니다. 브라우저 초안을 다시 올릴 위험이 없는 경로이기 때문입니다.
4. 엔드포인트
괄호 안은 필요한 권한 범위입니다.
4.1 Space
GET /workspace (pages:read)
토큰이 다루는 Space와 범위를 돌려줍니다. 토큰 점검용으로 쓰기 좋습니다.
4.2 페이지
GET /pages (pages:read)
최상위 페이지 목록입니다. 쿼리: limit, cursor, workspaceId(토큰의 Space와 같아야 합니다).
{
"pages": [{
"id": "8a3c…", "workspaceId": "b9f1…", "type": "document", "isDatabaseEntry": false,
"isTemplate": false, "title": "릴리스 노트", "icon": {}, "appearance": {}, "position": 3,
"contentRevision": 12, "generation": 1, "metadataRevision": 4, "updatedAt": 1758300000000,
"propertiesRevision": 0, "commentsRevision": 0, "aclRevision": 1,
"role": "owner", "capabilities": { "read": true, "edit": true, "propertiesEdit": true, "…": true }, "parent": null,
"shared": false
}],
"hasMore": false,
"nextCursor": null
}
role은 viewer·editor·manager·owner, capabilities는 read, edit, propertiesEdit, manage, share, schemaEdit, historyRead, historyRestore, trash, restore, purge의 참·거짓입니다. 읽을 수 없는 부모는 parent: null로 가려집니다. shared는 소유자 말고도 이 페이지를 열 수 있는 사람이 있으면 참이며, 권한을 상속하는 하위 페이지는 상속받는 페이지의 값을 따릅니다.
GET /pages/:pageId (pages:read)
페이지 하나의 같은 형태를 돌려줍니다. 읽을 권한이 없으면 존재도 알리지 않고 404 PAGE_NOT_FOUND입니다.
GET /pages/:pageId/children (pages:read)
하위 페이지 목록입니다. 쿼리는 limit, cursor입니다.
GET /pages/:pageId/content?format=json|markdown (pages:read)
format=json(기본):{ pageId, title, document }—document는 본문 블록 원형(ProseMirror 문서)입니다.format=markdown:{ pageId, title, markdown }— 링크와 첨부 주소는 저장된 값 그대로 담습니다. 페이지 멘션은@페이지, 사용자 멘션은@사용자로 적어 사람·페이지 이름이 이 경로로 흘러 나가지 않게 합니다.- 그 밖의 값은 400입니다.
GET /pages/:pageId/export?format=&scope= (pages:read)
앱의 내보내기와 같은 파일을 그대로 돌려줍니다. 응답은 JSON이 아니라 파일 바이트이며 Content-Type이 형식에 맞게 붙습니다.
| 쿼리 | 값 | 기본 |
|---|---|---|
format | markdown · html · archive | markdown |
scope | page · tree | page |
markdown+page와 html은 파일 하나, 나머지는 .zip입니다. 데이터베이스를 page로 내보내면 행이 속성 값과 함께 담기고 행 본문은 빠집니다. 한 번에 2,000페이지·64 MiB까지이며 넘으면 413 EXPORT_TOO_LARGE입니다.
POST /pages (pages:write)
{ "parentId": "8a3c…", "title": "릴리스 노트", "markdown": "# 소개\n\n- 하나\n- 둘\n" }
workspaceId도 넘길 수 있지만 토큰의 Space와 같아야 합니다. parentId를 비우면 최상위에 만듭니다. 응답은 만들어진 페이지의 메타데이터입니다.
PATCH /pages/:pageId (pages:write)
{ "title": "릴리스 노트 v2" } — 제목만 바꿉니다. 응답은 바뀐 페이지 메타데이터입니다.
PUT /pages/:pageId/content (pages:write)
{ "markdown": "완전히 새 본문\n" } — 본문을 통째로 교체합니다. 결과 본문은 보낸 Markdown 그대로이지만, 쓰기는 지금 본문과의 차이로 합니다(2026-11-06부터). 그대로인 블록과 글은 손대지 않고 바뀐 부분만 지우고 넣으므로, 같은 페이지를 자주 다시 써도 페이지가 불어나지 않고, 바뀌지 않은 블록은 ID와 댓글을 그대로 가집니다. 이 업데이트 하나를 기존 저장 경로로 보내므로, 검증·권한·세대 검사와 실시간 반영이 그대로 적용됩니다. 교체 대상은 요청의 차례가 왔을 때의 페이지이므로, 그 전에 들어온 편집도 함께 교체됩니다. 페이지를 열어 둔 사람은 연결이 끊기지 않고 새 본문을 봅니다. 부분 편집용 엔드포인트는 없습니다.
본문이 이미 같은 Markdown으로 읽히면 아무것도 쓰지 않습니다(2026-11-01부터). 응답의 changed가 false이고 revision은 그대로입니다. 열린 페이지에도 알리지 않고 기록도 늘지 않습니다. 같은 Markdown으로 읽히는 한 Markdown이 나타내지 못하는 서식은 그대로 남습니다. 같은 내용을 주기적으로 다시 넣는 동기화 스크립트도 페이지를 키우지 않지만, 바뀐 것이 있을 때만 쓰는 편이 낫습니다.
{ "pageId": "8a3c…", "revision": 13, "generation": 1, "changed": true }
Markdown 처리 범위: 제목(#~######), 목록·체크박스, 인용, 코드 펜스, 구분선, 표, 이미지, 인라인 서식과 링크를 다룹니다. 쓸 때 웹 주소(http(s), mailto:)가 아닌 링크와 앱 밖에서 해석할 수 없는 첨부 경로는 버립니다. 한 번에 최대 20,000블록입니다.
POST /pages/:pageId/assets?name=<파일 이름> (pages:write)
본문은 파일의 원본 바이트이고 Content-Type이 파일 형식입니다(JSON이 아님, 최대 10 MiB). 앱과 같은 첨부 서비스를 쓰므로 업로드는 토큰 주인의 초안으로 페이지의 현재 본문 세대에 묶여 임대됩니다. 그 세대 안에서 본문(PUT …/content의 Markdown에 응답의 url을 링크나 이미지로) 또는 파일 속성(PATCH …/properties에 응답의 id)이 이 첨부를 가리키면 페이지의 첨부가 되고, 아무것도 가리키지 않으면 유예 뒤 지워집니다.
{ "id": "3f1c…", "pageId": "8a3c…", "url": "/api/v2/pages/8a3c…/assets/3f1c…", "name": "note.txt", "type": "text/plain", "size": 13, "createdAt": 1758… }
url은 앱 화면의 주소입니다. API로 내려받을 때는 아래 경로를 씁니다.
GET /pages/:pageId/assets/:assetId (pages:read)
첨부의 바이트를 Content-Type·Content-Disposition: attachment로 돌려줍니다. 페이지의 첨부(본문·댓글·파일 속성·커버가 가리키는 것)와 토큰 주인 자신의 임대 중 초안만 읽을 수 있습니다. 나머지는 404 ASSET_NOT_FOUND.
GET /pages/:pageId/comments (pages:read)
{ "pageId": "8a3c…", "threads": [{ "id": "t1", "blockId": "b7", "orphan": false, "resolved": false, "createdAt": 1758…, "updatedAt": 1758…,
"comments": [{ "id": "c1", "authorId": "u1", "createdAt": 1758…, "updatedAt": 1758…, "deleted": false, "text": "금액을 적어 주세요." }] }] }
text는 댓글 본문의 평문입니다(서식·멘션·첨부는 앱에서). 지운 댓글은 deleted: true, text: null.
POST /pages/:pageId/comments (comments:write)
{ "text": "금액을 적어 주세요.", "quote": "예산" } — 새 댓글 스레드. MCP add_comment와 같은 규칙: quote는 댓글이 가리키는 본문의 정확한 글이며 한 블록 안에 한 번 나와야 합니다(없거나 여러 번이면 400). quote가 없으면 글이 있는 첫 블록에 붙습니다. text는 1~4000자 평문, 줄바꿈은 단락이 됩니다. 응답 { pageId, threadId, blockId }.
POST /pages/:pageId/threads/:threadId/replies (comments:write)
{ "text": "확인했습니다." } — 그 스레드에 답글. 응답 { pageId, threadId, commentId }. 이 페이지의 스레드가 아니면 404 COMMENT_THREAD_NOT_FOUND.
4.3 검색
GET /search?q=&limit=&workspaceId=&kinds=&createdBy=&updatedFrom=&updatedTo= (search:read)
{
"hits": [{
"pageId": "8a3c…", "workspaceId": "b9f1…", "type": "document", "entry": false, "title": "릴리스 노트 v2",
"icon": {}, "matches": ["title"], "snippet": null, "parent": null
}],
"hasMore": false
}
q는 200자까지, limit은 100까지입니다(2026-09-25에 50에서 늘었습니다). 결과는 항상 페이지이며, matches가 그 페이지가 나온 이유입니다: title·property·body·comment·attachment. 본문·댓글·첨부 일치에는 160자 발췌(snippet)가 붙고, 제목·속성만 맞은 결과는 snippet이 null입니다. comment와 attachment는 2026-09-20에 추가되었습니다 — matches의 값을 닫힌 집합으로 다루던 클라이언트는 모르는 값을 무시하도록 고쳐야 합니다.
- 한 글자 검색어는 제목과 속성에서만 찾습니다. 본문·댓글·첨부는 두 글자부터 찾습니다(2026-10-23부터). 띄어 쓴 검색어는 그 문구 그대로 찾습니다.
- 두 글자 검색어는 글자에 따라 다릅니다(2026-10-25부터): 한글이 든 두 글자는 본문·댓글·첨부에서 단어 안까지, 영문·숫자 두 글자는 따로 쓴 단어로만(
ai는 "AI를", "(AI)"는 찾고 "train"은 찾지 않음), 그 밖의 두 글자는 제목과 속성에서만 찾습니다. - 두 글자 본문 검색은 0.5초 안에서만 찾습니다(2026-10-25부터). 그 안에 찾을 Space의 모든 페이지를 보지 못하면 응답에
"incomplete": true가 붙습니다. 세 글자 이상은 색인으로 모든 페이지를 찾으므로 이 필드가 붙지 않습니다. MCPsearch도구도 같은 필드를 돌려줍니다. - 속성은 입력한 텍스트뿐 아니라 선택지 라벨(선택·상태·다중 선택)과 사용자 속성의 사람 이름으로도 찾습니다.
- 첨부는 파일 이름, 텍스트 계열 파일(
text/*, Markdown, CSV, JSON)의 앞쪽 256KB, PDF의 메타데이터(제목·작성자·주제·키워드)로 찾습니다. PDF와 Office 문서의 본문은 찾지 않습니다. 페이지에서 떼어 낸 첨부는 나오지 않습니다. - 필터는 모두 선택입니다.
kinds는document·database·entry(데이터베이스 항목)를 쉼표로 이은 값,createdBy는 사용자 id,updatedFrom·updatedTo는 마지막 변경 시각의 경계(epoch 밀리초, 양끝 포함)입니다.entry는 결과가 데이터베이스 항목인지 알려 줍니다. - 순서는 제목이 일치한 페이지, 토큰 사용자의 즐겨찾기, 최근 30일 안에 연 페이지 순이고, 같은 단 안에서는 최근에 바뀐 것이 먼저입니다.
4.4 데이터베이스
POST /databases (databases:write)
{ "parentId": "…", "title": "작업" } — 데이터베이스 페이지를 만듭니다. 응답은 페이지 메타데이터(type: "database")입니다.
GET /databases/:databaseId/schema (databases:read)
{
"schemaVersion": 3,
"description": "",
"properties": [
{ "id": "p_title", "name": "이름", "type": "title", "position": 0, "version": 1, "archived": false, "options": [] },
{ "id": "p_state", "name": "상태", "type": "select", "position": 1, "version": 2, "archived": false,
"options": [{ "id": "o_1", "label": "진행", "color": null, "position": 0, "version": 1, "archived": false }] }
]
}
속성 종류는 title, text, url, number, checkbox, date, select, status, multi_select, person, files, relation, place, created_time, edited_time, created_by, formula, rollup입니다.
PATCH /databases/:databaseId/schema (databases:write)
{ "expectedVersion": 3, "command": { "kind": "create", "name": "상태", "type": "select" } }
expectedVersion이 현재 schemaVersion과 다르면 409 DATABASE_REVISION_CONFLICT입니다. 응답은 { "changedId": "p_state", "schemaVersion": 4 }입니다.
속성 이름은 한 데이터베이스의 (삭제하지 않은) 속성끼리, 선택지 이름은 한 속성 안에서 겹칠 수 없습니다. 앞뒤 공백과 대소문자는 구별하지 않습니다. 겹치면 create·rename은 409 PROPERTY_NAME_TAKEN, addOption·renameOption은 409 OPTION_LABEL_TAKEN입니다. 이전부터 겹쳐 있던 이름은 그대로 둡니다.
command.kind | 필드 |
|---|---|
create | name, type, targetDatabaseId?, cardinality?(one·many). formula는 expression, timeZone?. rollup은 rollup: { relationPropertyId, targetPropertyId, aggregate } |
rename | propertyId, name |
archive | propertyId |
configure | propertyId, settings(숫자 서식 { format, decimals?, currency? } · 상태 묶음 · 수식 { expression, timeZone?, numberFormat? } · 롤업 { relationPropertyId, targetPropertyId, aggregate, numberFormat? } · 수식/롤업의 서식만 { numberFormat }) |
reorder | propertyIds |
addOption | propertyId, label, color?(gray·brown·orange·yellow·green·blue·purple·pink·red; 없으면 차례로 배색) |
renameOption | propertyId, optionId, label |
recolorOption | propertyId, optionId, color(위 색 또는 null) |
archiveOption | propertyId, optionId |
reorderOptions | propertyId, optionIds |
describe | description |
속성 종류 변환처럼 지원하지 않는 작업은 400 SCHEMA_OPERATION_UNSUPPORTED입니다. 롤업의 집계 종류(aggregate)는 show, count, count_values, count_empty, count_unique, sum, average, median, min, max, range, checked, unchecked, percent_checked, earliest, latest이며, 연결된 속성의 종류에 맞지 않으면 400 INVALID_ROLLUP입니다.
POST /databases/:databaseId/entries (databases:write)
{ "title": "설계", "properties": { "상태": "진행" }, "markdown": "본문 한 줄" }
응답은 페이지 메타데이터에 properties를 더한 형태입니다. 속성 표기 규칙은 5장을 보세요.
POST /databases/:databaseId/query (databases:read)
보기 설정을 그대로 조건으로 씁니다.
{
"config": {
"version": 1,
"filter": { "all": [{ "propertyId": "p_state", "op": "equals", "value": "o_1" }] },
"sorts": [{ "propertyId": "p_due", "direction": "asc" }],
"visibleProperties": ["p_title", "p_state"]
},
"limit": 50
}
config.version은 1입니다. 필터 연산자는equals,not_equals,contains,gt,gte,lt,lte,is_empty,is_not_empty,overlaps입니다.all·any로 묶을 수 있고 중첩은 8단계·노드 100개까지입니다.- 사람·만든 사람 필터에는
"@me"를 쓸 수 있습니다. 호출한 토큰의 주인으로 풀립니다. - 속성 없이 쓰는 기본 키
"@created_by"(작성자)·"@created_time"(작성일자)는 필터·정렬·visibleProperties에,"@edited_time"(수정 시각)은 정렬에만 씁니다(2026-11-03부터).@created_by필터 값은 구성원 ID나"@me",@created_time은 밀리초이며@created_by정렬은 이름순입니다.visibleProperties에 넣으면 각 행의properties에{ "definition": { "id": "@created_by", "name": "작성자", "type": "created_by" }, "value": { "id": …, "name": … } }·{ "definition": { "id": "@created_time", …, "type": "created_time" }, "value": 밀리초 }로 함께 오고(이름은 토큰 주인의 언어), 스키마(GET /databases/:id/schema)에는 없습니다. 정렬이 없거나 정렬 값이 같은 행은 만든 순서(오래된 것부터)로 옵니다.feed설정이 있고 정렬이 없으면 최근에 만든 행부터 옵니다. - 조회 조건은 이름을 받지 않습니다.
propertyId는 그 데이터베이스의 현재 속성 ID, 선택지·사람·관계 필터의value도 ID여야 합니다(속성 값을 쓸 때만 이름을 허용합니다). limit은 1~100(기본 50), 응답은{ rows, nextCursor }이며groupBy·보기별 설정을 주면summaries·boardGroups·chart가 함께 옵니다.- 표 설정(
table)과 함께면groupBy에 관계·사용자·다중 선택·날짜 속성도 씁니다. 날짜는table.dateUnit(day·week·month·quarter·year, 기본week) 기간으로 묶고, 기간은 첫날로 부릅니다. 값이 여러 개인 행은summaries에서 값마다 한 번씩 세고,rows에는 한 번만 옵니다. 그 행의groupKey는 첫 값의 그룹,groupKeys는 모든 그룹의 키입니다. 값이 없으면null그룹입니다.table.groupOrder·hiddenGroups·showEmptyGroups·groupSort는 화면의 그룹 표시만 정하고 이 조회의 행과 요약은 바꾸지 않습니다. - 잘못된 설정은 400(
INVALID_QUERY_INPUT,INVALID_QUERY_LIMIT,INVALID_PROPERTY_VALUE)입니다.
GET /pages/:pageId/properties (databases:read)
{
"pageId": "7c2e…",
"propertiesRevision": 5,
"properties": [
{ "definition": { "id": "p_title", "name": "이름", "type": "title" }, "value": "설계" },
{ "definition": { "id": "p_state", "name": "상태", "type": "select" }, "value": { "id": "o_1", "label": "진행" } }
]
}
수식을 이 항목에서 계산하지 못하면(지운 속성을 읽거나 값의 종류가 맞지 않음) 그 속성의 value는 null이고, 이유가 error에 붙습니다. 계산한 속성에는 error가 없습니다. 쿼리의 rows[].properties도 같습니다.
PATCH /pages/:pageId/properties (databases:write)
{ "properties": { "상태": "완료", "담당": ["u_1"], "마감": { "kind": "date", "start": "2026-10-01" } } }
데이터베이스 항목이 아닌 페이지에 쓰면 400 PAGE_NOT_AN_ENTRY입니다. 응답은 위와 같은 조회 형태입니다. 개정 검사는 서버가 방금 읽은 현재 값으로 채우므로 호출자가 propertiesRevision을 보낼 필요는 없습니다. 한 번에 속성 1,000개까지이며, 같은 속성을 두 번 담을 수 없습니다.
4.5 웹훅
| 요청 | 범위 | 내용 |
|---|---|---|
GET /webhooks | webhooks:read | 토큰 주인이 이 Space에 등록한 엔드포인트 목록(최대 100) |
GET /webhooks/:webhookId/deliveries | webhooks:read | 최근 배달 기록 50건 |
POST /webhooks | webhooks:write | { url, events } — 응답에만 서명 키(secret)가 담깁니다 |
PATCH /webhooks/:webhookId | webhooks:write | { status: "active" | "paused" } |
DELETE /webhooks/:webhookId | webhooks:write | { id } |
curl -sS https://estnote.example.com/api/v1/webhooks -X POST \
-H "Authorization: Bearer $ESTNOTE_TOKEN" -H 'Content-Type: application/json' \
-d '{"url":"https://hooks.example.com/estnote","events":["page.content_updated"]}'
자세한 배달 규칙은 6장에 있습니다.
4.6 피드백 (버그 신고·개선 제안)
버그 신고와 개선 제안은 어느 Space에도 속하지 않습니다. :kind는 bug 또는 suggestion입니다. feedback:create 범위가 있으면 검토자가 아니어도 토큰 주인 이름으로 등록할 수 있습니다. 목록·상세 조회, 답글, 완료 처리는 해당 범위와 피드백 검토자 자격이 모두 필요하며, 검토자가 아니면 403 FEEDBACK_REVIEWER_REQUIRED입니다. 기존 토큰으로 등록하려면 새 범위를 포함한 토큰을 발급하세요.
| 요청 | 범위 | 내용 |
|---|---|---|
POST /feedback/:kind | feedback:create | 버그 신고·개선 제안 등록. 201 { id, createdAt } |
GET /feedback/:kind?status=&limit=&offset= | feedback:read | 목록. status는 open(기본)·resolved·all, limit은 1~100(기본 50). 응답 { items, total, counts: { open, resolved } } |
GET /feedback/:kind/:id | feedback:read | 한 건: 제목·설명·기대 효과(버그는 재현 절차)·신고자·첨부 이름·댓글·완료 표시 |
POST /feedback/:kind/:id/comments | feedback:write | { text }(1~4,000자) — 그 건에 답글. 201 |
PUT /feedback/:kind/:id/resolution | feedback:write | { resolved: true | false } — 완료 표시하거나 다시 엽니다. 응답 { resolution } (열려 있으면 null) |
등록 입력은 브라우저 신고 화면과 같은 규칙으로 검증합니다. 개선 제안은 title(1~200자), description(1~10,000자)이 필수이고 expected(최대 5,000자)는 선택입니다. 버그 신고는 title(1~200자), description(1~4,000자), context, logs(빈 배열 가능)가 필수이며 steps(최대 4,000자), expected(최대 2,000자)는 선택입니다. 버그의 context에는 문제가 발생한 클라이언트의 capturedAt(ISO 시각), url, userAgent, language, timezone, viewport: { width, height, devicePixelRatio, scrollX, scrollY }, screen: { width, height }, online, buildId를 넣습니다. 버그는 screenshotDataUrl(base64 PNG, 최대 4MB) 또는 screenshotError(캡처를 첨부하지 못한 이유) 중 하나도 필요합니다. 개선 제안은 둘 다 생략할 수 있습니다. 로그는 at, kind(error·warn·network·action), message, 선택적인 details(값은 문자열·숫자·불리언·null)로 구성하며 최대 100개입니다. 버그 진단의 인증 정보는 브라우저 신고와 같은 검증기로 가립니다.
두 종류 모두 attachments: [{ name, base64 }]로 최대 5개, 파일당 5MB, 합계 10MB까지 첨부할 수 있습니다. base64를 포함한 JSON 본문 제한은 20MB입니다. 신고자 지정 등 정의되지 않은 필드는 거부합니다. 신고자는 항상 인증된 토큰 주인이며 앱의 내 신고에도 표시됩니다. 인증은 Authorization: Bearer …만 사용하며 쿠키나 CSRF 토큰은 필요하지 않습니다. feedback:create만으로 다른 사람의 신고를 조회하거나 완료 처리할 수는 없습니다.
curl -sS https://estnote.example.com/api/v1/feedback/suggestion -X POST \
-H "Authorization: Bearer $ESTNOTE_TOKEN" -H 'Content-Type: application/json' \
-d '{"title":"트리 펼침 유지","description":"하위 페이지를 삭제해도 펼친 상태를 유지해 주세요.","expected":"트리를 다시 열지 않고 작업을 이어갑니다."}'
# bug-report.json에는 위의 버그 신고 필드와 context, logs를 넣습니다.
curl -sS https://estnote.example.com/api/v1/feedback/bug -X POST \
-H "Authorization: Bearer $ESTNOTE_TOKEN" -H 'Content-Type: application/json' \
--data-binary @bug-report.json
답글과 완료 표시는 앱의 검토 화면에서 한 것과 같습니다. 답글은 개발팀 이름으로 남고, 신고자에게 알림이 갑니다. 완료 표시는 원하는 상태를 본문에 적는 방식이라 같은 요청을 다시 보내도 상태가 뒤집히지 않습니다. 없는 건은 404 FEEDBACK_NOT_FOUND.
# 열려 있는 개선 제안 → 답글 → 완료 표시
curl -sS "https://estnote.example.com/api/v1/feedback/suggestion" -H "Authorization: Bearer $ESTNOTE_TOKEN"
curl -sS "https://estnote.example.com/api/v1/feedback/suggestion/$ID/comments" -X POST \
-H "Authorization: Bearer $ESTNOTE_TOKEN" -H 'Content-Type: application/json' -d '{"text":"반영해 오늘 배포했습니다."}'
curl -sS "https://estnote.example.com/api/v1/feedback/suggestion/$ID/resolution" -X PUT \
-H "Authorization: Bearer $ESTNOTE_TOKEN" -H 'Content-Type: application/json' -d '{"resolved":true}'
4.7 변경 목록
GET /changes?cursor=&limit= (pages:read)
웹훅을 받을 수 없는 클라이언트를 위한 것입니다(2026-11-01부터). 데이터베이스와 페이지를 하나하나 다시 읽는 대신, 이것 하나로 무엇이 바뀌었는지 묻고 바뀐 페이지만 읽으세요.
{
"changes": [{ "pageId": "8a3c…", "workspaceId": "b9f1…", "kinds": ["page.content_updated", "entry.properties_updated"], "changedAt": 1761955200000 }],
"cursor": "c1.18234.912",
"hasMore": false
}
- 처음에는
cursor없이 부릅니다. 변경 없이 지금 위치의 커서만 돌아옵니다. 그 뒤 필요한 목록을 한 번 읽고, 다음부터는 받은cursor를 넘겨 그 뒤의 변경만 받습니다. - 페이지마다 한 줄이고,
kinds는 6.1의 이벤트 이름입니다. 받은함 이벤트는 없습니다.entry.properties_updated는 토큰에databases:read가 있을 때만 나옵니다. - 토큰이 닿는 Space 안에서, 토큰 주인이 지금 읽을 수 있는 페이지만 나옵니다. 휴지통으로 간 페이지는 웹훅과 같은 조건으로만 나옵니다.
limit은 1~500(기본 100) 페이지입니다.hasMore가true면 바로 다시 부르고,false면 몇 분 뒤에 부르세요. 몇 초마다 부르는 것은 폴링과 같습니다.- 형식이 틀린 커서는 400
INVALID_CHANGES_CURSOR입니다. 속성 변경 기록은 7일 동안 남으므로, 그보다 오래된 커서는 410CHANGES_CURSOR_EXPIRED입니다. 서버의 콘텐츠를 초기화한 뒤에도 같습니다. 목록을 다시 읽고 커서 없이 새로 시작하세요. - 나오지 않는 변경: 만들기만 하고 한 번도 고치지 않은 페이지(처음 고칠 때 나옵니다), 데이터베이스 스키마·보기 변경, 공유 설정만의 변경, 그리고 영구 삭제입니다. 영구 삭제하면 그 페이지의 변경 기록도 함께 지워지므로, 휴지통으로 간 변경을 받기 전에 영구 삭제된 페이지는 나오지 않습니다. 이런 것이 중요하면 가끔(예: 하루 한 번) 목록 전체를 다시 읽어 없어진 페이지를 지우세요.
5. 속성 값 쓰기 규칙
POST /databases/:id/entries와 PATCH /pages/:id/properties의 properties 객체에 같은 규칙이 적용됩니다.
- 키는 속성 이름이나 속성 ID 어느 쪽이어도 됩니다.
select·status는 선택지 이름(또는 선택지 ID) 하나,multi_select는 그 배열을 줍니다. 이름은 서버가 현재 선택지 목록에서 ID로 바꿉니다.text·url은 문자열(10만 자까지,url은http·https·mailto만),number는 유한한 수,checkbox는 참·거짓입니다.date는{ "kind": "date" | "datetime", "start": "…", "end"?: "…", "timeZone"?: "…" },place는{ "name": …, "address"?: …, "latitude"?: …, "longitude"?: … }입니다.person은 활성 구성원 ID 배열,relation은 연결할 항목 페이지 ID 배열입니다. 관계가one이면 하나만, 대상은 그 관계가 가리키는 데이터베이스의 항목이면서 토큰 주인이 읽을 수 있어야 합니다.- 값을 지울 때는
null입니다. - 제목은 속성으로 쓰지 않습니다. 만들 때는
title필드, 나중에는PATCH /pages/:pageId를 씁니다.title·created_time·edited_time·created_by에 쓰면 400PROPERTY_READ_ONLY,formula·rollup도 계산 속성이라 같은 400PROPERTY_READ_ONLY입니다. - 첨부(
files) 속성은 공개 API로 다루지 않습니다. - 없는 속성은 400
PROPERTY_NOT_FOUND, 없는 선택지는 400PROPERTY_OPTION_NOT_FOUND이며 아무것도 바꾸지 않습니다.
6. 웹훅
변경을 알아야 하면 폴링하지 말고 웹훅을 쓰세요. 몇십 초마다 데이터베이스를 조회하거나 페이지를 다시 읽으면 바뀐 것이 없어도 서버가 매번 일하고, 그런 클라이언트가 늘면 모두가 느려집니다. 웹훅은 바뀐 페이지만, 보통 바뀐 뒤 30초 안에 알려 줍니다. 받은 알림의 페이지만 API로 읽으세요. 웹훅을 받을 주소를 열 수 없다면 변경 목록을 몇 분 간격으로 쓰세요.
6.1 받을 변경
event | 뜻 |
|---|---|
page.content_updated | 본문 변경 |
page.comments_updated | 댓글 변경 |
page.metadata_updated | 제목·표시 변경 |
page.lifecycle_updated | 이동·휴지통·복원 |
entry.properties_updated | 데이터베이스 항목의 속성 변경 (12.8) |
inbox.created · inbox.updated | 받은함 항목 생성 · 읽음·보관 (12.8) |
6.2 배달 요청
POST /estnote HTTP/1.1
Content-Type: application/json
X-Estnote-Event: page.content_updated
X-Estnote-Delivery: 2f6b…
X-Estnote-Signature: t=1758300000000,v1=9c1f…
{
"event": "page.content_updated",
"deliveryId": "e91a…",
"workspaceId": "b9f1…",
"pageId": "8a3c…",
"revision": 13,
"generation": 1,
"occurredAt": 1758299999000
}
- 본문은 보내지 않습니다. 어떤 Space의 어떤 페이지가 언제 몇 번째 개정으로 바뀌었는지만 알립니다. 내용을 보려면 그 페이지를 API로 읽어야 하고, 그때 권한 검사를 다시 받습니다.
X-Estnote-Delivery는 배달 단위 ID로 재시도에도 같은 값이 옵니다. 중복 처리를 막는 열쇠로 쓰세요. 본문의deliveryId는 변경 아웃박스의 이벤트 ID이며, 한 변경이 여러 엔드포인트로 갈 때 같은 값입니다.
6.3 서명 확인
X-Estnote-Signature는 t=<밀리초>,v1=<HMAC-SHA256(서명 키, "<t>.<본문 원문>")의 16진수>입니다. 서명 키는 등록할 때 한 번만 보여 줍니다.
import { createHmac, timingSafeEqual } from 'node:crypto'
function verify(rawBody, header, secret) {
const parts = new Map(header.split(',').map(part => part.split('=', 2)))
const expected = createHmac('sha256', secret).update(`${parts.get('t')}.${rawBody}`).digest()
const actual = Buffer.from(parts.get('v1') ?? '', 'hex')
return expected.length === actual.length && timingSafeEqual(expected, actual)
}
본문을 JSON으로 다시 만들면 바이트가 달라질 수 있으니 받은 원문 그대로 계산하세요. t가 지나치게 오래되었으면 재사용으로 보고 거절하는 편이 안전합니다.
6.4 적재·재시도·멈춤
- 30초마다 페이지 변경 아웃박스를 커서로 훑어 최대 200건을 배달 대기열에 쌓고, 배달은 한 번에 최대 20건을 따로 보냅니다. 커서 덕분에 같은 변경이 두 번 쌓이지 않습니다.
- 배달 대상은 엔드포인트를 만든 사람이 지금 읽을 수 있는 페이지로 제한합니다. 권한이 없어진 페이지의 변경은 쌓이지 않으며, 구독하지 않은 종류도 걸러집니다.
- 요청 제한 시간은 10초이고 리다이렉트는 따라가지 않습니다. 2xx만 성공입니다.
- 실패하면 30초·1분·2분·4분·8분…(최대 1시간)으로 최대 6번까지 보내고, 그래도 실패하면 엔드포인트를
paused로 바꿉니다. 멈춘 엔드포인트에는 새 변경을 쌓지 않으며, 앱 화면이나PATCH /webhooks/:id로 다시 켤 수 있습니다.
6.5 주소 규칙
https만 받습니다. 계정 정보가 담긴 주소, 사설·루프백 IP 리터럴(10.*, 127.*, 0.*, 172.16~31.*, 192.168.*, 169.254.*, 100.64~127.*, ::, ::1, fc*, fd*, fe80*, 그리고 이들을 담은 IPv4 매핑·NAT64 주소)과 localhost는 400 INVALID_WEBHOOK_URL입니다. 공개 이름이 사설 주소로 해석되는 경우는 등록 때가 아니라 전송할 때 막습니다. 이름을 해석한 결과 중 하나라도 사설 주소면 그 전송은 실패로 기록되고, 연결은 검사한 바로 그 주소로만 맺습니다. 리디렉션은 따라가지 않습니다.
7. 오류 코드
| 상태 | code | 언제 |
|---|---|---|
| 400 | INVALID_REQUEST, INVALID_IDENTIFIER, INVALID_JSON | 형식·필드·경로 ID가 규칙에 맞지 않음 |
| 400 | PROPERTY_NOT_FOUND, PROPERTY_OPTION_NOT_FOUND, PROPERTY_READ_ONLY, PROPERTY_TYPE_UNSUPPORTED, INVALID_PROPERTY_VALUE | 속성 값 쓰기·보기 설정 검증 실패 |
| 400 | INVALID_QUERY_INPUT, INVALID_QUERY_LIMIT, INVALID_QUERY_CURSOR | 데이터베이스 조회 조건 |
| 400 | INVALID_PAGE_LIST_QUERY, INVALID_PAGE_CURSOR, INVALID_PAGE_SEARCH_QUERY | 목록·검색 조건 |
| 400 | SCHEMA_OPERATION_UNSUPPORTED, TITLE_PROPERTY_REQUIRED | 지원하지 않는 스키마 변경 |
| 400 | INVALID_WEBHOOK_URL, INVALID_WEBHOOK_EVENTS | 웹훅 등록 값 |
| 400 | PAGE_NOT_AN_ENTRY | 데이터베이스 항목이 아닌 페이지에 속성 변경 |
| 401 | API_TOKEN_INVALID | 헤더 없음·형식 불일치·비밀값 불일치·철회·만료·Space 이탈 |
| 403 | API_SCOPE_REQUIRED | 토큰에 그 범위가 없음 |
| 429 | API_RATE_LIMITED | 요청이 너무 잦음. Retry-After(초)만큼 기다렸다가 다시 시도 |
| 400 | INVALID_CHANGES_CURSOR | 변경 목록 커서의 형식이 틀림 |
| 410 | CHANGES_CURSOR_EXPIRED | 변경 목록 커서가 만료됨(7일보다 오래됨). 목록을 다시 읽고 커서 없이 새로 시작 |
| 403 | API_WORKSPACE_MISMATCH | 다른 Space를 지정 |
| 400 | API_WORKSPACE_REQUIRED | 여러 Space에 닿는 연결된 앱의 토큰이 workspaceId 없이 Space가 필요한 호출을 함 |
| 403 | API_GRANT_REQUIRED | 연결된 앱만 쓸 수 있는 호출(POST /embed/code)을 개인 토큰으로 함 |
| 403 | CHAT_SPACE_REQUIRED, CHAT_DESTINATION_DENIED | 그 Chat 스페이스에서 ESTnote를 실행한 적이 없음 · 저장 위치의 편집 권한이 없음 |
| 400 | CHAT_DATABASE_REQUIRED, CHAT_PAGE_REQUIRED | 캡처의 대상이 데이터베이스/문서가 아님 |
| 503 | CHAT_NOT_CONFIGURED | 서버에 Google Chat이 설정되지 않음 |
| 403 | PAGE_EDIT_REQUIRED, PROPERTIES_EDIT_REQUIRED, DATABASE_SCHEMA_EDIT_REQUIRED | 읽을 수는 있으나 고칠 권한이 없음 |
| 404 | PAGE_NOT_FOUND, DATABASE_NOT_FOUND, API_TOKEN_NOT_FOUND, WEBHOOK_NOT_FOUND | 없거나 볼 권한이 없음(존재를 알리지 않음) |
| 404 | NOT_FOUND, V2_NOT_ACTIVE | 없는 경로 · 공개 API 비활성 |
| 409 | CONTENT_REVISION_CONFLICT, CONTENT_GENERATION_CHANGED, DATABASE_REVISION_CONFLICT | 그 사이 누군가 먼저 바꿈 → 다시 읽고 재시도 |
| 409 | PAGE_LIST_STALE, QUERY_STALE | 커서 기준이 바뀜 → 첫 쪽부터 다시 |
| 409 | PROPERTY_NAME_TAKEN, OPTION_LABEL_TAKEN | 같은 이름의 속성·선택지가 이미 있음 → 다른 이름으로 |
| 413 | EXPORT_TOO_LARGE, PAYLOAD_TOO_LARGE | 내보내기 한도·요청 본문 한도 초과 |
| 403 | SIGNUP_ADMIN_REQUIRED | 가입 허용 목록을 관리자가 아닌 사용자가 호출 |
| 400 | INVALID_SIGNUP_ENTRY | 가입 허용 항목 입력값(우리 도메인·공용 메일 도메인 포함) |
| 409 | SIGNUP_ENTRY_EXISTS | 같은 대상에 살아 있는 가입 허용 항목이 이미 있음 |
| 404 | SIGNUP_ENTRY_NOT_FOUND | 없는 가입 허용 항목 제거 |
| 403 | ACCOUNT_DELETION_PENDING | 삭제가 예약된 계정의 요청. /api/auth/account*와 로그아웃만 허용 |
| 409 | SPACES_MUST_BE_TRANSFERRED, LAST_ADMIN | 다른 구성원이 있는 Space를 소유한 채 삭제 요청 · 마지막 관리자 삭제 |
| 404 | USER_NOT_FOUND, DELETION_NOT_PENDING | 없는(또는 이미 삭제된) 사용자 · 취소할 예약 없음 |
| 503 | SERVER_SHUTTING_DOWN | 서버 종료 중 |
| 500 | INTERNAL_ERROR | 그 밖의 실패 |
8. 관리 엔드포인트 (/api/v2)
토큰 발급·철회는 로그인 세션(쿠키 + X-CSRF-Token)으로만 할 수 있습니다. 토큰으로 토큰을 만드는 길은 열어 두지 않았습니다. 웹훅은 앱 화면과 공개 API 양쪽에서 관리합니다.
| 요청 | 내용 |
|---|---|
GET /api/v2/tokens?workspaceId= | 내 토큰 목록 |
POST /api/v2/tokens | { workspaceId, name, scopes, expiresInDays? } — 응답에만 token |
DELETE /api/v2/tokens/:tokenId | 철회(되돌릴 수 없음) |
GET /api/v2/webhooks?workspaceId= | 내 웹훅 목록 |
POST /api/v2/webhooks | { workspaceId, url, events } — 응답에만 secret |
GET /api/v2/webhooks/:id/deliveries | 배달 기록 |
PATCH /api/v2/webhooks/:id | { status } |
DELETE /api/v2/webhooks/:id | 삭제 |
GET /api/v2/signup-allowlist | 가입 허용 목록(관리자만). 항목마다 상태와 가입한 사람(userId 포함) |
POST /api/v2/signup-allowlist | { kind: 'email' | 'domain', target, note?, expiresInDays?, sendMail? } — 만료와 메일은 이메일 항목만. 응답에 mailed |
DELETE /api/v2/signup-allowlist/:entryId | 제거. 새 가입만 막히고 이미 만든 계정은 남음 |
GET /api/v2/users?q=&filter=&sort=&limit=&offset= | 모든 계정 목록(관리자만). filter: all(삭제되지 않은 계정)·organization·external·deletion_pending·deleted, sort: last_seen·created·name, 한 번에 50개(최대 100). 응답 { items, total, counts } |
GET /api/v2/users/:userId | 계정 하나(관리자만). 가입·마지막 로그인·마지막 활동, 들어온 경로, Space, 로그인 세션, 연결, 콘텐츠 개수, 삭제 상태 |
GET /api/v2/users/:userId/usage | 사용량(관리자만, 요청할 때만 계산). { uploaded: { files, bytes }, ownedSpaces: { spaces, fileBytes, contentBytes, versionBytes } } — 올린 파일은 저장된 파일마다 한 번, 소유한 Space는 구성원 누구든 넣은 것 전부 |
DELETE /api/v2/users/:userId/sessions | 모든 기기에서 로그아웃(관리자만). 응답 { revoked }. 자기 계정은 409 CANNOT_SIGN_OUT_SELF |
POST /api/v2/users/:userId/deletion | 삭제 요청(7일 뒤 실행). { now: true }면 즉시 실행하고 summary 반환 |
DELETE /api/v2/users/:userId/deletion | 예약 취소 |
GET /api/v2/dashboard | 서버 대시보드(관리자만). 서버·백업·큐·최근 60분 API 요청·예기치 않은 오류·주기 작업 실패·사용자와 콘텐츠 개수, 항목별 수준(items, levels). 숫자와 서버 정보만 담고 사람이나 페이지는 나오지 않음 |
POST /api/v2/dashboard/storage | 서버 전체 저장 용량을 지금 계산(관리자만, 캐시가 비면 몇 초). 응답 { fileBytes, contentBytes, versionBytes, at }, 오늘 기록에도 남음 |
GET /api/v2/dashboard/trends?days=30|90 | 일별 추세(관리자만). { days: [{ day, active, accounts, newAccounts, pages, views, siteViews, errors }] }, 기록이 없는 날은 null. 다른 값은 400 INVALID_DAYS |
8.1 Google Drive (/api/v2)
로그인 세션으로만 부릅니다. Drive에는 요청한 사람 자신의 Google 연결로 접근하므로, 그 사람이 파일 고르기 화면에서 고른 파일과 ESTnote가 만든 파일만 다룹니다. 공개 API 토큰으로는 부를 수 없습니다.
| 요청 | 내용 |
|---|---|
GET /api/v2/drive/status | 내 Drive 연결 상태. { state: 'connected' | 'reconnect' | 'unavailable' | 'unconfigured' | 'unsupported', email?, message? } |
POST /api/v2/drive/files/resolve | { fileIds, picked? } — 파일 이름·종류와 가져오기·첨부 가능 여부. picked: true는 방금 고른 파일이라는 뜻이며, 읽을 수 없으면 DRIVE_ACCOUNT_MISMATCH |
POST /api/v2/imports/google-drive | { key, workspaceId, parentPageId?, fileIds } — 파일을 받아 가져오기를 준비. 응답은 가져오기 계획이며 이후는 /api/v2/imports/:id/apply · cancel · undo |
POST /api/v2/pages/:pageId/drive/attach | { key, generation, fileId } — Drive 파일을 페이지 첨부로 복사. Google 문서는 PDF로 저장 |
GET /api/v2/pages/:pageId/drive/exports | 이 페이지에서 내가 Drive로 내보낸 사본과 최근 작업 |
POST /api/v2/pages/:pageId/drive/exports | { key, format, scope, target: 'new' | 'update', resourceId?, force? } — 내보내기 작업 등록. 202와 작업을 돌려줌 |
GET /api/v2/drive/exports/:jobId | 작업 상태. status: 'pending' | 'running' | 'done' | 'failed' | 'conflict', 끝나면 fileName, url |
format은 google-doc, markdown, html, archive 중 하나입니다. target: 'update'는 같은 형식·범위로 내보낸 사본(resourceId)만 받습니다. 마지막으로 내보낸 뒤 Drive에서 파일이 바뀌었으면 작업이 conflict(DRIVE_REMOTE_CHANGED)로 끝나고, force: true로 다시 요청하면 덮어씁니다.
| 오류 코드 | 상태 | 뜻 |
|---|---|---|
DRIVE_RECONNECT_REQUIRED | 403 | Drive 권한이 없거나 연결이 만료됨. /api/auth/google?integration=drive로 다시 연결 |
DRIVE_ACCOUNT_MISMATCH | 409 | 로그인한 계정이 아닌 Google 계정으로 고른 파일 |
DRIVE_FILE_NOT_FOUND | 404 | 파일이 없거나, 휴지통에 있거나, 이 앱에 연 적이 없음 |
DRIVE_FORBIDDEN | 403 | Google이 파일 권한이나 조직 정책으로 거절 |
DRIVE_NOT_IMPORTABLE | 400 | 페이지나 첨부로 바꿀 수 없는 종류 |
DRIVE_TOO_LARGE | 413 | 변환 한도(10 MB)나 첨부 한도(10 MiB)를 넘음 |
DRIVE_QUOTA_EXCEEDED | 403 | Drive 저장 공간 부족 |
DRIVE_API_DISABLED | 503 | Cloud 프로젝트에 Google Drive API가 꺼져 있음 |
DRIVE_UNAVAILABLE | 503 | Drive의 일시 장애나 사용량 제한. 내보내기 작업은 자동으로 다시 시도 |
MCP — 에이전트 연결
Claude 같은 에이전트 앱이 이 작업 공간을 다루게 하려면 REST API가 아니라 MCP 엔드포인트를 씁니다. 도구는 위 REST API와 같은 서비스를 같은 권한 검사로 부르는 두 번째 얼굴입니다.
- 주소:
https://<우리 주소>/mcp— Streamable HTTP, 무상태. MCP 2026-07-28과 2025-11-25 클라이언트가 같은 주소에 붙습니다. stdio 서버는 없습니다. - Space 관리자가 먼저 켜야 합니다. Space 설정 → 에이전트 →
에이전트 연결(MCP) 허용. 기본은 꺼짐이며, 꺼진 Space를 향한 호출은 JSON-RPC 오류MCP_DISABLED로 거절됩니다(HTTP 403이 아닌 이유: 일부 클라이언트가 403을 받으면 인증을 다시 시도합니다). 쓰기 도구는 둘째 스위치에이전트의 쓰기 허용(기본 꺼짐)을 켜야 보입니다.
연결하는 두 가지 방법
| 누가 | 방법 | |
|---|---|---|
| OAuth (권장) | Claude.ai·데스크톱·모바일·Claude Code 등 | 커넥터 추가 화면에 주소만 넣습니다. 앱이 Google 로그인 → 동의 화면(Space 하나와 권한 선택)으로 안내합니다 |
| API 토큰 | 헤더를 직접 넣는 개발자 | Authorization: Bearer estn_…. 예: claude mcp add --transport http estnote https://<주소>/mcp --header "Authorization: Bearer estn_…" |
API 토큰을 조직 공유 헤더로 넣지 마세요. Claude의 요청 헤더 인증(static_headers)은 조직 관리자가 한 번 넣어 조직 전체가 함께 쓰는 자격입니다. 우리 토큰은 한 사람의 권한이므로, 그렇게 넣으면 조직 전원이 그 한 사람의 권한으로 작업 공간을 읽게 됩니다. 여러 사람이 쓰는 연결은 OAuth로 하세요.
OAuth 쪽 사실들:
- 토큰 없는 요청의
WWW-Authenticate는 읽기·쓰기 범위 전부를scope=에 적습니다(2026-09-23부터. 전에는 읽기만 적어 클라이언트가 쓰기를 요청할 길이 없었습니다). 동의 화면에서 쓰기는 기본으로 꺼져 있고, 켜도 Space의에이전트의 쓰기 허용이 켜진 Space에서만 동작합니다. - 연결이 꺼진 Space를 향한 호출은 거절되면서 에이전트 활동에
거부됨으로 남습니다. - 클라이언트 등록은 Client ID Metadata Document(CIMD)만 받습니다.
client_id는 그 문서의 HTTPS 주소여야 하고, 동적 등록(/register)은 없습니다. 메타데이터는client_id_metadata_document_supported: true와token_endpoint_auth_methods_supported: ["none"]을 함께 광고합니다. - PKCE(S256)와
resource가 필수입니다.resource가 우리/mcp주소면 발급한 토큰은/mcp에서만 통하고/api/v1에서는 401입니다.resource가 우리/api주소면 연결된 앱의 그랜트가 되어/api/v1과/mcp양쪽에 통합니다(12.1). 메타데이터의token_endpoint_auth_methods_supported는none과private_key_jwt입니다(2026-10-03부터). - 액세스 토큰 1시간, 갱신 토큰 90일. 갱신 토큰은 쓸 때마다 바뀌고, 이미 쓴 갱신 토큰이나 인가 코드가 다시 오면 그 연결 전체를 끊습니다(
invalid_grant). - 루프백 리디렉션(
http://localhost/…,http://127.0.0.1/…)은 포트를 무시하고 맞추며, 동의 화면에 경고를 띄웁니다. - 토큰 없는 요청은 항상 401 +
WWW-Authenticate: Bearer resource_metadata="…", scope="…"입니다. 발견 문서는/.well-known/oauth-protected-resource(및/mcp접미 변형)와/.well-known/oauth-authorization-server에 있습니다. - 브라우저에서 도는 클라이언트는 받지 않습니다.
/mcp는 우리 origin이 아닌Origin헤더를 403으로 거절합니다(서버·데스크톱 클라이언트는Origin을 보내지 않습니다).
도구
연결의 권한 범위와 Space의 쓰기 스위치가 허용하는 도구만 tools/list에 나옵니다. 읽기만 받은 연결은 쓰기 도구가 있다는 것도 모릅니다.
| 도구 | 범위 | 하는 일 |
|---|---|---|
search | search:read | 전체 검색. 결과는 페이지이고 matches가 이유. 최대 20건 |
get_page | pages:read | 메타데이터 + 본문 Markdown + (항목이면) 속성 + 하위 페이지 제목. 본문은 200KB에서 자르고 nextOffset으로 이어 읽음 |
list_pages | pages:read | 최상위 또는 parentId의 하위 목록 |
get_database | databases:read | 속성(ID·유형·선택지)과 저장된 보기 |
query_database | databases:read | 필터·정렬·커서. 위 POST /databases/:id/query와 같은 형식, 최대 100행 |
create_page | pages:write (항목은 databases:write도) | 페이지, 또는 databaseId를 주면 항목. 속성은 이름·라벨로 지정 가능 |
edit_page | pages:write | 본문 부분 편집(아래). destructiveHint |
update_page | pages:write / databases:write | 제목 / 속성 |
add_comment | comments:write | 댓글. quote로 붙일 위치를 지정 |
trash_page | pages:write | 페이지를 하위 페이지(데이터베이스면 항목)와 함께 휴지통으로 이동. 한 번에 100개까지, 연결한 사람에게 휴지통 이동 권한이 있어야 함. 휴지통에서 복원할 수 있고 영구 삭제 도구는 없음. destructiveHint |
edit_page의 세 동작:
append— 본문 끝에 Markdown을 덧붙입니다.replace_text—old를new로.old는 한 블록 안의 일반 텍스트여야 하고 본문 전체에서 정확히 한 번 나와야 합니다. 0번이나 여러 번이면 아무것도 쓰지 않고 몇 번 나왔는지 알려 줍니다. 다른 블록의 ID와 댓글 위치는 그대로입니다.replace_all— 전체 교체. 바뀐 부분만 고쳐 씁니다. 본문에 Markdown으로 되돌아오지 않는 것(데이터베이스 보기, 임베드, 멘션, 업로드한 파일, 동기화 블록, 목차, 버튼, 단)이 있거나, 교체하면 달린 글이 지워지는 댓글이 있으면 거부하고 무엇 때문인지 알려 줍니다. 그대로 두는 블록의 댓글은 남습니다(2026-11-06부터). 본문이 이미 같은 Markdown으로 읽히면 아무것도 쓰지 않고changed: false를 돌려줍니다(복원 지점도 만들지 않음).
에이전트가 본문을 고치기 직전에 되돌릴 버전이 자동으로 남습니다. 같은 연결이 같은 페이지를 10분 안에 연달아 고치면 첫 편집 앞에만 남깁니다. 버전 기록에서 에이전트 편집 전 (앱 이름)으로 표시됩니다.
권한·검증 실패는 프로토콜 오류가 아니라 isError: true인 도구 결과로 옵니다(모델이 고쳐서 다시 부를 수 있게). 없는 페이지와 볼 수 없는 페이지는 같은 문구입니다. 영구 삭제·이동·공유·스키마 변경·자동화 도구는 없습니다(삭제는 trash_page로 휴지통까지만).
기록과 한도
- 모든 도구 호출이 에이전트 활동(Space 설정 → 에이전트)에 90일 동안 남습니다: 누구의 어느 연결이, 어느 도구로, 어느 페이지에, 어떤 결과로. 검색어·편집 동작 종류·바뀐 속성 이름은 남기지만 읽거나 쓴 내용은 남기지 않습니다. 자기 연결의 활동은 본인이, Space 전체의 활동은 관리자가 봅니다.
- 연결은 같은 화면의
연결된 앱에서 본인이, 또는 Space 관리자가 끊을 수 있습니다. 끊으면 다음 호출부터 401입니다. - 속도 한도는 REST와 같습니다(연결당 읽기 분당 60·한 번에 120까지, 쓰기 분당 30, Space 분당 600). 넘으면 "N초 뒤 다시"라는 도구 오류가 옵니다.
- 도구 결과는 신뢰할 수 없는 입력을 담습니다. 페이지 본문에 "이전 지시를 무시하고…"가 적혀 있을 수 있습니다. 본문은 메타데이터와 분리된
body필드로 가고 도구 설명에 우리 쪽 지시문은 없지만, 막을 수 있는 것은 아닙니다. 읽기 전용 연결을 기본으로 하고, 쓰기는 필요한 Space에서만 켜세요.
9. 한계와 아직 없는 것
- 요청 수 제한이 있습니다(2026-09-20, 2026-11-01에 바뀜). 토큰 하나당 읽기는 분당 60회이고 한 번에 120회까지 몰아서 부를 수 있습니다. 쓰기는 분당 30회, Space 전체는 분당 600회입니다.
POST …/query는 읽기로 셉니다. 넘으면 429API_RATE_LIMITED와Retry-After를 돌려줍니다. 거절된 요청은 한도를 쓰지 않습니다. Space 한도는 그 호출이 가리키는 페이지·데이터베이스의 Space에서 셉니다. MCP 호출과 같은 한도를 나눠 씁니다. 한도는 서버 메모리에 있어 재시작하면 초기화됩니다. 한도 안이라도 같은 것을 계속 다시 읽는 폴링은 하지 말고, 웹훅(6장)이나 변경 목록(4.7)을 쓰세요. - 댓글은 평문으로만 쓰고 읽습니다(해결·삭제·수정은 앱에서). 첨부는 페이지 단위 업로드·다운로드만 있고 목록 조회는 없습니다. 페이지 이동·복원·영구 삭제, 버전 기록, 공유 설정, 보기 저장은 앱 화면(
/api/v2) 전용입니다. 휴지통으로 보내기는 REST에는 없고 MCP의trash_page도구에만 있습니다. - 가져오기는 공개 API에 없습니다. 내보내기는 읽기 전용으로
GET /pages/:id/export만 있습니다. - REST의 본문 쓰기는 통째로 교체만 됩니다. 부분 편집(덧붙이기, 한 곳의 텍스트 바꾸기)은 MCP의
edit_page도구에 있습니다. - 웹훅은 Space 구성원 개인의 것입니다. Space 전체를 대표하는 엔드포인트라는 개념은 없고, 자동화 동작이 만든 엔드포인트는 이 목록에 나오지 않습니다.
- 개인 토큰은 여러 Space를 함께 다루지 못합니다. Space마다 토큰을 따로 만들거나, 여러 Space가 필요한 서버는 연결된 앱(12장)으로 연결하세요.
10. 참고 문서
- README — 설치·빌드·운영 배포
11. 휴대폰 앱 엔드포인트
휴대폰 앱(Android, iPhone)이 쓰는 경로입니다. 공개 API와 달리 토큰이 한 Space나 권한 범위에 묶이지 않고, 로그인한 사람 자신의 세션입니다. 앱 토큰(ests_…)은 브라우저 세션과 같은 sessions 행이며, 쿠키 대신 Authorization: Bearer로 실어 보냅니다. 그래서 /api/v2 전체를 웹과 같은 권한으로 쓰고, Origin·CSRF 검사는 받지 않습니다(쿠키처럼 브라우저가 알아서 붙이는 자격이 아니기 때문입니다). 요청에 앱 토큰과 쿠키가 함께 오면 토큰만 봅니다. 콘텐츠를 바꾸는 /api/v2 요청은 웹과 같이 X-Content-Reset-Epoch를 보내야 합니다.
11.1 로그인
| 순서 | 요청 | 내용 |
|---|---|---|
| 1 | GET /api/auth/app/start?client=android|ios&challenge=<S256>&state=<16~128자> | 앱이 브라우저 탭(Custom Tab, 인증 세션)으로 엽니다. 서버는 보통의 Google 로그인으로 보내고, 돌아올 곳을 앱 로그인 표시(/api/auth/app/finish?…)로 정합니다 |
| 2 | Google 콜백 | 가입 허용 목록·도메인·Drive 권한 검사는 웹과 같습니다. 통과하면 브라우저 세션을 만들지 않고 5분짜리 일회용 코드를 발급해 Android는 https://<서비스 주소>/app/auth/callback?code&state(App Link), iOS는 estnote://auth?code&state로 보냅니다. 거절은 웹과 같은 복구 페이지로 보입니다 |
| 3 | POST /api/auth/app/token { code, verifier, deviceName? } | PKCE verifier로 코드를 바꿔 { token, user, expiresAt, resetEpoch }를 받습니다. 코드는 첫 시도에 쓰이므로 틀린 verifier로 다시 시도할 수 없습니다(400 APP_AUTH_CODE_INVALID) |
/api/auth/app/finish를 직접 열면 1번부터 다시 시작합니다. 브라우저가 이미 가진 웹 세션을 앱으로 넘기지 않습니다. 탭이 브라우저 쿠키를 공유하므로, 넘기면 마지막으로 브라우저를 쓴 계정으로 묻지 않고 로그인되고 브라우저는 로그아웃되기 때문입니다.
App Link가 검증되지 않은 기기에서는 /app/auth/callback 페이지가 보이고, 그 안의 버튼(intent://…)이 같은 코드를 앱에 넘깁니다. 검증에는 /.well-known/assetlinks.json이 필요하며, 서버의 ANDROID_CERT_SHA256이 비어 있으면 404입니다. iPhone 앱의 유니버설 링크는 /.well-known/apple-app-site-association(같은 내용을 /apple-app-site-association에서도)을 읽으며, 페이지 링크(/ 경로의 #page=…)만 앱으로 엽니다. 서버의 IOS_APP_ID가 비어 있으면 404입니다.
11.2 세션
| 요청 | 인증 | 내용 |
|---|---|---|
POST /api/auth/app/touch | 앱 토큰 | 남은 기간이 15일보다 짧으면 30일로 연장합니다. 로그인 후 90일이 지나면 더 연장하지 않습니다. 응답 { expiresAt } |
POST /api/auth/app/web-code | 앱 토큰 | 앱 웹뷰에 세션을 넘길 1분짜리 코드 { code, expiresAt } |
GET /api/auth/app/web-session?code=&next= | 코드 | 같은 세션을 일반 세션 쿠키로 설정하고 next(기본 /embed.html)로 302합니다. 코드는 한 번만 쓰입니다 |
GET /api/auth/sessions | 쿠키 또는 앱 토큰 | 이 계정으로 로그인된 앱 목록 { sessions: [{ id, platform, deviceName, createdAt, lastSeenAt, expiresAt, current }] } |
DELETE /api/auth/sessions/:id | 쿠키(Origin·CSRF) 또는 앱 토큰 | 그 기기를 로그아웃하고 푸시 기기 등록도 지웁니다 |
POST /api/auth/logout | 쿠키 또는 앱 토큰 | 앱 토큰이면 그 세션과 푸시 기기를 함께 끝냅니다 |
11.3 푸시
| 요청 | 인증 | 내용 |
|---|---|---|
PUT /api/auth/app/push { platform, token, environment?, appVersion? } | 앱 토큰 | 현재 세션의 기기를 등록합니다. 같은 토큰이 다른 세션에 있었으면 이쪽으로 옮깁니다 |
DELETE /api/auth/app/push { token } | 앱 토큰 | 등록 해제 |
받은함에 알림이 생기는 바로 그 쓰기 안에서 푸시가 대기열에 들어가고, 서버가 3초마다 보냅니다. 받은함에 없는 알림은 푸시로도 가지 않으며, 보내기 전에 읽거나 보관한 알림은 건너뜁니다. Android는 FCM 데이터 메시지 { notificationId, kind, title, message, pageId, blockId, targetKind, targetId, unread }를 받습니다(같은 페이지의 알림은 collapse_key로 합쳐집니다). 서버의 FCM_PROJECT_ID·FCM_SERVICE_ACCOUNT_JSON이 없으면 Android 푸시는 꺼지고, /api/config의 apps.push.android가 false입니다. iPhone은 APNs 알림을 받습니다: aps에 제목·본문(alert)과 읽지 않은 수(badge)가 있고, estnote { notificationId, kind, pageId, blockId, target }이 함께 옵니다(같은 페이지의 알림은 thread-id로 묶입니다). 서버의 APNS_TEAM_ID·APNS_KEY_ID·APNS_KEY_FILE이 없으면 iOS 푸시는 꺼지고 apps.push.ios가 false입니다.
11.4 호환성과 웹뷰
GET /api/config의 apps는 { android: { minVersionCode, latestVersionCode }, ios: { minBuild, latestBuild }, windows: { minVersion }, bridgeVersion, push: { android, ios } }입니다. windows는 EST Chat 데스크톱의 문서 패널(12.6)을 위한 것입니다. 설치된 앱의 버전이 최소 버전보다 낮거나 서버의 bridgeVersion이 앱이 아는 것보다 높으면 앱은 업데이트 화면을 보입니다. latestVersionCode와 latestBuild는 나와 있는 가장 새 빌드이며, 그보다 낮은 앱은 "새 버전이 있습니다" 배너를 보입니다(Android 1.0.11부터, iOS 빌드 16부터). 이 값을 보내지 않는 서버에서는 아무것도 보이지 않습니다. 앱 요청에는 X-ESTnote-Client: android/<버전> 또는 ios/<버전>이 붙습니다.
앱 웹뷰는 /embed.html#page=<id>&block=&view=를 엽니다. 웹과 같은 페이지 화면이며, 다른 페이지로 가는 링크·외부 링크·휴지통 이동은 shared/embed-bridge.ts의 메시지로 앱에 넘깁니다.
12. 연결된 앱 (EST Chat 연동)
사용자 1,000명을 대신 호출하는 서버(EST Chat 서버 같은)는 사람마다 토큰을 만들어 줄 수 없습니다. 그래서 연결된 앱은 OAuth로 사용자 한 사람의 동의를 한 번 받고, 그 사람 몫의 토큰을 보관해 /api/v1을 호출합니다. 토큰은 여전히 그 사람의 권한 안에서만 동작하며, 아래 엔드포인트는 모두 앱 화면과 같은 서비스 계층을 거칩니다.
12.1 연결 (OAuth 2.1, 여러 Space)
MCP와 같은 인가 서버(/oauth/authorize, /oauth/token)를 쓰되 resource를 https://<우리 주소>/api로 요청합니다. 발견 문서는 /.well-known/oauth-protected-resource/api입니다.
| 항목 | 내용 |
|---|---|
| 클라이언트 | CIMD 그대로. 기밀 클라이언트(서버)는 메타데이터에 token_endpoint_auth_method: "private_key_jwt"와 jwks 또는 jwks_uri를 적고, 토큰 요청마다 client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer·client_assertion(RS256·PS256·ES256, iss=sub=client_id, aud=토큰 엔드포인트, 10분 이내, jti 재사용 불가)을 보냅니다. 공개 클라이언트(none)도 그대로 받습니다 |
| 동의 화면 | 그 사람이 속한 Space 여러 개를 고르거나 내가 속한 모든 Space(앞으로 참여하는 Space 포함)를 택합니다. 범위는 2장의 모든 범위를 요청할 수 있고 쓰기는 기본으로 꺼져 있습니다. Space의 에이전트 스위치는 보지 않습니다(개인 토큰과 같은 기준) |
| 토큰 | 액세스 1시간·갱신 90일, 회전과 재사용 감지는 MCP와 같습니다. /api 그랜트의 토큰은 /api/v1과, 첫 Space로 /mcp에도 통합니다. /mcp 그랜트의 토큰은 전처럼 /api/v1에서 401입니다 |
| Space 지정 | 여러 Space에 닿는 토큰은 목록·생성처럼 Space가 필요한 호출에 workspaceId를 반드시 넘깁니다(없으면 400 API_WORKSPACE_REQUIRED). ID로 가리키는 대상은 동의한 Space 안에 있어야 하고, 밖이면 404입니다. GET /search에서 workspaceId를 생략하면 동의한 모든 Space를 검색합니다 |
| 관리 | 계정 설정 → 연결된 에이전트 앱에 앱 이름·범위·Space 목록·마지막 사용·해제 버튼. 해제하면 다음 호출부터 401이고, 12.6의 문서 패널 세션도 함께 끝납니다 |
| 한도 | 토큰(그랜트)당 한도는 2장과 같고, 앱 하나의 모든 호출을 합쳐 분당 3,000회를 더 둡니다. 넘으면 429와 Retry-After |
| 요청 | 범위 | 내용 |
|---|---|---|
GET /workspaces | workspaces:read | 이 토큰이 닿는 Space 목록 { workspaces: [{ id, name, role }] }. 개인 토큰이면 하나 |
12.2 링크 미리보기
| 요청 | 범위 | 내용 |
|---|---|---|
POST /links/resolve { urls: [] } (최대 20) | pages:read | ESTnote 주소(/#page=…&block=&view=, /embed.html#page=, /site/<slug>)를 { url, kind: 'page' | 'entry' | 'database' | 'site' | 'unknown', pageId?, workspaceId?, blockId?, viewId? }로. 읽을 수 없거나 동의한 Space 밖이면 unknown |
POST /pages/preview { pageIds: [] } (최대 20) | pages:read | { pages: [{ pageId, workspaceId, type, entry, title, icon, cover?, excerpt(160자), breadcrumbs: [{ pageId, title }], updatedAt, updatedBy: { id, name } | null, commentCount, properties?, revision }] }. 읽을 수 없는 페이지는 목록에서 빠집니다. properties는 데이터베이스 항목의 상태·사용자·날짜·선택 속성 최대 5개(값이 있는 것만) |
응답에는 ETag가 붙습니다. 같은 pageIds로 If-None-Match를 보내면 바뀐 것이 없을 때 304입니다. revision은 본문·메타데이터·속성·댓글 개정의 합입니다.
12.3 메시지 저장 (캡처)
Chat 앱의 ESTnote에 저장과 같은 규칙으로, 선택한 메시지와 출처만 저장합니다. 저장은 공개가 아니며 공유는 별도 단계입니다.
| 요청 | 범위 | 내용 |
|---|---|---|
POST /captures | pages:write (task는 databases:write도) | { mode: 'note' | 'append' | 'task', workspaceId, pageId?, databaseId?, title?, body?(markdown), source: { kind?: 'google-chat', messageId, spaceId?, threadId?, url?, author?, sentAt? }, properties?: { status?, assignee?: [사용자 id], due?: 'YYYY-MM-DD' } } → { pageId, url, created }. note는 pageId 아래(없으면 Space 최상위)에 새 페이지, append는 그 페이지 끝에 덧붙이기, task는 데이터베이스 항목입니다 |
GET /captures/locations?workspaceId= | pages:read | { recent: [{ kind, workspaceId, pageId | databaseId, title, usedAt }](최대 10), teamDefaults: [{ chatSpaceId, workspaceId, pageId?, databaseId?, mapping: { statusProperty, assigneeProperty, dueProperty } }] }. 최근 위치는 API 저장과 Chat 앱 저장을 합치고, 팀 기본값은 그 사람이 ESTnote 앱을 쓴 Chat 스페이스의 설정입니다 |
- 중복 방지: 같은 사람이 같은
source.messageId를 같은 위치(mode+Space+대상)에 다시 저장하면 새로 만들지 않고created: false로 기존 페이지를 돌려줍니다. - 출처: 페이지 본문 맨 앞(
append는 덧붙인 내용 뒤)에 인용 블록Google Chat 원문: 작성자 · 시각 (링크)을 넣고, 저장을 만든 캡처의source를GET /pages/:id의source로 돌려줍니다. - 속성 역할:
GET /databases/:id/schema의 각 속성에role?: 'status' \| 'assignee' \| 'due'가 붙습니다. Chat 스페이스의 팀 기본값이 이 데이터베이스를 가리키면 그 매핑, 없으면 그 사람의 이전 저장에서 쓴 매핑, 그것도 없으면 해당 유형의 속성이 하나뿐일 때 그 속성입니다.task의properties는 이 역할에 따라 쓰이며, 역할이 없는 속성을 넘기면 400입니다.
12.4 받은함
위임 받은함에는 토큰이 허용한 Space에서 현재 원본 대상을 볼 수 있는 알림만 포함됩니다. 페이지 권한·초대 대상 자격·팀스페이스 공개 범위를 다시 검사하며, 삭제되거나 확인할 수 없는 대상과 피드백 같은 계정 단위 알림은 제외합니다. 목록·개수·상태 변경 모두 같은 경계를 적용하며 범위 밖 알림 ID는 404로 처리합니다. 여기서 읽음 처리하면 ESTnote 받은함도 읽음이 됩니다. 일반 웹·모바일 받은함 동작은 유지합니다.
| 요청 | 범위 | 내용 |
|---|---|---|
GET /inbox?cursor=&limit=&unread=true&box=inbox|archive | inbox:read | { items: [{ id, kind: 'mention' | 'comment' | 'assignment' | 'automation' | 'team', title, message, workspaceId, pageId, blockId, actor: { id, name } | null, createdAt, read, archived, target }], nextCursor }. unread=true와 권한 검사를 페이지 구분 전에 적용합니다 |
GET /inbox/unread | inbox:read | { count } |
GET /inbox/preferences | inbox:read | 종류별 켬·끔. EST Chat은 이 설정을 따르고 자체 설정을 두지 않습니다 |
POST /inbox/:id/read { read? }, POST /inbox/:id/archive { archived? }, POST /inbox/read { until } | inbox:write | 응답 { unread }. until은 epoch 밀리초로, 그 시각까지 생긴 위임 범위 내 접근 가능한 항목을 읽음 처리 |
12.5 담당 작업
| 요청 | 범위 | 내용 |
|---|---|---|
GET /me/assignments?workspaceId=&status=open|all&cursor=&limit= | assignments:read, databases:read | { rows: [{ pageId, workspaceId, databaseId, databaseTitle, title, status?: { id, label, group: 'todo' | 'inprogress' | 'done' }, due?: { start, end? }, assignees: [{ id, name }], updatedAt, roles: { statusProperty?, dueProperty?, assigneeProperty } }], nextCursor } |
홈의 담당 작업과 같은 행을 동의한 모든 Space에서 모아, 기한 순(기한 없는 항목 뒤)으로 돌려줍니다. status=open(기본)은 상태 그룹이 done인 행을 뺍니다. 완료·상태 변경은 PATCH /pages/:id/properties로 하되 roles가 어느 속성을 바꿀지 알려 줍니다. 속성 변경은 웹훅 entry.properties_updated로 돌아옵니다.
12.6 문서 패널 (WebView2)
| 요청 | 범위 | 내용 |
|---|---|---|
POST /embed/code { pageId?, blockId?, viewId? } | session:embed | { code, expiresAt, sessionExpiresAt, url }. 연결된 앱의 토큰만 쓸 수 있습니다(개인 토큰은 403 API_GRANT_REQUIRED). url은 GET /api/auth/app/web-session?code=…&next=/embed.html#page=…이며, 데스크톱의 WebView2가 이 주소를 열면 그 사람의 세션 쿠키가 설정되고 임베드로 302합니다 |
세션은 7일 동안 유효하며 그랜트에 동의한 Space와 API 범위로 제한됩니다. session:embed는 패널 세션 발급만 허용합니다. 문서 조회에는 pages:read, 편집에는 pages:write, 댓글 작성에는 comments:write, 데이터베이스 조회·편집에는 각각 databases:read·databases:write도 필요합니다. HTTP 요청과 실시간 연결 모두 현재 동의 범위를 다시 검사하며, 패널 세션으로 계정 설정이나 토큰 발급·연결 관리를 할 수 없습니다. 그랜트를 해제하면 세션도 끝나 패널은 401을 받습니다. 코드는 1분 안에 한 번만 쓰입니다. embed.html의 브리지 메시지(shared/embed-bridge.ts)는 WebView2에서 window.chrome.webview.postMessage로 데스크톱에 전해지고, 데스크톱의 답은 chrome.webview의 message 이벤트로 옵니다. GET /api/config의 apps.windows.minVersion은 서버가 받아 주는 가장 오래된 데스크톱 버전이며, 데스크톱 요청에는 X-ESTnote-Client: windows/<버전>이 붙습니다. 데스크톱 자체의 앱 로그인(client=windows)은 두지 않았습니다. 자격은 EST Chat 서버 한 곳에만 있습니다.
12.7 Chat 카드 동작
ESTnote가 Chat에 보낸 카드의 버튼은 Google Chat 앱 런타임만 누를 수 있습니다. 데스크톱이 카드를 그대로 그렸다면 그 버튼의 onClick.action을 이 엔드포인트로 보냅니다.
| 요청 | 범위 | 내용 |
|---|---|---|
POST /chat/actions { space, message?, action: { function, parameters: [{ key, value }] }, formInputs?: { 이름: [값] }, dialog? } | chat:act | Chat 앱이 CARD_CLICKED로 받는 것과 같은 처리기를 토큰 주인을 행위자로 호출합니다. 응답은 Chat 앱이 돌려주는 것과 같습니다: 대화상자(actionResponse.type: 'DIALOG'), 카드, 또는 텍스트. formInputs를 보내면 대화상자 제출로 처리합니다 |
연결과 직접 지정하거나 저장된 동작 대상은 토큰이 허용한 ESTnote Space에 속해야 합니다. parameter key가 중복되면 400으로 거부하며, Space 연결 없는 전체 검색도 거부합니다. 그 사람이 ESTnote 앱을 실행한 적이 있는 스페이스(또는 앱과의 DM)여야 하며(403 CHAT_SPACE_REQUIRED), Chat 계정이 연결되지 않았으면 Chat에서처럼 연결 링크가 돌아옵니다. 승인·반려·읽음 확인·완료·댓글 등 Chat 앱의 모든 동작이 같은 검사로 수행됩니다. 서버에 Google Chat이 설정되지 않았으면 503 CHAT_NOT_CONFIGURED입니다.
12.8 웹훅 이벤트 추가
event | 뜻 | 본문 |
|---|---|---|
inbox.created | 받은함 항목 생성 | { event, deliveryId, userId, notificationId, kind, workspaceId, pageId, occurredAt } |
inbox.updated | 읽음·보관 변경 | 위와 같고 read, archived가 더 붙습니다 |
entry.properties_updated | 데이터베이스 항목의 속성 변경 | { event, deliveryId, workspaceId, pageId, databaseId, propertyIds, propertiesRevision, occurredAt } |
받은함 이벤트는 그 사람의 엔드포인트 중 원본 대상의 현재 Space와 일치하고 접근 가능한 곳으로만 갑니다. 큐 적재와 매 전송 시도 전에 원본 대상을 다시 검사하므로 삭제·권한 철회·다른 Space로 이동한 대상은 전달되지 않습니다. 계정 단위 알림과 소속을 확인할 수 없는 알림은 제외합니다. 속성 이벤트는 그 Space의 엔드포인트 중 소유자가 그 행을 읽을 수 있는 것으로 갑니다. 제목·내용·속성 값은 싣지 않습니다. 받은함 쓰기와 같은 트랜잭션에서 아웃박스에 들어가며, 30초마다 적재되고 6장의 서명·재시도 규칙을 그대로 따릅니다. 연결된 앱은 POST /webhooks에 workspaceId를 함께 넘겨 Space마다 등록합니다.