Pages API
Pages are the fundamental content container: a titled, icon- and cover-decorated document that holds blocks (see blocks.md), child pages, comments, permissions, and version history. Every database record is also a page (see databases.md).
Shared conventions (auth, error model, pagination, rate limits): authentication.md.
Permission note: all page operations are validated server-side against the
RBAC role and resource-level permissions
(NONE/VIEW/COMMENT/EDIT/FULL_ACCESS) with inheritance from the
workspace → team → parent-page chain. Minimum levels per operation:
| Operation | Minimum permission |
|---|---|
| Read page / list tree / versions | VIEW |
| Create child page | EDIT on parent (or EDIT on workspace/team root) |
| Update / move / duplicate / trash | EDIT |
| Purge, restore, change permissions, export | EDIT (purge requires FULL_ACCESS or workspace Admin/Owner) |
Guests with VIEW-only access get 403 FORBIDDEN on writes; pages outside
their visible subtree are 404 NOT_FOUND (existence is not leaked).
Concurrency: PATCH accepts If-Match: "<version>" (see
authentication.md §9);
a stale version yields 409 CONFLICT with the actual version.
1. Page Resource
{
"id": "pg_01J9ZKQ4F8H5I7J9L1M3N5P7Q9",
"workspaceId": "wks_01J9ZKP5C9H7J9K1L3M5N7P9Q1",
"teamId": "tm_01J9ZKP8G5I7J9L1M3N5P7Q9R1",
"parentId": null,
"position": "a0",
"title": "Product Launch Plan",
"icon": "🚀",
"coverUrl": "https://factory.example.com/files/fil_01J9ZKP…/cover.jpg",
"description": "Q2 launch — objectives, requirements, tasks, risks.",
"isPrivate": false,
"isFavorite": false,
"archivedAt": null,
"trashedAt": null,
"version": 7,
"createdBy": "usr_01J9ZKQX4V2M8N3P5R7T9W1Y3A",
"updatedBy": "usr_01J9ZKQ2V4W6X8Y0Z2A4B6C8D0",
"createdAt": "2025-01-02T09:15:00.000Z",
"updatedAt": "2025-01-02T14:41:00.000Z",
"childCount": 3,
"blockCount": 48
}
2. Create a Page
POST /pages
Create a page, optionally as a child of another page or under a team root. Creating a page never creates blocks — add them via the blocks API.
- Auth: JWT or API key (scope
WRITE).
Request:
{
"parentId": "pg_01J9ZKQ4F8H5I7J9L1M3N5P7Q9",
"teamId": "tm_01J9ZKP8G5I7J9L1M3N5P7Q9R1",
"title": "Q2 Launch Checklist",
"icon": "✅",
"description": "Everything that must be true at launch day.",
"position": "a0V",
"isPrivate": false
}
| Field | Type | Required | Notes |
|---|---|---|---|
parentId |
string | null | no | Parent page (same workspace). null/omitted = root of the workspace or team. |
teamId |
string | null | no | Assign to a team (root-level placement). Ignored when parentId is set to a non-root page. |
title |
string (0–2000) | no | Empty allowed (page shows as "Untitled" in UI). |
icon |
string | no | Emoji or a file id (fil_…) from files. |
coverUrl |
string (url) | no | Must point to a workspace file URL. |
description |
string (≤ 2000) | no | |
position |
string (fractional) | no | Insertion point among siblings; default = append. See §9 Position Semantics. |
isPrivate |
boolean | no | Default false; private pages are hidden from the sidebar of non-editors. |
properties |
object | no | Free-form page properties (e.g. {"status": "draft"}) — shown in the page info panel. |
Exactly one of parentId / teamId may be set (or neither → workspace root).
Setting both → 400 VALIDATION_ERROR.
201 Created
{
"data": {
"id": "pg_01J9ZKQ8G2H4I6J8L0M2N4P6Q8",
"workspaceId": "wks_01J9ZKP5C9H7J9K1L3M5N7P9Q1",
"teamId": "tm_01J9ZKP8G5I7J9L1M3N5P7Q9R1",
"parentId": "pg_01J9ZKQ4F8H5I7J9L1M3N5P7Q9",
"position": "a0V",
"title": "Q2 Launch Checklist",
"icon": "✅",
"coverUrl": null,
"description": "Everything that must be true at launch day.",
"isPrivate": false,
"isFavorite": false,
"archivedAt": null,
"trashedAt": null,
"version": 1,
"createdBy": "usr_01J9ZKQX4V2M8N3P5R7T9W1Y3A",
"createdAt": "2025-01-02T15:00:00.000Z"
}
}
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR |
Bad icon file id, both parent+team set, field constraints. |
| 403 | FORBIDDEN |
No EDIT on the parent / team root. |
| 404 | NOT_FOUND |
Parent or team not visible to caller. |
| 422 | UNPROCESSABLE_ENTITY |
Would create a cycle (parent is a descendant of itself — impossible for new pages but reported uniformly) or nesting depth > 50 (details: [{issue: "max_depth"}]). |
Events emitted: page.created. The page is queued for search indexing.
3. Read a Page
GET /pages/:pageId
- Auth: JWT or API key (scope
READ).
Query params:
| Param | Type | Notes |
|---|---|---|
include |
comma-separated list | blocks — full block tree; children — direct child pages (1 page each); versions — last 10 version ids; shares — active share links. Multiple: ?include=blocks,children. |
depth |
int (1–10, default 1) | With include=children, how many levels of children to expand. |
200 OK (with ?include=blocks,children)
{
"data": {
"id": "pg_01J9ZKQ4F8H5I7J9L1M3N5P7Q9",
"workspaceId": "wks_01J9ZKP5C9H7J9K1L3M5N7P9Q1",
"teamId": "tm_01J9ZKP8G5I7J9L1M3N5P7Q9R1",
"parentId": null,
"position": "a0",
"title": "Product Launch Plan",
"icon": "🚀",
"coverUrl": "https://factory.example.com/files/fil_01J9ZKP…/cover.jpg",
"description": "Q2 launch — objectives, requirements, tasks, risks.",
"isPrivate": false,
"isFavorite": false,
"version": 7,
"createdBy": "usr_01J9ZKQX4V2M8N3P5R7T9W1Y3A",
"updatedBy": "usr_01J9ZKQ2V4W6X8Y0Z2A4B6C8D0",
"createdAt": "2025-01-02T09:15:00.000Z",
"updatedAt": "2025-01-02T14:41:00.000Z",
"blocks": [
{
"id": "blk_01J9ZKQ6A9B2C4D6E8F0A2B4C6",
"type": "heading_1",
"parentId": null,
"position": "a0",
"properties": {},
"content": { "text": "Objectives", "marks": [] },
"isSynced": false
},
{
"id": "blk_01J9ZKQ6B1C3D5E7F9A1B3C5D7",
"type": "paragraph",
"parentId": null,
"position": "a1",
"properties": {},
"content": { "text": "Reach GA for the new database engine by 2025-04-30.", "marks": [] },
"isSynced": false
},
{
"id": "blk_01J9ZKQ6C2D4E6F8A0B2C4D6E8",
"type": "todo",
"parentId": null,
"position": "a2",
"properties": { "checked": false },
"content": { "text": "Ship board view", "marks": [] },
"isSynced": false
},
{
"id": "blk_01J9ZKQ6D3E5F7A9B1C3D5E7F9",
"type": "database",
"parentId": null,
"position": "a3",
"properties": {},
"content": { "databaseId": "db_01J9ZKQ7E4F6A8B0C2D4E6F8A0" },
"isSynced": false
}
],
"children": [
{
"id": "pg_01J9ZKQ8G2H4I6J8L0M2N4P6Q8",
"title": "Q2 Launch Checklist",
"icon": "✅",
"position": "a0",
"childCount": 0,
"updatedAt": "2025-01-02T15:00:00.000Z"
},
{
"id": "pg_01J9ZKQ8H3I5J7L9M1N3P5Q7R9",
"title": "Risks & Mitigations",
"icon": "⚠️",
"position": "a1",
"childCount": 1,
"updatedAt": "2025-01-02T11:20:00.000Z"
}
]
}
}
Errors: 401 UNAUTHORIZED, 403 FORBIDDEN (page exists but you lack
VIEW on this page and an explicit deny was granted), 404 NOT_FOUND
(invisible page or different workspace).
GET /pages — List & Tree
List the pages the caller can see at a given level.
- Auth: JWT or API key (scope
READ).
Query params:
| Param | Type | Default | Notes |
|---|---|---|---|
parentId |
string | – | Siblings of this parent. Omit (or null) for workspace roots. |
teamId |
string | – | Root pages of a team (mutually exclusive with parentId). |
favorites |
boolean | false | Only the caller's favorite pages. |
trashed |
boolean | false | Include (only) pages in the trash (workspace Admin/Owner only). |
q |
string | – | Title substring filter. |
include |
list | – | children (one more level). |
cursor, limit |
– | 50 / max 200 | base64url (position[, tiebreak]) cursor. |
Ordering: position ASC (fractional order). The two filter flags are
mutually exclusive — sending both is a 400 VALIDATION_ERROR.
200 OK — { data: [ <page objects> ], nextCursor } (same page object as
above, without blocks/children unless include=children). Every row also
carries favorite (the caller's star state) and databaseId (the seeded
Database row's id — non-null ⇔ the page is a database; the Page model has
no type field).
trashed=true specifics (Admin/Owner only, 403 FORBIDDEN
details: [{reason: "trash_requires_admin"}] otherwise):
- Returns trashed roots only — pages whose own
trashedAtis set AND whose parent is null or not trashed — so a trashed subtree appears as one entry (restore it viaPOST /pages/:pageId/restoreto bring the subtree back). - Ordered by
(position ASC, id ASC)(position alone is not unique across parents); the cursor is a base64url(position, id)pair. The client typically re-sorts bytrashedAt DESCfor display.
favorites=true specifics:
- The caller's
PageFavoriterows (other members' stars are not visible). - Ordered by
(position ASC, pageId ASC)— all favorites currently share the default positiona0(the favorites reorder API is not built yet), so thepageIdtie-break keeps the cursor deterministic. - A page trashed after being starred still lists (its
trashedAtis set) so the UI can badge it "in trash".
Events emitted: none (read).
4. Update a Page
PATCH /pages/:pageId
Merge-patch of page metadata. Blocks are not edited here — use the
blocks API. Moving a page to a new parent and/or reordering
among new siblings happens via parentId + position.
- Auth: JWT or API key (scope
WRITE).EDITon the page.
Request (any subset):
{
"title": "Product Launch Plan v2",
"icon": "🚀",
"coverUrl": "https://factory.example.com/files/fil_01J9ZKP…/cover-2.jpg",
"description": "Q2 launch, revised.",
"isPrivate": true,
"parentId": null,
"teamId": null,
"position": "a0M",
"archived": false,
"If-Match": "7"
}
| Field | Notes |
|---|---|
title, icon, coverUrl, description, isPrivate, properties |
Metadata updates. |
parentId + position |
Move. parentId: null moves to root. Moving under a page requires EDIT on the destination parent and must not create a cycle (422 UNPROCESSABLE_ENTITY, details: [{issue: "cycle_detected"}]). |
archived |
true = archive (hide from sidebar, keep accessible); false = unarchive. |
version / If-Match |
Optimistic concurrency; stale → 409 CONFLICT. |
200 OK — updated page object (with new version).
Errors: 400 VALIDATION_ERROR, 403 FORBIDDEN, 404 NOT_FOUND,
409 CONFLICT (stale version), 422 UNPROCESSABLE_ENTITY (cycle, depth > 50,
destination not writable).
Events emitted: page.updated; when the move crosses a permission
boundary (different parent team) additionally page.shared (the new visible
set is computed and members gain/lose access) and permission.changed
(audit).
5. Trash, Restore, Purge
Deleting a page moves it to the trash (trashedAt set) — soft delete.
Trash keeps the page (with subtree) for the workspace's
trashRetentionDays (default 30), after which a worker purges it.
DELETE /pages/:pageId
- Auth: JWT or API key (scope
WRITE).EDITon the page.
202 Accepted
{
"data": {
"id": "pg_01J9ZKQ4F8H5I7J9L1M3N5P7Q9",
"trashedAt": "2025-01-02T16:00:00.000Z",
"scheduledPurgeAt": "2025-02-01T16:00:00.000Z",
"subtreePageCount": 4,
"subtreeBlockCount": 152
}
}
Errors: 403 FORBIDDEN, 404 NOT_FOUND, 409 CONFLICT
(already trashed → details: [{issue: "already_trashed"}]).
Events emitted: page.deleted (trash, not hard delete). Search index
marks the document hidden; child pages keep their trashedAt via the subtree.
POST /pages/:pageId/restore
Restore a trashed page (with its whole subtree) to its previous parent and position. If the previous parent was itself deleted, the page is restored to the nearest still-existing ancestor.
- Auth: JWT or API key (scope
WRITE).EDIT.
200 OK
{
"data": {
"id": "pg_01J9ZKQ4F8H5I7J9L1M3N5P7Q9",
"trashedAt": null,
"restoredAt": "2025-01-02T17:30:00.000Z",
"parentId": null,
"position": "a0",
"subtreeRestoredPageCount": 4
}
}
Errors: 404 NOT_FOUND (not in trash / already purged),
409 CONFLICT (not trashed), 403 FORBIDDEN.
Events emitted: page.restored.
POST /pages/:pageId/purge
Hard delete immediately (bypasses retention): page, subtree, blocks, comments, versions, and search documents are removed. The object-storage files are orphaned and reclaimed by the storage worker.
- Auth: JWT (workspace Admin/Owner, or
FULL_ACCESSresource permission on the page). API keys:ADMINscope.
Request (confirmation):
{ "confirm": "pg_01J9ZKQ4F8H5I7J9L1M3N5P7Q9" }
The body confirm must equal the page id (typo protection).
202 Accepted
{ "data": { "id": "pg_01J9ZKQ4F8H5I7J9L1M3N5P7Q9", "status": "purging" } }
Errors: 400 VALIDATION_ERROR (bad confirm), 403 FORBIDDEN,
404 NOT_FOUND.
Events emitted: page.purged (audit, subtree stats recorded).
6. Favorites
Favorites are per-user and per-workspace; the sidebar "Favorites" section
lists them in the user-chosen order (fractional position).
POST /pages/:pageId/favorite
- Auth: JWT only (a user preference — API keys get
403 FORBIDDEN,details: [{reason: "requires_user_credentials"}]).VIEWon the page.
Request (optional):
{ "position": "a0" }
201 Created
{
"data": {
"id": "fav_01J9ZKQ9J5L7M9N1P3Q5R7S9T1",
"workspaceId": "wks_01J9ZKP5C9H7J9K1L3M5N7P9Q1",
"userId": "usr_01J9ZKQX4V2M8N3P5R7T9W1Y3A",
"pageId": "pg_01J9ZKQ4F8H5I7J9L1M3N5P7Q9",
"position": "a0",
"createdAt": "2025-01-02T16:05:00.000Z"
}
}
Errors: 403 FORBIDDEN (API key / no VIEW), 404 NOT_FOUND,
409 CONFLICT (already favorited).
Events emitted: page.favorited.
DELETE /pages/:pageId/favorite
- Auth: JWT (the owning user).
204 No Content (idempotent).
Events emitted: page.unfavorited.
GET /favorites
List the caller's favorites across the workspace, in position order.
Query: cursor, limit. Returns favorite objects with an embedded page
preview (id, title, icon, updatedAt). pageIds whose page the user no longer
can see are omitted, and nextCursor reflects the filtered page.
7. Versions (Page History)
Every significant page mutation (title/metadata edit, structural block
change, move, trash/restore) snapshots the page + block tree into
page_versions. Snapshots are retained per plan (default: 90 days, 500
versions).
GET /pages/:pageId/versions
- Auth: JWT or API key (scope
READ).VIEW.
Query: cursor, limit (default 20, max 100), ordered newest first.
200 OK
{
"data": [
{
"id": "pvn_01J9ZKQA0B2C4D6E8F0A2B4C6",
"pageId": "pg_01J9ZKQ4F8H5I7J9L1M3N5P7Q9",
"version": 7,
"note": "Renamed to 'Product Launch Plan v2'",
"createdBy": "usr_01J9ZKQ2V4W6X8Y0Z2A4B6C8D0",
"createdAt": "2025-01-02T14:41:00.000Z",
"sizeBytes": 48213
},
{
"id": "pvn_01J9ZKQ9Z1A3B5C7D9E1F3A5B7",
"pageId": "pg_01J9ZKQ4F8H5I7J9L1M3N5P7Q9",
"version": 6,
"note": null,
"createdBy": "usr_01J9ZKQX4V2M8N3P5R7T9W1Y3A",
"createdAt": "2025-01-02T12:10:00.000Z",
"sizeBytes": 47102
}
],
"nextCursor": "cHVuXzAxSlpaS1E5WjFBM0I1QzdEOUUxRjNBNUI3"
}
Errors: 401 UNAUTHORIZED, 403 FORBIDDEN, 404 NOT_FOUND.
GET /pages/:pageId/versions/:version
Fetch one snapshot: the full page object plus the complete block tree as
of that version (read-only; blocks use the same shape as
GET /pages/:id?include=blocks).
- Auth: JWT or API key (scope
READ).VIEW.
200 OK
{
"data": {
"id": "pvn_01J9ZKQ9Z1A3B5C7D9E1F3A5B7",
"page": { "id": "pg_01J9ZKQ4F8H5I7J9L1M3N5P7Q9", "title": "Product Launch Plan", "version": 6, "…": "…" },
"blocks": [ { "id": "blk_…", "type": "heading_1", "content": { "text": "Objectives" }, "…": "…" } ],
"createdAt": "2025-01-02T12:10:00.000Z"
}
}
Errors: 404 NOT_FOUND (version pruned), 403 FORBIDDEN.
POST /pages/:pageId/versions/:version/restore
Replace the page's current content (metadata + block tree) with the snapshot.
This is a new mutation: it creates a new current version whose note is
"Restored from version N", so the restore itself is also restorable.
- Auth: JWT or API key (scope
WRITE).EDIT.
200 OK
{
"data": {
"id": "pg_01J9ZKQ4F8H5I7J9L1M3N5P7Q9",
"version": 8,
"restoredFromVersion": 6,
"updatedAt": "2025-01-02T18:00:00.000Z"
}
}
Errors: 403 FORBIDDEN, 404 NOT_FOUND (version pruned or page),
409 CONFLICT (page trashed — restore it first).
Events emitted: page.updated, page.version_restored.
POST /pages/:pageId/versions
Create a named manual snapshot (UI "Save version"). Useful before risky edits; also the only way for API clients to pin a point in time.
- Auth: JWT or API key (scope
WRITE).EDIT.
Request: { "note": "Before restructure" } → 201 Created with the
version object.
Errors: 400 VALIDATION_ERROR, 403 FORBIDDEN, 404 NOT_FOUND,
409 CONFLICT (version cap reached for this plan).
Events emitted: page.version_created.
8. Duplicate
POST /pages/:pageId/duplicate
Deep-copy a page: metadata (icon/cover kept), the full block tree (new block
ids), and all child pages. File references are shared (not re-uploaded);
databases embedded via database blocks are not cloned — the clone
links the same database. Comments are not copied.
- Auth: JWT or API key (scope
WRITE).EDITon the source.
Request:
{
"parentId": "pg_01J9ZKQ4F8H5I7J9L1M3N5P7Q9",
"position": "a0M",
"title": "Product Launch Plan (Copy)",
"includeChildren": true
}
| Field | Required | Notes |
|---|---|---|
parentId |
no | Destination; default = source's parent. |
position |
no | Default appends after the source. |
title |
no | Default "<source title> (Copy)". |
includeChildren |
no | Default true. |
201 Created
{
"data": {
"id": "pg_01J9ZKQB1C3D5E7F9A1B3C5D7",
"sourceId": "pg_01J9ZKQ4F8H5I7J9L1M3N5P7Q9",
"title": "Product Launch Plan (Copy)",
"parentId": "pg_01J9ZKQ4F8H5I7J9L1M3N5P7Q9",
"position": "a0M",
"copiedBlockCount": 48,
"copiedPageCount": 3,
"createdAt": "2025-01-02T18:20:00.000Z"
}
}
Errors: 403 FORBIDDEN, 404 NOT_FOUND, 409 CONFLICT (source
trashed), 422 UNPROCESSABLE_ENTITY (destination cycle), 413 PAYLOAD_TOO_LARGE (subtree exceeds 20 000 blocks → details: [{maxBlocks: 20000}]).
Events emitted: page.duplicated.
9. Position Semantics (Fractional Ordering)
Pages (and blocks, properties, records, teams) are never ordered by array
index. Order is a fractional string (position) comparable
lexicographically, using a base-64 alphabet ("0"…"9", "a"…"z", "A"…"Z"
→ keys like a0, a0V, a0M, a1). This is a LexoRank-style scheme:
inserting between two items only changes the inserted row, never its
neighbors.
Siblings in order: position
"Product Launch Plan" a0
"Q2 Launch Checklist" a0V
"Risks & Mitigations" a1
Rules:
-
Append (default): omit
positionand the server assigns the midpoint after the current last sibling (ora0for the first child). -
Insert between: pass the position of the item the new one should be before, and the server computes the midpoint. Example — insert a new page between
a0anda0V:{ "parentId": "pg_01J9ZKQ4F8H5I7J9L1M3N5P7Q9", "position": "a0M" }a0Msorts betweena0anda0V. -
Explicit positions: any string in the alphabet that sorts where you want; the server validates it (
400 VALIDATION_ERRORon illegal characters). Prefer passing a neighboring position and letting the server pick the midpoint to avoid collisions. -
Reorder:
PATCHthe page with the newposition(andparentIdif moving across parents). Example — move "Risks & Mitigations" (a1) before "Product Launch Plan" (a0): setposition: "a0"and the server shifts the displaced sibling to a new midpoint automatically. -
Normalization: if a position ever runs out of room (long runs of midpoints between the same neighbors — effectively impossible at 64 chars/level, but guarded), the server transparently re-normalizes the whole sibling set in the same transaction; clients never see intermediate values.
-
Determinism: equal positions are tie-broken by
createdAt, thenid.
10. Export
POST /pages/:pageId/export
Export a page (optionally its subtree) as a file. Export is an async job:
the response returns a job id; poll it or subscribe to export.completed
via webhooks. Formats: markdown, html, pdf.
- Auth: JWT or API key (scope
READ— export is a read; workspace Admin/Owner may restrict exports in settings).VIEW.
Request:
{
"format": "markdown",
"includeChildren": true,
"includeFiles": false
}
| Field | Notes |
|---|---|
format |
markdown | html | pdf. |
includeChildren |
Default true — nested pages become files in the archive. |
includeFiles |
Default false — when true, embedded files are included in a .zip (multi-page exports are always zipped; single-page exports return the raw file). |
202 Accepted
{
"data": {
"jobId": "job_01J9ZKQC2D4E6F8A0B2C4D6E8A",
"pageId": "pg_01J9ZKQ4F8H5I7J9L1M3N5P7Q9",
"format": "markdown",
"status": "PENDING",
"createdAt": "2025-01-02T18:30:00.000Z"
}
}
Errors: 403 FORBIDDEN, 404 NOT_FOUND, 409 CONFLICT (page trashed —
restore first), 422 UNPROCESSABLE_ENTITY (unknown format).
GET /pages/:pageId/export/:jobId
Poll the export job.
200 OK (when done)
{
"data": {
"jobId": "job_01J9ZKQC2D4E6F8A0B2C4D6E8A",
"status": "COMPLETED",
"downloadUrl": "https://factory.example.com/api/v1/jobs/job_01J9ZKQC2D4E6F8A0B2C4D6E8A/download",
"fileName": "Product-Launch-Plan.zip",
"sizeBytes": 182440,
"expiresAt": "2025-01-09T18:30:00.000Z",
"fileCount": 4,
"createdAt": "2025-01-02T18:30:00.000Z",
"finishedAt": "2025-01-02T18:30:04.211Z"
}
}
While running: status: "PENDING" | "RUNNING" (no downloadUrl). On
failure: status: "FAILED", error: {code, message} (same error model).
Downloads from downloadUrl are signed, single-link, 7-day expiry.
Events emitted: page.export_completed (on completion; webhooks can
subscribe) — the job itself is not a domain event.
11. curl Examples
# Create a page under a parent
curl -s -X POST https://factory.example.com/api/v1/pages \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"parentId":"pg_01J9ZKQ4F8H5I7J9L1M3N5P7Q9","title":"Q2 Launch Checklist","icon":"✅","position":"a0V"}'
# Fetch page with blocks + children
curl -s "https://factory.example.com/api/v1/pages/pg_01J9ZKQ4F8H5I7J9L1M3N5P7Q9?include=blocks,children" \
-H "Authorization: Bearer $ACCESS_TOKEN"
# Move + rename (optimistic concurrency)
curl -s -X PATCH https://factory.example.com/api/v1/pages/pg_01J9ZKQ8H3I5J7L9M1N3P5Q7R9 \
-H "Authorization: Bearer $ACCESS_TOKEN" -H 'Content-Type: application/json' -H 'If-Match: "4"' \
-d '{"title":"Risks","parentId":null,"position":"a0"}'
# Trash, then restore
curl -s -X DELETE https://factory.example.com/api/v1/pages/pg_01J9ZKQ4F8H5I7J9L1M3N5P7Q9 \
-H "Authorization: Bearer $ACCESS_TOKEN"
curl -s -X POST https://factory.example.com/api/v1/pages/pg_01J9ZKQ4F8H5I7J9L1M3N5P7Q9/restore \
-H "Authorization: Bearer $ACCESS_TOKEN"
# Favorite
curl -s -X POST https://factory.example.com/api/v1/pages/pg_01J9ZKQ4F8H5I7J9L1M3N5P7Q9/favorite \
-H "Authorization: Bearer $ACCESS_TOKEN"
# Restore an old version
curl -s -X POST https://factory.example.com/api/v1/pages/pg_01J9ZKQ4F8H5I7J9L1M3N5P7Q9/versions/6/restore \
-H "Authorization: Bearer $ACCESS_TOKEN"
# Export to PDF, async
curl -s -X POST https://factory.example.com/api/v1/pages/pg_01J9ZKQ4F8H5I7J9L1M3N5P7Q9/export \
-H "Authorization: Bearer $ACCESS_TOKEN" -H 'Content-Type: application/json' \
-d '{"format":"pdf","includeChildren":true}'
12. Recorded conflicts (AGENTS.md rule 29)
POST /templates/:id/applyrequires a workspace — the implementation (pages.controller.ts) takes the destination workspace frombody. workspaceIdor theX-Factory-Workspaceheader and returns400 VALIDATION_ERRORwhen neither is present. Neitherdocs/api/ openapi.yaml(no body, no header parameter on the operation) nordocs/product/functional-requirements.md§templates (names the routePOST /templates/:id/instantiate {targetParentId}) documents this. The web app always sendsworkspaceIdin the body (its fetch layer never setsX-Factory-Workspace). Resolution: the implementation is authoritative (it is the enforced contract and the web is aligned to it); aligning the docs — and decidingapplyvsinstantiatenaming — is a separate documentation task. Recorded, not resolved here.- Members list wire shape (flat) vs
docs/api/workspaces.md§3.1 (nested) — full note indocs/tasks/phase-02-workspace.md(task "Recorded conflict"). The web normalizes at its API boundary; the API stays flat until the e2e + integration suites are updated together.
13. Events Summary
| Operation | Domain event(s) |
|---|---|
| Create page | page.created |
| Update/move/archive page | page.updated (+ page.shared when visibility set changes, permission.changed audit) |
| Trash page | page.deleted |
| Restore from trash | page.restored |
| Purge page | page.purged (audit) |
| Favorite / unfavorite | page.favorited / page.unfavorited |
| List versions | — (read) |
| Read one version | — (read) |
| Restore version | page.updated, page.version_restored |
| Manual snapshot | page.version_created |
| Duplicate page | page.duplicated |
| Export finished | page.export_completed |