API reference
How an outside tool reads and writes pages, databases, properties and search with a personal access token, and receives change notifications through webhooks.
At a glance
| Item | Value |
|---|---|
| Base address | https://<service address>/api/v1 |
| Authentication | Authorization: Bearer estn_<token id>_<secret>, or a connected app's OAuth access token (section 12) |
| Request and response format | application/json (exports and attachment downloads are files; an attachment upload is raw bytes) |
| Request body limit | 4 MiB (10 MiB for a page attachment upload, 20 MiB for feedback submission) |
| Token scope | The one Space the token was made in, within the scopes chosen. A connected app's token reaches the several Spaces consented to |
| Cookies | Not read. A session cookie alone gets 401 |
| Time | Every timestamp is an integer of epoch milliseconds |
The API calls the same service layer as the app's screens. Page permissions, generation and revision checks apply exactly as they do for a person, so there is no path by which a token sees or writes more than its owner can.
Do not poll. Receive changes through webhooks. Reading databases or pages again every few seconds to find what changed keeps the server busy for nothing, and the load grows with every person using it. When you need to know what changed, subscribe to a webhook and read only the pages it tells you about. Where you cannot open an address for webhooks, call the changes feed (GET /changes) once every few minutes. When you must read the same thing again, sendIf-None-Matchand take a 304 when it has not changed (chapter 3).
1. Making a token
Tokens are issued only from the signed-in app. The API cannot make a token.
- Open the API tool on the developer tools page (
/dev#api) and pick the Space the token is for. Any signed-in user may open it. - Under API tokens, give a name (1–100 characters), choose the scopes needed and press Make token.
- The token value is shown once, then. The server keeps only a SHA-256 hash, so it cannot be shown again; a lost token is replaced by a new one.
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"] }
The token format is estn_<32 hex characters of id>_<43 base64url characters of secret>. Comparison is constant-time.
2. Scopes
A call outside the token's scopes gets 403 API_SCOPE_REQUIRED, and the message says which scope is needed.
| Scope | Name on screen | Used by |
|---|---|---|
pages:read | 페이지 읽기 (read pages) | GET /workspace, GET /pages*, reading content, exports, attachments and comments |
pages:write | 페이지 쓰기 (write pages) | POST /pages, PATCH /pages/:id, PUT /pages/:id/content, POST /pages/:id/assets |
databases:read | 데이터베이스 읽기 (read databases) | Reading schemas and properties, POST /databases/:id/query |
databases:write | 데이터베이스 쓰기 (write databases) | Making databases and entries, changing schemas and properties |
search:read | 검색 (search) | GET /search |
comments:write | 댓글 쓰기 (write comments) | POST /pages/:id/comments, POST /pages/:id/threads/:threadId/replies, the MCP add_comment tool (see MCP) |
webhooks:read | 웹훅 읽기 (read webhooks) | Webhook list and delivery records |
webhooks:write | 웹훅 관리 (manage webhooks) | Registering webhooks, changing their status, deleting them |
feedback:create | Submit bug reports and suggestions | POST /feedback/:kind; any token owner, without reviewer access |
feedback:read | 피드백 읽기 (검토자) (read feedback, reviewers) | Listing bug reports and suggestions and reading one. Only when the token's owner is a feedback reviewer (4.6) |
feedback:write | 피드백 답변·완료 표시 (검토자) (answer feedback and mark it done, reviewers) | Replying, marking done and reopening. Only when the token's owner is a feedback reviewer |
workspaces:read | 내 Space 목록 (list my Spaces) | GET /workspaces (12.1) |
inbox:read | 받은함 읽기 (read inbox) | GET /inbox* (12.4) |
inbox:write | 받은함 읽음·보관 (mark inbox read or archived) | Marking inbox items read or archived |
assignments:read | 담당 작업 읽기 (read assigned tasks) | GET /me/assignments (12.5) |
session:embed | 문서 패널 세션 (document panel session) | POST /embed/code (12.6), connected apps only |
chat:act | Chat 카드 동작 (Chat card actions) | POST /chat/actions (12.7) |
A token is bound to the Space named when it was made. Passing another workspaceId in a query gets 403 API_WORKSPACE_MISMATCH. The same holds when a page, database or webhook is named by id: even if the token's owner is a member of that other Space, its objects are treated as absent for this token, 404 (PAGE_NOT_FOUND, WEBHOOK_NOT_FOUND).
An expiry of 1 to 365 days can be set when making a token through /api/v2/tokens (the app screen has no expiry field, so its default is no expiry). A revoked or expired token, and the token of a user who has left the Space, gets 401 from the next call on.
3. Common rules
- Error format: every failure is
{ "error": { "code": "…", "message": "…" } }. Branch on the status code andcode. - Unknown fields are refused: a key in the body or query that this document does not name gets 400. Nothing is ignored leniently.
- Id format: an id in a path must match
[A-Za-z0-9_-]{1,256}, else 400INVALID_IDENTIFIER. - Empty answers: a call with nothing to return answers 204.
- Caching: every response carries
Cache-Control: no-store. - Conditional requests (since 2026-11-01): a body (
GET /pages/:id/content) and a database query (POST /databases/:id/query) answer with anETag. Send the same request withIf-None-Match: <that value>and, if the answer has not changed since, you get a 304 with no body. The server decides from the stored versions alone, without computing the answer again. Access is checked every time, so a target you may no longer read gets its usual error, not a 304. A database query never gets a 304 when that database, or one its relations reach, has a formula withnow()ortoday(), because its answer moves with the clock, nor when its relations reach more than 50 databases; and it is answered afresh at least every 10 minutes, so its next-page cursor stays alive. - List cursors:
limit(1–100, default 50) andcursor. Pass the response'snextCursorback as it is. A cursor is bound to the requesting account and scope, and a change at the head of the list is reported as 409 (PAGE_LIST_STALE,QUERY_STALE). A database query cursor expires after 15 minutes. - Server state: while the v2 storage is not active, 404
V2_NOT_ACTIVE; while shutting down, 503SERVER_SHUTTING_DOWN. - The public API does not require the content reset epoch header: there is no risk of a browser draft being re-uploaded on these paths.
4. Endpoints
The scope needed is in parentheses.
4.1 Space
GET /workspace (pages:read)
The Space the token works in and its scopes. Good for checking a token.
4.2 Pages
GET /pages (pages:read)
The top-level pages. Query: limit, cursor, workspaceId (must equal the token's Space).
{
"pages": [{
"id": "8a3c…", "workspaceId": "b9f1…", "type": "document", "isDatabaseEntry": false,
"isTemplate": false, "title": "Release notes", "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 is viewer, editor, manager or owner; capabilities holds true or false for read, edit, propertiesEdit, manage, share, schemaEdit, historyRead, historyRestore, trash, restore and purge. A parent that cannot be read is hidden as parent: null. shared is true when someone other than the owner can open the page; a sub-page that inherits its permissions takes the value of the page it inherits from.
GET /pages/:pageId (pages:read)
One page in the same shape. Without read permission the answer is 404 PAGE_NOT_FOUND, and nothing says whether the page exists.
GET /pages/:pageId/children (pages:read)
The sub-pages. Query: limit, cursor.
GET /pages/:pageId/content?format=json|markdown (pages:read)
format=json(default):{ pageId, title, document }—documentis the body's raw blocks (a ProseMirror document).format=markdown:{ pageId, title, markdown }— links and attachment addresses are carried as stored. A page mention is written@페이지and a user mention@사용자, so no person's or page's name leaks through this path.- Any other value is 400.
GET /pages/:pageId/export?format=&scope= (pages:read)
The same file the app's export makes. The response is file bytes, not JSON, with a Content-Type for the format.
| Query | Values | Default |
|---|---|---|
format | markdown · html · archive | markdown |
scope | page · tree | page |
markdown+page and html are one file; the rest are a .zip. A database exported with page includes its rows with their property values, but not their bodies. Up to 2,000 pages and 64 MiB at once; beyond that, 413 EXPORT_TOO_LARGE.
POST /pages (pages:write)
{ "parentId": "8a3c…", "title": "Release notes", "markdown": "# Introduction\n\n- one\n- two\n" }
workspaceId may be passed but must equal the token's Space. Without parentId the page is made at the top level. The answer is the new page's metadata.
PATCH /pages/:pageId (pages:write)
{ "title": "Release notes v2" } — changes the title only. The answer is the changed page's metadata.
PUT /pages/:pageId/content (pages:write)
{ "markdown": "A whole new body\n" } — replaces the body as a whole. The resulting body is exactly the Markdown sent, but it is written as the difference from the current body (since 2026-11-06): blocks and text that stay are left alone and only what changed is deleted and inserted, so rewriting the same page often does not make it grow, and unchanged blocks keep their IDs and comments. That one update goes down the ordinary save path, so validation, permissions, the generation check and realtime delivery all apply. The body replaced is the page as it is when the request's turn comes, so edits that arrived before it are replaced too; people with the page open stay connected and see the new body. There is no endpoint for a partial edit.
When the body already reads as the same Markdown, nothing is written (since 2026-11-01): changed is false and revision stays as it was. Open pages are not told and no history is added. Formatting that Markdown cannot express stays, as long as the body reads as the same Markdown. A sync script that puts the same content back on a schedule no longer makes the page grow, but writing only when something changed is still better.
{ "pageId": "8a3c…", "revision": 13, "generation": 1, "changed": true }
What the Markdown covers: headings (# to ######), lists and checkboxes, quotes, code fences, dividers, tables, images, inline formatting and links. On write, links that are not web addresses (http(s), mailto:) and attachment paths that cannot be resolved outside the app are dropped. At most 20,000 blocks at once.
POST /pages/:pageId/assets?name=<file name> (pages:write)
The body is the file's raw bytes and Content-Type is the file's type (not JSON; up to 10 MiB). The same attachment service as the app is used, so the upload is leased as the token owner's draft, bound to the page's current body generation. Within that generation, once the body (the response's url as a link or image in the Markdown of PUT …/content) or a files property (the response's id in PATCH …/properties) points at it, the upload becomes the page's attachment; if nothing points at it, it is deleted after a grace period.
{ "id": "3f1c…", "pageId": "8a3c…", "url": "/api/v2/pages/8a3c…/assets/3f1c…", "name": "note.txt", "type": "text/plain", "size": 13, "createdAt": 1758… }
url is the app's address. To download through the API, use the path below.
GET /pages/:pageId/assets/:assetId (pages:read)
The attachment's bytes with Content-Type and Content-Disposition: attachment. Only the page's attachments (those the body, comments, files properties or cover point at) and the token owner's own leased drafts can be read. Anything else is 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": "Please add the amount." }] }] }
text is the comment's plain text (formatting, mentions and attachments are in the app). A deleted comment has deleted: true and text: null.
POST /pages/:pageId/comments (comments:write)
{ "text": "Please add the amount.", "quote": "budget" } — a new comment thread. The same rules as the MCP add_comment tool: quote is the exact body text the comment is about and must occur once within one block (400 when absent or repeated). Without quote the comment is attached to the first block that has text. text is 1–4000 characters of plain text; line breaks become paragraphs. Answer: { pageId, threadId, blockId }.
POST /pages/:pageId/threads/:threadId/replies (comments:write)
{ "text": "Confirmed." } — a reply in that thread. Answer: { pageId, threadId, commentId }. A thread that is not this page's gets 404 COMMENT_THREAD_NOT_FOUND.
4.3 Search
GET /search?q=&limit=&workspaceId=&kinds=&createdBy=&updatedFrom=&updatedTo= (search:read)
{
"hits": [{
"pageId": "8a3c…", "workspaceId": "b9f1…", "type": "document", "entry": false, "title": "Release notes v2",
"icon": {}, "matches": ["title"], "snippet": null, "parent": null
}],
"hasMore": false
}
q is up to 200 characters and limit up to 100 (raised from 50 on 2026-09-25). Results are always pages, and matches says why a page came up: title, property, body, comment or attachment. A body, comment or attachment match carries a 160-character excerpt (snippet); a result matched on title or properties only has snippet: null. comment and attachment were added on 2026-09-20 — a client that treated matches as a closed set must be changed to ignore values it does not know.
- A one-character query is looked for in titles and properties only. Bodies, comments and attachments are searched from two characters (since 2026-10-23). A query with spaces is found as that phrase.
- What a two-character query is looked for in depends on its characters (since 2026-10-25): two holding Korean in bodies, comments and attachments inside words too; two Latin letters or digits only as a word of their own (
aifinds "AI를" and "(AI)", not "train"); any other two in titles and properties only. - A two-character body search looks through bodies, comments and attachments for up to half a second (since 2026-10-25). When it could not look through every page of the Spaces searched, the answer carries
"incomplete": true. Three characters or more search every page through the index, so they do not carry the field. The MCPsearchtool returns the same field. - Properties are found not only by typed text but by option labels (select, status, multi-select) and by the names of people in person properties.
- Attachments are found by file name, by the first 256 KB of text-like files (
text/*, Markdown, CSV, JSON), and by PDF metadata (title, author, subject, keywords). The bodies of PDFs and Office documents are not searched. An attachment detached from its page does not come up. - Every filter is optional.
kindsis a comma-joined list ofdocument,databaseandentry(a database entry);createdByis a user id;updatedFromandupdatedTobound the last change time (epoch milliseconds, both ends inclusive).entrysays whether a result is a database entry. - The order is: pages whose title matched, then the token user's favourites, then pages opened within the last 30 days; within a tier, the most recently changed first.
4.4 Databases
POST /databases (databases:write)
{ "parentId": "…", "title": "Tasks" } — makes a database page. The answer is page metadata with type: "database".
GET /databases/:databaseId/schema (databases:read)
{
"schemaVersion": 3,
"description": "",
"properties": [
{ "id": "p_title", "name": "Name", "type": "title", "position": 0, "version": 1, "archived": false, "options": [] },
{ "id": "p_state", "name": "Status", "type": "select", "position": 1, "version": 2, "archived": false,
"options": [{ "id": "o_1", "label": "In progress", "color": null, "position": 0, "version": 1, "archived": false }] }
]
}
Property types are title, text, url, number, checkbox, date, select, status, multi_select, person, files, relation, place, created_time, edited_time, created_by, formula and rollup.
PATCH /databases/:databaseId/schema (databases:write)
{ "expectedVersion": 3, "command": { "kind": "create", "name": "Status", "type": "select" } }
When expectedVersion differs from the current schemaVersion, 409 DATABASE_REVISION_CONFLICT. The answer is { "changedId": "p_state", "schemaVersion": 4 }.
Two (undeleted) properties of a database cannot share a name, nor two options of a property a label; surrounding spaces and case are ignored. A clash is 409 PROPERTY_NAME_TAKEN for create and rename, and 409 OPTION_LABEL_TAKEN for addOption and renameOption. Names that already clashed before this rule are left as they are.
command.kind | Fields |
|---|---|
create | name, type, targetDatabaseId?, cardinality? (one · many). A formula takes expression, timeZone?. A rollup takes rollup: { relationPropertyId, targetPropertyId, aggregate } |
rename | propertyId, name |
archive | propertyId |
configure | propertyId, settings (number format { format, decimals?, currency? } · status groups · formula { expression, timeZone?, numberFormat? } · rollup { relationPropertyId, targetPropertyId, aggregate, numberFormat? } · only the format of a formula or rollup { numberFormat }) |
reorder | propertyIds |
addOption | propertyId, label, color? (gray · brown · orange · yellow · green · blue · purple · pink · red; without one, colours are dealt in turn) |
renameOption | propertyId, optionId, label |
recolorOption | propertyId, optionId, color (one of the above, or null) |
archiveOption | propertyId, optionId |
reorderOptions | propertyId, optionIds |
describe | description |
An operation that is not supported, such as changing a property's type, gets 400 SCHEMA_OPERATION_UNSUPPORTED. A rollup's aggregate is one of show, count, count_values, count_empty, count_unique, sum, average, median, min, max, range, checked, unchecked, percent_checked, earliest and latest; one that does not suit the related property's type gets 400 INVALID_ROLLUP.
POST /databases/:databaseId/entries (databases:write)
{ "title": "Design", "properties": { "Status": "In progress" }, "markdown": "One line of body" }
The answer is page metadata with properties added. The rules for writing property values are in section 5.
POST /databases/:databaseId/query (databases:read)
A view configuration used as the query.
{
"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.versionis 1. Filter operators areequals,not_equals,contains,gt,gte,lt,lte,is_empty,is_not_emptyandoverlaps. They group underallandany, nested up to 8 levels and 100 nodes.- Person and created-by filters accept
"@me", which resolves to the calling token's owner. - Built-in keys need no property:
"@created_by"(who made the row) and"@created_time"(when) go in filters, sorts andvisibleProperties, and"@edited_time"(last edited) in sorts only (from 2026-11-03). A@created_byfilter takes a member id or"@me",@created_timetakes milliseconds, and@created_bysorts by name. Listed invisibleProperties, they come in each row'spropertiesas{ "definition": { "id": "@created_by", "name": "Created by", "type": "created_by" }, "value": { "id": …, "name": … } }and{ "definition": { "id": "@created_time", …, "type": "created_time" }, "value": milliseconds }(named in the token owner's language), and they are not in the schema (GET /databases/:id/schema). Rows with no sort, or with equal sort values, come in the order they were made (oldest first). Withfeedsettings and no sorts, the newest rows come first. - Query conditions take no names:
propertyIdmust be a current property id of that database, and thevalueof a select, person or relation filter must be an id too (names are accepted only when writing property values). limitis 1–100 (default 50). The answer is{ rows, nextCursor }; withgroupByor view settings it also carriessummaries,boardGroupsandchart.- With table settings (
table),groupByalso takes a relation, person, multi-select or date property. Dates fall in periods oftable.dateUnit(day,week,month,quarteroryear;weekby default), each named by its first day. A row with several values counts once for each of them insummariesand comes once inrows: itsgroupKeyis its first value's group andgroupKeyslists the keys of all its groups. A row with no value is in thenullgroup.table.groupOrder,hiddenGroups,showEmptyGroupsandgroupSortonly set how the screen shows groups; they do not change this query's rows or summaries. - Invalid configuration is 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": "Name", "type": "title" }, "value": "Design" },
{ "definition": { "id": "p_state", "name": "Status", "type": "select" }, "value": { "id": "o_1", "label": "In progress" } }
]
}
When a formula cannot be worked out for this entry (it reads a deleted property, or a value of the wrong kind), that property's value is null and error says why. A property that was worked out has no error. Query rows[].properties are the same.
PATCH /pages/:pageId/properties (databases:write)
{ "properties": { "Status": "Done", "Assignee": ["u_1"], "Due": { "kind": "date", "start": "2026-10-01" } } }
Writing to a page that is not a database entry gets 400 PAGE_NOT_AN_ENTRY. The answer has the shape of the read above. The server fills the revision check from the current value it has just read, so the caller need not send propertiesRevision. Up to 1,000 properties at once, and no property twice.
4.5 Webhooks
| Request | Scope | What it does |
|---|---|---|
GET /webhooks | webhooks:read | The endpoints the token owner registered in this Space (up to 100) |
GET /webhooks/:webhookId/deliveries | webhooks:read | The last 50 delivery records |
POST /webhooks | webhooks:write | { url, events } — the signing key (secret) is in this response only |
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"]}'
The delivery rules are in section 6.
4.6 Feedback (bug reports and suggestions)
Bug reports and suggestions belong to no Space. :kind is bug or suggestion. A token with feedback:create can submit either as its owner; reviewer access is not required. Listing, reading, replying and resolving still require both the corresponding scope and a feedback reviewer as the token owner, otherwise they return 403 FEEDBACK_REVIEWER_REQUIRED. Existing tokens need the new scope to submit.
| Request | Scope | What it does |
|---|---|---|
POST /feedback/:kind | feedback:create | Submit a bug report or suggestion. 201 { id, createdAt } |
GET /feedback/:kind?status=&limit=&offset= | feedback:read | The list. status is open (default), resolved or all; limit is 1–100 (default 50). Response { items, total, counts: { open, resolved } } |
GET /feedback/:kind/:id | feedback:read | One item: title, description, expected effect (reproduction steps for a bug), reporter, attachment names, comments, and the done mark |
POST /feedback/:kind/:id/comments | feedback:write | { text } (1–4,000 characters) — a reply under that item. 201 |
PUT /feedback/:kind/:id/resolution | feedback:write | { resolved: true | false } — marks it done or opens it again. Response { resolution } (null while open) |
Submission uses the same validation as the browser form. Suggestions require title (1–200 characters) and description (1–10,000), with optional expected (up to 5,000). Bugs require title (1–200), description (1–4,000), context and logs (an empty array is allowed); steps (up to 4,000) and expected (up to 2,000) are optional. Bug context contains capturedAt (ISO timestamp), url, userAgent, language, timezone, viewport: { width, height, devicePixelRatio, scrollX, scrollY }, screen: { width, height }, online and buildId, describing the client where the problem occurred. Bugs also require either screenshotDataUrl (base64 PNG, up to 4 MB) or screenshotError (why a screenshot is unavailable). Suggestions may omit both. Each log has at, kind (error, warn, network or action), message and optional scalar-valued details; up to 100 entries. Credentials in bug diagnostics are redacted by the same validator as the browser form.
Both kinds accept attachments: [{ name, base64 }]: at most 5 files, 5 MB per file, 10 MB total. The JSON body limit is 20 MB including base64. Unknown fields, including caller-supplied reporter identities, are rejected. The reporter is always the authenticated token owner, and submissions appear in the app's My reports list. Authentication uses Authorization: Bearer …; cookies and CSRF tokens are not needed. feedback:create does not grant review access.
curl -sS https://estnote.example.com/api/v1/feedback/suggestion -X POST \
-H "Authorization: Bearer $ESTNOTE_TOKEN" -H 'Content-Type: application/json' \
-d '{"title":"Keep expanded branches","description":"Keep the sidebar expanded after deleting a child.","expected":"Continue without reopening the tree."}'
# bug-report.json contains the bug fields described above, including context and 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
A reply and a done mark are the same as one made on the app's review screen. The reply is left in the name of the team (개발팀) and the reporter is notified. The done mark names the state wanted in the body, so sending the same request again does not flip it. An item that does not exist is 404 FEEDBACK_NOT_FOUND.
# Open suggestions → a reply → the done mark
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 Changes feed
GET /changes?cursor=&limit= (pages:read)
For a client that cannot receive webhooks (since 2026-11-01). Instead of reading databases and pages again one by one, ask this once what changed and read only the pages that did.
{
"changes": [{ "pageId": "8a3c…", "workspaceId": "b9f1…", "kinds": ["page.content_updated", "entry.properties_updated"], "changedAt": 1761955200000 }],
"cursor": "c1.18234.912",
"hasMore": false
}
- Call it first without
cursor. You get the current position and no changes. Then read what you need once, and from then on pass thecursoryou were given to get only what changed after it. - One row per page;
kindsare the event names of 6.1. There are no inbox events.entry.properties_updatedappears only when the token hasdatabases:read. - Only pages in the Spaces the token reaches that its owner may read now. A page moved to the trash appears only on the terms a webhook uses.
limitis 1–500 pages (default 100). WhenhasMoreistrue, call again at once; whenfalse, call again in a few minutes. Calling every few seconds is polling.- A malformed cursor is 400
INVALID_CHANGES_CURSOR. Property changes are kept for 7 days, so an older cursor is 410CHANGES_CURSOR_EXPIRED, as is one from before the server's content was reset: read your lists again and start over without a cursor. - Changes it does not report: a page created and never edited (it appears on its first edit), database schema and view changes, sharing-only changes, and permanent deletion. Deleting a page for good deletes its change records too, so a page deleted for good before you saw it go to the trash does not appear. If those matter, read the full lists again now and then (say, once a day) and drop the pages that are gone.
5. Rules for writing property values
The same rules apply to the properties object of POST /databases/:id/entries and PATCH /pages/:id/properties.
- A key may be a property name or a property id.
selectandstatustake one option label (or option id);multi_selecttakes an array of them. The server turns a label into an id against the current option list.textandurltake a string (up to 100,000 characters;urlonlyhttp,httpsandmailto);numbera finite number;checkboxtrue or false.dateis{ "kind": "date" | "datetime", "start": "…", "end"?: "…", "timeZone"?: "…" };placeis{ "name": …, "address"?: …, "latitude"?: …, "longitude"?: … }.personis an array of active member ids;relationan array of entry page ids to link. A relation of cardinalityonetakes one; a target must be an entry of the database the relation points at and readable by the token owner.- To clear a value, send
null. - The title is not written as a property. Use the
titlefield when creating andPATCH /pages/:pageIdlater. Writingtitle,created_time,edited_timeorcreated_bygets 400PROPERTY_READ_ONLY;formulaandrollupare computed and get the same 400PROPERTY_READ_ONLY. - The attachment (
files) property is not handled through the public API. - A missing property is 400
PROPERTY_NOT_FOUND, a missing option 400PROPERTY_OPTION_NOT_FOUND, and nothing is changed.
6. Webhooks
When you need to know what changed, use webhooks, not polling. Querying a database or reading pages again every few seconds makes the server work every time even when nothing changed, and as such clients add up everyone slows down. A webhook tells you only about pages that changed, usually within 30 seconds of the change. Read only those pages through the API. If you cannot open an address for webhooks, use the changes feed every few minutes.
6.1 Changes you can receive
event | Meaning |
|---|---|
page.content_updated | The body changed |
page.comments_updated | Comments changed |
page.metadata_updated | Title or display changed |
page.lifecycle_updated | Moved, trashed or restored |
entry.properties_updated | A database entry's properties changed (12.8) |
inbox.created · inbox.updated | An inbox notice was made · read or archived (12.8) |
6.2 The delivery request
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
}
- The body is not sent. Only which page of which Space changed, when, and to which revision. To see the content, read the page through the API, where permissions are checked again.
X-Estnote-Deliveryis the delivery's id and stays the same across retries; use it as the key against duplicate handling.deliveryIdin the body is the change outbox's event id, the same for one change sent to several endpoints.
6.3 Checking the signature
X-Estnote-Signature is t=<milliseconds>,v1=<hex of HMAC-SHA256(signing key, "<t>.<raw body>")>. The signing key is shown once, at registration.
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)
}
Re-serialising the body as JSON may change its bytes, so compute over the raw body as received. It is safer to treat a t that is far too old as a replay and refuse it.
6.4 Queueing, retries and pausing
- Every 30 seconds the page change outbox is scanned by cursor, up to 200 changes are put into the delivery queue, and deliveries go out separately, up to 20 at a time. The cursor keeps one change from being queued twice.
- Deliveries are limited to pages the endpoint's creator can read now. Changes to a page they lost access to are not queued, and kinds they did not subscribe to are filtered out.
- The request timeout is 10 seconds and redirects are not followed. Only 2xx counts as success.
- On failure the delivery is retried after 30 seconds, 1, 2, 4, 8 minutes and so on (up to 1 hour), 6 times at most; after that the endpoint is set to
paused. A paused endpoint gets no new changes queued, and can be turned on again from the app or withPATCH /webhooks/:id.
6.5 Address rules
Only https. An address with credentials, a private or loopback IP literal (10.*, 127.*, 0.*, 172.16–31.*, 192.168.*, 169.254.*, 100.64–127.*, ::, ::1, fc*, fd*, fe80*, and IPv4-mapped or NAT64 addresses holding these) and localhost are 400 INVALID_WEBHOOK_URL. A public name that resolves to a private address is blocked when sending, not at registration: if any resolved address is private, that send is recorded as failed, and the connection is made only to the very address that was checked. Redirects are not followed.
7. Error codes
| Status | code | When |
|---|---|---|
| 400 | INVALID_REQUEST, INVALID_IDENTIFIER, INVALID_JSON | Format, fields or a path id break a rule |
| 400 | PROPERTY_NOT_FOUND, PROPERTY_OPTION_NOT_FOUND, PROPERTY_READ_ONLY, PROPERTY_TYPE_UNSUPPORTED, INVALID_PROPERTY_VALUE | A property value write or a view configuration failed validation |
| 400 | INVALID_QUERY_INPUT, INVALID_QUERY_LIMIT, INVALID_QUERY_CURSOR | Database query conditions |
| 400 | INVALID_PAGE_LIST_QUERY, INVALID_PAGE_CURSOR, INVALID_PAGE_SEARCH_QUERY | List and search conditions |
| 400 | SCHEMA_OPERATION_UNSUPPORTED, TITLE_PROPERTY_REQUIRED | An unsupported schema change |
| 400 | INVALID_WEBHOOK_URL, INVALID_WEBHOOK_EVENTS | Webhook registration values |
| 400 | PAGE_NOT_AN_ENTRY | Property change on a page that is not a database entry |
| 401 | API_TOKEN_INVALID | No header, wrong format, wrong secret, revoked, expired, or the owner left the Space |
| 403 | API_SCOPE_REQUIRED | The token lacks that scope |
| 429 | API_RATE_LIMITED | Too many requests. Wait Retry-After seconds and try again |
| 400 | INVALID_CHANGES_CURSOR | The changes feed cursor is malformed |
| 410 | CHANGES_CURSOR_EXPIRED | The changes feed cursor has expired (older than 7 days). Read your lists again and start over without a cursor |
| 403 | API_WORKSPACE_MISMATCH | Another Space named |
| 400 | API_WORKSPACE_REQUIRED | A connected app's token reaching several Spaces made a call that needs a Space without workspaceId |
| 403 | API_GRANT_REQUIRED | A personal token made a call reserved for connected apps (POST /embed/code) |
| 403 | CHAT_SPACE_REQUIRED, CHAT_DESTINATION_DENIED | The person never used ESTnote in that Chat space · no edit permission on the save destination |
| 400 | CHAT_DATABASE_REQUIRED, CHAT_PAGE_REQUIRED | A capture's destination is not a database / a document |
| 503 | CHAT_NOT_CONFIGURED | Google Chat is not configured on the server |
| 403 | PAGE_EDIT_REQUIRED, PROPERTIES_EDIT_REQUIRED, DATABASE_SCHEMA_EDIT_REQUIRED | Readable, but no permission to change |
| 404 | PAGE_NOT_FOUND, DATABASE_NOT_FOUND, API_TOKEN_NOT_FOUND, WEBHOOK_NOT_FOUND | Absent, or no permission to see (existence is not revealed) |
| 404 | NOT_FOUND, V2_NOT_ACTIVE | No such path · public API not active |
| 409 | CONTENT_REVISION_CONFLICT, CONTENT_GENERATION_CHANGED, DATABASE_REVISION_CONFLICT | Someone changed it first → read again and retry |
| 409 | PAGE_LIST_STALE, QUERY_STALE | The cursor's basis changed → start from the first page |
| 409 | PROPERTY_NAME_TAKEN, OPTION_LABEL_TAKEN | A property or option of that name already exists → use another name |
| 413 | EXPORT_TOO_LARGE, PAYLOAD_TOO_LARGE | Export limit or request body limit exceeded |
| 403 | SIGNUP_ADMIN_REQUIRED | The sign-up allowlist called by a non-admin |
| 400 | INVALID_SIGNUP_ENTRY | A sign-up allowlist entry's input (including our own domains and public mail domains) |
| 409 | SIGNUP_ENTRY_EXISTS | A live allowlist entry for the same target already exists |
| 404 | SIGNUP_ENTRY_NOT_FOUND | Removing an allowlist entry that is not there |
| 403 | ACCOUNT_DELETION_PENDING | A request from an account scheduled for deletion. Only /api/auth/account* and sign-out are allowed |
| 409 | SPACES_MUST_BE_TRANSFERRED, LAST_ADMIN | A deletion request while owning a Space with other members · deleting the last admin |
| 404 | USER_NOT_FOUND, DELETION_NOT_PENDING | No such (or already deleted) user · no scheduled deletion to cancel |
| 503 | SERVER_SHUTTING_DOWN | The server is shutting down |
| 500 | INTERNAL_ERROR | Any other failure |
8. Management endpoints (/api/v2)
Tokens are issued and revoked with a signed-in session only (cookie + X-CSRF-Token). There is no way to make a token with a token. Webhooks are managed from both the app and the public API.
| Request | What it does |
|---|---|
GET /api/v2/tokens?workspaceId= | My tokens |
POST /api/v2/tokens | { workspaceId, name, scopes, expiresInDays? } — token is in this response only |
DELETE /api/v2/tokens/:tokenId | Revoke (cannot be undone) |
GET /api/v2/webhooks?workspaceId= | My webhooks |
POST /api/v2/webhooks | { workspaceId, url, events } — secret is in this response only |
GET /api/v2/webhooks/:id/deliveries | Delivery records |
PATCH /api/v2/webhooks/:id | { status } |
DELETE /api/v2/webhooks/:id | Delete |
GET /api/v2/signup-allowlist | The sign-up allowlist (admins only), each entry with its status and who signed up through it (with userId) |
POST /api/v2/signup-allowlist | { kind: 'email' | 'domain', target, note?, expiresInDays?, sendMail? } — expiry and mail apply to email entries only. The answer carries mailed |
DELETE /api/v2/signup-allowlist/:entryId | Remove. Only new sign-ups are blocked; accounts already made remain |
GET /api/v2/users?q=&filter=&sort=&limit=&offset= | Every account (admins only). filter: all (accounts not deleted), organization, external, deletion_pending, deleted; sort: last_seen, created, name; 50 per page (at most 100). Answers { items, total, counts } |
GET /api/v2/users/:userId | One account (admins only): joined, last sign-in and last activity, how it came in, Spaces, sessions, connections, content counts, deletion state |
GET /api/v2/users/:userId/usage | Storage (admins only, worked out only on request). { uploaded: { files, bytes }, ownedSpaces: { spaces, fileBytes, contentBytes, versionBytes } } — uploads count each stored file once; owned Spaces count everything in them, by any member |
DELETE /api/v2/users/:userId/sessions | Sign out everywhere (admins only). Answers { revoked }; your own account is 409 CANNOT_SIGN_OUT_SELF |
POST /api/v2/users/:userId/deletion | Request deletion (runs after 7 days). With { now: true } it runs at once and answers a summary |
DELETE /api/v2/users/:userId/deletion | Cancel the schedule |
GET /api/v2/dashboard | The server dashboard (admins only): server, backups, queues, the last 60 minutes of API requests, unexpected errors, job failures, people and content counts, and a level per item (items, levels). Numbers and server details only, never a person or a page |
POST /api/v2/dashboard/storage | Works out the server-wide storage now (admins only; seconds on a cold cache). Answers { fileBytes, contentBytes, versionBytes, at } and keeps it with today's counts |
GET /api/v2/dashboard/trends?days=30|90 | Daily trends (admins only). { days: [{ day, active, accounts, newAccounts, pages, views, siteViews, errors }] }; a day with nothing kept is null. Any other value is 400 INVALID_DAYS |
8.1 Google Drive (/api/v2)
Called with a signed-in session only. Drive is reached through the requesting person's own Google connection, so only the files they picked in the file chooser and the files ESTnote made are handled. A public API token cannot call these.
| Request | What it does |
|---|---|
GET /api/v2/drive/status | My Drive connection: { state: 'connected' | 'reconnect' | 'unavailable' | 'unconfigured' | 'unsupported', email?, message? } |
POST /api/v2/drive/files/resolve | { fileIds, picked? } — each file's name and kind, and whether it can be imported or attached. picked: true means the files were just chosen; one that cannot be read answers DRIVE_ACCOUNT_MISMATCH |
POST /api/v2/imports/google-drive | { key, workspaceId, parentPageId?, fileIds } — fetches the files and prepares an import. The answer is the import plan; what follows is /api/v2/imports/:id/apply · cancel · undo |
POST /api/v2/pages/:pageId/drive/attach | { key, generation, fileId } — copies a Drive file in as a page attachment. A Google document is saved as PDF |
GET /api/v2/pages/:pageId/drive/exports | The copies I exported from this page to Drive, and the recent jobs |
POST /api/v2/pages/:pageId/drive/exports | { key, format, scope, target: 'new' | 'update', resourceId?, force? } — registers an export job. Answers 202 and the job |
GET /api/v2/drive/exports/:jobId | The job's state: status: 'pending' | 'running' | 'done' | 'failed' | 'conflict', and once finished fileName, url |
format is one of google-doc, markdown, html and archive. target: 'update' accepts only a copy (resourceId) exported with the same format and scope. If the file changed in Drive since the last export, the job ends as conflict (DRIVE_REMOTE_CHANGED); asking again with force: true overwrites.
| Error code | Status | Meaning |
|---|---|---|
DRIVE_RECONNECT_REQUIRED | 403 | No Drive permission, or the connection expired. Connect again at /api/auth/google?integration=drive |
DRIVE_ACCOUNT_MISMATCH | 409 | A file picked with a Google account other than the signed-in one |
DRIVE_FILE_NOT_FOUND | 404 | The file is missing, in the trash, or was never opened to this app |
DRIVE_FORBIDDEN | 403 | Google refused for file permissions or an organisation policy |
DRIVE_NOT_IMPORTABLE | 400 | A kind that cannot become a page or an attachment |
DRIVE_TOO_LARGE | 413 | Over the conversion limit (10 MB) or the attachment limit (10 MiB) |
DRIVE_QUOTA_EXCEEDED | 403 | Drive storage is full |
DRIVE_API_DISABLED | 503 | The Google Drive API is off in the Cloud project |
DRIVE_UNAVAILABLE | 503 | A passing Drive outage or a usage limit. Export jobs retry by themselves |
MCP — agent connections
To let an agent app such as Claude work in this workspace, use the MCP endpoint, not the REST API. Its tools are a second face of the same services, called with the same permission checks as the REST API above.
- Address:
https://<our address>/mcp— Streamable HTTP, stateless. MCP 2026-07-28 and 2025-11-25 clients connect to the same address. There is no stdio server. - A Space admin must turn it on first. Space settings → Agents →
에이전트 연결(MCP) 허용(allow agent connection). Off by default; a call towards a Space where it is off is refused with the JSON-RPC errorMCP_DISABLED(not HTTP 403, because some clients retry authentication on a 403). Write tools appear only once the second switch,에이전트의 쓰기 허용(allow agents to write, off by default), is on.
Two ways to connect
| Who | How | |
|---|---|---|
| OAuth (recommended) | Claude.ai, desktop, mobile, Claude Code and the like | Put the address into the add-connector screen. The app leads through Google sign-in to a consent screen (one Space and a choice of permissions) |
| API token | Developers who set headers themselves | Authorization: Bearer estn_…. For example: claude mcp add --transport http estnote https://<address>/mcp --header "Authorization: Bearer estn_…" |
Do not put an API token in as an organisation-wide header. Claude's request header authentication (static_headers) is a credential an organisation admin enters once and the whole organisation shares. Our token is one person's permission; put in that way, everyone in the organisation would read the workspace with that one person's rights. Connections used by several people should be OAuth.
Facts about the OAuth side:
- The
WWW-Authenticateof a request without a token names every read and write scope inscope=(since 2026-09-23; before that only the read scopes were named and a client had no way to ask for write). On the consent screen write is off by default, and even when turned on it works only in Spaces where에이전트의 쓰기 허용is on. - A call towards a Space where the connection is off is refused and recorded in the agent activity as
거부됨(refused). - Client registration accepts a Client ID Metadata Document (CIMD) only.
client_idmust be that document's HTTPS address; there is no dynamic registration (/register). The metadata advertisesclient_id_metadata_document_supported: truetogether withtoken_endpoint_auth_methods_supported: ["none"]. - PKCE (S256) and
resourceare required. Withresource= our/mcpaddress the issued tokens work on/mcponly and get 401 on/api/v1. Withresource= our/apiaddress the grant is a connected app's and its tokens work on both/api/v1and/mcp(12.1). The metadata'stoken_endpoint_auth_methods_supportedisnoneandprivate_key_jwt(since 2026-10-03). - Access tokens last 1 hour, refresh tokens 90 days. A refresh token changes every time it is used, and a refresh token or authorisation code that comes again after being used cuts off that whole connection (
invalid_grant). - Loopback redirects (
http://localhost/…,http://127.0.0.1/…) are matched with the port ignored, and the consent screen shows a warning. - A request without a token is always 401 +
WWW-Authenticate: Bearer resource_metadata="…", scope="…". The discovery documents are at/.well-known/oauth-protected-resource(and its/mcpsuffix variant) and/.well-known/oauth-authorization-server. - Clients running in a browser are not accepted:
/mcprefuses anOriginheader that is not ours with 403 (server and desktop clients send noOrigin).
Tools
tools/list shows only the tools the connection's scopes and the Space's write switch allow. A read-only connection does not even learn that write tools exist.
| Tool | Scope | What it does |
|---|---|---|
search | search:read | Full search. Results are pages and matches says why. Up to 20 |
get_page | pages:read | Metadata + body Markdown + properties (for an entry) + sub-page titles. The body is cut at 200 KB and continued with nextOffset |
list_pages | pages:read | The top-level pages, or the children of parentId |
get_database | databases:read | Properties (id, type, options) and saved views |
query_database | databases:read | Filter, sort, cursor. The same format as POST /databases/:id/query above; up to 100 rows |
create_page | pages:write (and databases:write for an entry) | A page, or an entry when databaseId is given. Properties may be named by name and label |
edit_page | pages:write | Partial edits of the body (below). destructiveHint |
update_page | pages:write / databases:write | Title / properties |
add_comment | comments:write | A comment; quote says where it goes |
trash_page | pages:write | Moves a page to Trash with its sub-pages (a database with its entries). Up to 100 pages at once; the connected person needs permission to trash the page. It can be restored from Trash; there is no tool for permanent deletion. destructiveHint |
The three actions of edit_page:
append— adds Markdown at the end of the body.replace_text— replacesoldwithnew.oldmust be plain text within one block and occur exactly once in the whole body. With 0 or several occurrences nothing is written and the count is reported. Other blocks' ids and comment anchors stay as they are.replace_all— replaces everything, changing only what differs. When the body holds things that do not come back from Markdown (database views, embeds, mentions, uploaded files, synced blocks, tables of contents, buttons, columns), or the rewrite would remove the words a comment is on, the call is refused with the reason. Comments on blocks it leaves as they were stay (since 2026-11-06). When the body already reads as the same Markdown, nothing is written andchanged: falsecomes back (no restore point is made).
A version to go back to is kept automatically just before an agent changes a body. When the same connection changes the same page again within 10 minutes, only the first edit gets one. It shows in the version history as 에이전트 편집 전 (app name) (before agent edit).
Permission and validation failures come back as tool results with isError: true, not as protocol errors, so the model can correct itself and call again. A missing page and a page that cannot be seen get the same wording. There are no tools for permanent deletion, moving, sharing, changing schemas or automations (trash_page deletes only as far as Trash).
Records and limits
- Every tool call is kept for 90 days in agent activity (Space settings → Agents): whose connection, which tool, which page, what result. Search terms, the kind of edit action and the names of changed properties are kept; what was read or written is not. People see their own connections' activity; admins see the whole Space's.
- A connection can be cut by its owner under
연결된 앱(connected apps) on the same screen, or by a Space admin. Once cut, the next call gets 401. - Rate limits are the same as REST (per connection 60 reads a minute with bursts of up to 120, 30 writes a minute, per Space 600 a minute). Beyond them a tool error says "try again in N seconds".
- Tool results hold untrusted input. A page body may say "ignore the previous instructions and…". The body comes in a
bodyfield apart from the metadata and the tool descriptions carry no instructions of ours, but this cannot be prevented. Default to read-only connections, and turn on write only in the Spaces that need it.
9. Limits and what is not there yet
- Requests are rate-limited (2026-09-20, changed 2026-11-01): per token 60 reads a minute, with bursts of up to 120 at once; 30 writes a minute; per Space 600 a minute in all.
POST …/querycounts as a read. Beyond that, 429API_RATE_LIMITEDwithRetry-After. A refused request uses none of the limit. The Space limit is counted in the Space of the page or database the call names. MCP calls share the same limits. The limits live in server memory and reset on restart. Even within them, do not poll by reading the same things again: use webhooks (chapter 6) or the changes feed (4.7). - Comments are written and read as plain text only (resolving, deleting and editing are in the app). Attachments have per-page upload and download only, no listing. Moving, restoring and permanently deleting pages, version history, sharing settings and saving views are for the app (
/api/v2) only. Moving a page to the trash is not in REST; only the MCPtrash_pagetool does it. - Import is not in the public API. Export is read-only,
GET /pages/:id/exportalone. - REST body writes replace the whole body only. Partial edits (appending, replacing one piece of text) are in the MCP
edit_pagetool. - A webhook belongs to one member of the Space. There is no notion of an endpoint for the whole Space, and endpoints made by automation actions do not appear in this list.
- A personal token cannot work across Spaces. Make one token per Space, or, for a server that needs several Spaces, connect it as a connected app (section 12).
10. Related documents
- README — installation, build, production deployment
11. Phone app endpoints
The paths the phone apps (Android, iPhone) use. Unlike the public API, the token is not bound to one Space or a set of scopes: it is the signed-in person's own session. An app token (ests_…) is the same sessions row as a browser session, carried as Authorization: Bearer instead of a cookie. So it uses all of /api/v2 with the same permissions as the web, without the Origin and CSRF checks (it is not a credential the browser attaches by itself, as a cookie is). When a request carries both an app token and a cookie, only the token is read. /api/v2 requests that change content must send X-Content-Reset-Epoch, as on the web.
11.1 Signing in
| Step | Request | What happens |
|---|---|---|
| 1 | GET /api/auth/app/start?client=android|ios&challenge=<S256>&state=<16–128 chars> | The app opens this in a browser tab (Custom Tab, authentication session). The server sends the person through the ordinary Google sign-in and sets the return address to the app sign-in marker (/api/auth/app/finish?…) |
| 2 | Google callback | The sign-up allowlist, domain and Drive permission checks are as on the web. On success the server makes no browser session: it issues a 5-minute single-use code and sends Android to https://<service address>/app/auth/callback?code&state (an App Link) and iOS to estnote://auth?code&state. A refusal shows the same recovery page as the web |
| 3 | POST /api/auth/app/token { code, verifier, deviceName? } | Exchanges the code with the PKCE verifier for { token, user, expiresAt, resetEpoch }. The code is used up on the first attempt, so a wrong verifier cannot be retried (400 APP_AUTH_CODE_INVALID) |
Opening /api/auth/app/finish directly starts again from step 1. A web session the browser already holds is not handed to the app: the tab shares the browser's cookies, so handing it over would sign in as whichever account last used the browser, without asking, and sign the browser out.
On a device where the App Link is not verified, the /app/auth/callback page shows, and its button (intent://…) hands the same code to the app. Verification needs /.well-known/assetlinks.json, which is 404 while the server's ANDROID_CERT_SHA256 is empty. The iPhone app's Universal Links read /.well-known/apple-app-site-association (also served at /apple-app-site-association), which claims only page links (#page=… on /); it is 404 while the server's IOS_APP_ID is empty.
11.2 Sessions
| Request | Authentication | What it does |
|---|---|---|
POST /api/auth/app/touch | App token | Extends the session to 30 days when less than 15 remain. No extension after 90 days from sign-in. Answers { expiresAt } |
POST /api/auth/app/web-code | App token | A 1-minute code { code, expiresAt } for handing the session to the app's web view |
GET /api/auth/app/web-session?code=&next= | Code | Sets the same session as an ordinary session cookie and 302s to next (default /embed.html). The code is single-use |
GET /api/auth/sessions | Cookie or app token | The apps signed in to this account: { sessions: [{ id, platform, deviceName, createdAt, lastSeenAt, expiresAt, current }] } |
DELETE /api/auth/sessions/:id | Cookie (Origin, CSRF) or app token | Signs that device out and removes its push registration |
POST /api/auth/logout | Cookie or app token | With an app token, ends that session and its push device together |
11.3 Push
| Request | Authentication | What it does |
|---|---|---|
PUT /api/auth/app/push { platform, token, environment?, appVersion? } | App token | Registers the current session's device. A token that belonged to another session moves here |
DELETE /api/auth/app/push { token } | App token | Unregister |
A push is queued in the very write that creates the Inbox notification, and the server sends every 3 seconds. A notification that is not in the Inbox is not pushed either, and one read or archived before sending is skipped. Android receives an FCM data message { notificationId, kind, title, message, pageId, blockId, targetKind, targetId, unread } (notifications of one page are merged by collapse_key). Without the server's FCM_PROJECT_ID and FCM_SERVICE_ACCOUNT_JSON, Android push is off and apps.push.android in /api/config is false. The iPhone receives an APNs alert: aps carries the title and text (alert) and the unread count (badge), with estnote { notificationId, kind, pageId, blockId, target } beside it (notifications of one page are grouped by thread-id). Without the server's APNS_TEAM_ID, APNS_KEY_ID and APNS_KEY_FILE, iOS push is off and apps.push.ios is false.
11.4 Compatibility and the web view
apps in GET /api/config is { android: { minVersionCode, latestVersionCode }, ios: { minBuild, latestBuild }, windows: { minVersion }, bridgeVersion, push: { android, ios } }. windows is for the EST Chat desktop's document panel (12.6). When the installed app is older than the minimum version, or the server's bridgeVersion is higher than the app knows, the app shows an update screen. latestVersionCode and latestBuild name the newest build out; an app below it shows a "new version is available" banner (Android from 1.0.11, iOS from build 16), and a server that does not send them shows nothing. App requests carry X-ESTnote-Client: android/<version> or ios/<version>.
The app's web view opens /embed.html#page=<id>&block=&view=. It is the same page screen as the web; links to other pages, outside links and moving to the trash are handed to the app as messages from shared/embed-bridge.ts.
12. Connected apps (EST Chat integration)
A server that calls on behalf of a thousand people (the EST Chat server, say) cannot be given a token per person. A connected app therefore obtains one person's consent once, over OAuth, keeps that person's token and calls /api/v1 with it. The token still acts only within that person's permissions, and every endpoint below goes through the same service layer as the app's screens.
12.1 Connecting (OAuth 2.1, several Spaces)
The same authorization server as MCP (/oauth/authorize, /oauth/token), with resource set to https://<our address>/api. The discovery document is /.well-known/oauth-protected-resource/api.
| Item | Detail |
|---|---|
| Client | CIMD as before. A confidential client (a server) states token_endpoint_auth_method: "private_key_jwt" and jwks or jwks_uri in its document, and sends client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer with a client_assertion on every token request (RS256, PS256 or ES256; iss = sub = client_id; aud = the token endpoint; valid at most ten minutes; jti never reused). Public clients (none) are accepted as before |
| Consent screen | The person picks several Spaces they belong to, or all my Spaces (including ones joined later). Every scope in section 2 may be requested; writing is unticked by default. The Space's agent switch is not consulted (the same basis as a personal token) |
| Tokens | Access 1 hour, refresh 90 days; rotation and reuse detection as for MCP. An /api grant's token works on /api/v1 and, through its first Space, on /mcp. An /mcp grant's token still gets 401 on /api/v1 |
| Naming the Space | A token reaching several Spaces must pass workspaceId on calls that need one, such as listing and creating (otherwise 400 API_WORKSPACE_REQUIRED). Anything addressed by ID must lie in a consented Space; outside it is 404. GET /search without workspaceId searches every consented Space |
| Management | Account settings → Connected agent apps shows the app's name, scopes, Spaces, last use and a disconnect button. After disconnecting, the next call is 401 and the document-panel sessions of 12.6 end with it |
| Limits | The per-token (per-grant) limits of section 2 apply, plus 3,000 calls a minute for everything one app does for everyone. Beyond that, 429 with Retry-After |
| Request | Scope | Detail |
|---|---|---|
GET /workspaces | workspaces:read | The Spaces this token reaches, { workspaces: [{ id, name, role }] }. One for a personal token |
12.2 Link previews
| Request | Scope | Detail |
|---|---|---|
POST /links/resolve { urls: [] } (up to 20) | pages:read | Turns ESTnote addresses (/#page=…&block=&view=, /embed.html#page=, /site/<slug>) into { url, kind: 'page' | 'entry' | 'database' | 'site' | 'unknown', pageId?, workspaceId?, blockId?, viewId? }. Unreadable, or outside the consented Spaces: unknown |
POST /pages/preview { pageIds: [] } (up to 20) | pages:read | { pages: [{ pageId, workspaceId, type, entry, title, icon, cover?, excerpt (160 characters), breadcrumbs: [{ pageId, title }], updatedAt, updatedBy: { id, name } | null, commentCount, properties?, revision }] }. Unreadable pages are left out. properties are up to five status, person, date and select properties of a database entry, those with a value |
The response carries an ETag. Sending If-None-Match with the same pageIds returns 304 when nothing changed. revision is the sum of the body, metadata, property and comment revisions.
12.3 Saving a message (captures)
The rules of the Chat app's Save to ESTnote: the selected message and its source, nothing more. Saving does not publish; sharing is a separate step.
| Request | Scope | Detail |
|---|---|---|
POST /captures | pages:write (task also needs 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?: [user ids], due?: 'YYYY-MM-DD' } } → { pageId, url, created }. note makes a page under pageId (or at the Space root), append adds to the end of that page, task makes a database entry |
GET /captures/locations?workspaceId= | pages:read | { recent: [{ kind, workspaceId, pageId | databaseId, title, usedAt }] (up to 10), teamDefaults: [{ chatSpaceId, workspaceId, pageId?, databaseId?, mapping: { statusProperty, assigneeProperty, dueProperty } }] }. Recent destinations merge API and Chat app saves; team defaults are the settings of the Chat spaces the person has used the ESTnote app in |
- No duplicates: the same person saving the same
source.messageIdto the same destination (mode+ Space + target) again gets the existing page back withcreated: false. - Source: a quote block
Google Chat source: author · time (link)is the first block of the page (after the added text forappend), and the capture that made the page is returned assourcebyGET /pages/:id. - Property roles: each property in
GET /databases/:id/schemamay carryrole?: 'status' \| 'assignee' \| 'due': the Chat space's team mapping when it names this database, else the mapping of the person's earlier save, else the one property of that type when there is exactly one. Atask'spropertiesare written through these roles; naming a role the database lacks is 400.
12.4 Inbox
The delegated inbox includes only notices whose original target is currently readable in a Space allowed by the token. Page access, invitation eligibility and teamspace visibility are rechecked; deleted, unknown and account-level targets (including feedback) are excluded. Lists, counts and mutations share this boundary, and an out-of-scope notice ID returns 404. Marking read here marks read in ESTnote too. Ordinary web and mobile inboxes retain their existing behavior.
| Request | Scope | Detail |
|---|---|---|
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 and permission checks apply before pagination |
GET /inbox/unread | inbox:read | { count } |
GET /inbox/preferences | inbox:read | Which kinds are on. EST Chat follows these and keeps no settings of its own |
POST /inbox/:id/read { read? }, POST /inbox/:id/archive { archived? }, POST /inbox/read { until } | inbox:write | Response { unread }. until is epoch milliseconds: accessible delegated notices created up to then are marked read |
12.5 Assigned tasks
| Request | Scope | Detail |
|---|---|---|
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 } |
The same rows as the home screen's assigned to me, gathered across every consented Space, in due-date order with undated rows last. status=open (the default) leaves out rows whose status group is done. Completing or changing status is PATCH /pages/:id/properties; roles says which property to change. Property changes come back through the entry.properties_updated webhook.
12.6 The document panel (WebView2)
| Request | Scope | Detail |
|---|---|---|
POST /embed/code { pageId?, blockId?, viewId? } | session:embed | { code, expiresAt, sessionExpiresAt, url }. For connected apps only (a personal token gets 403 API_GRANT_REQUIRED). url is GET /api/auth/app/web-session?code=…&next=/embed.html#page=…; when the desktop's WebView2 opens it, the person's session cookie is set and it is redirected to the embed |
The session lasts seven days and is restricted to the Spaces and API scopes consented to in the grant. session:embed only permits issuing the panel session. Reading a document also requires pages:read, editing requires pages:write, comments require comments:write, and database reads and writes require databases:read and databases:write, respectively. HTTP requests and live connections recheck the current consent. Panel sessions cannot change account settings, issue tokens, or manage connections. Revoking the grant ends the session and the panel gets 401. The code is spent once, within a minute. The embed's bridge messages (shared/embed-bridge.ts) reach the desktop through window.chrome.webview.postMessage in WebView2, and the desktop answers through chrome.webview's message event. apps.windows.minVersion in GET /api/config is the oldest desktop version the server serves; desktop requests carry X-ESTnote-Client: windows/<version>. There is no separate desktop app sign-in (client=windows): the credential lives in one place, the EST Chat server.
12.7 Chat card actions
The buttons on cards ESTnote posts to Chat can be pressed only by the Google Chat app runtime. A desktop that drew the card itself sends the button's onClick.action here.
| Request | Scope | Detail |
|---|---|---|
POST /chat/actions { space, message?, action: { function, parameters: [{ key, value }] }, formInputs?: { name: [values] }, dialog? } | chat:act | Runs the same handler the Chat app runs for CARD_CLICKED, with the token's owner as the actor. The response is what the Chat app would return: a dialog (actionResponse.type: 'DIALOG'), a card, or text. With formInputs it is handled as a dialog submit |
The connection and each direct or saved action target must be within the token's allowed ESTnote Spaces. Parameter keys must be unique (400 on duplicates). Search without a Space connection is refused. The space must be one the person has used the ESTnote app in (or their direct message with it), otherwise 403 CHAT_SPACE_REQUIRED; an account not yet connected to Chat gets the connect link, as in Chat. Approvals, rejections, acknowledgements, completions and comments all run with the Chat app's own checks. Without Google Chat configured on the server, 503 CHAT_NOT_CONFIGURED.
12.8 New webhook events
event | Meaning | Body |
|---|---|---|
inbox.created | An inbox notice was made | { event, deliveryId, userId, notificationId, kind, workspaceId, pageId, occurredAt } |
inbox.updated | Read or archived | As above, plus read and archived |
entry.properties_updated | A database entry's properties changed | { event, deliveryId, workspaceId, pageId, databaseId, propertyIds, propertiesRevision, occurredAt } |
Inbox events go only to that person's endpoints in the target's currently accessible Space. The original target is rechecked when queueing and before every delivery attempt; deletion, access revocation or a move outside the endpoint's Space prevents delivery. Account-level and unresolvable notices are excluded. Property events go to the Space's endpoints whose owner can read the row. Titles, texts and property values are never included. They enter the outbox in the same transaction as the inbox write, are queued every 30 seconds and follow the signature and retry rules of section 6. A connected app registers per Space by passing workspaceId to POST /webhooks.