Blocks API
Blocks are the atomic content units inside a
page. A page's content is a block tree: blocks have an
optional parentId (parent block or null for page-top level) and a
fractional position that defines order among siblings. There are no
array indexes anywhere.
Shared conventions (auth, error model, pagination, rate limits): authentication.md.
Permission note: reading blocks requires VIEW on the page; creating,
updating, deleting, or moving blocks requires EDIT. Commenting on blocks
requires at least COMMENT (see comments.md).
1. Block JSON Schema
{
"id": "blk_01J9ZKQ6A9B2C4D6E8F0A2B4C6",
"pageId": "pg_01J9ZKQ4F8H5I7J9L1M3N5P7Q9",
"workspaceId": "wks_01J9ZKP5C9H7J9K1L3M5N7P9Q1",
"parentId": null,
"type": "paragraph",
"position": "a1",
"properties": {},
"content": { "text": "Reach GA for the new database engine by 2025-04-30." },
"isSynced": false,
"syncedSourceId": null,
"createdBy": "usr_01J9ZKQX4V2M8N3P5R7T9W1Y3A",
"updatedBy": "usr_01J9ZKQ2V4W6X8Y0Z2A4B6C8D0",
"version": 3,
"createdAt": "2025-01-02T09:16:00.000Z",
"updatedAt": "2025-01-02T14:40:12.000Z"
}
| Field | Type | Notes |
|---|---|---|
id |
string | blk_…, stable for the lifetime of the block. |
pageId |
string | Owning page. Blocks cannot cross pages except via synced_block. |
parentId |
string | null | Parent block id; null = direct child of the page. |
type |
BlockType | One of the 30 types in §2. |
position |
string | Fractional order among siblings (see §5). |
properties |
object | Type-agnostic decoration: colors, alignment, width, language, checked state, callout emoji, etc. |
content |
object | Type-specific payload (table below). |
isSynced |
boolean | true for synced_block instances. |
syncedSourceId |
string | null | For synced_block: the source block it mirrors. |
version |
int | Optimistic concurrency counter (bumped per mutation). |
Text content is stored as rich text:
{
"text": "Hello **world** and a @mention",
"marks": [
{ "type": "bold", "from": 6, "to": 12 },
{ "type": "mention", "from": 20, "to": 31, "attrs": { "mentionId": "usr_01J9ZKQ2V4W6X8Y0Z2A4B6C8D0", "mentionType": "user" } }
]
}
Marks: bold, italic, underline, strikethrough, code,
highlight, link (attrs.url), mention (attrs.mentionType:
user|team|page|database), equation (inline). Offsets are UTF-16 code
units into text.
2. Block Types — Content Shapes
All 30 BlockType values (matching prisma/schema.prisma). content is
the type-specific payload; properties holds the shared decorations listed
per type.
| # | type |
Group | content shape (example) |
properties (type-specific) |
|---|---|---|---|---|
| 1 | paragraph |
Text | { "text": "Hello world", "marks": [] } |
align, color |
| 2 | heading_1 |
Text | { "text": "Objectives", "marks": [] } |
align, showDivider |
| 3 | heading_2 |
Text | { "text": "Goals", "marks": [] } |
align |
| 4 | heading_3 |
Text | { "text": "Scope", "marks": [] } |
align |
| 5 | quote |
Text | { "text": "The first principle is…", "marks": [], "citation": "Galileo" } |
align |
| 6 | callout |
Text | { "text": "Ship it before the deadline.", "marks": [] } |
emoji: "⚠️", backgroundColor, textColor |
| 7 | bulleted_list |
Lists | { "text": "Board view", "marks": [] } |
— |
| 8 | numbered_list |
Lists | { "text": "Set up CI", "marks": [], "start": 1 } |
— |
| 9 | todo |
Lists | { "text": "Ship board view", "marks": [] } |
checked: false |
| 10 | toggle |
Lists | { "text": "Design decisions", "marks": [] } |
collapsed: true |
| 11 | image |
Media | { "fileId": "fil_01J9ZKP…", "caption": "Architecture sketch" } |
align, widthPct (10–100), display: "fullPage" | "pageWidth" | "auto" |
| 12 | video |
Media | { "fileId": "fil_01J9ZKP…", "url": null, "caption": null } |
align, widthPct |
| 13 | audio |
Media | { "fileId": "fil_01J9ZKP…", "url": null } |
— |
| 14 | file |
Media | { "fileId": "fil_01J9ZKP…", "name": "spec.pdf", "size": 482113, "mimeType": "application/pdf" } |
caption |
| 15 | bookmark |
Media | { "url": "https://www.rfc-editor.org/rfc/rfc9110", "title": null, "description": null } |
— |
| 16 | embed |
Media | { "url": "https://www.youtube.com/embed/dQw4w9WgXcQ" } |
heightPx (120–800) |
| 17 | code |
Code | { "code": "curl -s /api/v1/pages", "language": "bash" } |
showLineNumbers: true, wrap: false |
| 18 | equation |
Code | { "latex": "E = mc^2" } |
align |
| 19 | column |
Layout | { "text": null } — a column is a container: its child blocks (parentId = this column's id) render side-by-side. |
widthPct (20–100) |
| 20 | column_list |
Layout | { "text": null } — container that lays out its column children horizontally. |
background, padding |
| 21 | divider |
Layout | {} |
— |
| 22 | spacer |
Layout | { "heightPx": 32 } (8–200) |
— |
| 23 | database |
Advanced | { "databaseId": "db_01J9ZKQ7E4F6A8B0C2D4E6F8A0" } — embeds a database (created at the same time, see databases.md POST /databases with pageId). |
viewId (which view to show) |
| 24 | linked_database |
Advanced | { "databaseId": "db_01J9ZKQ7E4F6A8B0C2D4E6F8A0", "viewId": "view_01J9ZKQ8F5A7B9C1D3E5F7A9B1" } |
— |
| 25 | table |
Advanced | { "rows": [ [ { "text": "ID", "marks": [] }, { "text": "Owner", "marks": [] } ], [ { "text": "1", "marks": [] }, { "text": "Ada", "marks": [] } ] ], "headerRow": true } |
columnWidths: [160, 160], align |
| 26 | chart |
Advanced | { "chartId": "cht_01J9ZKQ9G6B8C0D2E4F6A8B0C2", "databaseId": "db_01J9ZKQ7E4F6A8B0C2D4E6F8A0" } |
widthPct |
| 27 | form |
Advanced | { "formId": "frm_01J9ZKQA7C9D1E3F5A7B9C1D3E5", "databaseId": "db_01J9ZKQ7E4F6A8B0C2D4E6F8A0" } |
— |
| 28 | synced_block |
Advanced | { "text": "…(mirrors source)", "marks": [] } — content is read-only and always equal to its source block; only the source can be edited. |
sourceSynced: true |
| 29 | child_page |
Advanced | { "pageId": "pg_01J9ZKQ8G2H4I6J8L0M2N4P6Q8" } — a page link rendered as a block (row of the page tree). |
— |
| 30 | child_database |
Advanced | { "databaseId": "db_01J9ZKQ7E4F6A8B0C2D4E6F8A0", "title": "Tasks (linked)" } — embeds another database's records in this page's block flow. |
viewId |
Container rules (enforced server-side):
column_listmay only containcolumnchildren;columnmay contain any block type except anothercolumn_list/columnat its top level.- Violations return
422 UNPROCESSABLE_ENTITYwithdetails: [{issue: "invalid_nesting", block, parent}]. - Maximum tree depth: 50 levels.
3. Read Blocks
GET /pages/:pageId/blocks
- Auth: JWT or API key (scope
READ).VIEWon the page.
Query params:
| Param | Type | Default | Notes |
|---|---|---|---|
parentBlockId |
string | "" (empty = page top level) |
Only this parent's direct children. |
flat |
boolean | false | false → nested tree (each node has children: []); true → flat list, ordered by tree position (parent before children, depth-first). |
includeDeleted |
boolean | false | Admin/Owner only. |
cursor, limit |
– | – | Applies to the top level only (default 100, max 500). Deeper levels always fully expanded up to limit. |
200 OK (tree mode)
{
"data": [
{
"id": "blk_01J9ZKQ6A9B2C4D6E8F0A2B4C6",
"type": "heading_1",
"parentId": null,
"position": "a0",
"properties": {},
"content": { "text": "Objectives", "marks": [] },
"isSynced": false,
"version": 1,
"createdAt": "2025-01-02T09:16:00.000Z",
"updatedAt": "2025-01-02T09:16:00.000Z",
"children": []
},
{
"id": "blk_01J9ZKQ6E4F6A8B0C2D4E6F8A0",
"type": "toggle",
"parentId": null,
"position": "a2",
"properties": { "collapsed": true },
"content": { "text": "Design decisions", "marks": [] },
"isSynced": false,
"version": 2,
"createdAt": "2025-01-02T09:17:00.000Z",
"updatedAt": "2025-01-02T10:02:00.000Z",
"children": [
{
"id": "blk_01J9ZKQ6F5A7B9C1D3E5F7A9B1",
"type": "paragraph",
"parentId": "blk_01J9ZKQ6E4F6A8B0C2D4E6F8A0",
"position": "a0",
"properties": {},
"content": { "text": "We chose CRDTs for offline sync.", "marks": [] },
"isSynced": false,
"version": 1,
"createdAt": "2025-01-02T09:17:30.000Z",
"updatedAt": "2025-01-02T09:17:30.000Z",
"children": []
}
]
}
],
"nextCursor": null
}
Errors: 401 UNAUTHORIZED, 403 FORBIDDEN, 404 NOT_FOUND,
400 VALIDATION_ERROR (bad parentBlockId not in this page).
GET /blocks/:blockId
Fetch one block with its full subtree (children nested).
200 OK — one block object (shape as above, with children).
Errors: 401 UNAUTHORIZED, 403 FORBIDDEN, 404 NOT_FOUND.
4. Write Blocks — Single & Batch Operations
All block writes go through one endpoint: POST /blocks. It accepts
either a single operation or a batch of operations, and batches are
transactional (all-or-nothing): any validation or permission failure
rolls the entire batch back — no partial application, no error codes
interleaved in a success response.
POST /blocks
- Auth: JWT or API key (scope
WRITE). Per-blockEDITon the page is checked for every operation in the batch.
Request — a batch of 1–200 operations (≤ 1 MB body), applied in a single Postgres transaction in list order (FIFO — an op may reference rows created by an earlier op in the same batch):
{
"pageId": "pg_01J9ZKQ4F8H5I7J9L1M3N5P7Q9",
"baseVersion": 3,
"ops": [
{
"op": "upsert",
"block": {
"id": "blk_aaaa1111aaaa1111aaaa1111aaaa1111",
"type": "toggle",
"content": "<p>Toggle <b>title</b></p>",
"properties": { "collapsed": false },
"parentId": null
}
},
{
"op": "upsert",
"block": {
"id": "blk_bbbb2222bbbb2222bbbb2222bbbb2222",
"type": "bulleted_list",
"content": "<p>Child</p>",
"properties": {},
"parentId": "blk_aaaa1111aaaa1111aaaa1111aaaa1111",
"afterBlockId": "blk_aaaa1111aaaa1111aaaa1111aaaa1111"
}
},
{
"op": "update",
"blockId": "blk_01J9ZKQ6B1C3D5E7F9A1B3C5D7",
"patch": { "content": "<p>Reach GA by 2025-05-15.</p>" }
},
{
"op": "move",
"blockId": "blk_01J9ZKQ6C2D4E6F8A0B2C4D6E8",
"move": { "parentId": "blk_01J9ZKQ6E4F6A8B0C2D4E6F8A0", "afterBlockId": null }
},
{
"op": "delete",
"blockId": "blk_01J9ZKQ6Z9Y8X7W6V5"
}
]
}
Operation fields:
| Op | Required fields | Optional fields | Semantics |
|---|---|---|---|
upsert |
block.type |
block.id (client id), block.content (string or record, default {}), block.properties (default {}), block.parentId (null = top level), block.afterBlockId / block.beforeBlockId (id anchors), block.position (explicit fractional; mutually exclusive with the id anchors) |
Create the block (no update-in-place — "upsert" = insert with an optional client id). The server resolves the fractional position from the anchors; a missing parent → 404. |
update |
blockId, patch |
patch.type ("turn into" — content is re-validated and type-specific properties are cleared), patch.content (string or record), patch.properties (REPLACES the bag — send the full object), patch.parentId, patch.position |
Merge-patch of the named fields only. |
delete |
blockId |
— | Deletes the block and its subtree (soft delete; recoverable via page versions). |
move |
blockId |
move.parentId, move.afterBlockId / move.beforeBlockId (mutually exclusive with move.position), move.position |
Re-parents and/or reorders via id anchors. Moving a block into its own descendant → 422 (cycle_detected). |
Constraints:
- One
pageIdper batch (all ops act on that page). - Client-chosen ids:
block.idonupsertpins the server row to the given id (the web paste path generatesblk_-prefixed ids so a nested tree can reference its parents by id inside ONE batch). Uniqueness is the DB primary key — a collision aborts the whole batch with409. Omitted → the server assigns a cuid. - Id anchors over positions: clients should anchor with
afterBlockId/beforeBlockIdand let the server compute the fractional position (base-62). Client-computed positions are still accepted (position) but the client and server engines are different alphabets (base-36 vs base-62) — never mix. - Parents before children: an
upsertwithparentIdset requires the parent row to already exist (in the DB or an earlier op of this batch). - Chaining siblings: to insert several blocks in one batch in document order, anchor each one after the previously-emitted sibling (anchoring them all to the same block would interleave reversed).
201 Created ({ applied, blockIds, version } — blockIds lists the
created/upserted ids in op order; version is the new pages.version;
clients that sent a stale baseVersion get 409 with the fresh version):
{ "applied": 5, "blockIds": ["blk_aaaa1111aaaa1111aaaa1111aaaa1111", "blk_bbbb2222bbbb2222bbbb2222bbbb2222"], "version": 4 }
Batch transaction behavior:
-
Executed in a single Postgres transaction. Any operation failing validation, permissions, or a constraint → the whole batch is rolled back and the request fails with one error describing the first failed operation:
{ "error": { "code": "VALIDATION_ERROR", "message": "Batch rolled back: 2 of 5 operations failed.", "details": [ { "op": 2, "field": "content.text", "issue": "required for type paragraph" }, { "op": 4, "field": "blockId", "issue": "not_found_in_page" } ], "requestId": "req_01J9ZKWD5E7F9A1B3C5D7E9F1A" } } -
Optimistic concurrency: if an
updatecarriesversion(or the request carriesIf-Match) and it is stale →409 CONFLICT, batch rolled back,detailsincludes the actual version. -
Batches are idempotent with
Idempotency-Key(replay returns the cached result; see authentication.md §9). -
A single
oprequest that fails also returns the standard error envelope.
Error codes: 400 VALIDATION_ERROR, 401 UNAUTHORIZED, 403 FORBIDDEN,
404 NOT_FOUND (block/page not visible), 409 CONFLICT (stale version),
413 PAYLOAD_TOO_LARGE (batch > 1 MB or > 200 ops), 422 UNPROCESSABLE_ENTITY (invalid nesting, cycle), 429 RATE_LIMITED.
Convenience: top-level aliases
The following routes are thin aliases over the batch endpoint (same semantics, same transaction rules):
| Alias | Equivalent operation |
|---|---|
POST /pages/:pageId/blocks |
upsert (body: the block object directly — type, content (string or record), properties?, id?, parentId?, afterBlockId?/beforeBlockId? or position?) |
PATCH /blocks/:blockId |
update (body: the patch object directly — type?, content?, properties? (full replacement), archived?, baseVersion?) |
DELETE /blocks/:blockId |
delete (recursive by definition — the subtree goes with it) |
POST /blocks/:blockId/move |
move (body: { parentId?, afterBlockId?/beforeBlockId?, position? }) |
POST /blocks/:blockId/duplicate |
copies the block's WHOLE subtree with fresh ids (body: { parentId? (null = same parent), afterBlockId? }; the copy is anchored after afterBlockId ?? the source block in the same or the given parent). Response: { duplicated: true, newRootId, blockIds } |
5. Position Semantics
Same fractional scheme as pages §9, applied per sibling set (children of a page, children of a block):
after: "<blockId>"→ new position = midpoint between that sibling and its successor (append if it is the last). Most common and recommended form.position: "<string>"→ explicit; validated against the alphabet and the current sibling set. Use only when you already hold a position (e.g. after reading the page).- Omit both → append at the end.
- Move to start:
target: { parentId: null, position: "a0" }— if a block already sits ata0, the server shifts it to the computed successor and places the moved block ata0. - Reorder within the same parent without changing parent:
movewithtarget: { after: "<siblingId>" }only. - Ties (identical positions) are broken by
createdAt, thenid. - Midpoint exhaustion is transparent: the server re-normalizes the sibling set in the same transaction.
Worked example — inserting between two blocks:
Current: "Alpha: internal dogfooding." a0
"Beta: 50 external teams." a1
Request: insert with after: <id of a0>
Computed: new position a0M (midpoint of a0, a1)
Result: "Alpha: internal dogfooding." a0
"Second milestone: beta with 50 external teams." a0M
"Beta: 50 external teams." a1
6. Synced Blocks
A synced block is an instance that mirrors one source block. The source may live on any page in the workspace.
- Creating:
insertwithtype: "synced_block"andcontent: { "sourceBlockId": "blk_01J9ZKQ6E4F6A8B0C2D4E6F8A0" }. The instance'sisSyncedistrue,syncedSourceIdis set, and itscontent/propertiesare copied from the source at creation time. - Editing: only the source accepts
update.updateon an instance →422 UNPROCESSABLE_ENTITYwithdetails: [{issue: "synced_block_readonly", sourceBlockId}]. - Deleting the source deletes all instances in the same transaction (they become dangling otherwise). Deleting one instance does not affect the source or other instances.
- A block cannot be a synced block whose source is itself (directly or via a
chain): cycles are rejected with
422(sync_cycle). - Reads always return the current source content in the instance object (no stale mirrors).
7. Events Emitted
All block mutations are batched into one event per HTTP request (so a 5-op batch emits one event whose payload lists 5 changes) — this keeps the search indexer and webhook dispatcher from being spammed per block.
| Operation(s) | Domain event(s) |
|---|---|
insert (one or more) |
blocks.changed with changes: [{op: "insert", blockId, pageId, type}] |
update (one or more) |
blocks.changed with op: "update" per changed block |
delete (one or more) |
blocks.changed with op: "delete" per deleted block |
move (one or more) |
blocks.changed with op: "move" (old/new parentId + position) |
| Any batch | Additionally page.updated (page's updatedAt and block count bumped), which drives search re-indexing of the page. |
blocks.changed also carries actor (user id or api key id), workspaceId,
and pageId. Webhook subscribers can filter by workspaceId
(webhooks.md).
8. curl Examples
# Read a page's block tree
curl -s "https://factory.example.com/api/v1/pages/pg_01J9ZKQ4F8H5I7J9L1M3N5P7Q9/blocks" \
-H "Authorization: Bearer $ACCESS_TOKEN"
# Insert a paragraph after a specific block
curl -s -X POST https://factory.example.com/api/v1/blocks \
-H "Authorization: Bearer $ACCESS_TOKEN" -H 'Content-Type: application/json' \
-d '{
"op": "insert",
"pageId": "pg_01J9ZKQ4F8H5I7J9L1M3N5P7Q9",
"after": "blk_01J9ZKQ6B1C3D5E7F9A1B3C5D7",
"block": { "type": "paragraph", "content": { "text": "Second milestone: beta with 50 external teams.", "marks": [] } }
}'
# Batch: insert + update + move + delete, all-or-nothing
curl -s -X POST https://factory.example.com/api/v1/blocks \
-H "Authorization: Bearer $ACCESS_TOKEN" -H 'Content-Type: application/json' \
-H 'Idempotency-Key: 7c1e2f3a-4b5c-4d6e-8f7a-9b0c1d2e3f4a' \
-d '{
"operations": [
{ "op": "insert", "pageId": "pg_01J9ZKQ4F8H5I7J9L1M3N5P7Q9", "after": "blk_01J9ZKQ6A9B2C4D6E8F0A2B4C6",
"block": { "type": "heading_2", "content": { "text": "Milestones", "marks": [] } } },
{ "op": "update", "pageId": "pg_01J9ZKQ4F8H5I7J9L1M3N5P7Q9", "blockId": "blk_01J9ZKQ6B1C3D5E7F9A1B3C5D7", "version": 3,
"block": { "content": { "text": "Reach GA for the new database engine by 2025-05-15.", "marks": [] } } },
{ "op": "move", "pageId": "pg_01J9ZKQ4F8H5I7J9L1M3N5P7Q9", "blockId": "blk_01J9ZKQ6C2D4E6F8A0B2C4D6E8",
"target": { "parentId": "blk_01J9ZKQ6E4F6A8B0C2D4E6F8A0" } },
{ "op": "delete", "pageId": "pg_01J9ZKQ4F8H5I7J9L1M3N5P7Q9", "blockId": "blk_01J9ZKQ6Z9Y8X7W6V5" }
]
}'
# Turn a block into a todo (update with type change)
curl -s -X PATCH https://factory.example.com/api/v1/blocks/blk_01J9ZKQ6B1C3D5E7F9A1B3C5D7 \
-H "Authorization: Bearer $ACCESS_TOKEN" -H 'Content-Type: application/json' \
-d '{ "block": { "type": "todo", "properties": { "checked": false } } }'
# Create a synced block instance
curl -s -X POST https://factory.example.com/api/v1/blocks \
-H "Authorization: Bearer $ACCESS_TOKEN" -H 'Content-Type: application/json' \
-d '{
"op": "insert",
"pageId": "pg_01J9ZKQ8G2H4I6J8L0M2N4P6Q8",
"block": { "type": "synced_block", "content": { "sourceBlockId": "blk_01J9ZKQ6E4F6A8B0C2D4E6F8A0" } }
}'