{
  "openapi": "3.1.0",
  "info": {
    "title": "Orbit Office API",
    "version": "1",
    "description": "Orbit’s HTTP API covers every action a workspace can perform — the same list the automations window offers when you pick what a rule should DO. Everything is under /v1, and scoped to one workspace by the key you send.\n\n## Base URL\n\nEverything is under `/v1`, on the host you are reading this from, and every path in this reference is relative to it. The web reference fills your own origin into each example, so a copied cURL works as it stands; the text and OpenAPI copies leave the paths relative, because a generated document does not know the host it will be fetched from.\n\nTHERE IS NO WORKSPACE ID IN ANY PATH. A key belongs to one workspace, so there is nothing to address and nothing to get wrong — a caller cannot reach another tenant even by accident, and the whole “did you check the id against the credential” class of bug does not exist here.\n\n## Authentication\n\nTwo credentials exist, and they are not interchangeable.\n\nA SESSION is what the app uses: an access token from this workspace’s own auth server, sent as `Authorization: Bearer <jwt>`. It acts as a person and is subject to that person’s page grants.\n\nTHE AUTH SERVER IS SELF-HOSTED. The library is still `supabase-js` and these variables are still named `SUPABASE_*`, because renaming them would be a deploy-time break for no gain — but there is no Supabase-hosted project behind this API, and nothing you send to one reaches it.\n\nAn API KEY acts as the WORKSPACE. It is created by the owner in Automation → Api Keys, shown once, and stored as a hash — it cannot be recovered, only replaced. Send it the same way: `Authorization: Bearer orb_…`.\n\nA key authenticates everything under `/v1`, which is nearly all of this reference. 11 entries are marked \"session\", and they divide in two.\n\nNINE HAVE NO KEY EQUIVALENT AND SHOULD NOT HAVE ONE: the workspaces a PERSON belongs to and creating one — a key belongs to exactly one workspace, so it has nothing to list and nothing to found — that person’s notifications, and listing, minting and revoking API keys. The last especially: a key that could mint keys would be a privilege escalation with no ceiling.\n\nTHE OTHER TWO ARE THE APP’S OWN PATHS for something `/v1` does differently, so what a key lacks is the URL and not the capability. The one-shot table read hands back columns, views and every row together and unpaginated, which is what a screen wants and what an integration should never ask for; `/v1` splits it into three paged reads. And locking a table’s shape is `POST /v1/databases/{id}/lock` for a key.\n\nA KEY ACTS AS THE WORKSPACE, not as a person. What it writes is attributed to nobody, and the key’s own id goes in the event payload instead — which is the thing you want when tracing a write back weeks later, rather than the name of whoever minted the key, who may have left.\n\n## Scopes\n\nA key carries any of `read`, `write`, `config` and `spend`, and they do not imply one another — a key with `write` and no `read` can post a record and not read one back. Mint the narrowest set that does the job, and mint a second key rather than widening the first.\n\nWHAT THE THIRD ONE IS FOR: `write` changes the workspace’s CONTENTS — records, values, page bodies. `config` changes its SHAPE — columns, views, variables, members, roles, share links, and the workspace’s own settings. The split exists because one word used to cover “add a timesheet” and “change which timesheets a public link shows”, and an integration that does the first has no business being able to do the second.\n\nSO THE COMMON CASE IS NARROWER THAN IT WAS: an integration that reads and files records wants `read` and `write`, and nothing else. Reach for `config` only when the integration genuinely builds tables, manages people, or publishes links — and when it does, consider whether that work belongs on a second key used by a setup script rather than on the one running every day.\n\nAND THE FOURTH IS A BUDGET RATHER THAN A PERMISSION. `spend` is checked by the four routes that spend the workspace’s money with somebody else — address search, address details, reverse geocoding and the AI formula writer. It is the one scope that can be refused for a reason that is not about access: the daily unit cap, answered with 429 and `spend_capped`. Reading the meter at `GET /v1/spend` deliberately takes `read` instead, so a program that has just been refused can find out why with the key it is already holding.\n\n## Errors\n\nFailures return `{ \"error\": { \"code\": \"…\", \"message\": \"…\" } }` with a real status. The `code` is stable and worth branching on; the `message` is written for a person and may be reworded.\n\n401 no credential or a bad one · 403 not allowed · 404 not here, or not in your workspace · 409 the request is well formed and the state says no · 423 the page is locked · 429 too many requests.\n\nA 404 and a \"not yours\" are deliberately the same answer: telling somebody a record exists but belongs to another workspace is itself a disclosure.\n\n## Rate limits\n\n300 requests a minute, keyed on the credential rather than the IP — an office behind one address shares nothing, and a key with several addresses gains nothing. Over the limit you get 429 with `code: \"rate_limited\"`.\n\n## Records are addressed by column KEY\n\nA record’s `values` are keyed by a column’s `key` — `{\"values\": {\"k_7f3a91\": \"Ready\"}}` — and a COLUMN NAME IS SILENTLY DROPPED. Sending `{\"Status\": \"Ready\"}` succeeds and writes nothing, which is the one mistake here that does not announce itself.\n\nSo every column answers with BOTH its `id` and its `key`, and every page of records carries the column list beside them. Paths take the id; values take the key.\n\n## And a value that is not one is narrowed, not refused\n\nA WRITE IS NARROWED TO WHAT THE COLUMN TAKES. A number column sent \"twelve\" stores nothing; a clock sent `\"09:30\"` stores nothing, because a clock cell is an object; a place sent a string stores nothing. Each of those is answered **201**, and the cell is simply absent when you read the record back.\n\nTHIS IS THE SAME TRAP AS THE ONE ABOVE, one level down: the column key was right, so nothing complained, and the value went nowhere. The rule exists because the alternative is a column whose settings are true of the screen and false of the data — every write goes through one narrowing, a keystroke and an API call alike, so what lands is always what the column says it takes.\n\nWHAT TO DO ABOUT IT: read the record back after writing a structured value — a clock, a place, a person, a relation — the first time you write one, and compare. The response to a write carries the stored record, so this costs no extra call. A strict phone column is the one exception that refuses rather than narrows, and says so.\n\nThe shape each type takes is on its entry under “Column types”.\n\n## Lists are cursor-paged\n\n`nextCursor` is on every page of a list and is `null` on the last one. It is not optional and it is not omitted when there is no more — a loop that stops when a page comes back shorter than the limit is wrong the moment anybody passes a limit, and wrong silently.\n\nPass it back as `cursor` to continue. `total` is how many the QUERY has: the table for a plain read, the matching records for a filtered one.\n\n## A frozen page refuses writes\n\nA page can be LOCKED and a table’s shape can be locked separately. Both refuse with 423 — from a key exactly as from a person, because a lock is a state and not a permission — and they say which in the code: `page_locked` or `database_locked`.\n\nA shape lock still accepts records: adding and filling them in is what it is for. Read `locked` and `databaseLocked` on any page answer rather than discovering either by being refused.\n\n## Ask what your key can do\n\n`GET /v1/key` answers with the key’s own scopes and needs NO scope to call — deliberately, because scopes are flat: a key minted with only `write` carries no `read`, and putting introspection behind `read` would mean that key could never discover what it was missing.\n\nWithout it the only way to find out a scope is absent is to be refused by the route that needed it.\n\n## A machine-readable copy\n\nTHREE OTHER COPIES OF THIS REFERENCE EXIST, all generated from it rather than written beside it, so none of them can describe a different API:\n\n`/llms.txt` is the orientation — this introduction and an index of the topics, a few kilobytes, and the one to hand an agent first.\n\n`/llms-full.txt` is every endpoint in plain text: parameters, examples, and the whole of each description.\n\n`/openapi.json` is OpenAPI 3.1.\n\nTHE TEXT ONES EXIST BECAUSE THIS PAGE IS A REACT APPLICATION. Fetching `/docs` without running JavaScript returns about 1.2 KB of empty shell, so for a crawler, a script, or an assistant holding a URL and a key, the reference was unreadable while being perfectly public.\n\nAND AN MCP SERVER, for an assistant rather than a program: `apps/mcp/server.mjs` in this repository, run on your own machine with one of these keys. It is a CLIENT of this API — seven tools over documented calls — so an assistant gets exactly the scopes and the confinement the key has, and its escape-hatch tool refuses any path `/openapi.json` does not describe. What it adds over plain HTTP is the translation this introduction warns about twice: it takes column NAMES and a select’s LABELS and turns them into the keys and ids a cell needs. Settings ▸ MCP has the command.\n\nALL OF THEM CARRY EXAMPLES RATHER THAN INVENTED SCHEMAS: the reference holds real answers, not types, and a document that guessed a schema from one example would publish a contract this server never agreed to."
  },
  "servers": [
    {
      "url": "/v1",
      "description": "Relative to the origin this document was fetched from."
    }
  ],
  "security": [
    {
      "bearerAuth": []
    },
    {
      "apiKeyHeader": []
    }
  ],
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "Authorization: Bearer orb_…"
      },
      "apiKeyHeader": {
        "type": "apiKey",
        "in": "header",
        "name": "x-api-key"
      }
    }
  },
  "paths": {
    "/key": {
      "get": {
        "operationId": "whoami",
        "summary": "What this key is",
        "description": "Which workspace this key is pointed at, and what it is allowed to do. CALL IT ON STARTUP.\n\nWITHOUT IT THE ONLY WAY TO DISCOVER A MISSING SCOPE IS TO BE REFUSED BY THE ROUTE THAT NEEDED IT — a 403 in production, found by a customer, instead of a check at boot.\n\nIT NEEDS NO SCOPE, and that is the point. Scopes here are flat: a key minted with only `write` carries no `read` at all. Behind `read`, this would be unanswerable for exactly the key that most needs to ask.\n\nIT ANSWERS ABOUT ITSELF AND NOTHING ELSE. There is no listing of the workspace’s other keys — a read-only credential able to enumerate every integration a business runs is a reconnaissance tool, not an answer to \"what am I\".\n\nIt is `/key` rather than `/me` because they are different questions. `me` asks who is signed in, and the honest answer from a key is that there is nobody there.",
        "tags": [
          "Getting started"
        ],
        "parameters": [],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "key": {
                    "id": "01a0169e-…",
                    "scopes": [
                      "read",
                      "write"
                    ],
                    "name": "invoice filer",
                    "prefix": "orb_7f3a",
                    "createdAt": "2026-09-02T08:00:00.000Z",
                    "lastUsedAt": "2026-10-05T09:12:44.000Z",
                    "expiresAt": null
                  },
                  "workspace": {
                    "id": "01a0169d-…",
                    "name": "Kohavim Media",
                    "slug": "kohavim-media"
                  }
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/api-keys": {
      "get": {
        "operationId": "list-keys",
        "summary": "List the workspace’s keys",
        "description": "Every key this workspace has: its name, its prefix, what it may do and when it was last used. No secrets — a secret exists once, in the answer that minted it, and is a hash everywhere else.\n\nIT ASKS `config`, NOT `read`, and that is deliberate. Revoking a key needed an id and nothing listed them, so this closes that; but a read-only credential able to enumerate every integration the business has is a reconnaissance tool, so the listing sits where minting and revoking already are. A `config` key can already do both, so seeing them is strictly less than what it has.",
        "tags": [
          "Getting started"
        ],
        "parameters": [],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "keys": [
                    {
                      "id": "01a01700-…",
                      "name": "Billing sync",
                      "prefix": "orb_7f3a",
                      "scopes": [
                        "read",
                        "write"
                      ],
                      "lastUsedAt": "2026-10-06T09:12:00.000Z",
                      "expiresAt": null,
                      "revokedAt": null,
                      "createdAt": "2026-08-01T10:00:00.000Z"
                    }
                  ]
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/api-keys/{id}/grants": {
      "get": {
        "operationId": "key-grants",
        "summary": "Which pages, columns and rows a key is limited to",
        "description": "The pages one key may reach, and what it may do on each.\n\nAN EMPTY LIST MEANS THE KEY IS NOT LIMITED — which reads backwards, so `confined` is sent beside it rather than left to be inferred from a length. A key with no grants reaches the whole workspace, which is what every key did before this existed.\n\n`config`, not `read`: knowing which integration reaches which table is the same picture as knowing its scopes, and a read-only key able to draw it has drawn somebody a map of where to push. A key reading its OWN limits needs no scope at all — that is `GET /v1/key`.",
        "tags": [
          "Getting started"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The key to describe.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "confined": true,
                  "grants": [
                    {
                      "pageId": "01a0169e-…",
                      "scopes": [
                        "read",
                        "write"
                      ],
                      "columns": [
                        "f_name",
                        "f_status",
                        "f_brief"
                      ],
                      "writeColumns": [
                        "f_status"
                      ]
                    }
                  ]
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      },
      "put": {
        "operationId": "set-key-grants",
        "summary": "Limit a key to some pages",
        "description": "Confines a key to the pages listed. It is then refused everywhere else — including listing pages, creating them, the member roster, automations, share links and the other keys.\n\nPUT AND THE WHOLE SET. Sending two grants means the key has two grants, not that two were added. \"Stop this key reaching that table\" has to be expressible, and an add-only interface expresses it by OMISSION — the shape that leaves somebody believing they revoked something they did not.\n\n**AN EMPTY LIST WIDENS THE KEY.** `{\"grants\": []}` removes the confinement and returns it to the whole workspace. It is the one call here that grants rather than restricts, and it is spelled as sending nothing, so it is worth knowing before you send it.\n\nA GRANT CANNOT OUTRANK THE KEY. Scopes here are intersected with the key’s own at read time, so a grant naming `write` on a read-only key stores and returns nothing — and narrowing a key narrows every grant it has in the same moment, rather than leaving rows that say otherwise. The response shows what was STORED, not what was sent.\n\nA CONFINED KEY CANNOT CALL THIS. Not by a rule written for it: this route is workspace-wide, so the confinement refuses it on the way in. An unconfined `config` key can narrow itself — and will lock itself out of widening itself again, which is reversible from the app.\n\n`spend` IS NOT A PER-PAGE SCOPE. The metered routes touch no page, so a confined key reaches them through its own `spend` scope and the workspace cap instead.\n\nAND WHICH COLUMNS, WHICH IS THE SECOND HALF (0117). A grant may name `columns` — the column KEYS it exposes — and `writeColumns`, which of those it may change. EMPTY OR ABSENT MEANS EVERY COLUMN, the same sense `grants` itself has one level up: nothing named is no narrowing, so every grant that predates this keeps the access it had.\n\n`columns` GOVERNS WHAT COMES BACK, not only what may be sent. A column outside it is stripped from every record, from the column list beside them, from the record page’s `fields`, and from every revision in `GET …/items/{id}/history`; a query may not `filter` or `sort` by one, because ordering by a column reveals it one comparison at a time; and the routes that address a single cell — `…/columns/{key}/at`, `…/columns/{key}/history`, a select column’s `options` — refuse it outright.\n\n`writeColumns` MUST BE A SUBSET of `columns`, and a write naming a column outside it is REFUSED rather than dropped — `{status, fee}` against a key that may change only `status` changes NEITHER, and answers 403 `column_not_granted`. A dropped column would report success for a write that did not happen.\n\nTHE LIST MUST INCLUDE THE NAME COLUMN. Every record answer carries its title, which is that column’s value, so leaving it out would publish it as a title or hand back records with no name. Columns on a page that is not a table are refused: a document is granted whole or not at all.\n\nA REVERT IS REFUSED to a column-limited key, and the tracked-changes read makes it name a column — both answer about every cell at once, and half of either is a record in a state no revision ever had.\n\nA FORMULA COLUMN SENDS WHAT IT READS. Exposing `Margin` sends what the cameraman costs although nobody exposed the cost. That is often exactly what the owner means, so it is disclosed rather than refused — the app shows it on the screen where the box is ticked.\n\nAND WHICH ROWS, WHICH IS THE THIRD AND LAST NARROWING (0118). A grant may carry `rowFilter` — rules in the same grammar `POST …/items/query` takes, `{key, op, value}` with a one-level group allowed — and `rowFilterMatch` (`all`, `any`, or a count). EMPTY OR ABSENT MEANS EVERY ROW.\n\nIT IS A FLOOR, NOT ONE ALTERNATIVE AMONG SEVERAL. Your own `filter` and `match` apply to your own rules; the grant’s filter is ANDed with the result, so `match: \"any\"` cannot reach around it. A row outside it is refused by EVERY route, including by id — 403 `row_not_granted` — and that covers the record page, the revision history, a cell’s value at a date, the tracked changes, the links, and the formula preview.\n\nTHE COLUMNS THE FILTER NAMES BECOME READ-ONLY for that key, even if `writeColumns` names them: a key that could edit the column deciding which rows it reaches would be choosing its own reach. So a row can leave a key’s reach and can never enter it.\n\nA GATED KEY CANNOT CREATE, DUPLICATE, IMPORT OR RESTORE RECORDS. Whether a new record falls inside the filter depends on values the server works out after the write, so the alternatives were a write that succeeds and then vanishes, or a refusal. For filing records, use a key limited to the page rather than to its rows.\n\nA FILTER NAMING A COLUMN THAT NO LONGER EXISTS REACHES NOTHING, deliberately: the evaluator skips a missing column, so the alternative would turn a narrow grant into an open one. A filter on a FORMULA column is refused when it is written, because the paged read computes formulas and the single-record routes do not — it would show you a record that every other route refused. A value may roll: `{{ startOf(today(), \"months\") }}` is evaluated per request against the workspace’s clock.",
        "tags": [
          "Getting started"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The key to limit.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "grants": {
                    "type": "string",
                    "description": "One entry per page: `{pageId, scopes, columns, writeColumns, rowFilter, rowFilterMatch}`. Up to 200. An empty array removes the limit."
                  }
                },
                "required": [
                  "grants"
                ]
              },
              "example": {
                "grants": [
                  {
                    "pageId": "01a0169e-…",
                    "scopes": [
                      "read",
                      "write"
                    ],
                    "columns": [
                      "f_name",
                      "f_status",
                      "f_brief"
                    ],
                    "writeColumns": [
                      "f_status"
                    ],
                    "rowFilter": [
                      {
                        "key": "f_client",
                        "op": "is",
                        "value": "c_acme"
                      }
                    ],
                    "rowFilterMatch": "all"
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "confined": true,
                  "grants": [
                    {
                      "pageId": "01a0169e-…",
                      "scopes": [
                        "read",
                        "write"
                      ],
                      "columns": [
                        "f_name",
                        "f_status",
                        "f_brief"
                      ],
                      "writeColumns": [
                        "f_status"
                      ],
                      "rowFilter": [
                        {
                          "key": "f_client",
                          "op": "is",
                          "value": "c_acme"
                        }
                      ],
                      "rowFilterMatch": "all"
                    }
                  ]
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/api-keys/{id}": {
      "delete": {
        "operationId": "revoke-key-v1",
        "summary": "Stop a key",
        "description": "Revokes a key. Every request it makes afterwards is a 401.\n\nMINTING IS NOT HERE, DELIBERATELY: a key minted from a key is a bootstrap with no beginning, so the first one comes from a browser. REVOKING IS THE OPPOSITE CASE — a key that cannot be revoked over the API is a key that in practice is never rotated, because rotation then needs a person at a keyboard at the least convenient moment.\n\nIT IS NOT RESTRICTED TO OTHER KEYS, OR TO LESSER ONES. A leaked key has to be killable by whoever notices, at the moment they notice; a rule saying \"only a key weaker than yourself\" is a rule that leaves the strongest leaked key alive.\n\nSO A KEY MAY REVOKE ITSELF, which is the end of a rotation: mint the new one, move the traffic, have the old one stop itself. The answer says `wasItself` when it did.\n\nIT TAKES `config`, because a credential is the shape of what can reach this workspace rather than a record in it. A key minted to file invoices must not be able to switch another integration off.",
        "tags": [
          "Getting started"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The key to revoke. Its own id, from `GET /v1/key`, is allowed.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "ok": true,
                  "revoked": "01a0169e-…",
                  "wasItself": false
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/workspace": {
      "get": {
        "operationId": "get-workspace",
        "summary": "Read the workspace",
        "description": "Singular, and with no id in the path: the key IS the workspace. A plural path here would invite exactly the mistake this prefix exists to make impossible — a caller passing an id, and a handler having to remember to check it against the credential.\n\nStorage settings and the custom domain are deliberately not in the answer. Both are deployment configuration, and a reference that leaks infrastructure into an integration’s view of a tenant is how that infrastructure ends up in somebody’s logs.",
        "tags": [
          "Workspace"
        ],
        "parameters": [],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "workspace": {
                    "id": "019ffaea-8b00-77b7-b5e9-c580228d24d4",
                    "name": "Acme",
                    "slug": "acme",
                    "icon": {
                      "kind": "emoji",
                      "value": "🎥"
                    },
                    "defaultPage": "01a0169d-1586-776f-b92a-133830f16c4e",
                    "timezone": "Asia/Jerusalem",
                    "defaultLanguage": "he",
                    "createdAt": "2026-01-04T10:00:00.000Z"
                  }
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      },
      "patch": {
        "operationId": "update-workspace",
        "summary": "Change its settings",
        "description": "Its name, its face, its front door.\n\nABSENT AND NULL ARE DIFFERENT, which is the contract every patch in this API keeps: a key left out is untouched, a key sent as `null` is cleared. Read by presence rather than truthiness — otherwise clearing an icon and not mentioning one would be the same request.\n\n`name` is the exception: it can be changed but not cleared. Every screen in the product labels itself with it.",
        "tags": [
          "Workspace"
        ],
        "parameters": [],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string",
                    "description": "Up to 120 characters. Cannot be null — a workspace with no name has nothing to label itself with."
                  },
                  "icon": {
                    "type": "string",
                    "description": "An emoji, or `{kind: \"emoji\"|\"lucide\", value, color}`. `null` removes it."
                  },
                  "defaultPage": {
                    "type": "string",
                    "description": "Where members land when they open the workspace. Checked against the two cases a foreign key cannot see — a page in the trash, and an id that is not a page. `null` clears it."
                  },
                  "timezone": {
                    "type": "string",
                    "description": "An IANA zone NAME, like `Asia/Jerusalem` — never an offset like `+02:00`, which is right for half the year and an hour wrong for the other half. Every date question a formula asks is asked in it. `null` means UTC."
                  },
                  "spendCapDaily": {
                    "type": "integer",
                    "description": "Spend units this workspace may burn in a UTC day. Zero is meaningful and switches paid calls off; there is no unlimited. The meter is `GET /v1/spend`."
                  },
                  "defaultLanguage": {
                    "type": "string",
                    "description": "The language the workspace reads in — a code it already publishes, refused with `not_published` otherwise. `null` means English, left to right."
                  }
                }
              },
              "example": {
                "name": "Acme Studio",
                "icon": "🎬"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "workspace": {
                    "id": "019ffaea-8b00-77b7-b5e9-c580228d24d4",
                    "name": "Acme Studio",
                    "slug": "acme"
                  }
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/workspace/languages": {
      "get": {
        "operationId": "list-languages",
        "summary": "The languages it publishes in",
        "description": "A workspace declares the languages it publishes in, and the owner decides what is on that list. It is the door everything about translation goes through: a value written in a language that is not here is refused rather than stored.\n\nTHE DEFAULT IS RESOLVED, NEVER NULL. `default` always carries a code, a label and a direction, even when nobody has chosen one — in which case it is English, left to right, and `published` is `false`. A caller rendering a page should not have to carry its own idea of what an unset language means.\n\n`catalogue` is a convenience and never a constraint: the languages this app knows the name and direction of, so publishing Hebrew can be one word and still read right to left. Any well-formed tag can be published, whether it is in there or not.",
        "tags": [
          "Workspace"
        ],
        "parameters": [],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "languages": [
                    {
                      "code": "he",
                      "label": "עברית",
                      "dir": "rtl",
                      "position": 1,
                      "isDefault": true
                    },
                    {
                      "code": "ru",
                      "label": "Русский",
                      "dir": "ltr",
                      "position": 2,
                      "isDefault": false
                    }
                  ],
                  "default": {
                    "code": "he",
                    "label": "עברית",
                    "dir": "rtl",
                    "published": true
                  },
                  "catalogue": [
                    {
                      "code": "he",
                      "label": "Hebrew",
                      "native": "עברית",
                      "dir": "rtl"
                    }
                  ]
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      },
      "post": {
        "operationId": "add-language",
        "summary": "Publish in one more",
        "description": "`config`, not `write`: this changes the shape of what every page in the workspace can carry, the same way adding a column does.\n\nTHE TAG IS CANONICALISED BEFORE IT IS STORED. `HE-il` and `he-IL` are one language, not two rows — a translation stored against one of several spellings is invisible through the others. Primary subtag lower-case, a four-letter script Title-case, a two-letter region upper-case: the spelling a browser sends.\n\nLEAVE `label` AND `dir` OUT and they come from the catalogue. At most eight languages per workspace.",
        "tags": [
          "Workspace"
        ],
        "parameters": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "code": {
                    "type": "string",
                    "description": "A BCP-47 tag: `he`, `ru`, `pt-BR`, `zh-Hant`. Checked as a shape rather than against a list of the world’s languages, so a language this app has never heard of can still be published."
                  },
                  "label": {
                    "type": "string",
                    "description": "What the menu shows, up to 60 characters. Defaults to the catalogue’s English name, or to the tag itself."
                  },
                  "dir": {
                    "type": "string",
                    "description": "`ltr` or `rtl`. Defaults from the catalogue. `auto` is refused by name: that is a COLUMN setting, where the text itself decides which way a cell runs."
                  }
                },
                "required": [
                  "code"
                ]
              },
              "example": {
                "code": "he"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "language": {
                    "code": "he",
                    "label": "Hebrew",
                    "dir": "rtl",
                    "position": 1,
                    "isDefault": false
                  }
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/workspace/languages/{code}": {
      "patch": {
        "operationId": "update-language",
        "summary": "Rename, flip or re-order one",
        "description": "The label is the owner’s words, so it is stored rather than derived and this is how it changes. `dir` is here too, because the catalogue is a default and not an authority: a language it got wrong, or never heard of, is one request from right.\n\n`position` orders the menu, sparse and fractional-friendly — moving one language touches one row.",
        "tags": [
          "Workspace"
        ],
        "parameters": [
          {
            "name": "code",
            "in": "path",
            "required": true,
            "description": "The tag, as it was stored.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "label": {
                    "type": "string",
                    "description": "Up to 60 characters. Cannot be blank."
                  },
                  "dir": {
                    "type": "string",
                    "description": "`ltr` or `rtl`."
                  },
                  "position": {
                    "type": "string",
                    "description": "Where it sits in the list."
                  }
                }
              },
              "example": {
                "label": "עברית"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "language": {
                    "code": "he",
                    "label": "עברית",
                    "dir": "rtl",
                    "position": 1,
                    "isDefault": true
                  }
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      },
      "delete": {
        "operationId": "remove-language",
        "summary": "Stop publishing in one",
        "description": "REFUSED FOR THE WORKSPACE’S OWN LANGUAGE, with `is_default`: change `defaultLanguage` on the workspace first. A composite foreign key refuses it underneath as well, so the rule holds for anything that goes round this endpoint.",
        "tags": [
          "Workspace"
        ],
        "parameters": [
          {
            "name": "code",
            "in": "path",
            "required": true,
            "description": "The tag, as it was stored.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "ok": true
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/pages/{id}/translations": {
      "get": {
        "operationId": "list-translations",
        "summary": "Every second name on a page",
        "description": "A column header, a select’s choices, a view’s name, a page’s title and description and EVERY LINE OF ITS BODY can each carry a second name in a language the workspace publishes (`GET /v1/workspace/languages`). They are written by a person — nothing here translates anything.\n\nTHE ORIGIN IS THE TRUTH AND THIS IS A DISPLAY LAYER. Reading the table still answers the original name, and filters, sorts and formulas all read the original value. A translation that took part in logic would make the same automation behave differently for different readers.\n\n`targets` IS WHAT COULD CARRY ONE — the page’s own name, its columns and their choices, its views, and its lines — each with what is written there now and what to call it on screen. It is what a “what is still missing” screen is built from, and it saves a caller copying this app’s rules about which lines have words at all (a code block has none, deliberately).\n\n`records` IS THE SAME QUESTION ABOUT THE RECORDS, AS ARITHMETIC. A table has as many records as it has, so naming them here would mean sending the table: `filled` is how many values have something written in them, `translated` is how many carry a second value in each language, and `columns` names the columns counted. One record at a time is `GET /v1/pages/{id}/items/{itemId}/translations`.\n\n`stale` MEANS THE ORIGINAL CHANGED since somebody wrote this one. It is still shown, because it is usually still right; `origin` is what it was written against and `current` is what the original says now.\n\nEverything on one page in one request, because that is the shape a screen and a cache both want.",
        "tags": [
          "Workspace"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The page. A table is a page, so this is the table id for column and view translations.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "translations": [
                    {
                      "subject": "column",
                      "id": "019ffaea-8b00-77b7-b5e9-c580228d24d4",
                      "path": "name",
                      "lang": "he",
                      "value": "הוצאות",
                      "origin": "Expenses",
                      "current": "Expenses",
                      "stale": false,
                      "updatedAt": "2026-10-12T09:00:00.000Z"
                    },
                    {
                      "subject": "column",
                      "id": "019ffaea-8b00-77b7-b5e9-c580228d24d4",
                      "path": "choice:booked",
                      "lang": "he",
                      "value": "הוזמן",
                      "origin": "Booked",
                      "current": "Booked",
                      "stale": false,
                      "updatedAt": "2026-10-12T09:00:00.000Z"
                    }
                  ],
                  "targets": [
                    {
                      "subject": "page",
                      "id": "01a0169d-1586-776f-b92a-133830f16c4e",
                      "path": "title",
                      "kind": "title",
                      "origin": "Budget"
                    },
                    {
                      "subject": "column",
                      "id": "019ffaea-8b00-77b7-b5e9-c580228d24d4",
                      "path": "name",
                      "kind": "column",
                      "origin": "Expenses"
                    },
                    {
                      "subject": "page",
                      "id": "01a0169d-1586-776f-b92a-133830f16c4e",
                      "path": "block:b1a2b3c4:text",
                      "kind": "heading",
                      "origin": "Call times"
                    }
                  ],
                  "records": {
                    "filled": 980,
                    "translated": {
                      "he": 412
                    },
                    "columns": [
                      {
                        "key": "k_7f3a91",
                        "name": "Job"
                      }
                    ]
                  }
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      },
      "put": {
        "operationId": "write-translation",
        "summary": "Write one",
        "description": "Idempotent: the same four identifiers always address the same translation, so this creates or replaces without a caller having to know which.\n\n`config`, NOT `write`: a second name for a column is part of the column, the way its type and its choices are. A key that files records in is not renaming things in any language. A RECORD’S VALUE IS THE OTHER SIDE OF THAT LINE and has its own route — `PUT /v1/pages/{id}/items/{itemId}/translations`, at `write`. Sending `subject: \"row\"` here is refused rather than quietly accepted, because a scope distinction undone by a field in a body is not a distinction.\n\nFOUR REFUSALS WORTH KNOWING. A language the workspace does not publish (`not_published`). The language the workspace is WRITTEN in, which would be a second copy of the same name with nothing keeping the two in step (`is_default`). A subject and path that do not go together — a page has a `title`, never a `name` (`bad_path`). And anything whose original is blank or missing, because a translation of a word nobody wrote renders as nothing (`nothing_there`).",
        "tags": [
          "Workspace"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The page everything here belongs to.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "subject": {
                    "type": "string",
                    "description": "`column`, `view` or `page`. A record is `row`, and goes to the record route instead."
                  },
                  "id": {
                    "type": "string",
                    "format": "uuid",
                    "description": "Which column, view or page. A column is named by its ID rather than its key, because an id survives a rename and the whole point of this is to outlive one."
                  },
                  "path": {
                    "type": "string",
                    "description": "Which part of it: `name` for a column or a view, `title` or `description` for a page, `choice:<id>` for one option of a select, or `block:<id>:text` for one line of a page’s body. `GET` this endpoint for the list (`targets`) rather than working them out."
                  },
                  "lang": {
                    "type": "string",
                    "description": "A language this workspace publishes in."
                  },
                  "value": {
                    "type": "string",
                    "description": "The words. Capped at whatever the original field allows — 120 for a name, 500 for a title, 2000 for a description or a line. Never empty: delete it instead."
                  }
                },
                "required": [
                  "subject",
                  "id",
                  "path",
                  "lang",
                  "value"
                ]
              },
              "example": {
                "subject": "column",
                "id": "019ffaea-8b00-77b7-b5e9-c580228d24d4",
                "path": "name",
                "lang": "he",
                "value": "הוצאות"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "translation": {
                    "subject": "column",
                    "id": "019ffaea-8b00-77b7-b5e9-c580228d24d4",
                    "path": "name",
                    "lang": "he",
                    "value": "הוצאות",
                    "origin": "Expenses",
                    "current": "Expenses",
                    "stale": false
                  }
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      },
      "delete": {
        "operationId": "remove-translation",
        "summary": "Remove one",
        "description": "THE FOUR IDENTIFIERS GO IN THE QUERY, not in a body: a DELETE carrying one is a request that half the tooling between a caller and this server will quietly drop.\n\nRemoving a translation is how a name goes back to being shown in the original language for everybody. It is also the only way to “empty” one — a blank value is refused, because no-translation and an empty translation would look identical and render differently.",
        "tags": [
          "Workspace"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The page.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "subject",
            "in": "query",
            "required": true,
            "description": "`column`, `view` or `page`. A record’s value is removed at the record route, which asks for `write`.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "id",
            "in": "query",
            "required": true,
            "description": "Which one.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "path",
            "in": "query",
            "required": true,
            "description": "Which part of it.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "lang",
            "in": "query",
            "required": true,
            "description": "Which language.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "ok": true
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/pages/{id}/items/{itemId}/translations": {
      "get": {
        "operationId": "list-record-translations",
        "summary": "One record’s second values",
        "description": "A RECORD’S NAME ALWAYS, AND ANY TEXT COLUMN ITS OWNER SWITCHED ON (`translated: true` in the column’s options). Nothing else: a number, a date and a timestamp are FORMATTED in the reader’s own language from one stored value rather than translated; a select’s words are its choices, translated once on the column; a person’s name is their own; a URL would stop working.\n\nONE RECORD AT A TIME, and that is the whole shape of this endpoint. A page’s structure is bounded by the schema somebody designed and is read whole; a table’s records are not, so there is no “every translated value in this table” read — `GET /v1/pages/{id}/translations` answers it as arithmetic, and `?lang=` on the item reads answers it for the records a page is already carrying.\n\n`targets` is what this record could carry, with the words that are there now; `translations` is what it does carry. A value with nothing written in it is in neither — there is nothing to translate.",
        "tags": [
          "Workspace"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table, which is a page.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "itemId",
            "in": "path",
            "required": true,
            "description": "The record.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "translations": [
                    {
                      "subject": "row",
                      "id": "01a02a70-b4c9-7d31-9f0e-2c5b8a1d4e77",
                      "path": "cell:k_7f3a91",
                      "lang": "he",
                      "value": "צילומי חוץ",
                      "origin": "Outdoor shoot",
                      "current": "Outdoor shoot",
                      "stale": false,
                      "updatedAt": "2026-10-12T09:00:00.000Z"
                    }
                  ],
                  "targets": [
                    {
                      "subject": "row",
                      "id": "01a02a70-b4c9-7d31-9f0e-2c5b8a1d4e77",
                      "path": "cell:k_7f3a91",
                      "kind": "name",
                      "origin": "Outdoor shoot"
                    },
                    {
                      "subject": "row",
                      "id": "01a02a70-b4c9-7d31-9f0e-2c5b8a1d4e77",
                      "path": "description",
                      "kind": "description",
                      "origin": "Two days on location"
                    }
                  ]
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      },
      "put": {
        "operationId": "write-record-translation",
        "summary": "Write one of a record’s",
        "description": "`write`, NOT `config`, and that is why this is a route of its own. Translating a record is filing data about that record; renaming its table’s columns is shaping the table. A key built to fill a table in two languages should not need the scope that can also change a column’s type, and a `config` key should not get every value in the table because it may name them.\n\nTHE ORIGIN IS WHAT EVERY DOOR ANSWERS WITH, in every language. A record’s values ROUND-TRIP — the bag you read is the bag you send back — so a translation substituted into `values` would be saved over the original by the first integration that read a record and patched it. Translated values travel beside the records instead: `?lang=` on the item reads, in `translated`.\n\nIT IS A DISPLAY LAYER AND NOTHING ELSE. Filters, sorts and formulas read the original value, so a query for `contains: \"batteries\"` keeps matching a record somebody has translated, and a query for the Hebrew matches nothing.\n\nTHE LENGTH LIMIT IS THE COLUMN’S OWN: 250 characters for a short text column, 10,000 for a long one, or whatever a custom one was set to — the same limit the original value has. Refusals: a column whose values are not translated, or a value with nothing written in it (`nothing_there`); a path that names no column (`bad_path`); a language the workspace does not publish (`not_published`) or is written in (`is_default`).",
        "tags": [
          "Workspace"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "itemId",
            "in": "path",
            "required": true,
            "description": "The record. The subject and the id are the route, so they are not in the body.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "path": {
                    "type": "string",
                    "description": "`cell:<column key>` for one value — by KEY, which is what a record’s `values` are keyed by — or `description` for the line under the record’s title. `GET` this endpoint for the list."
                  },
                  "lang": {
                    "type": "string",
                    "description": "A language this workspace publishes in."
                  },
                  "value": {
                    "type": "string",
                    "description": "The words, within the column’s own limit. Never empty: delete it instead."
                  }
                },
                "required": [
                  "path",
                  "lang",
                  "value"
                ]
              },
              "example": {
                "path": "cell:k_7f3a91",
                "lang": "he",
                "value": "צילומי חוץ"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "translation": {
                    "subject": "row",
                    "id": "01a02a70-b4c9-7d31-9f0e-2c5b8a1d4e77",
                    "path": "cell:k_7f3a91",
                    "lang": "he",
                    "value": "צילומי חוץ",
                    "origin": "Outdoor shoot",
                    "current": "Outdoor shoot",
                    "stale": false
                  }
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      },
      "delete": {
        "operationId": "remove-record-translation",
        "summary": "Remove one of a record’s",
        "description": "THE RECORD GOES IN THE PATH AND THE REST IN THE QUERY, because a DELETE carrying a body is a request half the tooling between a caller and this server will quietly drop.\n\nA PURGED RECORD TAKES ITS TRANSLATIONS WITH IT, so this is for changing your mind rather than for cleaning up. A record in the trash KEEPS them — restoring it restores them too — and emptying the trash removes both.",
        "tags": [
          "Workspace"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "itemId",
            "in": "path",
            "required": true,
            "description": "The record.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "path",
            "in": "query",
            "required": true,
            "description": "`cell:<column key>` or `description`.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "lang",
            "in": "query",
            "required": true,
            "description": "Which language.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "ok": true
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/workspace/empty-trash": {
      "post": {
        "operationId": "empty-trash",
        "summary": "Empty the trash",
        "description": "Deletes every trashed page for good. There is no undo and no second confirmation — an API key has no dialog to put one in.\n\nIT ANSWERS WITH A COUNT rather than `ok`. Zero and three hundred and forty are very different afternoons, and it is also what makes a retry legible: the second call says 0, which is how it should read.",
        "tags": [
          "Workspace"
        ],
        "parameters": [],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "emptied": 12
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/unfurl": {
      "get": {
        "operationId": "unfurl",
        "summary": "What a link is",
        "description": "Title, description and image for an address, so a card can be drawn for it.\n\nTHE KEY IS HERE TO SAY WHO IS ASKING. This endpoint reads nothing of the workspace, but it is the one that makes the server fetch an address somebody else chose, so it must not be reachable without a credential. The hops are followed in the outbound guard, which refuses the loopback and the private ranges — a second check in the route would be a second answer to “is this address allowed”, and the one that disagreed would be the one somebody found.",
        "tags": [
          "Workspace"
        ],
        "parameters": [
          {
            "name": "url",
            "in": "query",
            "required": true,
            "description": "The address to look at.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "link": {
                    "url": "https://example.com/post",
                    "title": "A post",
                    "description": "Something about it",
                    "image": "https://example.com/og.png"
                  }
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/variables": {
      "get": {
        "operationId": "list-variables",
        "summary": "List variables",
        "description": "A VARIABLE IS A NAMED EXPRESSION the whole workspace shares: `$vat`, `$monthStart`, `$refPrefix`. Write it as `$name` wherever a formula goes — in a formula column, in an automation’s `{{ }}`, and in a filter’s value — and change what it means in one place when the thing it stands for changes.\n\nIT IS THE SAME FOR EVERY RECORD, so it cannot read a column and cannot reach another table. That is the definition rather than a limit: a filter’s value is what every record is compared against, so one that varied per record would not be a filter — and a filter and a rule both refuse anything that reaches, which is why keeping a rate on a one-row table was never an answer.\n\nA variable may name another one. The NAME cannot change once it exists, because it is written into every formula, filter and rule that uses it; the definition can, and that is the edit you actually want.\n\nEach one comes back with `now`: WHAT IT ANSWERS AT THIS MOMENT. A caller deciding whether `$vat` is the name they want cannot tell from the expression alone, and a second request to find out would be a second moment.",
        "tags": [
          "Variables"
        ],
        "parameters": [],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "variables": [
                    {
                      "id": "019ffaea-8b00-77b7-b5e9-c580228d24d4",
                      "name": "vat",
                      "source": "0.17",
                      "result": "number",
                      "description": "VAT",
                      "uses": [],
                      "now": 0.17
                    },
                    {
                      "id": "019ffaea-8b00-77b7-b5e9-c580228d24d4",
                      "name": "monthStart",
                      "source": "startOf(today(), \"months\")",
                      "result": "date",
                      "description": null,
                      "uses": [],
                      "now": "2026-10-01"
                    }
                  ]
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      },
      "post": {
        "operationId": "create-variable",
        "summary": "Add a variable",
        "description": "The expression is checked before it is stored, and refused with a sentence: a name that is not one word, a cycle, a column it cannot read, a table it cannot reach. What it ANSWERS — number, text, date, yes/no or list — is worked out here and comes back as `result`.",
        "tags": [
          "Variables"
        ],
        "parameters": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string",
                    "description": "One word: letters, digits and underscores, starting with a letter. Without the `$` — the sigil is how it is written in a formula, not part of the name. One per workspace, case-insensitively."
                  },
                  "source": {
                    "type": "string",
                    "description": "The expression. May name another variable as `$other`."
                  },
                  "description": {
                    "type": "string",
                    "description": "What it is for, for whoever reads the list next year."
                  }
                },
                "required": [
                  "name",
                  "source"
                ]
              },
              "example": {
                "name": "vat",
                "source": "0.17",
                "description": "The rate invoices are raised at"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "variable": {
                    "id": "019ffaea-8b00-77b7-b5e9-c580228d24d4",
                    "name": "vat",
                    "source": "0.17",
                    "result": "number",
                    "uses": []
                  }
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/variables/{id}": {
      "patch": {
        "operationId": "update-variable",
        "summary": "Change what it means",
        "description": "THE DEFINITION CHANGES AND THE NAME DOES NOT. \"VAT went to 18%\" is one PATCH here, and every formula column, filter and rule that names `$vat` follows on its next read — which is the whole reason to have a name. Sending a different `name` is refused rather than silently ignored.",
        "tags": [
          "Variables"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The variable.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "source": {
                    "type": "string",
                    "description": "The new expression, checked the same way."
                  },
                  "description": {
                    "type": "string",
                    "description": "What it is for."
                  }
                }
              },
              "example": {
                "source": "0.18"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "variable": {
                    "id": "019ffaea-8b00-77b7-b5e9-c580228d24d4",
                    "name": "vat",
                    "source": "0.18",
                    "result": "number",
                    "uses": []
                  }
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      },
      "delete": {
        "operationId": "remove-variable",
        "summary": "Remove a variable",
        "description": "Refused while ANOTHER VARIABLE names it, saying which. Formula columns, view filters and automations are not checked — finding every one of them would be a scan of the whole workspace on the way to a delete — and what happens to those instead is why that is acceptable: a formula naming a variable that is gone is REFUSED BY NAME the next time it is read or saved, rather than quietly reading as blank.",
        "tags": [
          "Variables"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The variable.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "ok": true
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/members": {
      "get": {
        "operationId": "list-members-v1",
        "summary": "Who can reach this workspace",
        "description": "Everyone in the workspace with the roles they hold, AND everyone who has been asked and not yet joined — in one batched read. A list that answered with ids and made you fetch each member’s roles would turn a forty-person workspace into forty-one requests.\n\nTHE PENDING INVITATIONS USED TO BE MISSING HERE, and that mattered next door: you can invite over this API and get an id back, but a caller that lost the id had nowhere to look for it — which would have made `DELETE /v1/invitations/{id}` a verb only usable by somebody already holding the answer. One question, one call, the same shape the app’s own members screen answers in.\n\nTIER AND ROLES ARE DIFFERENT THINGS, and both read as “role” in English. `tier` is owner / member / guest / ghost and decides whether access expires; `roles` are the named permission bags somebody can hold several of. The API uses two words so you never have to guess which one an endpoint means. An invitation carries the `tier` it was offered at.",
        "tags": [
          "Members"
        ],
        "parameters": [],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "members": [
                    {
                      "id": "01a02a70-…",
                      "email": "ada@acme.com",
                      "tier": "owner",
                      "expiresAt": null,
                      "roles": [],
                      "joinedAt": "2026-01-04T10:00:00.000Z"
                    },
                    {
                      "id": "01a02a71-…",
                      "email": "grace@acme.com",
                      "tier": "member",
                      "expiresAt": null,
                      "roles": [
                        {
                          "id": "01a02a80-…",
                          "name": "Editor",
                          "color": "blue"
                        }
                      ],
                      "joinedAt": "2026-02-11T08:30:00.000Z"
                    }
                  ],
                  "invitations": [
                    {
                      "id": "01a02a90-…",
                      "email": "linus@acme.com",
                      "tier": "guest",
                      "expiresAt": "2026-11-01T00:00:00.000Z",
                      "createdAt": "2026-10-05T09:12:44.000Z"
                    }
                  ]
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      },
      "post": {
        "operationId": "invite-member",
        "summary": "Invite somebody",
        "description": "IT MAKES AN INVITATION, NOT A MEMBER. The person is not in the workspace until they accept, so the answer says `status: \"pending\"`. An integration that treats the 201 as “they are in” and then polls `/v1/members` for that address will poll forever.\n\nTHIS NEEDS `config`, NOT `write`. Membership is a change to the SHAPE of the workspace — who is in it and what they may do — so a key minted to file records cannot invite anybody, remove anybody, or change what a role grants. That used to be untrue: `write` covered both until migration 0114 split them, and this paragraph used to end by recommending a `read` key as the only defence. Keys that already held `write` were given `config` at the same time, so nothing that worked stopped working — but a key minted TODAY has to be given `config` deliberately.\n\nGUESTS AND END DATES ARE PAIRED BOTH WAYS: a guest invitation needs `expiresAt`, and nobody else may carry one.",
        "tags": [
          "Members"
        ],
        "parameters": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "email": {
                    "type": "string",
                    "description": "Where the invitation goes. Somebody already in the workspace is refused by name rather than invited twice."
                  },
                  "tier": {
                    "type": "string",
                    "description": "`member` (default), `guest` or `ghost`. `owner` is not among them — a workspace has exactly one and it changes only through a transfer, which is not in this API."
                  },
                  "expiresAt": {
                    "type": "string",
                    "description": "When a guest’s MEMBERSHIP ends — not when the invitation stops being acceptable. Required for `guest`, refused for every other tier."
                  },
                  "roles": {
                    "type": "string",
                    "description": "Roles applied on acceptance. Held on the invitation rather than written against a member who does not exist yet, so revoking it takes the grants with it."
                  },
                  "allPages": {
                    "type": "boolean",
                    "description": "Grant every page on acceptance, rather than naming them."
                  },
                  "pages": {
                    "type": "string",
                    "description": "The pages this person gets. Ignored when `allPages` is true."
                  }
                },
                "required": [
                  "email"
                ]
              },
              "example": {
                "email": "grace@acme.com",
                "tier": "member",
                "roles": [
                  "01a02a80-1586-776f-b92a-133830f16c4e"
                ]
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "invitation": {
                    "id": "01a02a90-…",
                    "email": "grace@acme.com",
                    "tier": "member",
                    "expiresAt": null,
                    "status": "pending",
                    "invitedBy": null,
                    "viaKey": "01a02aa0-…"
                  }
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/invitations/{id}": {
      "delete": {
        "operationId": "revoke-invitation",
        "summary": "Withdraw an invitation",
        "description": "Cancels a pending invitation, so the address it was sent to can no longer accept it.\n\nYOU COULD INVITE OVER THIS API AND NOT UN-INVITE — the one irreversible half of a pair where both halves are a single call.\n\nFIND THE ID ON `GET /v1/members`, which answers with pending invitations beside the members. `POST /v1/members` also returns it, but a caller that lost it needed somewhere to look.\n\nIT TAKES `config`. An invitation is a standing offer of access to the workspace, so it belongs with the scope that decides who can reach it — the same scope that made the offer.\n\nNot owner-only, matching the app: withdrawing a mistaken invitation is damage control, and the people who should be able to do it are the people who notice.",
        "tags": [
          "Members"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The invitation, from `GET /v1/members`.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "ok": true,
                  "revoked": "01a0169e-…"
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/members/{id}": {
      "patch": {
        "operationId": "update-member",
        "summary": "Change a member’s tier",
        "description": "The tier, and only the tier. Roles are a LIST, and a list is replaced rather than patched — which is a different verb on a different path, below. Folding both into one PATCH would make `{\"roles\": []}` ambiguous between “take them all away” and “I did not mention roles”.\n\nThe owner cannot be demoted here. Promoting and demoting are one atomic operation or they are a broken workspace, and that operation is not in this API.",
        "tags": [
          "Members"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The member id from `GET /v1/members` — not the user id.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "tier": {
                    "type": "string",
                    "description": "`member`, `guest` or `ghost`."
                  },
                  "expiresAt": {
                    "type": "string",
                    "description": "Required when the tier is `guest`."
                  }
                }
              },
              "example": {
                "tier": "guest",
                "expiresAt": "2026-12-31T00:00:00.000Z"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "member": {
                    "id": "01a02a71-…",
                    "email": "grace@acme.com",
                    "tier": "guest",
                    "expiresAt": "2026-12-31T00:00:00.000Z",
                    "roles": []
                  }
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      },
      "delete": {
        "operationId": "remove-member",
        "summary": "Remove a member",
        "description": "A SOFT DELETE, so the audit trail survives and a re-invite is a new row rather than a resurrection carrying whatever permissions it had in January.\n\nThe owner is refused, with the reason: a workspace with no owner is unrepairable through any path that checks permissions first, because there would be nobody left who could grant them.",
        "tags": [
          "Members"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The member id.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "ok": true
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/members/{id}/roles": {
      "put": {
        "operationId": "set-member-roles",
        "summary": "Set a member’s roles",
        "description": "PUT AND NOT PATCH, because that is what it does: send two ids and they end up with exactly those two. `[]` means none, which is only safe to express because the verb says the list is being replaced rather than added to.\n\nAn unknown role id is refused rather than skipped. Silently dropping one would report success for a grant that did not happen.",
        "tags": [
          "Members"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The member id.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "roles": {
                    "type": "string",
                    "description": "The complete list. Every id must exist in this workspace."
                  }
                },
                "required": [
                  "roles"
                ]
              },
              "example": {
                "roles": [
                  "01a02a80-1586-776f-b92a-133830f16c4e"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "roles": [
                    {
                      "id": "01a02a80-…",
                      "name": "Editor",
                      "color": "blue",
                      "permissions": {
                        "page.update": true
                      },
                      "enabled": true,
                      "position": 1
                    }
                  ]
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/roles": {
      "get": {
        "operationId": "list-roles",
        "summary": "List roles",
        "description": "Every role in the workspace, in the order they are drawn, with the permissions each one carries.",
        "tags": [
          "Members"
        ],
        "parameters": [],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "roles": [
                    {
                      "id": "01a02a80-…",
                      "name": "Editor",
                      "color": "blue",
                      "icon": null,
                      "permissions": {
                        "page.update": true
                      },
                      "enabled": true,
                      "position": 1
                    }
                  ]
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      },
      "post": {
        "operationId": "create-role",
        "summary": "Create a role",
        "description": "A named bag of permissions somebody can hold.\n\nUNKNOWN PERMISSION KEYS ARE DROPPED RATHER THAN REFUSED, deliberately: a role written against a newer version of the product should not fail to be created on an older one. It carries the permissions that exist here and gains the rest on upgrade.",
        "tags": [
          "Members"
        ],
        "parameters": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string",
                    "description": "Up to 60 characters — it is drawn as a chip."
                  },
                  "color": {
                    "type": "string",
                    "description": "One of the nine choice colours: gray, brown, orange, yellow, green, blue, purple, pink, red."
                  },
                  "icon": {
                    "type": "string",
                    "description": "A lucide icon name."
                  },
                  "permissions": {
                    "type": "object",
                    "description": "`{\"page.update\": true, …}`. Keys this build does not check are dropped."
                  },
                  "enabled": {
                    "type": "boolean",
                    "description": "Off suspends everybody holding it without unpicking who has what. Defaults to on."
                  }
                },
                "required": [
                  "name"
                ]
              },
              "example": {
                "name": "Editor",
                "color": "blue",
                "permissions": {
                  "page.update": true,
                  "row.update": true
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "role": {
                    "id": "01a02a80-…",
                    "name": "Editor",
                    "color": "blue",
                    "icon": null,
                    "permissions": {
                      "page.update": true
                    },
                    "enabled": true,
                    "position": 1
                  }
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/roles/{id}": {
      "patch": {
        "operationId": "update-role",
        "summary": "Edit a role",
        "description": "Any of the fields create takes, plus `position`. `permissions` REPLACES the bag rather than merging into it — a merge could never turn a permission off.",
        "tags": [
          "Members"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The role.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string",
                    "description": "Up to 60 characters."
                  },
                  "color": {
                    "type": "string",
                    "description": "One of the nine choice colours."
                  },
                  "icon": {
                    "type": "string",
                    "description": "A lucide icon name, or `null`."
                  },
                  "permissions": {
                    "type": "object",
                    "description": "The whole bag, replaced."
                  },
                  "enabled": {
                    "type": "boolean",
                    "description": "Suspend or restore everybody holding it."
                  },
                  "position": {
                    "type": "string",
                    "description": "Where it sits in the list."
                  }
                }
              },
              "example": {
                "name": "Editor",
                "enabled": false
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "role": {
                    "id": "01a02a80-…",
                    "name": "Editor",
                    "enabled": false
                  }
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      },
      "delete": {
        "operationId": "delete-role",
        "summary": "Delete a role",
        "description": "The answer says how many people were holding it. `{\"heldBy\": 12}` is the sentence “you just changed what twelve people can do” — an `ok: true` here would be a success message for an action whose entire risk is invisible from the request.",
        "tags": [
          "Members"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The role.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "ok": true,
                  "heldBy": 12
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/pages": {
      "get": {
        "operationId": "list-pages-v1",
        "summary": "List pages",
        "description": "FLAT, WITH `parent` ON EACH, rather than nested. The tree is what the app draws; the list is what an integration wants. A nested answer forces every caller to walk it to find one page, and a caller who wants the tree can build it from `parent` in four lines. The reverse direction is the awkward one.\n\nNO BODIES. A workspace with four hundred pages of writing would send megabytes to answer “what pages are there”. `GET /v1/pages/{id}` has the document.\n\n`?lang=he` ANSWERS WITH THE TITLES IN THAT LANGUAGE. This answer is nothing but names, so it is the one read where a missing `?lang=` is most visible: a caller who reads a page in Hebrew should be able to find it in Hebrew. One query for every page’s names, not one per page.",
        "tags": [
          "Pages"
        ],
        "parameters": [
          {
            "name": "lang",
            "in": "query",
            "required": false,
            "description": "Read the titles and descriptions in another language the workspace publishes, or `auto` to decide from your `Accept-Language`. Without it nothing changes, byte for byte, and no language is read.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "pages": [
                    {
                      "id": "01a0169d-1586-776f-b92a-133830f16c4e",
                      "title": "Weekly report",
                      "kind": "doc",
                      "parent": null,
                      "path": "/w/acme/p/01a0169d-1586-776f-b92a-133830f16c4e"
                    },
                    {
                      "id": "01a0169e-…",
                      "title": "Jobs",
                      "kind": "database",
                      "parent": "01a0169d-1586-776f-b92a-133830f16c4e",
                      "path": "/w/acme/p/01a0169e-…",
                      "locked": false,
                      "databaseLocked": true
                    }
                  ]
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      },
      "post": {
        "operationId": "create-page-v1",
        "summary": "Create a page",
        "description": "A whole page in one call: its title, its face, and its entire document.\n\nThe workspace comes from the key, which is why there is no workspace id in the path — a key cannot address another tenant even by accident.\n\nONE WRITE. The body is compiled and every reference in it checked before anything is inserted, so a malformed document is a 400 and not a blank page left in somebody’s sidebar.",
        "tags": [
          "Pages"
        ],
        "parameters": [],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "title": {
                    "type": "string",
                    "description": "Up to 500 characters. Left out, the page is untitled and shows the placeholder — the words “New page” live in the client, where they can be translated, and are never stored."
                  },
                  "icon": {
                    "type": "string",
                    "description": "An emoji, or `{kind: \"emoji\"|\"lucide\", value, color}`. `lucide` takes an icon name, which is what the picker writes."
                  },
                  "banner": {
                    "type": "string",
                    "description": "An image URL, or `{url, offsetY}` where `offsetY` is 0–100: how far down the image the crop sits. Defaults to 50."
                  },
                  "description": {
                    "type": "string",
                    "description": "A line under the title. Up to 2000 characters."
                  },
                  "parent": {
                    "type": "string",
                    "format": "uuid",
                    "description": "The page this sits under. Omit for the root. An id from another workspace is simply not found — the same answer as one that never existed."
                  },
                  "body": {
                    "type": "string",
                    "description": "The document. “The page body” below is the language, and the four element pages are what can go in it. Omit for a blank page."
                  }
                }
              },
              "example": {
                "title": "Weekly report",
                "icon": "📈",
                "body": [
                  {
                    "h1": "Weekly report"
                  },
                  "Everything below this line is generated.",
                  {
                    "divider": true
                  },
                  {
                    "callout": {
                      "icon": "📌",
                      "children": [
                        {
                          "toggle": {
                            "text": "This week’s jobs",
                            "children": [
                              {
                                "dbview": "01a02a70-b4c9-7d31-9f0e-2c5b8a1d4e99"
                              }
                            ]
                          }
                        },
                        "Figures refresh with the table — nothing here is a snapshot."
                      ]
                    }
                  },
                  {
                    "columns": [
                      [
                        {
                          "h3": "Shot"
                        },
                        {
                          "num": "Rooftop, 06:00"
                        },
                        {
                          "num": "Lobby, 11:00"
                        }
                      ],
                      [
                        {
                          "h3": "Outstanding"
                        },
                        {
                          "todo": {
                            "text": "Chase the permit",
                            "checked": false
                          }
                        }
                      ]
                    ]
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "page": {
                    "id": "01a0169d-1586-776f-b92a-133830f16c4e",
                    "title": "Weekly report",
                    "description": null,
                    "icon": {
                      "kind": "emoji",
                      "value": "📈"
                    },
                    "banner": null,
                    "parent": null,
                    "kind": "doc",
                    "path": "/w/acme/p/01a0169d-1586-776f-b92a-133830f16c4e",
                    "createdAt": "2026-08-22T09:14:03.221Z"
                  },
                  "blocks": 14,
                  "body": [
                    {
                      "h1": "Weekly report"
                    },
                    {
                      "txt": "Everything below this line is generated."
                    }
                  ]
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/pages/{id}": {
      "get": {
        "operationId": "get-page-v1",
        "summary": "Read a page",
        "description": "One page, with its document in the language you write it in rather than as stored. Sugar in, sugar out: take this answer, change one line, send it back — and with `ids=1` the ids come too, which is what makes sending it back SAFE rather than merely possible. A body with no ids is a body of NEW blocks: every reference to the old ones, including a share link’s hidden-block list, is pointing at nothing afterwards.\n\n`body` IS NULL ON A TABLE, not `[]`. Empty would say “nothing written here”; null says “this is not a thing that gets written in”.",
        "tags": [
          "Pages"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The page. An id from another workspace is a 404 — the same answer as one that never existed.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "ids",
            "in": "query",
            "required": false,
            "description": "`1` gives every element its block `id`, which is how the element endpoints address one. It changes the shape: `{\"txt\": \"hello\"}` becomes `{\"txt\": {\"text\": \"hello\", \"id\": \"b7f3a91x\"}}`, because the string shorthand has nowhere to put an id. Both forms are valid to send back.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "unsupported",
            "in": "query",
            "required": false,
            "description": "`error` (the default) or `mark`. A sub-page, inline table or synced block cannot be written in this language, so by default a page holding one is refused rather than described wrongly. `mark` answers with `{\"keep\": \"<id>\"}` in its place: send that back unchanged and the block is kept. There is deliberately no `skip` — a body read with blocks missing would DELETE them when sent back.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "lang",
            "in": "query",
            "required": false,
            "description": "Read it in another language the workspace publishes, or `auto` to decide from your `Accept-Language`. The title, the description and EVERY LINE OF THE BODY come back translated wherever somebody has written a translation.\n\n**A BODY READ IN ANOTHER LANGUAGE IS FOR READING.** Sending it back through `PATCH` would store the translation as the original — the one way to lose the writing this feature exists to leave alone, and the same hazard that keeps a record’s `values` untranslated on every endpoint. Read without `?lang=` when you intend to write back; `GET …/elements`, the block-addressed editing surface, never translates for this reason.\n\nA translated line also comes back PLAIN. A line’s formatting is stored as character offsets into the sentence somebody wrote, and on a different sentence they point somewhere else — bold mid-word, a link over the wrong phrase.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "page": {
                    "id": "01a0169d-1586-776f-b92a-133830f16c4e",
                    "title": "Weekly report",
                    "kind": "doc",
                    "parent": null,
                    "path": "/w/acme/p/01a0169d-1586-776f-b92a-133830f16c4e",
                    "locked": false,
                    "databaseLocked": false
                  },
                  "body": [
                    {
                      "h1": "Weekly report"
                    },
                    {
                      "txt": "Everything below this line is generated."
                    }
                  ]
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      },
      "patch": {
        "operationId": "update-page-v1",
        "summary": "Edit a page",
        "description": "Change what you name and leave the rest alone.\n\nABSENT AND NULL ARE DIFFERENT, and that is the whole contract: a field left out is untouched, a field sent as `null` is cleared. They are read apart by presence, never by truthiness — collapsing the two is how a PATCH wipes a page’s icon because a client serialised its whole model and every empty field went out as null.\n\n`body` REPLACES the document. There is no block addressing here, so there is no honest way to read it as “merge these in” — leave it out and not one block is touched.\n\nSEND THE IDS BACK. An element with no `id` is a NEW element, so a body of plain elements replaces every block with a fresh one carrying a fresh id. That is usually invisible and occasionally serious: a share link hides blocks BY ID and absence from that list means VISIBLE, so re-identifying a page would publish whatever it was hiding, on a link already handed out. Rather than let that happen quietly, a body that drops the id of a currently hidden block is REFUSED with `would_unhide`, naming the ids. Read with `?ids=1`, send them back, and the page edits while the link keeps its promise.\n\nAND `{\"keep\": \"<id>\"}` KEEPS WHAT THIS LANGUAGE CANNOT WRITE. A sub-page, an inline table or a synced block reads back as `{\"keep\": id}` under `?unsupported=mark`; sent back unchanged, the original block is put back where the marker sat. An id the page does not hold is a 400, never a silent drop — dropping one would delete a sub-page nobody mentioned.\n\nAn empty patch is a 400, not a cheerful 200: a caller sending `{}` has a bug, most often a field name that did not survive their serialiser, and a success for a write that did not happen is the one answer that would not help.",
        "tags": [
          "Pages"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The page. An id from another workspace is not found — the same answer as one that never existed.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "title": {
                    "type": "string",
                    "description": "Up to 500 characters. `null` or an empty string makes it untitled again."
                  },
                  "icon": {
                    "type": "string",
                    "description": "An emoji, or `{kind: \"emoji\"|\"lucide\", value, color}`. `null` removes it."
                  },
                  "banner": {
                    "type": "string",
                    "description": "An image URL, or `{url, offsetY}`. `null` removes it."
                  },
                  "description": {
                    "type": "string",
                    "description": "The line under the title. `null` removes it."
                  },
                  "parent": {
                    "type": "string",
                    "description": "Move it under another page, or `null` to move it to the root. Moving a page inside itself is a 409."
                  },
                  "body": {
                    "type": "string",
                    "description": "REPLACES the document. Carry each element’s `id` to keep it — an element without one is a NEW element, and a body that drops the id of a block a share link is hiding is refused with `would_unhide`. Refused on a table (`kind: \"database\"`), which shows its rows where a document shows its body — everything else on this list still works on one."
                  }
                }
              },
              "example": {
                "title": "Weekly report — Q3",
                "icon": "📉",
                "description": null
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "page": {
                    "id": "01a0169d-1586-776f-b92a-133830f16c4e",
                    "title": "Weekly report — Q3",
                    "description": null,
                    "icon": {
                      "kind": "emoji",
                      "value": "📉"
                    },
                    "banner": null,
                    "parent": null,
                    "kind": "doc",
                    "path": "/w/acme/p/01a0169d-1586-776f-b92a-133830f16c4e",
                    "createdAt": "2026-08-22T09:14:03.221Z",
                    "updatedAt": "2026-08-25T11:02:44.900Z"
                  }
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      },
      "delete": {
        "operationId": "delete-page-v1",
        "summary": "Delete a page",
        "description": "Works on a document and on a table alike — a table is a page, and deleting one takes its columns, its views and its rows with it.\n\nTRASH IS THE DEFAULT, AND THE DEFAULT IS THE POINT. The page leaves the tree, stops appearing anywhere, and can be restored. That is what the app’s own delete does, and what something running in a loop at four in the morning should do too: the mistake is recoverable in the morning.\n\n`?permanent=true` IS THE OTHER ONE, AND IT IS FOREVER. The rows are deleted, and with a table that is every record in it. No trash, no restore, nothing to undo it with — so it is a query parameter somebody has to type rather than a body field that could arrive by accident from a serialised model.\n\nIt works on a LIVE page only, which is what makes it predictable: the engine purges by trash BATCH, so purging something already in the trash could take siblings nobody named. Here the page is trashed and purged in one breath, and the batch is exactly this page and its descendants.\n\nEither way it takes the subtree, and either way the response names every id that went — an integration cannot watch a sidebar empty out, and “deleted: true” for a call that removed nine pages is the kind of quiet that gets discovered much later.",
        "tags": [
          "Pages"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The page. Already trashed, or from another workspace, is a 404 — the honest answer either way is that there is no such page to delete.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "permanent",
            "in": "query",
            "required": false,
            "description": "Destroy it instead of trashing it. There is no way back from this one.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "trashed": [
                    "01a0169d-1586-776f-b92a-133830f16c4e",
                    "01a0169d-1586-776f-b92a-133830f16c50"
                  ],
                  "alsoTrashed": 1,
                  "restorable": true
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/pages/{id}/duplicate": {
      "post": {
        "operationId": "duplicate-page",
        "summary": "Duplicate a page",
        "description": "A copy, WITH EVERYTHING UNDER IT. A page’s sub-pages are part of what it is — duplicating a project and getting an empty shell is never what anybody meant.\n\nA table’s rows come too, which is why this can be a slow call on a large one and is not something to put in a loop.",
        "tags": [
          "Pages"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The page to copy.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "201": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "page": {
                    "id": "01a016a0-…",
                    "title": "Weekly report (copy)",
                    "kind": "doc",
                    "parent": null
                  }
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/pages/{id}/restore": {
      "post": {
        "operationId": "restore-page",
        "summary": "Restore a page",
        "description": "Out of the trash.\n\nITS PARENT MAY NOT BE THERE ANY MORE. A page trashed inside a folder that was then trashed too comes back to the top level rather than to a parent nobody can see.",
        "tags": [
          "Pages"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "A trashed page. One that was purged is gone for good and answers 404.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "page": {
                    "id": "01a0169d-1586-776f-b92a-133830f16c4e",
                    "title": "Weekly report",
                    "parent": null
                  }
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/pages/{id}/lock": {
      "post": {
        "operationId": "lock-page",
        "summary": "Lock or unlock a page",
        "description": "ONE ENDPOINT AND A BOOLEAN, not a lock and an unlock. Two paths for one boolean is two things to get wrong, and an integration that wants to SET a state rather than toggle one would have to read the current value first to know which path to call.\n\nA locked page refuses edits with 423 — from a key exactly as from a person. Its rows and its settings are a separate lock — see “Lock a table’s shape”.\n\nREAD IT BACK from `locked` and `databaseLocked` on any page answer. Both were settable and unreadable until the keyed path began enforcing them, which made being refused the only way to discover the state.",
        "tags": [
          "Pages"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The page.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "locked": {
                    "type": "boolean",
                    "description": "`true` locks it, `false` unlocks it."
                  }
                },
                "required": [
                  "locked"
                ]
              },
              "example": {
                "locked": true
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "ok": true,
                  "locked": true
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/pages/trash": {
      "get": {
        "operationId": "list-trash",
        "summary": "What is in the trash",
        "description": "Pages thrown away and still gettable back, newest first, with how many days the workspace keeps them.\n\nWITHOUT THIS, EMPTYING THE TRASH WAS THE ONLY THING YOU COULD DO TO IT — all or nothing, unseen. `alsoTrashed` is how many child pages went with each entry, so a caller can see what restoring or purging one row is actually about to move.",
        "tags": [
          "Pages"
        ],
        "parameters": [],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "retentionDays": 30,
                  "total": 1,
                  "pages": [
                    {
                      "id": "01a0169d-1586-776f-b92a-133830f16c4e",
                      "title": "Old notes",
                      "kind": "doc",
                      "parent": null,
                      "deletedAt": "2026-09-28T14:02:00.000Z",
                      "alsoTrashed": 2
                    }
                  ]
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/pages/{id}/purge": {
      "delete": {
        "operationId": "purge-page",
        "summary": "Remove one trashed page for good",
        "description": "NOT UNDOABLE. Takes the whole batch the page was trashed with — a page and the children that went with it — so `count` is what actually went rather than the one id you named.\n\nTHIS IS NOT THE SAME AS `DELETE /v1/pages/{id}?permanent=true`. That one deletes a LIVE page outright and answers 404 for anything already in the trash. This is for what is already there.",
        "tags": [
          "Pages"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "A page that is in the trash. A live page, or one that never existed, is a 404.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "ok": true,
                  "count": 3
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/synced-blocks": {
      "get": {
        "operationId": "list-synced",
        "summary": "List synced blocks",
        "description": "A synced block is one passage stored once and shown on several pages — a standard footer, a set of terms — so editing it changes every page at once.\n\nNO BODIES HERE, the same decision the page listing makes. These are by definition the passages somebody thought worth repeating, and answering “what blocks are there” with all of them is megabytes to answer a question about names. `GET /v1/synced-blocks/{id}` has the writing.",
        "tags": [
          "Synced blocks"
        ],
        "parameters": [],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "blocks": [
                    {
                      "id": "01a016b0-…",
                      "name": "Standard footer",
                      "updatedAt": "2026-09-01T09:00:00.000Z"
                    },
                    {
                      "id": "01a016b1-…",
                      "name": null,
                      "updatedAt": "2026-09-02T11:30:00.000Z"
                    }
                  ]
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      },
      "post": {
        "operationId": "create-synced",
        "summary": "Create a synced block",
        "description": "Makes the passage. Putting it ON a page is a separate act the page body language cannot yet express, so this is for building and maintaining the library an editor then points at.\n\nA BLOCK WITH NO BODY IS NOT AN ERROR — reserving the name and filling it in later is what the app’s own panel does.",
        "tags": [
          "Synced blocks"
        ],
        "parameters": [],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string",
                    "description": "What it is called in the picker, up to 120 characters. Optional: unnamed is how one starts."
                  },
                  "body": {
                    "type": "array",
                    "items": {},
                    "description": "The writing, in the element language. Absent means empty."
                  }
                }
              },
              "example": {
                "name": "Standard footer",
                "body": [
                  {
                    "h2": "Terms"
                  },
                  {
                    "txt": "Payment within 30 days."
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "block": {
                    "id": "01a016b0-…",
                    "name": "Standard footer",
                    "updatedAt": "2026-09-01T09:00:00.000Z",
                    "body": [
                      {
                        "h2": "Terms"
                      },
                      {
                        "txt": "Payment within 30 days."
                      }
                    ]
                  }
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/synced-blocks/{id}": {
      "get": {
        "operationId": "get-synced",
        "summary": "Read a synced block",
        "description": "The writing, in the same element language a page body uses — so what you read is what you can send back.\n\nTHE ID COMES FROM THE LISTING, not from a page. A page showing one reads as `{\"keep\": \"<id>\"}` under `unsupported=mark`, and that id is the BLOCK’s rather than this one — so a marked body tells you something is there and not which passage it is. That is a known limitation, not a shortcut: widening the marked answer changes a shape other callers already parse.",
        "tags": [
          "Synced blocks"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The synced block. One from another workspace is a 404, the same answer as one that never existed.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "ids",
            "in": "query",
            "required": false,
            "description": "`1` gives every element its block `id`, exactly as on a page body.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "unsupported",
            "in": "query",
            "required": false,
            "description": "`error` (the default) or `mark`, exactly as on a page body.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "block": {
                    "id": "01a016b0-…",
                    "name": "Standard footer",
                    "updatedAt": "2026-09-01T09:00:00.000Z",
                    "body": [
                      {
                        "h2": "Terms"
                      },
                      {
                        "txt": "Payment within 30 days."
                      }
                    ]
                  }
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      },
      "patch": {
        "operationId": "update-synced",
        "summary": "Update a synced block",
        "description": "Changes the passage everywhere it appears, which is the point of storing it once.\n\n`name: null` CLEARS THE NAME and is not the same as leaving it out. An empty patch is a 400 rather than a success that would hide whichever field name did not survive.",
        "tags": [
          "Synced blocks"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The synced block.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string",
                    "description": "A new name, or `null` to take the name off."
                  },
                  "body": {
                    "type": "array",
                    "items": {},
                    "description": "The writing, in the element language. Replaces what was there."
                  }
                }
              },
              "example": {
                "body": [
                  {
                    "h2": "Terms"
                  },
                  {
                    "txt": "Payment within 14 days."
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "block": {
                    "id": "01a016b0-…",
                    "name": "Standard footer",
                    "updatedAt": "2026-09-03T08:15:00.000Z",
                    "body": [
                      {
                        "h2": "Terms"
                      },
                      {
                        "txt": "Payment within 14 days."
                      }
                    ]
                  }
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      },
      "delete": {
        "operationId": "delete-synced",
        "summary": "Delete a synced block",
        "description": "Stops it being offered. It does not unwrite the pages that show it — worth knowing before sending, because “delete the thing six pages point at” is a sentence whose meaning a caller should be able to predict.",
        "tags": [
          "Synced blocks"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The synced block.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "ok": true
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/snapshots": {
      "get": {
        "operationId": "snapshots",
        "summary": "What backups exist",
        "description": "A snapshot is the only copy of a workspace that lives outside the database.\n\nREAD `hasStorage` FIRST. A workspace with no bucket takes no snapshots, by design rather than by accident, and this is the only place a program finds that out before needing one.",
        "tags": [
          "Backups"
        ],
        "parameters": [],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "snapshots": [
                    {
                      "id": "01a016f0-…",
                      "workspaceName": "Acme",
                      "rows": 1840,
                      "bytes": 262144,
                      "tables": 24,
                      "sha256": "9f2c…",
                      "takenAt": "2026-10-01T02:00:00.000Z",
                      "takenBy": null,
                      "verifiedAt": null
                    }
                  ],
                  "hasStorage": true
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      },
      "post": {
        "operationId": "take-snapshot",
        "summary": "Take a snapshot now",
        "description": "SKIPPED WHEN NOTHING HAS HAPPENED since the last one, because a backup of an unchanged workspace is a second copy of a file that already exists. `force: true` takes one regardless, which is what “before I run the migration” means. The answer says which happened rather than leaving you to infer it from a timestamp.",
        "tags": [
          "Backups"
        ],
        "parameters": [],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "force": {
                    "type": "boolean",
                    "description": "Take one even if nothing has changed."
                  }
                }
              },
              "example": {
                "force": true
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "id": "01a016f0-…",
                  "skipped": false,
                  "rows": 1840,
                  "bytes": 262144
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/snapshots/{id}": {
      "get": {
        "operationId": "snapshot",
        "summary": "Read one snapshot",
        "description": "The manifest, and what is inside it as ROW COUNTS per section, so “what would come back” is answerable without moving the file.\n\nThe file itself is behind the link endpoint below. A snapshot is every row in the workspace at once, and an endpoint that returned it inline would be the largest answer this API can give, through a path with no paging.",
        "tags": [
          "Backups"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The snapshot.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "snapshot": {
                    "id": "01a016f0-…",
                    "workspaceName": "Acme",
                    "rows": 1840,
                    "bytes": 262144,
                    "tables": 24,
                    "sha256": "9f2c…",
                    "takenAt": "2026-10-01T02:00:00.000Z"
                  },
                  "sections": [
                    {
                      "name": "pages",
                      "rows": 42
                    },
                    {
                      "name": "db_rows",
                      "rows": 1631
                    }
                  ]
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/snapshots/{id}/url": {
      "get": {
        "operationId": "snapshot-url",
        "summary": "A link to the file",
        "description": "A signed URL, good for five minutes.\n\nTHE MANIFEST IS READ FIRST and the link minted from the key it names, so a snapshot row that has drifted from its object cannot hand out a URL for the wrong file. `url: null` means there is no storage configured: the snapshot row is there and the bucket is not, which are different problems with different fixes.\n\nTHERE IS NO RESTORE OVER THIS API. Putting a snapshot back makes a NEW workspace owned by whoever asked, and a key has nobody behind it, so the only available answer would hand whoever minted the key, possibly someone who has left, a brand-new workspace against their plan. A person does that from the app.",
        "tags": [
          "Backups"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The snapshot.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "url": "https://…",
                  "expiresInSeconds": 300
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/page-access": {
      "get": {
        "operationId": "page-access",
        "summary": "Which pages each member sees",
        "description": "Every member, whether they see everything, and which pages the restricted ones were given.\n\n`allPages` IS TRUE BY DEFAULT, so the interesting rows are the false ones — a caller reading only `grants` would conclude nobody is restricted. The pages and members come with the answer because grants are ids, and a list of id pairs reads as nothing on its own.",
        "tags": [
          "Who may see what"
        ],
        "parameters": [],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "members": [
                    {
                      "id": "01a016d0-…",
                      "email": "dana@acme.com",
                      "allPages": false,
                      "tier": "member"
                    }
                  ],
                  "pages": [
                    {
                      "id": "01a0169d-1586-776f-b92a-133830f16c4e",
                      "title": "Alpha",
                      "parent": null
                    }
                  ],
                  "grants": {
                    "01a016d0-…": [
                      "01a0169d-1586-776f-b92a-133830f16c4e"
                    ]
                  }
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/members/{id}/page-access": {
      "put": {
        "operationId": "set-page-access",
        "summary": "Set which pages one person sees",
        "description": "THIS REPLACES, it does not add. `all: true` gives them every page and clears their grants; `pages` restricts them to exactly that list.\n\nTO GIVE SOMEBODY ONE MORE PAGE, USE `PUT /v1/pages/{id}/grants` INSTEAD. Phrased as a whole-access write, \"give them this page too\" takes away everything else they had.\n\nAn empty body is refused rather than read as “restrict them to nothing”, which nobody types on purpose.",
        "tags": [
          "Who may see what"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The member.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "all": {
                    "type": "boolean",
                    "description": "`true` means every page, now and in future."
                  },
                  "pages": {
                    "type": "array",
                    "items": {},
                    "description": "Page ids they may see. Anything not listed is taken away."
                  }
                }
              },
              "example": {
                "all": false,
                "pages": [
                  "01a0169d-1586-776f-b92a-133830f16c4e"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "ok": true
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/pages/{id}/grants": {
      "put": {
        "operationId": "set-page-grant",
        "summary": "Give one page to a member or a role",
        "description": "One page, one member or one role, at a level — AND IT LEAVES THEIR OTHER PAGES ALONE. That is the whole difference from setting a person’s page access, which replaces.\n\n`level: null` takes this page back without touching the rest. Giving a page to somebody who already sees every page is refused rather than silently written, because it is a no-op they would not understand: restrict them first.",
        "tags": [
          "Who may see what"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The page.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "member": {
                    "type": "string",
                    "format": "uuid",
                    "description": "The member to give it to. One of `member` or `role`."
                  },
                  "role": {
                    "type": "string",
                    "format": "uuid",
                    "description": "The role to give it to, for everybody holding it."
                  },
                  "level": {
                    "type": "string",
                    "description": "How much of it they get. `null` takes the page back."
                  },
                  "caps": {
                    "type": "object",
                    "description": "Finer permissions within that level."
                  }
                }
              },
              "example": {
                "member": "01a016d0-…",
                "level": "edit"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "ok": true
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/access-requests": {
      "get": {
        "operationId": "access-requests",
        "summary": "Who has asked to be let in",
        "description": "A share link set to `request` collects people knocking. Reading the queue over the API is what lets an integration route the asking somewhere a person will actually see it, rather than it waiting in a screen nobody has open.",
        "tags": [
          "Who may see what"
        ],
        "parameters": [],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "requests": [
                    {
                      "id": "01a016e0-…",
                      "email": "new@acme.com",
                      "page": "01a0169d-1586-776f-b92a-133830f16c4e",
                      "status": "pending"
                    }
                  ]
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/access-requests/{id}": {
      "post": {
        "operationId": "decide-access-request",
        "summary": "Let them in, or turn them down",
        "description": "`approve: false` IS A DECISION AND NOT A DELETION — the row stays answered rather than vanishing, so the same person asking twice is visible as that rather than as a first request.\n\n`level` is what THIS person is let in as; omitted means the link’s own level.",
        "tags": [
          "Who may see what"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The request.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "approve": {
                    "type": "boolean",
                    "description": "true or false. There is no default: letting somebody in and turning them away must not share one."
                  },
                  "level": {
                    "type": "string",
                    "description": "What to let them in as."
                  }
                },
                "required": [
                  "approve"
                ]
              },
              "example": {
                "approve": true,
                "level": "view"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "status": "approved",
                  "needsInvite": false
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/shares": {
      "get": {
        "operationId": "list-all-shares",
        "summary": "Every live link",
        "description": "Every link in the workspace that still opens, newest first, each naming the page it opens and whether that page is in the trash. This is the \"what is open right now, and to what\" call ’ before it existed you could list links only one page at a time, so an integration auditing the workspace had to walk the whole page tree first.\n\nREVOKED LINKS ARE NOT HERE. This answers what is open; `GET /v1/pages/{id}/shares?revoked=true` answers what ever was.\n\nAND NO TOKENS, for the same reason the per-page listing has none: one `read` key that could enumerate every live door is a different credential from the one somebody minted.",
        "tags": [
          "Shares"
        ],
        "parameters": [],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "shares": [
                    {
                      "id": "01a02ab0-…",
                      "page": "01a0169d-1586-776f-b92a-133830f16c4e",
                      "pageTitle": "Projects",
                      "pageTrashed": false,
                      "mode": "public",
                      "level": "view",
                      "label": "Client portal",
                      "caps": {},
                      "expiresAt": null,
                      "revokedAt": null,
                      "createdAt": "2026-08-01T12:00:00.000Z"
                    }
                  ]
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/pages/{id}/shares": {
      "get": {
        "operationId": "list-shares",
        "summary": "List a page’s links",
        "description": "The live doors into this page. Send `?revoked=true` for the dead ones too — “was this page ever public, and until when” is the question somebody asks after an incident, and a listing that could only show live links would answer it wrongly and confidently.\n\nTHE TOKENS ARE NOT HERE. Only the create call returns one — otherwise a single `read` key could enumerate every live door into the workspace, which is a very different credential from the one somebody thought they were minting.",
        "tags": [
          "Shares"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The page.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "revoked",
            "in": "query",
            "required": false,
            "description": "`true` includes links that have been revoked, each with its `revokedAt`. Off by default: a list of links nobody can use is a list nobody can act on.",
            "schema": {
              "type": "boolean"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "shares": [
                    {
                      "id": "01a02ab0-…",
                      "page": "01a0169d-1586-776f-b92a-133830f16c4e",
                      "mode": "public",
                      "level": "view",
                      "label": "Client preview",
                      "caps": {},
                      "expiresAt": null,
                      "revokedAt": null,
                      "createdAt": "2026-08-01T12:00:00.000Z"
                    }
                  ]
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      },
      "post": {
        "operationId": "create-share",
        "summary": "Create a link",
        "description": "THE ANSWER CARRIES THE ONLY COPY OF THE TOKEN. Same bargain as an API key: shown once, never again. Lose it and you revoke the link and make another — which is also the right response to “somebody may have seen it”.\n\nMODE AND LEVEL ARE DIFFERENT QUESTIONS. `mode` is who the door is for; `level` is what they may do once through it — `view`, `values`, `edit`, `full`, or `custom`, which reads its permissions from `caps`.\n\nA PUBLIC LINK CAN ONLY BE `view`. Nobody is signed in behind one, so a change made through it could not be attributed to anybody — and a URL that grants writing is a URL that ends up in a chat log. Refused with that reason rather than by a constraint three layers down.\n\nThe token is returned rather than a URL because this install does not know the host it is served from — behind a proxy the request’s own Host header is a guess. Append it to `/share/` on an origin you already know.",
        "tags": [
          "Shares"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The page to open.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "mode": {
                    "type": "string",
                    "description": "`members` (default), `public` — anyone with the link — or `request`, where anyone may ask and somebody approves."
                  },
                  "level": {
                    "type": "string",
                    "description": "`view` (default), `values` — view and edit cells but not the page — `edit`, `full`, or `custom`, which takes its permissions from `caps`. A public link can only be `view`."
                  },
                  "label": {
                    "type": "string",
                    "description": "What this link is for, so a list of six is readable. Up to 120 characters."
                  },
                  "caps": {
                    "type": "object",
                    "description": "Fine-grained overrides on top of the level, e.g. `{\"download\": false}`."
                  },
                  "expiresAt": {
                    "type": "string",
                    "description": "When the link stops working. A date already past is refused."
                  }
                }
              },
              "example": {
                "mode": "public",
                "level": "view",
                "label": "Client preview"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "share": {
                    "id": "01a02ab0-…",
                    "page": "01a0169d-1586-776f-b92a-133830f16c4e",
                    "mode": "public",
                    "level": "view",
                    "label": "Client preview",
                    "expiresAt": null,
                    "revokedAt": null
                  },
                  "token": "shr_7f3a91c4e0d2"
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/shares/{id}": {
      "patch": {
        "operationId": "update-share",
        "summary": "Edit a link",
        "description": "The level, the label, the caps and the expiry. Not the mode: who a door is for is decided when it is opened, and changing it under an existing token would silently widen a link somebody has already sent.",
        "tags": [
          "Shares"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The share.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "level": {
                    "type": "string",
                    "description": "`view`, `values`, `edit`, `full` or `custom`."
                  },
                  "label": {
                    "type": "string",
                    "description": "Up to 120 characters."
                  },
                  "caps": {
                    "type": "object",
                    "description": "Overrides on top of the level."
                  },
                  "expiresAt": {
                    "type": "string",
                    "description": "`null` makes it permanent."
                  }
                }
              },
              "example": {
                "level": "values",
                "expiresAt": "2026-12-31T00:00:00.000Z"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "share": {
                    "id": "01a02ab0-…",
                    "level": "values",
                    "expiresAt": "2026-12-31T00:00:00.000Z"
                  }
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      },
      "delete": {
        "operationId": "delete-share",
        "summary": "Revoke a link",
        "description": "REVOKED, NOT DELETED. The row stays with `revokedAt` set, so the answer to “was this ever open, and until when” survives the closing of it. A link whose history is erased when it is revoked is a link nobody can investigate.",
        "tags": [
          "Shares"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The share.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "ok": true
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      },
      "get": {
        "operationId": "share-detail",
        "summary": "What a link shows",
        "description": "One link’s whole configuration: every element of the page that can be shared, the tabs inside it, which columns each tab shows, which blocks are hidden, and who the link is for.\n\nSTART HERE BEFORE WRITING ANY RULE. `element` and `tab` in the rules call come from this answer and nowhere else — a tab key is derived from the view it stands for, not something a caller can construct.\n\nThe password is never in it. A lock can be set and replaced, never read.",
        "tags": [
          "Shares"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The link.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "share": {
                    "id": "01a02ab0-…",
                    "page": "01a0169d-1586-776f-b92a-133830f16c4e",
                    "mode": "public",
                    "level": "view",
                    "label": "Client portal",
                    "caps": {},
                    "expiresAt": null,
                    "revokedAt": null,
                    "createdAt": "2026-08-01T12:00:00.000Z"
                  },
                  "elements": [
                    {
                      "id": "table",
                      "kind": "table",
                      "tabs": [
                        {
                          "key": "v:01a1…",
                          "name": "All",
                          "visible": true,
                          "columns": [
                            "f_052ba5ec071d",
                            "f_7c1e…"
                          ]
                        }
                      ]
                    }
                  ],
                  "audiences": [
                    {
                      "id": "01a02ac1-…",
                      "kind": "anyone",
                      "level": "view"
                    }
                  ]
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/shares/{id}/rules": {
      "put": {
        "operationId": "share-rules",
        "summary": "Choose a tab’s columns",
        "description": "What one tab of the link shows: whether it appears at all, which columns, which of them a guest may edit, and whether rows can be added.\n\nONE TAB PER CALL, on purpose. A single call that replaced every rule at once would mean a caller reading, changing one thing and writing back the lot — and whatever they had not refreshed would quietly revert.\n\nCOLUMNS ARE KEYS, not names: a column renamed on the table does not change what a link shows. Get them from `GET /v1/shares/{id}`.",
        "tags": [
          "Shares"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The link.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "element": {
                    "type": "string",
                    "description": "The element holding the view, from `GET /v1/shares/{id}`. A table page’s single element is `table`."
                  },
                  "tab": {
                    "type": "string",
                    "description": "The tab key, from the same place. Derived from the view, so it cannot be constructed."
                  },
                  "visible": {
                    "type": "boolean",
                    "description": "Whether the tab appears on the link at all."
                  },
                  "columns": {
                    "type": "string",
                    "description": "The column KEYS this tab shows, in order. Anything left out is not sent to a guest — not hidden in their browser, absent from the response."
                  },
                  "edit": {
                    "type": "string",
                    "description": "Which of those columns a guest may write. Only on a link whose `level` allows it."
                  },
                  "add": {
                    "type": "boolean",
                    "description": "Whether a guest may add rows."
                  },
                  "notes": {
                    "type": "boolean",
                    "description": "Whether a guest sees each row’s page."
                  },
                  "refresh": {
                    "type": "boolean",
                    "description": "Re-copy the arrangement from the view as it is now, instead of keeping what this link was given. A link does not follow its view; this is how it catches up on purpose."
                  }
                },
                "required": [
                  "element",
                  "tab"
                ]
              },
              "example": {
                "element": "table",
                "tab": "v:01a1…",
                "visible": true,
                "columns": [
                  "f_052ba5ec071d",
                  "f_7c1e…"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "share": {
                    "id": "01a02ab0-…",
                    "mode": "public",
                    "level": "view"
                  },
                  "elements": [
                    {
                      "id": "table",
                      "kind": "table",
                      "tabs": [
                        {
                          "key": "v:01a1…",
                          "name": "All",
                          "visible": true,
                          "columns": [
                            "f_052ba5ec071d",
                            "f_7c1e…"
                          ]
                        }
                      ]
                    }
                  ]
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/shares/{id}/blocks": {
      "put": {
        "operationId": "share-blocks",
        "summary": "Hide a block from a link",
        "description": "Show or hide one block of the page on this link.\n\nA HIDDEN CONTAINER TAKES WHAT IS INSIDE IT. Content is a flat list with a depth, so \"inside\" is \"deeper than this, until something is not\" — hiding a toggle and publishing its children would be the worst kind of half-kept promise.\n\nThe block id comes from `GET /v1/pages/{id}?ids=1`.",
        "tags": [
          "Shares"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The link.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "block": {
                    "type": "string",
                    "description": "The block id, from `GET /v1/pages/{id}?ids=1`. A block this page does not hold is a 400."
                  },
                  "visible": {
                    "type": "boolean",
                    "description": "`false` hides it, `true` shows it again."
                  }
                },
                "required": [
                  "block",
                  "visible"
                ]
              },
              "example": {
                "block": "b7f3a91x",
                "visible": false
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "share": {
                    "id": "01a02ab0-…",
                    "mode": "public",
                    "level": "view"
                  },
                  "hidden": [
                    "b7f3a91x"
                  ]
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/shares/{id}/access": {
      "put": {
        "operationId": "share-access",
        "summary": "Put a lock on a link",
        "description": "What the link asks for before it opens: nothing, a password, or a code sent to a listed address.\n\nCHANGING IT ASKS EVERYBODY AGAIN. Whoever was already through is not grandfathered, which is the whole point of changing a lock.\n\nTHE PASSWORD GOES UP AND NEVER COMES BACK. It is stored as a hash and no route answers with it, so there is no \"read the current password\" — only setting a new one.",
        "tags": [
          "Shares"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The link.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "access": {
                    "type": "string",
                    "description": "`anyone`, `password`, or `code`."
                  },
                  "password": {
                    "type": "string",
                    "description": "The new password. `null` removes it. Never answered with."
                  },
                  "allowedEmails": {
                    "type": "string",
                    "description": "For `code`: the addresses a one-time code may be sent to. Nobody else can get in."
                  },
                  "askFor": {
                    "type": "string",
                    "description": "What a guest is asked to give before opening it, such as their name."
                  }
                }
              },
              "example": {
                "access": "password",
                "password": "correct-horse-battery"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "share": {
                    "id": "01a02ab0-…",
                    "mode": "public",
                    "level": "view",
                    "access": "password"
                  }
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/shares/{id}/audiences": {
      "get": {
        "operationId": "list-audiences",
        "summary": "Who a link is for",
        "description": "One link can be for several people at different levels, each seeing a different part of it. A public link is born with the one row its kind stands for.",
        "tags": [
          "Shares"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The link.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "audiences": [
                    {
                      "id": "01a02ac1-…",
                      "kind": "anyone",
                      "level": "view",
                      "visibility": null
                    }
                  ]
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      },
      "post": {
        "operationId": "add-audience",
        "summary": "Add somebody to a link",
        "description": "A row saying who, at what level, and what they see of it.\n\nONE ROW PER AUDIENCE. A second row for the same person, role or for everybody is a 409 rather than a silent write: two rules about one audience is a question nobody could answer by reading them.",
        "tags": [
          "Shares"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The link.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "kind": {
                    "type": "string",
                    "description": "`anyone`, `member`, `role`, or `request` (somebody who asked)."
                  },
                  "level": {
                    "type": "string",
                    "description": "`view`, `values` or `page`. Defaults to `view`."
                  },
                  "member": {
                    "type": "string",
                    "format": "uuid",
                    "description": "Required when `kind` is `member`."
                  },
                  "role": {
                    "type": "string",
                    "format": "uuid",
                    "description": "Required when `kind` is `role`."
                  },
                  "visibility": {
                    "type": "string",
                    "description": "The part of the link’s own contents this row sees, or `null` for all of it. A row can never see more than the link does."
                  }
                },
                "required": [
                  "kind"
                ]
              },
              "example": {
                "kind": "member",
                "member": "01a02ad0-…",
                "level": "values"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "audience": "01a02ac1-…",
                  "audiences": [
                    {
                      "id": "01a02ac1-…",
                      "kind": "member",
                      "level": "values",
                      "email": "client@example.com"
                    }
                  ]
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/shares/{id}/audiences/{audienceId}": {
      "patch": {
        "operationId": "update-audience",
        "summary": "Change what somebody sees",
        "description": "The level or the visible part of one row.\n\nA ROW FOR SOMEBODY WHO ASKED IS FIXED. Turning a request row into a member row would quietly re-point the answer at a different person, so it is refused — delete it and add them deliberately.",
        "tags": [
          "Shares"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The link.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "audienceId",
            "in": "path",
            "required": true,
            "description": "The row, from `GET /v1/shares/{id}/audiences`. An id this link does not have is a 404 — including one that is not a uuid.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "level": {
                    "type": "string",
                    "description": "`view`, `values` or `page`."
                  },
                  "visibility": {
                    "type": "string",
                    "description": "What this row sees of the link, or `null` for all of it."
                  },
                  "kind": {
                    "type": "string",
                    "description": "Changing it is allowed except away from `request`."
                  },
                  "member": {
                    "type": "string",
                    "format": "uuid",
                    "description": "With `kind: \"member\"`."
                  },
                  "role": {
                    "type": "string",
                    "format": "uuid",
                    "description": "With `kind: \"role\"`."
                  }
                }
              },
              "example": {
                "level": "page"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "audiences": [
                    {
                      "id": "01a02ac1-…",
                      "kind": "anyone",
                      "level": "page",
                      "visibility": null
                    }
                  ]
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      },
      "delete": {
        "operationId": "remove-audience",
        "summary": "Take somebody off a link",
        "description": "Removes the row. Whoever it was for stops getting in on the next request.\n\nREMOVING SOMEBODY WHO ASKED IS TURNING THEM AWAY: the request is marked denied rather than deleted, so asking again does not put them back in the queue as though nobody had decided.",
        "tags": [
          "Shares"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The link.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "audienceId",
            "in": "path",
            "required": true,
            "description": "The row. A 404 if this link does not have it.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "audiences": []
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/databases/{id}/formula/tables": {
      "get": {
        "operationId": "formula-tables",
        "summary": "What a formula here may name",
        "description": "Every column a formula on this table can refer to, keyed by table, with each table’s name beside it.\n\nSTART HERE BEFORE BUILDING A FORMULA FROM SOMEBODY ELSE’S INPUT. Without it there is no way to learn that this table can see `Client.first().Rate`, so an integration can offer no field picker and, when a formula is refused, cannot say which of the names was the wrong one.\n\nIT INCLUDES EVERY TABLE IN THE WORKSPACE, not only the ones reachable through a relation — `all(\"Invoices\")` may name any of them, so any of them can appear in a formula. Relations are what make a DOTTED path work; this list is what makes a NAME resolvable.\n\nA walk over columns, never rows: two or three small queries whatever the size of the workspace.",
        "tags": [
          "Formulas"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table the formula would live on.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "tables": {
                    "01a0169e-…": [
                      {
                        "key": "f_052ba5ec071d",
                        "name": "Days",
                        "type": "number"
                      }
                    ],
                    "01a016a0-…": [
                      {
                        "key": "f_7c1e4a90b2d1",
                        "name": "Rate",
                        "type": "number"
                      }
                    ]
                  },
                  "names": {
                    "01a0169e-…": "Jobs",
                    "01a016a0-…": "Clients"
                  }
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/databases/{id}/formula/preview": {
      "post": {
        "operationId": "formula-preview",
        "summary": "Try a formula without saving it",
        "description": "Compiles a draft against this table and, if you name a record or two, works out what it would give.\n\nTHE ALTERNATIVE WAS CREATE-AND-DROP. Until this existed the only way to find out whether a formula was valid was to create the column and read a row back — so an integration had to WRITE to a table in order to answer a question about it. Nothing here is written: no column, no value, no row.\n\nA MISTAKE IN THE FORMULA IS A 200 WITH `ok: false`, NOT A 400. The question being asked is \"is this valid?\", and \"no, and here is where\" is a successful answer to it; a 400 would say the REQUEST was wrong when the request was perfect and the formula was not. A malformed request — no `source`, `items` not a list, a page that is not a table — is still a 400.\n\nThe error carries `at: {from, to}`, the characters to underline, and `meant`, the nearest real name. Both exist so an integration can show somebody their own mistake rather than forwarding ours.\n\nIT TAKES `read`, because it reads. A POST only because a formula is up to 4000 characters and belongs in a body.",
        "tags": [
          "Formulas"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table to compile against.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "source": {
                    "type": "string",
                    "description": "The formula, up to 4000 characters, in column NAMES as somebody would type it."
                  },
                  "items": {
                    "type": "string",
                    "description": "Up to three records to work it out for. Leave it out to compile only — which is all you need to answer \"is this valid\"."
                  },
                  "self": {
                    "type": "string",
                    "description": "The key of the column being edited, left out of the table so a formula cannot be previewed against its own half-finished answer."
                  }
                },
                "required": [
                  "source"
                ]
              },
              "example": {
                "source": "Days * 2",
                "items": [
                  "01a0169f-…"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "ok": true,
                  "type": "number",
                  "shown": "Days * 2",
                  "stored": "f_052ba5ec071d * 2",
                  "values": [
                    {
                      "id": "01a0169f-…",
                      "label": "Rooftop shoot",
                      "value": 6
                    }
                  ]
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/databases/{id}/formula/write": {
      "post": {
        "operationId": "formula-write",
        "summary": "Describe a formula, and get one",
        "description": "Asks a language model to write a formula for this table from a sentence, and hands back only formulas that COMPILE.\n\nCOSTS 10 UNITS PER ASK, and takes the `spend` scope. It is ten rather than one because it is a model call against a prompt holding every function in the language, and up to two of them.\n\nIT NEVER RETURNS A FORMULA THAT DOES NOT COMPILE. The answer is read by the real engine, against this table’s real columns, before you see it — and when it does not compile the engine’s own refusal goes back to the model as the next message. One retry, then it gives up and tells you what it tried, in `tried`. A suggestion box that handed back a plausible `sumIf(…)` that does not exist would leave somebody debugging the assistant instead of their formula.\n\nIT NEEDS THE WORKSPACE’S OWN AI KEY, set in Settings → AI. Without one this is a 4xx naming that screen, not a 429 — a missing credential and an exhausted cap are different things to go and do.\n\nNO CELL LEAVES THE WORKSPACE. The prompt carries column names and types and no values.\n\n\"I COULD NOT WRITE THAT\" IS A 200 with `ok: false`, the same rule `/formula/preview` follows: it is an answer to the question, not a failure of the request.\n\nREFUSED ON A FROZEN TABLE, like every other route that is about a column’s shape — including `preview`, which also saves nothing.",
        "tags": [
          "Formulas"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table the formula is for.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "ask": {
                    "type": "string",
                    "description": "What the formula should work out, in words, up to 400 characters."
                  },
                  "source": {
                    "type": "string",
                    "description": "A formula to change rather than start from nothing — which is what makes \"now round it\" answerable."
                  },
                  "self": {
                    "type": "string",
                    "description": "The key of the column being written, left out of the columns the model may name so it cannot produce a cycle."
                  }
                },
                "required": [
                  "ask"
                ]
              },
              "example": {
                "ask": "the fee plus VAT, rounded to two places"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "ok": true,
                  "shown": "round(Fee * 1.17, 2)",
                  "stored": "round(f_9d41f7 * 1.17, 2)",
                  "type": "number",
                  "note": "Multiplies the fee by 1.17 and rounds to two decimal places."
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/meta/columns": {
      "get": {
        "operationId": "meta-columns",
        "summary": "The column types",
        "description": "Every value `type` accepts when you create a column, and the two catalogues that two of those types check against.\n\nWHAT THIS DOES NOT ANSWER, said plainly: there is no machine description of each type’s `options` bag anywhere in this server, so there is none here. `options` is a string pointing you at the reference rather than a schema, because publishing the names and implying the rest would be worse than publishing nothing. What each type takes is documented on the column endpoints.\n\n`phoneCountries` and `email` are the rules a phone and an email column validate with. The app has read them from its own copy of this endpoint since its first phone column; this is the same answer at the public door, so there is one home for each rule rather than two that drift.\n\nThe same answer for every workspace — cacheable, and only `read`.",
        "tags": [
          "Formulas"
        ],
        "parameters": [],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "types": [
                    "text",
                    "number",
                    "select",
                    "date",
                    "formula",
                    "relation",
                    "…"
                  ],
                  "options": "Each type’s `options` bag is described per endpoint in the API reference.",
                  "phoneCountries": [
                    {
                      "code": "IL",
                      "dial": "+972",
                      "name": "Israel"
                    }
                  ],
                  "email": {
                    "maxLength": 320,
                    "typos": {
                      "gmial.com": "gmail.com"
                    }
                  }
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/spend": {
      "get": {
        "operationId": "spend",
        "summary": "What this workspace has spent today",
        "description": "Units spent today, the daily cap, and what is left.\n\nPOLL THIS BEFORE A BATCH, not after a refusal. Four endpoints in this API cost the workspace real money with a third party — address search, address details, reverse geocoding and the AI formula writer — and they are refused with a 429 once the daily cap is reached. Reading this first is how a program decides whether to start, or how far it can get.\n\nA UNIT IS A RANKING, NOT A PRICE. One address lookup is 1 unit; one AI formula write is 10. Provider prices differ by region and plan and change, so this API deliberately does not pretend to convert — the money is on the provider’s own invoice. What a unit tells you is how many calls you have left.\n\nTHE DAY IS UTC and resets at midnight UTC, so the same number is the same number wherever it is read from. It is PER WORKSPACE, not per key: five keys at nine-tenths of their own ceiling would be a workspace at four and a half times its intended spend.\n\nIT TAKES `read`, not `spend` — asking costs nothing, and a program that has just been refused must be able to find out why with the key it already holds.",
        "tags": [
          "Spending & lookups"
        ],
        "parameters": [],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "spend": {
                    "day": "2026-10-07",
                    "spent": 240,
                    "cap": 1000,
                    "remaining": 760
                  }
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/spend/history": {
      "get": {
        "operationId": "spend-history",
        "summary": "A few days of it",
        "description": "One entry per UTC day that had any spending, newest first.\n\nFor a dashboard, or for noticing that something has been running up units since Tuesday. Days with no paid calls have no row rather than a zero — a workspace that never spends keeps no counter.\n\n`cap` IS TODAY’S, AND ONLY TODAY’S. The ceiling is a setting and keeps no history of itself, so there is one `cap` field beside the list rather than one per day; pretending each day knew its own would be inventing a number.\n\n`days` is clamped to 90 rather than refused, because the honest answer to \"give me a year\" is ninety days of rows.",
        "tags": [
          "Spending & lookups"
        ],
        "parameters": [
          {
            "name": "days",
            "in": "query",
            "required": false,
            "description": "How many days back, 1 to 90. Default 14.",
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "cap": 1000,
                  "days": [
                    {
                      "day": "2026-10-07",
                      "spent": 240
                    },
                    {
                      "day": "2026-10-06",
                      "spent": 31
                    }
                  ]
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/places/status": {
      "get": {
        "operationId": "places-status",
        "summary": "Whether address lookup is set up",
        "description": "Whether this deployment has a maps provider configured, and where the workspace stands against its cap.\n\nASK THIS FIRST. Address lookup needs a provider credential that belongs to the DEPLOYMENT, not to the workspace, so it may simply not be configured — in which case the three lookups below answer with a provider error that looks like a fault rather than like a missing setting.\n\nIT RETURNS THE METER TOO, because `configured: true` on a workspace with no units left is a yes to the wrong question.\n\nTHE ONE ROUTE IN THIS SECTION THAT TAKES `read` RATHER THAN `spend`. It calls nothing and costs nothing, and a status endpoint behind a paid scope would be a toll on the question \"is this free to ask\".",
        "tags": [
          "Spending & lookups"
        ],
        "parameters": [],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "configured": true,
                  "spend": {
                    "day": "2026-10-07",
                    "spent": 240,
                    "cap": 1000,
                    "remaining": 760
                  }
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/places/search": {
      "get": {
        "operationId": "places-search",
        "summary": "Search for a place by name",
        "description": "Predictions for a partial address or place name, to be resolved with `/v1/places/details`.\n\nCOSTS 1 UNIT PER CALL, and takes the `spend` scope. A 429 with `spend_capped` means the workspace’s daily cap is reached; the message says the numbers and when it resets.\n\nYOU MAY NOT NEED THIS AT ALL. A place value in Orbit is an object of coordinates and address parts, not a provider id — so a program that already has a geocoder can write the value straight into a place column and never call this. It exists for the program that has an address string and nothing else.\n\nPASS `session` AND REUSE IT for every call of one lookup, including the `details` call that closes it. The provider bills autocomplete PER SESSION rather than per request, so a shared token is one billable request instead of one per keystroke. Orbit’s own meter charges per CALL either way, which over-counts a client that types character by character and is exact for a program sending one query per lookup.",
        "tags": [
          "Spending & lookups"
        ],
        "parameters": [
          {
            "name": "q",
            "in": "query",
            "required": true,
            "description": "What to search for, up to 200 characters.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "session",
            "in": "query",
            "required": false,
            "description": "A token of your own making, reused across one lookup. Cheaper at the provider — see above.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "predictions": [
                    {
                      "id": "ChIJ…",
                      "label": "Rothschild Blvd 1, Tel Aviv-Yafo, Israel"
                    }
                  ]
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/places/details": {
      "get": {
        "operationId": "places-details",
        "summary": "One place, in full",
        "description": "Resolves a prediction id into the object a place column stores: coordinates, a formatted address, and the parts.\n\nCOSTS 1 UNIT PER CALL, and takes the `spend` scope.\n\nWHAT COMES BACK IS WRITEABLE AS-IS into a place column’s value — that is the point of the shape. Nothing here is saved by this call; it is a lookup.\n\nPASS THE SAME `session` you used for the search. It is what makes the whole lookup one billable request at the provider instead of several.",
        "tags": [
          "Spending & lookups"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "query",
            "required": true,
            "description": "A prediction id from `/v1/places/search`.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "session",
            "in": "query",
            "required": false,
            "description": "The token from the search that found this id.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "place": {
                    "name": "Rothschild Blvd 1",
                    "address": "Rothschild Blvd 1, Tel Aviv-Yafo, Israel",
                    "lat": 32.0634,
                    "lng": 34.7723,
                    "components": {
                      "city": "Tel Aviv-Yafo",
                      "country": "IL"
                    }
                  }
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/places/reverse": {
      "get": {
        "operationId": "places-reverse",
        "summary": "Coordinates, turned into an address",
        "description": "The nearest address to a latitude and longitude, in the same shape `/v1/places/details` returns.\n\nCOSTS 1 UNIT PER CALL, and takes the `spend` scope.\n\nIT USES A KEYLESS PROVIDER, so this one answers even where `/v1/places/status` reports `configured: false` — \"not configured\" is about address SEARCH. It still charges, because it is still a request to somebody else’s service.",
        "tags": [
          "Spending & lookups"
        ],
        "parameters": [
          {
            "name": "lat",
            "in": "query",
            "required": true,
            "description": "Latitude, -90 to 90.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "lng",
            "in": "query",
            "required": true,
            "description": "Longitude, -180 to 180.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "place": {
                    "address": "Rothschild Blvd 1, Tel Aviv-Yafo, Israel",
                    "lat": 32.0634,
                    "lng": 34.7723,
                    "components": {
                      "city": "Tel Aviv-Yafo",
                      "country": "IL"
                    }
                  }
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/automations": {
      "get": {
        "operationId": "list-automations",
        "summary": "List rules",
        "description": "Every rule in the workspace, in the order they run. `?table=` narrows to one table’s rules, which is how you find the `button`-triggered rule a button column has to name.\n\n`effective` IS THE FIELD TO READ. A rule can be enabled and still do nothing — a webhook action with no address, a verb nothing records yet — and `effective` is the one answer to \"would this do anything at all right now\".\n\nNEVER THE SIGNING SECRET. `transport.signed` says whether there is one; the value is not in any listing.",
        "tags": [
          "Automations"
        ],
        "parameters": [
          {
            "name": "table",
            "in": "query",
            "required": false,
            "description": "Only rules scoped to this table. Leave it out for all of them, workspace-wide rules included.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "automations": [
                    {
                      "id": "01a02b10-…",
                      "name": "Tell the courier",
                      "enabled": true,
                      "scope": "page",
                      "scopeId": "01a0169d-1586-776f-b92a-133830f16c4e",
                      "position": 1,
                      "trigger": {
                        "type": "event",
                        "config": {
                          "verbs": [
                            "db.row_created"
                          ]
                        },
                        "live": true
                      },
                      "actions": [
                        {
                          "id": "a_abf8990306",
                          "type": "send_webhook",
                          "method": "POST",
                          "ready": true,
                          "live": true
                        }
                      ],
                      "transport": {
                        "url": "https://couriers.example.com/hook",
                        "method": "POST",
                        "signed": true
                      },
                      "effective": true,
                      "nextRunAt": null
                    }
                  ]
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      },
      "post": {
        "operationId": "create-automation",
        "summary": "Create a rule",
        "description": "A rule is a trigger and a list of actions. THIS IS THE CALL THAT LETS AN INTEGRATION INSTALL ITSELF: create the rule, point its transport at your own address, keep the signing secret, and Orbit calls you instead of you polling us.\n\nIT NEEDS `config`, NOT `write`, and deliberately so: a rule sends mail, posts to addresses and writes rows, for ever, with nobody present. That is the sharp end of the scope split — the key an integration uses to file records cannot do any of it.\n\nAN ACTION OR A VERB THIS SERVER HAS NEVER HEARD OF IS REFUSED, rather than dropped. A rule stored with a misspelled action has no actions, and a rule waiting on a verb nothing emits can never fire — both of which look exactly like a rule that is working and quiet.",
        "tags": [
          "Automations"
        ],
        "parameters": [],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string",
                    "description": "What it is called. Up to 200 characters."
                  },
                  "description": {
                    "type": "string",
                    "description": "What it is for, in your words."
                  },
                  "trigger": {
                    "type": "object",
                    "description": "`{type, config}`. `type` is `event`, `schedule`, `button` or `api`; `config` depends on it. For `event`, `config.verbs` is the list of events — see `GET /v1/meta/events`."
                  },
                  "actions": {
                    "type": "string",
                    "description": "What it does, in order, up to ten. Each is `{type, …}`; an unknown `type` is a 400 listing the real ones."
                  },
                  "enabled": {
                    "type": "boolean",
                    "description": "Whether it runs. A rule can be created switched off and wired up before it is switched on."
                  },
                  "table": {
                    "type": "string",
                    "description": "The table this rule belongs to. It then appears in that table’s menu and dies with it. Null for a workspace-wide rule."
                  },
                  "icon": {
                    "type": "string",
                    "description": "`{kind, value}`. Null uses the trigger’s own icon."
                  }
                }
              },
              "example": {
                "name": "Tell the courier",
                "table": "01a0169d-1586-776f-b92a-133830f16c4e",
                "trigger": {
                  "type": "event",
                  "config": {
                    "verbs": [
                      "db.row_created"
                    ]
                  }
                },
                "actions": [
                  {
                    "type": "send_webhook"
                  }
                ],
                "enabled": true
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "automation": {
                    "id": "01a02b10-…",
                    "name": "Tell the courier",
                    "enabled": true,
                    "scope": "page",
                    "scopeId": "01a0169d-1586-776f-b92a-133830f16c4e",
                    "position": 1,
                    "trigger": {
                      "type": "event",
                      "config": {
                        "verbs": [
                          "db.row_created"
                        ]
                      },
                      "live": true
                    },
                    "actions": [
                      {
                        "id": "a_abf8990306",
                        "type": "send_webhook",
                        "method": "POST",
                        "ready": true,
                        "live": true
                      }
                    ],
                    "transport": {
                      "url": "https://couriers.example.com/hook",
                      "method": "POST",
                      "signed": true
                    },
                    "effective": true,
                    "nextRunAt": null
                  }
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/automations/{id}": {
      "patch": {
        "operationId": "update-automation",
        "summary": "Edit a rule",
        "description": "Change what you name and leave the rest alone. `actions` and `trigger` REPLACE rather than merge — there is no addressing inside them, so there is no honest way to read a partial list as \"add these\".",
        "tags": [
          "Automations"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The rule.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string",
                    "description": "Up to 200 characters."
                  },
                  "trigger": {
                    "type": "object",
                    "description": "Replaces the trigger. Same shape as on create."
                  },
                  "actions": {
                    "type": "string",
                    "description": "Replaces the action list."
                  },
                  "enabled": {
                    "type": "boolean",
                    "description": "Switch it on or off without changing anything else."
                  },
                  "table": {
                    "type": "string",
                    "description": "Move it to another table, or to the workspace."
                  },
                  "position": {
                    "type": "integer",
                    "description": "Where it sits in the order rules run."
                  }
                }
              },
              "example": {
                "enabled": false
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "automation": {
                    "id": "01a02b10-…",
                    "name": "Tell the courier",
                    "enabled": true,
                    "scope": "page",
                    "scopeId": "01a0169d-1586-776f-b92a-133830f16c4e",
                    "position": 1,
                    "trigger": {
                      "type": "event",
                      "config": {
                        "verbs": [
                          "db.row_created"
                        ]
                      },
                      "live": true
                    },
                    "actions": [
                      {
                        "id": "a_abf8990306",
                        "type": "send_webhook",
                        "method": "POST",
                        "ready": true,
                        "live": true
                      }
                    ],
                    "transport": {
                      "url": "https://couriers.example.com/hook",
                      "method": "POST",
                      "signed": true
                    },
                    "effective": true,
                    "nextRunAt": null
                  }
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      },
      "delete": {
        "operationId": "delete-automation",
        "summary": "Delete a rule",
        "description": "It stops firing. ITS RUNS SURVIVE IT — \"what did this rule do before somebody deleted it\" is the question asked after an incident, and it cannot be answered from a row that is gone, so `GET /v1/automation-runs` still has its history.",
        "tags": [
          "Automations"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The rule.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "ok": true
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/automations/{id}/run": {
      "post": {
        "operationId": "run-automation",
        "summary": "Run a rule now",
        "description": "Fires it immediately and answers with WHAT HAPPENED — whether it ran, what each action did, what failed. Not an acknowledgement: a 200 meaning \"queued\" on an endpoint called run is the kind of API that teaches people to poll.\n\nANY rule, including a scheduled one somebody wants to test without waiting for its hour. That is different from the unauthenticated trigger at `POST /run/{workspaceId}/{id}`, which exists so a rule can be fired with no credential at all and only fires rules built to accept it.",
        "tags": [
          "Automations"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The rule.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "item": {
                    "type": "string",
                    "format": "uuid",
                    "description": "The record to run it against, for a rule whose actions are about one row."
                  }
                }
              },
              "example": {
                "item": "01a0169f-…"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "ran": true,
                  "results": [
                    {
                      "action": "a_abf8990306",
                      "type": "send_webhook",
                      "ok": true
                    }
                  ]
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/automations/{id}/transport": {
      "put": {
        "operationId": "set-transport",
        "summary": "Point a rule at your own server",
        "description": "Where this rule posts when it posts. THE ONE CALL THAT TURNS THIS API ROUND: every other endpoint is you asking Orbit something, and this is you saying where to be told.\n\n**THE ADDRESS MUST BE PUBLIC, AND THIS IS THE STEP THAT SURPRISES PEOPLE.** A receiver on `localhost`, `127.0.0.1` or anything else inside a private network is refused with 400 `private_address`, and a host that does not resolve is refused with `unresolvable`. Orbit’s server sits inside a private network and holds its credentials, so it will not fetch on your behalf from addresses you could not reach yourself.\n\nWHICH MEANS YOU CANNOT TEST THIS AGAINST A RECEIVER ON YOUR OWN MACHINE without putting it somewhere public first — a tunnel, a staging host, a request-bin. That is the normal way to build a webhook integration and the refusal arrives at exactly the moment you are doing it, so it is said here rather than left to a 400. (A self-hosted deployment can set `ORBIT_ALLOW_PRIVATE_WEBHOOKS=1` for a local test; it is off by default, refuses outright in production, and exists for the delivery suite.)\n\nTHE SIGNING SECRET COMES BACK ONCE, on the call that mints it. A rule that already had one answers `signingSecret: null` rather than repeating a credential — read it deliberately with `…/transport/secret`, which is a different question and leaves a different line in the audit log.\n\nTHE ADDRESS IS CHECKED BEFORE IT IS STORED. Anything inside this network is refused by name; an API that let a rule be pointed at `169.254.169.254` would be an SSRF with a scheduler attached.",
        "tags": [
          "Automations"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The rule.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "url": {
                    "type": "string",
                    "description": "Where to post. Null removes the address, which leaves every webhook action on this rule unready."
                  },
                  "method": {
                    "type": "string",
                    "description": "`POST`, `PUT`, `PATCH` or `GET`."
                  },
                  "headers": {
                    "type": "object",
                    "description": "Extra headers to send."
                  },
                  "authHeader": {
                    "type": "string",
                    "description": "An authorization header value, stored sealed and never answered with."
                  },
                  "bodyTemplate": {
                    "type": "string",
                    "description": "A template for the body. Null sends the event as JSON, which is what most receivers want."
                  }
                }
              },
              "example": {
                "url": "https://couriers.example.com/hook",
                "method": "POST"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "transport": {
                    "url": "https://couriers.example.com/hook",
                    "method": "POST",
                    "signed": true
                  },
                  "signingSecret": "whsec_…"
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/automations/{id}/transport/secret": {
      "post": {
        "operationId": "transport-secret",
        "summary": "Read the signing secret",
        "description": "A receiver needs this to verify that a request came from us at all — an unverified webhook endpoint is a URL anybody who learns it can post to.\n\nA POST FOR A READ, on purpose. A GET that answers with a credential is a GET something will one day prefetch, log, or leave in a browser’s history; and reading a secret is an event worth recording, which a cacheable GET invites a proxy to hide.",
        "tags": [
          "Automations"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The rule.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "signingSecret": "whsec_…"
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/automations/{id}/transport/rotate": {
      "post": {
        "operationId": "rotate-transport",
        "summary": "Rotate the signing secret",
        "description": "Mints a new one and the old one stops verifying at once. THIS IS THE HALF THAT MAKES THE WHOLE ARRANGEMENT RECOVERABLE: a secret an integration cannot rotate is one it can never recover from a leak of without a person opening a browser, so a leak becomes a support ticket instead of one call.\n\nThere is a gap of one delivery’ width here by design — anything already in flight was signed with the old secret. Accept both for a moment, or rotate when the queue is quiet.",
        "tags": [
          "Automations"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The rule.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "signingSecret": "whsec_…"
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/automation-runs": {
      "get": {
        "operationId": "automation-runs",
        "summary": "Did it work",
        "description": "The log, newest first: what fired, when, how long it took, and what each action answered.\n\nWITHOUT THIS A RULE IS A THING YOU HOPE ABOUT. An integration that installs its own webhook and cannot ask whether the rule has fired, or what the last attempt answered, is back to polling the table it was trying to stop polling.",
        "tags": [
          "Automations"
        ],
        "parameters": [
          {
            "name": "automation",
            "in": "query",
            "required": false,
            "description": "One rule’s history. Leave it out for the whole workspace.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Up to 200. Defaults to 50.",
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "runs": [
                    {
                      "id": "01a02b20-…",
                      "automationId": "01a02b10-…",
                      "triggerType": "event",
                      "status": "done",
                      "subjectId": "01a0169f-…",
                      "durationMs": 412,
                      "createdAt": "2026-10-05T09:57:14.252Z"
                    }
                  ]
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/meta/events": {
      "get": {
        "operationId": "meta-events",
        "summary": "Every event a rule can wait for",
        "description": "The verb is the WIRE NAME and it does not change because a screen decided to call something else — a record being added is `db.row_created`, however the app labels it. Which is why this list exists: `item.created` is the obvious guess, and a rule waiting on it could never fire.\n\n`live: false` MEANS NOTHING RECORDS IT YET, not that it is unimportant. Such a verb is still accepted on a rule, deliberately: an integration can be wired ahead of a feature and starts working the day the verb does.\n\nThe same answer for every workspace, so it is cacheable and needs only `read`.",
        "tags": [
          "Automations"
        ],
        "parameters": [],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "events": [
                    {
                      "verb": "db.row_created",
                      "group": "item",
                      "label": "Created",
                      "blurb": "A record was added to a table.",
                      "live": true
                    },
                    {
                      "verb": "variable.changed",
                      "group": "workspace",
                      "label": "Variable changed",
                      "blurb": "A named value was edited.",
                      "live": true
                    }
                  ]
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/pages/{id}/elements": {
      "get": {
        "operationId": "list-elements",
        "summary": "Read a document",
        "description": "The page’s elements, in the language you write them in — and with `ids=1`, each carrying the `id` the other four endpoints address it by. WITHOUT `ids=1` THERE ARE NO IDS, which is worth saying plainly because this sentence used to promise them unconditionally and the endpoint never gave any: `PATCH`, `DELETE` and `move` were reachable only for elements a program had just created and remembered itself.\n\nA TABLE HAS NO ELEMENTS and says so rather than answering with an empty list: it shows its rows where a document shows its blocks.",
        "tags": [
          "Elements"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "A document page.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "ids",
            "in": "query",
            "required": false,
            "description": "`1` gives every element its block `id`, which is how the element endpoints address one. It changes the shape: `{\"txt\": \"hello\"}` becomes `{\"txt\": {\"text\": \"hello\", \"id\": \"b7f3a91x\"}}`, because the string shorthand has nowhere to put an id. Both forms are valid to send back.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "unsupported",
            "in": "query",
            "required": false,
            "description": "`error` (the default) or `mark`. A sub-page, inline table or synced block cannot be written in this language, so by default a page holding one is refused rather than described wrongly. `mark` answers with `{\"keep\": \"<id>\"}` in its place: send that back unchanged and the block is kept. There is deliberately no `skip` — a body read with blocks missing would DELETE them when sent back.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "elements": [
                    {
                      "h1": {
                        "text": "Weekly report",
                        "id": "b7f3a91x"
                      }
                    },
                    {
                      "txt": {
                        "text": "Generated below this line.",
                        "id": "b2c4d8e1"
                      }
                    }
                  ],
                  "count": 2
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      },
      "post": {
        "operationId": "create-element",
        "summary": "Add an element",
        "description": "WHY THIS EXISTS BESIDE `PATCH /v1/pages/{id}`: that endpoint replaces the whole document, which means reading the page, editing one paragraph in memory and writing all of it back. Two integrations doing that at once lose one of the two edits entirely, and neither is told.\n\nSTILL READ-MODIFY-WRITE, AND HONEST ABOUT IT. A page’s blocks are one JSON column, so one element cannot be written without rewriting the list. What this buys is that the rest of the document is read and written by the SERVER microseconds apart, rather than by a client across a round trip it may not complete. Two callers editing two paragraphs in the same second can still lose one.\n\nONE ELEMENT CAN BECOME SEVERAL BLOCKS — a list with three items is three — so the answer gives every id it minted.",
        "tags": [
          "Elements"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "A document page.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "element": {
                    "type": "string",
                    "description": "One element in the body language — see “The page body”."
                  },
                  "after": {
                    "type": "string",
                    "description": "Put it directly below the element with this id."
                  },
                  "at": {
                    "type": "string",
                    "description": "Put it at this index. Sending both `after` and `at` is refused — they are two answers to one question. Neither means the end."
                  }
                },
                "required": [
                  "element"
                ]
              },
              "example": {
                "element": {
                  "txt": "One more paragraph."
                },
                "after": "b7f3a91c"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "elements": [
                    {
                      "id": "b2c4e0d1",
                      "type": "text"
                    }
                  ],
                  "at": 3
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/pages/{id}/elements/{elementId}": {
      "patch": {
        "operationId": "update-element",
        "summary": "Edit an element",
        "description": "IT REPLACES RATHER THAN MERGES. An element’s fields differ by type — a heading has a level, an image has a URL — so “merge these keys in” would mean the server deciding which of an unknown type’s fields are safe to keep.\n\nTHE ID SURVIVES, so links and references to it do too. An element that compiles to more than one block is refused here: delete it and create the new ones, rather than having one id silently become three.",
        "tags": [
          "Elements"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The page.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "elementId",
            "in": "path",
            "required": true,
            "description": "From `GET /v1/pages/{id}/elements?ids=1` — the `ids=1` matters, the plain read carries none. An id this page does not have is a 400 that says so.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "element": {
                    "type": "string",
                    "description": "The replacement, in the body language."
                  }
                },
                "required": [
                  "element"
                ]
              },
              "example": {
                "element": {
                  "h2": "Revised heading"
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "element": {
                    "id": "b7f3a91c",
                    "type": "heading"
                  }
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      },
      "delete": {
        "operationId": "delete-element",
        "summary": "Delete an element",
        "description": "AND ITS CHILDREN GO WITH IT. The blocks are a flat list and a child is the next block with a greater indent, so deleting only the parent would leave its children behind, indented under whatever happened to be above them — which reads as the document rearranging itself.\n\nThe answer says how many lines went, which is the only way that is visible from the request.",
        "tags": [
          "Elements"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The page.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "elementId",
            "in": "path",
            "required": true,
            "description": "The element.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "ok": true,
                  "removed": 4
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/pages/{id}/elements/{elementId}/move": {
      "post": {
        "operationId": "move-element",
        "summary": "Move an element",
        "description": "The index is where it lands in the list AS IT IS AFTER THE REMOVAL, which is the only reading that makes “move it to 0” mean the top whichever direction it came from.",
        "tags": [
          "Elements"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The page.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "elementId",
            "in": "path",
            "required": true,
            "description": "The element to move.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "at": {
                    "type": "string",
                    "description": "The index it ends up at."
                  },
                  "after": {
                    "type": "string",
                    "description": "Or: directly below this element."
                  }
                }
              },
              "example": {
                "at": 0
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "element": {
                    "id": "b7f3a91c"
                  },
                  "at": 0
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/databases": {
      "post": {
        "operationId": "create-database-v1",
        "summary": "Create a table",
        "description": "A table, with its columns, in one call.\n\nWHY NOT `POST /v1/pages` WITH A KIND: a document has a body and no columns, a table has columns and no writing surface. One endpoint taking both would have two fields that are each meaningless half the time. Two endpoints, every field of each one live.\n\nEVERYTHING IS CHECKED BEFORE ANYTHING IS WRITTEN. A table is a page, a view and one write per column, and a request refused at the ninth column must not leave a table with eight — so the types, the option bags and the choices are all validated first, through the very same functions that would accept or refuse them later.\n\nIT ALWAYS COMES OUT WITH A NAME COLUMN. Send no columns and you get what the button makes: one text column called Name. Send columns and the first text one becomes the name; send none that are text and a Name is added in front — a table whose records cannot be named is not a table anybody can use.",
        "tags": [
          "Tables"
        ],
        "parameters": [],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "title": {
                    "type": "string",
                    "description": "Up to 500 characters. Left out, the table is untitled and shows the placeholder."
                  },
                  "icon": {
                    "type": "string",
                    "description": "An emoji, or `{kind: \"emoji\"|\"lucide\", value, color}`."
                  },
                  "banner": {
                    "type": "string",
                    "description": "An image URL, or `{url, offsetY}` where `offsetY` is 0–100."
                  },
                  "description": {
                    "type": "string",
                    "description": "A line under the title. Up to 2000 characters."
                  },
                  "parent": {
                    "type": "string",
                    "format": "uuid",
                    "description": "The page this sits under. Omit for the root."
                  },
                  "columns": {
                    "type": "string",
                    "description": "The columns, IN ORDER — which matters for the ones that point at each other. Each is `{name, type, options}`; only `type` is required, and every option is optional. Up to 100. “Column types” below is the full list."
                  }
                }
              },
              "example": {
                "title": "Jobs",
                "icon": "📸",
                "columns": [
                  {
                    "name": "Job",
                    "type": "text"
                  },
                  {
                    "name": "Stage",
                    "type": "select",
                    "options": {
                      "choices": [
                        {
                          "name": "Booked",
                          "color": "blue"
                        },
                        {
                          "name": "Shot",
                          "color": "green"
                        }
                      ]
                    }
                  },
                  {
                    "name": "Shoot day",
                    "type": "date",
                    "options": {
                      "date_format": "dmy"
                    }
                  },
                  {
                    "name": "Fee",
                    "type": "number",
                    "options": {
                      "number_format": "currency",
                      "decimals": 2
                    }
                  },
                  {
                    "name": "Delivered",
                    "type": "checkbox"
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "page": {
                    "id": "01a0169d-1586-776f-b92a-133830f16c4e",
                    "title": "Jobs",
                    "description": null,
                    "icon": {
                      "kind": "emoji",
                      "value": "📸"
                    },
                    "banner": null,
                    "parent": null,
                    "kind": "database",
                    "path": "/w/acme/p/01a0169d-1586-776f-b92a-133830f16c4e",
                    "createdAt": "2026-08-26T09:14:03.221Z"
                  },
                  "view": {
                    "id": "01a0169d-1586-776f-b92a-133830f16c51",
                    "name": "Table"
                  },
                  "columns": [
                    {
                      "id": "01a0169d-…",
                      "key": "k_7f3a91",
                      "name": "Job",
                      "type": "text",
                      "primary": true
                    },
                    {
                      "id": "01a0169e-…",
                      "key": "k_2b8c04",
                      "name": "Stage",
                      "type": "select",
                      "primary": false
                    }
                  ]
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/databases/{id}": {
      "patch": {
        "operationId": "update-database-v1",
        "summary": "Edit a table",
        "description": "The table, and its columns.\n\nThe page half — title, icon, banner, description, parent — behaves exactly as it does on a document, absent and null included. `PATCH /v1/pages/{id}` accepts the same four on a table; this endpoint is the one that also takes columns.\n\nIT IS NOT DECLARATIVE, AND THAT IS THE POINT. `columns` is `{add, update, remove}` and never a list. A list could only mean “these are the columns now”, so the column a client left out — the one its model did not know about — would be deleted data, reported as a success. Nobody writing that client would have typed “delete”. A column you do not mention is a column nothing happens to.\n\nEVERY OPERATION IS CHECKED BEFORE ANY OF THEM RUNS. A page can be rolled back by trashing it; a table half-updated cannot be rolled back at all. So the whole batch is validated first — the columns exist, the types are known, the options pass the engine’s own checks, the retype is one it would allow, the name column is not being removed — and only then is anything written. Then: update, add, remove. Destructive last.",
        "tags": [
          "Tables"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table. A document here is a 400 that names the endpoint you wanted; an id from another workspace is a 404.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "title": {
                    "type": "string",
                    "description": "As on a page. `null` makes it untitled."
                  },
                  "icon": {
                    "type": "string",
                    "description": "As on a page. `null` removes it."
                  },
                  "banner": {
                    "type": "string",
                    "description": "As on a page. `null` removes it."
                  },
                  "description": {
                    "type": "string",
                    "description": "As on a page. `null` removes it."
                  },
                  "parent": {
                    "type": "string",
                    "description": "Move it, or `null` for the root."
                  },
                  "columns.add": {
                    "type": "string",
                    "description": "New columns, same shape as on create — `{name, type, options}`. Added after the updates."
                  },
                  "columns.update": {
                    "type": "string",
                    "description": "Each names one column by `id` or `key` — the create response gives you both — and carries any of `name`, `type`, `options`. A type change is nearly always refused, and the refusal says which pair it was: reinterpreting every value in a column is a migration, not a PATCH."
                  },
                  "columns.remove": {
                    "type": "string",
                    "description": "Ids or keys. The column stops appearing; its values stay on the rows, and nothing in this API brings it back. The name column cannot be removed — rename it instead."
                  }
                }
              },
              "example": {
                "title": "Jobs 2026",
                "columns": {
                  "add": [
                    {
                      "name": "Invoice",
                      "type": "url"
                    }
                  ],
                  "update": [
                    {
                      "key": "k_2b8c04",
                      "name": "Stage"
                    }
                  ],
                  "remove": [
                    "k_7f3a91"
                  ]
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "page": {
                    "id": "01a0169d-1586-776f-b92a-133830f16c4e",
                    "title": "Jobs 2026",
                    "kind": "database",
                    "path": "/w/acme/p/01a0169d-1586-776f-b92a-133830f16c4e"
                  },
                  "changed": {
                    "added": [
                      "01a0169f-…"
                    ],
                    "updated": [
                      "01a0169e-…"
                    ],
                    "removed": [
                      "01a0169d-…"
                    ]
                  },
                  "columns": [
                    {
                      "id": "01a0169e-…",
                      "key": "k_2b8c04",
                      "name": "Stage",
                      "type": "select",
                      "primary": false
                    },
                    {
                      "id": "01a0169f-…",
                      "key": "k_9d1e77",
                      "name": "Invoice",
                      "type": "url",
                      "primary": false
                    }
                  ]
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/databases/{id}/views": {
      "get": {
        "operationId": "list-views",
        "summary": "List views",
        "description": "Every arrangement of this table, in tab order, each with the whole of its `config`.\n\n`?lang=he` ANSWERS WITH THE TAB NAMES IN THAT LANGUAGE, if somebody has written them (`PUT /v1/pages/{id}/translations`). Names are substituted in place, because a name is read here and written through a rename — and `config` is untouched, so a view read in Hebrew can still be sent back.",
        "tags": [
          "Views"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table. A document here is refused by name.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "lang",
            "in": "query",
            "required": false,
            "description": "Read the names in another language the workspace publishes, or `auto` to decide from your `Accept-Language`. Without it nothing changes, byte for byte, and no language is read.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "views": [
                    {
                      "id": "01a02a70-b4c9-7d31-9f0e-2c5b8a1d4e99",
                      "name": "Table",
                      "kind": "table",
                      "config": {
                        "hidden": [],
                        "sorts": []
                      },
                      "position": 1
                    }
                  ]
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      },
      "post": {
        "operationId": "create-view",
        "summary": "Create a view",
        "description": "Another arrangement of the same rows.\n\n`config` IS THE WHOLE ARRANGEMENT — which columns are hidden, the filters, the sorts, the splitter groups, the conditional colours. It is passed through rather than validated key by key, because the shape belongs to the client that renders it and a server policing it would have to be updated before any new view setting could ship.\n\nA NESTING TABLE HIDES ITS TREE COLUMNS in every new view, the same as in the ones that already existed. Once a table nests, the indent says what “Sub-items” and “Parent item” say.",
        "tags": [
          "Views"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string",
                    "description": "Up to 120 characters. Left out, it is named after its position."
                  },
                  "kind": {
                    "type": "string",
                    "description": "`table` (default), `board`, `gallery`, `list`, `calendar`, `timeline`, `chart`, `feed`, `map`, `archive`."
                  },
                  "config": {
                    "type": "object",
                    "description": "The arrangement: `hidden`, `order`, `widths`, `filters`, `filterMatch`, `sorts`, `splits`, `colorRules`, `calcs`, `wrap`, `align`. A filter rule’s `value` may be `\"{{ startOf(today(), \\\"months\\\") }}\"` — worked out every time the view is read, the same as in `POST /items/query`."
                  }
                }
              },
              "example": {
                "name": "By stage",
                "kind": "board",
                "config": {
                  "groupBy": "k_2b8c04"
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "view": {
                    "id": "01a02a70-b4c9-7d31-9f0e-2c5b8a1d4e99",
                    "name": "By stage",
                    "kind": "board",
                    "config": {
                      "groupBy": "k_2b8c04"
                    },
                    "position": 2
                  }
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/databases/{id}/views/{viewId}": {
      "patch": {
        "operationId": "update-view",
        "summary": "Edit a view",
        "description": "`config` REPLACES the arrangement rather than merging into it. A merge could never turn a filter off, and “send the config you want” is the only rule a caller can hold in their head.",
        "tags": [
          "Views"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "viewId",
            "in": "path",
            "required": true,
            "description": "The view.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string",
                    "description": "Up to 120 characters."
                  },
                  "kind": {
                    "type": "string",
                    "description": "Change how it draws."
                  },
                  "config": {
                    "type": "object",
                    "description": "The whole arrangement, replaced."
                  },
                  "position": {
                    "type": "string",
                    "description": "Where its tab sits."
                  }
                }
              },
              "example": {
                "config": {
                  "hidden": [
                    "k_7f3a91"
                  ],
                  "sorts": [
                    {
                      "key": "k_2b8c04",
                      "direction": "asc"
                    }
                  ]
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "view": {
                    "id": "01a02a70-b4c9-7d31-9f0e-2c5b8a1d4e99",
                    "name": "By stage",
                    "kind": "board"
                  }
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      },
      "delete": {
        "operationId": "delete-view",
        "summary": "Delete a view",
        "description": "NEVER THE LAST ONE. A table with no views is a table nothing can draw, and the app offers no way back to one — so the last is refused with that reason.",
        "tags": [
          "Views"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "viewId",
            "in": "path",
            "required": true,
            "description": "The view.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "ok": true
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/databases/{id}/columns": {
      "get": {
        "operationId": "list-columns",
        "summary": "List columns",
        "description": "Every column, with BOTH its `id` and its `key`. A record’s `values` are addressed by key and this API’s paths take the id — a caller holding only one of them cannot get from a listing to a write. Two ids for one thing is confusing exactly once; needing a second request to translate is confusing every time.\n\n`?lang=he` ANSWERS WITH THE HEADERS AND THE CHOICE LABELS IN THAT LANGUAGE, where somebody has written them (`PUT /v1/pages/{id}/translations`). The `id`, the `key` and a choice’s `id` never change — a translation is a display layer, so the things you address a column and a cell by are the same in every language.",
        "tags": [
          "Columns"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "lang",
            "in": "query",
            "required": false,
            "description": "Read the names in another language the workspace publishes, or `auto` to decide from your `Accept-Language`. Without it nothing changes, byte for byte, and no language is read.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "columns": [
                    {
                      "id": "01a0169d-…",
                      "key": "k_7f3a91",
                      "name": "Job",
                      "type": "text",
                      "options": {},
                      "dynamic": null,
                      "isPrimary": true,
                      "position": 1
                    },
                    {
                      "id": "01a0169e-…",
                      "key": "k_2b8c04",
                      "name": "Stage",
                      "type": "select",
                      "options": {
                        "choices": []
                      },
                      "dynamic": {
                        "on": true,
                        "mode": "set",
                        "askReason": "optional",
                        "reasons": [
                          {
                            "id": "sale",
                            "name": "Sale",
                            "correction": false
                          }
                        ],
                        "askWhen": false,
                        "since": "2026-09-01T08:00:00.000Z",
                        "filled": null
                      },
                      "isPrimary": false,
                      "position": 2
                    }
                  ]
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      },
      "post": {
        "operationId": "create-column",
        "summary": "Add a column",
        "description": "THE TYPE IS REQUIRED AND IS NOT GUESSED. A column created with no type would default to text, and a caller who meant `date` would get a table of strings that sort alphabetically — working, wrong, and expensive to correct once rows are in it.\n\n“Column types” under Tables is the full list and what each one’s `options` bag takes.",
        "tags": [
          "Columns"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string",
                    "description": "Up to 120 characters."
                  },
                  "type": {
                    "type": "string",
                    "description": "One of the types listed under “Column types”."
                  },
                  "options": {
                    "type": "object",
                    "description": "The type’s option bag. Unknown keys inside it are kept, because option bags grow; an unknown TYPE is refused with the nearest real one named."
                  },
                  "dynamic": {
                    "type": "object",
                    "description": "Make it keep its history from its first moment — see “Dynamic columns”. `{ \"on\": true }` is enough; `mode`, `askReason`, `reasons` and `askWhen` take the rest."
                  }
                },
                "required": [
                  "name",
                  "type"
                ]
              },
              "example": {
                "name": "In stock",
                "type": "number",
                "dynamic": {
                  "on": true
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "column": {
                    "id": "01a0169f-…",
                    "key": "k_9d1e77",
                    "name": "In stock",
                    "type": "number",
                    "options": {},
                    "dynamic": {
                      "on": true,
                      "mode": "adjust",
                      "askReason": "off",
                      "reasons": [
                        {
                          "id": "sale",
                          "name": "Sale",
                          "correction": false
                        }
                      ],
                      "askWhen": false,
                      "since": "2026-09-22T10:00:00.000Z",
                      "filled": null
                    },
                    "isPrimary": false,
                    "position": 3
                  }
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/databases/{id}/columns/{columnId}": {
      "patch": {
        "operationId": "update-column",
        "summary": "Edit a column",
        "description": "Its name, its type, its options.\n\nA TYPE CHANGE IS NEARLY ALWAYS REFUSED, and the refusal says which pair it was: reinterpreting every value in a column is a migration, not a PATCH. Nothing can be converted INTO a relation or a sync — both need a target a type name cannot carry.",
        "tags": [
          "Columns"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "columnId",
            "in": "path",
            "required": true,
            "description": "The column id — not its key.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string",
                    "description": "Up to 120 characters. Renaming never touches the key, so nothing that reads values breaks."
                  },
                  "type": {
                    "type": "string",
                    "description": "Only where the pair is convertible."
                  },
                  "options": {
                    "type": "object",
                    "description": "The option bag, replaced."
                  },
                  "dynamic": {
                    "type": "object",
                    "description": "Switch history keeping on or off, or change how it records. MERGED, not replaced: `{ \"askReason\": \"required\" }` leaves the reasons alone. Sent on its own it is the only thing written — the column’s name, type and options are not touched."
                  }
                }
              },
              "example": {
                "name": "Invoice URL"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "column": {
                    "id": "01a0169f-…",
                    "key": "k_9d1e77",
                    "name": "Invoice URL",
                    "type": "url",
                    "isPrimary": false
                  }
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      },
      "delete": {
        "operationId": "delete-column",
        "summary": "Delete a column",
        "description": "THE VALUES STAY. A dropped column is a soft delete — the cells remain on disk so the drop can be undone from the table’s trash, and the table simply stops projecting them.\n\nThat is not what makes this safe to send by accident: a table nobody un-drops it on has lost a column from every view. The NAME column cannot be deleted at all; rename it instead.",
        "tags": [
          "Columns"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "columnId",
            "in": "path",
            "required": true,
            "description": "The column. Both halves of a relation go together.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "ok": true
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/databases/{id}/columns/{columnId}/duplicate": {
      "post": {
        "operationId": "duplicate-column",
        "summary": "Duplicate a column",
        "description": "A copy with the same type and options and a new key, so the values do not come with it. A duplicate that carried the data would be a second column that silently disagrees with the first the moment either is edited.",
        "tags": [
          "Columns"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "columnId",
            "in": "path",
            "required": true,
            "description": "The column to copy.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "201": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "column": {
                    "id": "01a016a1-…",
                    "key": "k_4c8b12",
                    "name": "Invoice URL 2",
                    "type": "url",
                    "isPrimary": false
                  }
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/databases/{id}/columns/{columnId}/sync": {
      "post": {
        "operationId": "sync-column",
        "summary": "Force a sync into line",
        "description": "Applies what has come due, then copies across — both halves, in that order.\n\nIT IS THE ONLY THING THAT FIRES THE `cron`, `button` AND `automation` TIMINGS. Those are stored and nothing else drains them, so a sync column created over this API with one of them sat there looking configured and correct and never ran. This is the verb that starts it.\n\nThe order is not interchangeable: a copy that runs before the waiting propagations are applied can leave both sides agreeing on a value one of them had already superseded.\n\nIT TAKES `write`, not `config`, and it is the one route where the path argues the other way. It lives under `columns` and what it writes is CELL VALUES — records on both sides. Nothing about the column changes.",
        "tags": [
          "Columns"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table the sync column lives on.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "columnId",
            "in": "path",
            "required": true,
            "description": "The sync column, by id or by key.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "drained": {
                    "applied": 3,
                    "skipped": 0,
                    "failed": 0
                  },
                  "copied": {
                    "pulled": 12,
                    "pushed": 1,
                    "skipped": 0
                  }
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/databases/{id}/columns/{columnId}/relation": {
      "patch": {
        "operationId": "set-relation",
        "summary": "Re-configure a relation",
        "description": "Changes what a relation points at, its shape, its cap, or the name of its other end — and moves BOTH ends together.\n\nWHY IT IS NOT THE ORDINARY COLUMN PATCH: a relation could be CREATED pointed at a table and then never changed, because three of these four settings belong to the LINK rather than to either column. Writing them through an `options` bag sets one side and leaves the two ends disagreeing about what the relation is.\n\n`maxLinks: null` IS AN INSTRUCTION — \"no ceiling\" — and is a different thing to say from leaving the key out, which means \"do not touch the ceiling\".\n\nIT TAKES `config`: a key minted to file records must not be able to re-point a relation at another table.",
        "tags": [
          "Columns"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table the relation lives on.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "columnId",
            "in": "path",
            "required": true,
            "description": "The relation column, by id or by key.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "cardinality": {
                    "type": "string",
                    "description": "The shape of the relation. It constrains both ends."
                  },
                  "twoWay": {
                    "type": "boolean",
                    "description": "Whether the far table carries the matching column."
                  },
                  "maxLinks": {
                    "type": "string",
                    "description": "How many a record here may name. `null` is no ceiling."
                  },
                  "target": {
                    "type": "string",
                    "format": "uuid",
                    "description": "The table it points at."
                  },
                  "reverseName": {
                    "type": "string",
                    "description": "What the column on the far table is called."
                  }
                }
              },
              "example": {
                "maxLinks": 5,
                "reverseName": "Sales"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "column": {
                    "id": "01a016a1-…",
                    "name": "Products",
                    "type": "relation"
                  }
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/databases/{id}/columns/{columnId}/options": {
      "get": {
        "operationId": "column-options",
        "summary": "What this column can offer",
        "description": "The list a column offers to pick from, already matched — so nobody has to rebuild the picker.\n\nWITHOUT IT AN INTEGRATION DRAWING A FORM had to read the column’s `options`, work out it was a relation, read the TARGET table, and then redo the matching — including `pickerShow`, `pickerFilters` and `pickerMatch`, which are stored ON THE RELATION precisely so every place a cell is opened agrees about which columns tell the records apart. A second implementation of that is a second answer.\n\nIT ANSWERS FOR BOTH KINDS. A select returns its choices (`kind: \"choices\"`); a relation returns the target table’s matching records (`kind: \"records\"`), with the relation’s own picker settings applied unless you override them. Any other column type is refused with a sentence, rather than an empty list that reads like \"nothing matched\".\n\nA MALFORMED `filters` IS REFUSED HERE, and the app’s own equivalent ignores it. That is deliberate: ignoring a filter answers a DIFFERENT QUESTION from the one asked, which is tolerable for a person halfway through a gesture and not for a program that built the filter on purpose.",
        "tags": [
          "Columns"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table the column lives on.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "columnId",
            "in": "path",
            "required": true,
            "description": "The column, by id or by key.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "q",
            "in": "query",
            "required": false,
            "description": "Match records by name. Up to 120 characters.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "show",
            "in": "query",
            "required": false,
            "description": "Comma-separated keys of the far table’s columns to include, in order. Defaults to the relation’s own `pickerShow`.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "filters",
            "in": "query",
            "required": false,
            "description": "A JSON array of `{key, op, value}` over the far table. Defaults to the relation’s own. Unreadable JSON is refused, not ignored.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "match",
            "in": "query",
            "required": false,
            "description": "`all` or `any`. Defaults to the relation’s own.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "How many records to return.",
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "kind": "records",
                  "of": "01a016a0-…",
                  "options": [
                    {
                      "id": "01a016a2-…",
                      "label": "Rooftop shoot",
                      "values": {
                        "Rate": 450
                      }
                    }
                  ],
                  "columns": [
                    {
                      "key": "f_7c1e4a90b2d1",
                      "name": "Rate",
                      "type": "number"
                    }
                  ]
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/databases/{id}/columns/{columnId}/choices/{choiceId}": {
      "delete": {
        "operationId": "remove-choice",
        "summary": "Delete one choice of a select",
        "description": "Removes an option from a choice column AND clears it from every cell that held it, answering with how many that was.\n\nTHE SWEEP IS THE REASON THIS EXISTS. The alternative was patching the whole `options` bag — read it, remove one entry, write it back, hope the diff is right — while every row that held the deleted choice went on holding it. No caller can do the sweep without N requests and a window where the column and the data disagree.\n\nIT TAKES `config`, because it changes what the column IS. The rows changing is a consequence of that, not the purpose.",
        "tags": [
          "Columns"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "columnId",
            "in": "path",
            "required": true,
            "description": "The select column, by id or by key.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "choiceId",
            "in": "path",
            "required": true,
            "description": "The choice to delete.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "column": {
                    "id": "01a016a1-…",
                    "name": "Stage",
                    "type": "select"
                  },
                  "cleared": 4
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/databases/{id}/items": {
      "get": {
        "operationId": "list-items",
        "summary": "List items",
        "description": "A page of records, WITH THE COLUMNS. A record’s `values` are keyed by a column key, and a caller holding a list of records and no column list cannot read any of them — so the columns come back beside every page rather than as a second request.\n\n`?lang=he` READS IT IN ANOTHER LANGUAGE, and the shape of that answer is the one thing worth reading twice. The column HEADERS are translated in place. The VALUES are not: `values` always holds what somebody typed, and the translated ones arrive in a `translated` map beside the records — `translated[itemId].values[columnKey]`, plus `description`. The reason is that a record’s values round-trip, so a substituted translation would be written back over the original by the first integration that read a record and patched it; a column name is read from the table’s shape and written through a rename. Only the records whose owner translated something appear in the map, and only the columns whose values are translated at all (a record’s name always, a text column when its owner switched it on).\n\nIT IS A PAGE, AND `nextCursor` IS HOW YOU KNOW. Ask for the next one by passing it back; it is `null` on the last. A caller that ignores it sees the first `limit` records and no sign there are more, which is the one way to be wrong here.\n\nTHESE ARE THE TABLE’S RECORDS, in position order — not a view’s. A view’s filters change when somebody clicks in the app, and an integration should not. To filter, use `POST /v1/databases/{id}/items/query`, where the filter is yours and lives in your code.\n\nArchived and trashed records are not here. Relation columns arrive filled in: the server batches the link reads, so a two-hundred-row page with a relation is one query and not two hundred.",
        "tags": [
          "Items"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Records per page. 100 by default, 500 at most. Anything that is not a whole number is refused rather than quietly replaced.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "cursor",
            "in": "query",
            "required": false,
            "description": "The `nextCursor` from the previous page. Opaque — it keysets on `(position, id)`, so inserts elsewhere in the table do not shift your walk.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "lang",
            "in": "query",
            "required": false,
            "description": "Read this page in another language the workspace publishes, or `auto` to decide from your `Accept-Language`. Without it nothing changes, byte for byte: column headers come back in the original and no extra field appears.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "items": [
                    {
                      "id": "01a02a70-b4c9-7d31-9f0e-2c5b8a1d4e77",
                      "values": {
                        "k_7f3a91": "Rooftop shoot",
                        "k_2b8c04": "c_live"
                      },
                      "position": 1
                    }
                  ],
                  "columns": [
                    {
                      "id": "01a0169d-…",
                      "key": "k_7f3a91",
                      "name": "Job",
                      "type": "text",
                      "isPrimary": true,
                      "position": 1
                    }
                  ],
                  "nextCursor": "eyJwIjoiMiIsImkiOiIwMWEwMTY5ZC3igKYifQ",
                  "total": 1240
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      },
      "post": {
        "operationId": "create-item",
        "summary": "Create an item",
        "description": "`values` IS KEYED BY COLUMN KEY, not by name. Names change and keys do not, which is the whole reason keys exist — `GET /v1/databases/{id}/columns` gives you both.\n\nThe record’s “Created by” reads API rather than nobody: a key acts as the workspace, and the key’s own id is in the event payload for tracing it back.",
        "tags": [
          "Items"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "values": {
                    "type": "object",
                    "description": "`{\"k_7f3a91\": \"Rooftop shoot\"}`. A key no column has is refused rather than stored where nothing will read it."
                  },
                  "position": {
                    "type": "string",
                    "description": "Where it sits. Left out, it goes last."
                  }
                }
              },
              "example": {
                "values": {
                  "k_7f3a91": "Rooftop shoot",
                  "k_2b8c04": "c_live"
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "item": {
                    "id": "01a02a70-b4c9-7d31-9f0e-2c5b8a1d4e77",
                    "values": {
                      "k_7f3a91": "Rooftop shoot"
                    },
                    "position": 4
                  }
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/databases/{id}/items/query": {
      "post": {
        "operationId": "query-items",
        "summary": "Query items",
        "description": "The same records as `GET /items`, filtered and sorted by rules you write. Same envelope, same paging, same columns — so you can move from one to the other without changing how you read the answer.\n\nA RULE IS `{ key, op, value }`. `key` is the COLUMN KEY (`GET /v1/databases/{id}/columns`), never the name. A rule may itself be a group — `{ match, rules }` — one level deep, which is enough for “these, and either of those”.\n\nWHICH `op` DEPENDS ON THE COLUMN, and asking for one that does not apply is refused with the list that does. Numbers take `eq neq gt lt gte lte`. Dates take `is before after on_or_before on_or_after`, comparing by day. Multi-selects and people take `contains` / `not_contains`, and match by CHOICE ID or USER ID rather than by label — a renamed option would otherwise stop matching silently. Checkboxes take `checked` / `unchecked`. Everything else takes `is is_not contains not_contains starts_with ends_with`, case-insensitively. Any column except a checkbox also takes `empty` / `not_empty`, which take no value.\n\n**EVERYTHING THAT CANNOT BE ANSWERED IS REFUSED, and this is the important part.** A column key that does not exist, an op that does not apply, a word where a number belongs, an empty group — each of them would otherwise match EVERY row, and you would receive the whole table with a 200 and read it as your filtered set. `regex` is refused by name: it exists in the app, where the pattern runs in the tab of the person who wrote it, and a catastrophic pattern here would stop the server for every workspace.\n\n**A VALUE CAN BE WORKED OUT WHEN THE QUERY RUNS, rather than written into your code.** `\"value\": \"{{ startOf(today(), \\\"months\\\") }}\"` — the whole value, in double braces, is a formula the server evaluates before it compares anything. It is what makes a standing question possible: `\"the first three days of this month\"` asked once keeps meaning this month, and your deployment never has to change. Without it the only way to ask is to compute a date in your own code and send it, which is a date that is wrong the moment nobody re-runs it.\n\nThe useful ones: `today()`, `now()`, `startOf(date, unit)` and `endOf(date, unit)` where unit is `minutes hours days weeks months years` (a week starts Monday), `dateAdd(date, n, unit)`, `dateSubtract(date, n, unit)`, `formatDate(date, pattern)`, and arithmetic. Dates are worked out in the WORKSPACE’S timezone, because “the start of this month” is the 1st where the work happens; the answer is compared as a wall clock, the same as a literal date.\n\nIt works on any column, not only dates: `\"{{ 1000 * 1.17 }}\"` on a number, `\"{{ formatDate(today(), \\\"YYYY\\\") }}\"` with `starts_with` on text.\n\n**A value may name a VARIABLE**: `\"{{ $vat }}\"`, `\"{{ $monthStart }}\"`. `GET /v1/variables` lists them with what each one answers right now. That is how a rate stops living in your code as well as in ours — one definition, named from both sides.\n\n**It is checked harder than a literal, because both ways it can be wrong end in “matches every row”.** A formula that cannot be read is refused `not_workable`; one that answers the wrong kind for the column — `{{ today() }}` against a number — is refused `wrong_kind`. A value CANNOT read a column (`{{ Deadline }}`) or reach another table (`all(…)`): it is what every record is compared against, so a value that varied per record would not be a filter. Both are refused, naming themselves.\n\nThe same `{{ … }}` value can be saved on a view (`POST /v1/databases/{id}/views`), so what somebody opens in the app is the query you run. It is not a cell value: a record’s `values` take literal data, and braces written there are stored as the text they are.\n\nAt most 50 rules, counted across groups.",
        "tags": [
          "Items"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "lang",
            "in": "query",
            "required": false,
            "description": "In the QUERY STRING even though the filter is in the body: it says what language to render the answer in, which is what it says on every other route, and a reader that looked in two places would be two rules. The filter itself always runs on the original values. With it the answer also carries `linked`: the names of the RELATED records these items point at, keyed by the related record’s id, because a relation cell holds a copy of that name taken when the rows were read and the copy is what you send back.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "filter": {
                    "type": "object",
                    "description": "`{ match, rules }` — a GROUP, not a bare list of rules. `match` is `\"all\"`, `\"any\"`, or a whole number meaning “at least this many”. Left out, every record matches."
                  },
                  "sort": {
                    "type": "string",
                    "description": "`[{ \"key\": \"k_7f3a91\", \"dir\": \"desc\" }]`. `dir` defaults to `asc`. A sort on a column that is not there is refused, because silently leaving the records in position order reads as the sort being ignored."
                  },
                  "limit": {
                    "type": "string",
                    "description": "Records per page. 100 by default, 500 at most."
                  },
                  "cursor": {
                    "type": "string",
                    "description": "The `nextCursor` from the previous page."
                  }
                }
              },
              "example": {
                "filter": {
                  "match": "all",
                  "rules": [
                    {
                      "key": "k_2b8c04",
                      "op": "is",
                      "value": "c_live"
                    },
                    {
                      "match": "any",
                      "rules": [
                        {
                          "key": "k_9d41f7",
                          "op": "gt",
                          "value": 5000
                        },
                        {
                          "key": "k_4c0be2",
                          "op": "before",
                          "value": "{{ startOf(today(), \"months\") }}"
                        }
                      ]
                    }
                  ]
                },
                "sort": [
                  {
                    "key": "k_9d41f7",
                    "dir": "desc"
                  }
                ],
                "limit": 50
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "items": [
                    {
                      "id": "01a02a70-b4c9-7d31-9f0e-2c5b8a1d4e77",
                      "values": {
                        "k_7f3a91": "Rooftop shoot",
                        "k_9d41f7": 8200
                      },
                      "position": 1
                    }
                  ],
                  "columns": [
                    {
                      "id": "01a0169d-…",
                      "key": "k_7f3a91",
                      "name": "Job",
                      "type": "text",
                      "isPrimary": true,
                      "position": 1
                    }
                  ],
                  "nextCursor": null,
                  "total": 3
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/databases/{id}/items/{itemId}": {
      "patch": {
        "operationId": "update-item",
        "summary": "Edit an item",
        "description": "IT MERGES. A column you do not mention is a column nothing happens to — two people editing two different cells of one record must not overwrite each other.\n\nAn empty `values` is refused rather than answered with a cheerful 200: a caller sending one has a bug, most often a key that did not survive their serialiser.",
        "tags": [
          "Items"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "itemId",
            "in": "path",
            "required": true,
            "description": "The record.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "values": {
                    "type": "object",
                    "description": "Keyed by column key. `null` clears a cell."
                  }
                },
                "required": [
                  "values"
                ]
              },
              "example": {
                "values": {
                  "k_2b8c04": "c_done"
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "item": {
                    "id": "01a02a70-b4c9-7d31-9f0e-2c5b8a1d4e77",
                    "values": {
                      "k_7f3a91": "Rooftop shoot",
                      "k_2b8c04": "c_done"
                    }
                  }
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      },
      "delete": {
        "operationId": "delete-item",
        "summary": "Trash an item",
        "description": "TO THE TRASH, NOT OUT OF IT. A soft delete with a batch id, which is what lets `restore` put back exactly what one call removed.\n\nPermanent deletion is NOT in this API. It is in the table’s own trash screen, behind a confirmation — and an endpoint that skipped that would be the single most dangerous thing here.",
        "tags": [
          "Items"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "itemId",
            "in": "path",
            "required": true,
            "description": "The record.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "ok": true
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/databases/{id}/items/{itemId}/position": {
      "post": {
        "operationId": "move-item",
        "summary": "Reorder an item",
        "description": "Sets the record’s position. A sorted view draws its own order and ignores this — position is what an unsorted table reads, and it is the order somebody dragged rows into.",
        "tags": [
          "Items"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "itemId",
            "in": "path",
            "required": true,
            "description": "The record.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "position": {
                    "type": "string",
                    "description": "Fractional positions are fine — the midpoint between two rows is how the app inserts between them without renumbering."
                  }
                },
                "required": [
                  "position"
                ]
              },
              "example": {
                "position": 2.5
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "item": {
                    "id": "01a02a70-b4c9-7d31-9f0e-2c5b8a1d4e77",
                    "position": 2.5
                  }
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/databases/{id}/items/{itemId}/duplicate": {
      "post": {
        "operationId": "duplicate-item",
        "summary": "Duplicate an item",
        "description": "A copy, placed directly below. Computed columns are not copied — they are recomputed, so the copy says it was created now rather than pretending it was made when the original was.",
        "tags": [
          "Items"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "itemId",
            "in": "path",
            "required": true,
            "description": "The record to copy.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "201": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "items": [
                    {
                      "id": "01a02a71-…",
                      "values": {
                        "k_7f3a91": "Rooftop shoot"
                      }
                    }
                  ]
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/databases/{id}/items/restore": {
      "post": {
        "operationId": "restore-items",
        "summary": "Restore items",
        "description": "THE COUNT, NOT `ok`. Restoring records that were purged, or that were never in the trash, is a request that succeeds and does nothing — and `{\"restored\": 0}` is the only answer that says so.",
        "tags": [
          "Items"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "items": {
                    "type": "string",
                    "description": "The record ids to bring back."
                  }
                },
                "required": [
                  "items"
                ]
              },
              "example": {
                "items": [
                  "01a02a70-b4c9-7d31-9f0e-2c5b8a1d4e77"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "restored": 1
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/databases/{id}/items/bulk-set": {
      "post": {
        "operationId": "bulk-set",
        "summary": "Set one column across many records",
        "description": "One column, one value, up to 500 records.\n\nTHIS IS NOT A CONVENIENCE. The rate limit is 300 requests a minute, so setting a field on 500 records one at a time cannot finish inside a minute — it is cut off partway with half the work done and no record of which half.\n\n`column` is a column KEY, as `GET /v1/databases/{id}/columns` gives it. A missing `value` is refused rather than read as “empty them”; send `null` when that is what you mean.",
        "tags": [
          "Items"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "items": {
                    "type": "array",
                    "items": {},
                    "description": "Record ids, 1 to 500. A string, an empty list or a 501st id is a 400."
                  },
                  "column": {
                    "type": "string",
                    "description": "The column KEY to set."
                  },
                  "value": {
                    "type": "string",
                    "description": "What to put in it, of whatever type the column takes. `null` empties the cells."
                  }
                },
                "required": [
                  "items",
                  "column",
                  "value"
                ]
              },
              "example": {
                "items": [
                  "01a016c0-…",
                  "01a016c1-…"
                ],
                "column": "k_7f3a91",
                "value": "Ready"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "updated": 2
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/databases/{id}/items/bulk-duplicate": {
      "post": {
        "operationId": "bulk-duplicate",
        "summary": "Duplicate many records",
        "description": "Copies up to 500 records in one call. A shape-locked table still accepts this, because adding records is what that lock leaves open.",
        "tags": [
          "Items"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "items": {
                    "type": "array",
                    "items": {},
                    "description": "Record ids to copy, 1 to 500."
                  }
                },
                "required": [
                  "items"
                ]
              },
              "example": {
                "items": [
                  "01a016c0-…"
                ]
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "items": [
                    {
                      "id": "01a016c9-…",
                      "label": "One (copy)"
                    }
                  ]
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/databases/{id}/items/bulk-delete": {
      "post": {
        "operationId": "bulk-delete",
        "summary": "Trash many records",
        "description": "Moves up to 500 records to the table’s trash in ONE batch — which is what makes them restorable as one. Fifty records cleared from a selection are a single act, and putting them back one at a time is how somebody ends up with thirty-nine and no way to learn which eleven are still missing. `POST /v1/databases/{id}/items/restore` is the other half.\n\nA shape-locked table REFUSES this: losing records is what that lock is for.",
        "tags": [
          "Items"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "items": {
                    "type": "array",
                    "items": {},
                    "description": "Record ids to trash, 1 to 500."
                  }
                },
                "required": [
                  "items"
                ]
              },
              "example": {
                "items": [
                  "01a016c0-…",
                  "01a016c1-…"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "deleted": 2,
                  "ignored": 0
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/databases/{id}/import": {
      "post": {
        "operationId": "import-rows",
        "summary": "Import a grid of text",
        "description": "A spreadsheet, become records — up to 5,000 in one call.\n\nNOT UNDER `/items`, AND IT ASKS `config`. With `createChoices` an import invents options on a select column, which changes the TABLE and not only its contents. A scope that let a record-filing key do that would not be a scope.\n\n`columns` is ONE ENTRY PER COLUMN OF YOUR GRID — the column key it feeds, or `null` to skip that column. Positional rather than a map because a spreadsheet can have two headings with the same name, and a map would silently keep one of them.",
        "tags": [
          "Items"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "columns": {
                    "type": "array",
                    "items": {},
                    "description": "One entry per column of the grid: a column key, or null to ignore it."
                  },
                  "rows": {
                    "type": "array",
                    "items": {},
                    "description": "The grid: a list of rows, each a list of strings. At most 5,000."
                  },
                  "createChoices": {
                    "type": "boolean",
                    "description": "Invent missing options on select columns, up to 60 new ones per column. This is what makes the endpoint `config`."
                  }
                },
                "required": [
                  "columns",
                  "rows"
                ]
              },
              "example": {
                "columns": [
                  "k_7f3a91",
                  "k_2b8c04"
                ],
                "rows": [
                  [
                    "Four",
                    "Ready"
                  ],
                  [
                    "Five",
                    "Ready"
                  ]
                ]
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "created": 2,
                  "choicesAdded": {}
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/databases/{id}/items/{itemId}/page": {
      "get": {
        "operationId": "get-item-page",
        "summary": "Read an item’s page",
        "description": "THE FOUR THINGS A RECORD HAS THAT ITS CELLS DO NOT: an icon, a banner, a description and a document. None of them is a column — every record in the table would need one, and a table of forty records would grow four columns nobody asked for.\n\nSeparate from the record itself because the document can be long: listing items would otherwise ship every record’s writing to answer “what records are there”.\n\n`?lang=he` READS IT IN ANOTHER LANGUAGE, the same three rules as `GET /items`: the column names in `fields` are translated in place, `values` always holds what somebody typed, and the translated values arrive in `translated` beside them (`translated.values[columnKey]`, plus `description`). `title` is NOT translated — it is the name column’s cell, so it is a value, and a program that reads this and patches it back must be sending the original. The record’s `content` is not translated either: a record’s document is not a translatable surface (`domain/cellWords.ts`).",
        "tags": [
          "Items"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "itemId",
            "in": "path",
            "required": true,
            "description": "The record.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "lang",
            "in": "query",
            "required": false,
            "description": "Read it in another language the workspace publishes, or `auto` to decide from your `Accept-Language`. Without it nothing changes, byte for byte.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "page": {
                    "id": "01a02a70-b4c9-7d31-9f0e-2c5b8a1d4e77",
                    "title": "Rooftop shoot",
                    "titleKey": "k_7f3a91",
                    "icon": null,
                    "banner": null,
                    "description": null,
                    "content": [
                      {
                        "id": "m1",
                        "type": "metadata"
                      }
                    ],
                    "subItemField": null
                  }
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      },
      "patch": {
        "operationId": "update-item-page",
        "summary": "Edit an item’s page",
        "description": "Absent and null behave as everywhere else: a key left out is untouched, `null` clears.\n\n`content` REPLACES the document. There is no element addressing on a record’s page — `/v1/pages/{id}/elements` is for pages — so “merge these in” would have no honest meaning here.",
        "tags": [
          "Items"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "itemId",
            "in": "path",
            "required": true,
            "description": "The record.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "icon": {
                    "type": "string",
                    "description": "An emoji, or `{kind, value, color}`."
                  },
                  "banner": {
                    "type": "string",
                    "description": "An image URL, or `{url, offsetY}`."
                  },
                  "description": {
                    "type": "string",
                    "description": "A line under the title."
                  },
                  "content": {
                    "type": "string",
                    "description": "The document, in the body language. Replaces what is there."
                  }
                }
              },
              "example": {
                "icon": "🎥",
                "description": "Shot list and call sheet."
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "page": {
                    "id": "01a02a70-b4c9-7d31-9f0e-2c5b8a1d4e77",
                    "title": "Rooftop shoot",
                    "description": "Shot list and call sheet."
                  }
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/databases/{id}/items/{itemId}/columns/{key}/freeze": {
      "post": {
        "operationId": "freeze-cell",
        "summary": "This record is final",
        "description": "Pins a computed cell to the value it has right now, so it stops moving when the numbers behind it do.\n\nTHE CALLER THIS EXISTS FOR IS AN EXTERNAL BILLING SYSTEM. A rule freezes a cell on every write, which covers the invoice that has a rule; this is the hand, for the one that does not. It was the single thing a settling integration could not say.\n\nIT TAKES `write`, NOT `config`. Freezing says something about ONE RECORD, not about the column — the people who may edit the record are the people who may settle it, which is the same rule the app applies on screen.\n\nOnly a formula column with freezing switched on can be frozen; anything else is refused and says so.",
        "tags": [
          "Items"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "itemId",
            "in": "path",
            "required": true,
            "description": "The record.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "key",
            "in": "path",
            "required": true,
            "description": "The column, by id or by key.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "frozen": true,
                  "value": 1800,
                  "at": "2026-10-05T09:12:44.000Z"
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      },
      "delete": {
        "operationId": "thaw-cell",
        "summary": "Let it work itself out again",
        "description": "Undoes a freeze, and answers with what the formula says now — which is the number worth seeing, because it is the one the freeze had been hiding.",
        "tags": [
          "Items"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "itemId",
            "in": "path",
            "required": true,
            "description": "The record.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "key",
            "in": "path",
            "required": true,
            "description": "The column, by id or by key.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "frozen": false,
                  "value": 1950
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/databases/{id}/items/{itemId}/columns/{key}/press": {
      "post": {
        "operationId": "press-button",
        "summary": "Press a button column",
        "description": "Runs the automation a button column points at, for one record, and answers with the run — no queue, no cursor.\n\nA PROGRAM COULD ALREADY CONFIGURE A BUTTON AND NOT PRESS ONE, which is the gap in a sentence.\n\nWHICH AUTOMATION IS NOT IN THIS REQUEST. It is read from the column, exactly as on screen, so a key cannot name a different rule and have it run \"as the button\". The refusals are about the button: not a button, pointed at nothing yet, or a record that is not in this table.",
        "tags": [
          "Items"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "itemId",
            "in": "path",
            "required": true,
            "description": "The record whose button is being pressed.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "key",
            "in": "path",
            "required": true,
            "description": "The button column, by id or by key.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "status": "ok",
                  "ran": 2,
                  "results": [
                    {
                      "action": "webhook",
                      "ok": true
                    }
                  ]
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/databases/{id}/items/{itemId}/revert": {
      "post": {
        "operationId": "revert-item",
        "summary": "Put a record back",
        "description": "Undoes a revision and everything after it, and answers with the record as it now stands.\n\nTHE PATCH IS COMPUTED FROM THE LOG, not guessed at by the caller. An integration holding only \"something went wrong at 14:02\" cannot work out the edit that undoes it; the log can.\n\nIt goes through the ordinary write path and records its own before-and-after, so a revert is itself revertible. Take the `revision` from `…/items/{itemId}/history`.",
        "tags": [
          "Items"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "itemId",
            "in": "path",
            "required": true,
            "description": "The record.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "revision": {
                    "type": "string",
                    "description": "The revision to undo, from the record’s history."
                  }
                },
                "required": [
                  "revision"
                ]
              },
              "example": {
                "revision": "01a0169e-…"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "item": {
                    "id": "01a0169f-…",
                    "values": {
                      "Rate": 450
                    }
                  }
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/databases/{id}/items/{itemId}/history": {
      "get": {
        "operationId": "item-revisions",
        "summary": "Everything that happened to one record",
        "description": "The revision log, newest first, and where `revert` gets its id.\n\nPER-COLUMN HISTORY ALREADY HAD A DOOR; THE RECORD’S DID NOT — so an integration could ask what one cell had been and not what the record had been, which is the question somebody reconciling an invoice actually has.",
        "tags": [
          "Items"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "itemId",
            "in": "path",
            "required": true,
            "description": "The record.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "revisions": [
                    {
                      "id": "01a0169e-…",
                      "at": "2026-10-05T09:12:44.000Z",
                      "by": "Dana",
                      "changed": [
                        "Rate"
                      ]
                    }
                  ]
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/databases/{id}/items/{itemId}/tracked": {
      "get": {
        "operationId": "item-tracked",
        "summary": "One record’s tracked columns",
        "description": "Which of this record’s columns keep a past, and their changes over a range — the whole record at once, where the rest of this API answers one column at a time.\n\nNarrow it with `column`, which takes an id or a key like every other column parameter here.",
        "tags": [
          "Items"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "itemId",
            "in": "path",
            "required": true,
            "description": "The record.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "column",
            "in": "query",
            "required": false,
            "description": "One column, by id or by key. Leave it out for all of them.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "from",
            "in": "query",
            "required": false,
            "description": "ISO date, inclusive.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "to",
            "in": "query",
            "required": false,
            "description": "ISO date, inclusive.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "columns": [
                    {
                      "key": "f_052ba5ec071d",
                      "name": "Rate",
                      "points": [
                        {
                          "at": "2026-09-01",
                          "value": 400
                        }
                      ]
                    }
                  ]
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/databases/{id}/items/{itemId}/shares": {
      "get": {
        "operationId": "item-shares",
        "summary": "Links that publish one record",
        "description": "Every share link pointed at this one record.\n\nA LINK TO A TABLE PUBLISHES A TABLE. Before this, giving one client a page about one job meant publishing the whole table and hiding almost all of it with rules — a configuration whose correctness depends on every rule staying right forever, including after somebody adds a column. A link to one record cannot leak the next record, because the next record is not on it.",
        "tags": [
          "Item links & presets"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "itemId",
            "in": "path",
            "required": true,
            "description": "The record.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "shares": [
                    {
                      "id": "01a016a5-…",
                      "label": "Rooftop for Dana",
                      "mode": "public",
                      "level": "view",
                      "revokedAt": null
                    }
                  ]
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      },
      "post": {
        "operationId": "create-item-share",
        "summary": "Publish one record",
        "description": "Opens a link that shows a single record, and returns its TOKEN once — join it to the address you reach this workspace on, because this install does not reliably know its own origin and a wrong one is worse than none. The listing never repeats it: an endpoint that handed out every live token would turn one `read` key into every open door.\n\n\n\nTHIS IS HOW A PER-CUSTOMER PORTAL IS BUILT — one call per customer, each link showing exactly the record that customer is allowed to see.\n\nA NEW LINK IS DELIBERATELY THE NARROWEST THING POSSIBLE: public, nothing changeable, notes hidden. The server decides that rather than the caller, so the API and the screen open identical links. Widening it is a second, deliberate call to the share-rule endpoints — the right shape for a thing whose mistakes are invisible from the inside.\n\nIT TAKES `config`, like every other share, and NOT `write` even though the path runs through `items`: what this creates is a door into the workspace, not a change to the record it points at.",
        "tags": [
          "Item links & presets"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "itemId",
            "in": "path",
            "required": true,
            "description": "The record.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "label": {
                    "type": "string",
                    "description": "What to call it, for the listing."
                  },
                  "expiresAt": {
                    "type": "string",
                    "description": "ISO date. `null` is no expiry."
                  },
                  "maxOpens": {
                    "type": "string",
                    "description": "Stop working after this many opens. `null` is unlimited."
                  }
                }
              },
              "example": {
                "label": "Rooftop for Dana",
                "maxOpens": 20
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "share": {
                    "id": "01a016a5-…",
                    "level": "view",
                    "mode": "public"
                  },
                  "token": "7f3a91c2…"
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/share-presets": {
      "get": {
        "operationId": "share-presets",
        "summary": "Saved share shapes",
        "description": "The named configurations a new link can start from.\n\nWHY A PROGRAM WANTS THESE: an integration opening a link per customer is making the same shape every time, and the way to get that wrong is to rebuild it from scratch each time — twelve calls where one was meant, and the twelfth customer configured slightly differently from the first because a column was added in between.",
        "tags": [
          "Item links & presets"
        ],
        "parameters": [],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "presets": [
                    {
                      "id": "01a016a6-…",
                      "name": "Client read-only",
                      "settings": {
                        "level": "view"
                      }
                    }
                  ]
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      },
      "post": {
        "operationId": "save-share-preset",
        "summary": "Save a share shape",
        "description": "Creates a preset, or replaces one when you send its `id`.\n\nNO ID IN THE PATH, deliberately: the same body creates or replaces, because the server upserts on the id you send. A PUT with an id in the path would need this endpoint to decide what a MISSING id means, and there is no good answer to that.\n\nIt answers 201 either way — the useful answer is the preset that now exists under that name, and whether it was new is not something a caller acted on.",
        "tags": [
          "Item links & presets"
        ],
        "parameters": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "id": {
                    "type": "string",
                    "description": "An existing preset to replace. Leave it out to create one."
                  },
                  "name": {
                    "type": "string",
                    "description": "What to call the shape."
                  },
                  "settings": {
                    "type": "object",
                    "description": "The share configuration this shape stands for."
                  }
                },
                "required": [
                  "name",
                  "settings"
                ]
              },
              "example": {
                "name": "Client read-only",
                "settings": {
                  "level": "view",
                  "mode": "public"
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "preset": {
                    "id": "01a016a6-…",
                    "name": "Client read-only"
                  }
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/share-presets/{id}": {
      "delete": {
        "operationId": "delete-share-preset",
        "summary": "Delete a share shape",
        "description": "Removes a saved preset.\n\nIT DOES NOT TOUCH THE LINKS MADE FROM IT. A preset is a starting point that was COPIED, not something links point at — so deleting one cannot change what any existing link shows. That is the question somebody will ask before calling this, so it is answered here.",
        "tags": [
          "Item links & presets"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The preset.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "ok": true,
                  "deleted": "01a016a6-…"
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/databases/{id}/lock": {
      "post": {
        "operationId": "lock-table",
        "summary": "Freeze a table’s shape",
        "description": "Stops the columns of a table being added, removed or re-typed.\n\nTHE PAGE-LEVEL LOCK HAS HAD A DOOR SINCE THIS PREFIX EXISTED and this one did not — two locks, one reachable, so a caller could freeze a page and not the table on it.\n\nONE ENDPOINT AND A BOOLEAN, not a lock and an unlock, so you can set a state without reading the current one first. That is the difference between \"make sure this is frozen\" and a race.\n\nWHY IT IS A SHARING CONTROL: the reason to freeze a table’s shape is that somebody outside can see it, and a column added after a link was configured is a column no share rule has an opinion about yet.",
        "tags": [
          "Item links & presets"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "locked": {
                    "type": "boolean",
                    "description": "`true` freezes the shape, `false` releases it."
                  }
                },
                "required": [
                  "locked"
                ]
              },
              "example": {
                "locked": true
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "locked": true
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/deliveries": {
      "get": {
        "operationId": "deliveries",
        "summary": "Did the webhook arrive?",
        "description": "What was sent where, when, how many times, and what came back. Newest first.\n\nTHIS IS THE ONLY PLACE A FAILED WEBHOOK IS VISIBLE. An integration could create a rule, point it at its own endpoint, and have no way to learn whether a single delivery ever arrived — the answer was to ask somebody with the app open. A retry policy nobody can see is a retry policy nobody trusts.\n\n`responseBody` IS THE FIELD THAT ACTUALLY DIAGNOSES THINGS. A 502 says the endpoint was down; the body saying \"unknown tenant\" is a different fix entirely.\n\n`before` IS A CURSOR, NOT AN OFFSET. This table is written to while you read it, and an offset over a table growing at the head shows one row twice and skips another. Ids are uuidv7, so they sort by time.\n\n`more` IS ANSWERED RATHER THAN IMPLIED: the server fetches one row past the limit so it can say whether another page exists. A caller looping \"until fewer than `limit` come back\" is wrong exactly once — on the page that happens to come out full.",
        "tags": [
          "Deliveries"
        ],
        "parameters": [
          {
            "name": "automation",
            "in": "query",
            "required": false,
            "description": "Only this rule’s deliveries.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "before",
            "in": "query",
            "required": false,
            "description": "A delivery id. Returns the ones older than it.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Up to 200. Defaults to 25.",
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "deliveries": [
                    {
                      "id": "01a016a4-…",
                      "automationId": "01a016a0-…",
                      "verb": "db.row_created",
                      "status": "delivered",
                      "attempts": 2,
                      "responseStatus": 200,
                      "responseBody": "ok",
                      "durationMs": 143,
                      "nextAttemptAt": null,
                      "createdAt": "2026-10-05T09:12:44.000Z"
                    }
                  ],
                  "more": false
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/deliveries/{id}/replay": {
      "post": {
        "operationId": "replay-delivery",
        "summary": "Send one again",
        "description": "Replays a delivery and sends it immediately, answering with what the send did.\n\nIT CLEARS THE BACKOFF, WHICH IS USUALLY THE WHOLE REQUEST. The common case is not a dead delivery — it is somebody who has just fixed their endpoint and does not want to wait out a ten-hour retry. So a delivery that is merely `pending` can be replayed too.\n\nIT PUMPS AFTERWARDS, so \"replayed\" means sent rather than queued. Without that you would get a 200 and no idea whether the second attempt failed as well.\n\nIT TAKES `config`, because it makes this workspace send signed traffic to a configured endpoint. That is operating the workspace’s integrations rather than filing a record in it.",
        "tags": [
          "Deliveries"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "From the delivery log.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "replayed": "01a016a4-…",
                  "sent": 1,
                  "failed": 0
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/deliveries/run": {
      "post": {
        "operationId": "run-deliveries",
        "summary": "Send what is due, now",
        "description": "Walks the event stream, runs the rules it finds waiting, then sends what that queued.\n\nTHE SCHEDULER ALREADY DOES THIS. It is on by default in production and ticks the same three steps every pass, so deliveries send themselves — what this gives you is IMMEDIACY rather than existence: an integration that has just written a row and wants the webhook out before the next tick rather than within it.\n\nTHE ORDER MATTERS AND IS NOT OBVIOUS. The rules run BEFORE the send, because a `send_webhook` action is what writes the delivery row — sending first would deliver everything except what this call just queued, and answer `sent: 0` for a call whose whole purpose was to send the waiting thing.\n\nIT TAKES `config`: running the rules runs whatever they do.",
        "tags": [
          "Deliveries"
        ],
        "parameters": [],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "queued": 3,
                  "sent": 3,
                  "failed": 0
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/automations/run-queued": {
      "post": {
        "operationId": "run-queued",
        "summary": "Run waiting rules, now",
        "description": "Walks the event stream and executes the rules that are waiting on it, and stops there.\n\nTHE RULE QUEUE, NOT THE SENDING QUEUE. Draining both is `POST /v1/deliveries/run`, which does all three steps in the one order that works. Use this when you care that the rule RAN — a rule whose actions only write records has nothing to send.\n\nAs with the other drain, the scheduler already does this in production. This is \"now rather than within the tick\".\n\nIT TAKES `config`, because running a rule runs whatever that rule does — which may write every record in the workspace.",
        "tags": [
          "Deliveries"
        ],
        "parameters": [],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "queued": 2,
                  "ran": 2,
                  "failed": 0
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/databases/{id}/trash": {
      "get": {
        "operationId": "read-trash",
        "summary": "What could be got back",
        "description": "The records and the columns in this table’s trash.\n\nBEFORE THIS, A RECORD COULD BE RESTORED ONLY BY A CALLER THAT HAD KEPT ITS ID, and nothing could ask what was in there. A dropped COLUMN could not be put back at all.\n\nRECORDS COME BACK GROUPED BY THE ACT THAT BINNED THEM, not listed flat. Somebody who cleared a selection of fifty remembers one thing happening, and restoring it should be one decision rather than fifty about records nobody looked at individually.\n\nCOLUMNS COME BACK WITH A VALUE COUNT, which is the reason listing them is worth anything: \"Stage, dropped Tuesday, 412 values\" is a different decision from \"Stage, dropped Tuesday\", and no caller can work the number out for itself — the values are behind the same flag that hides the column.\n\nThat count is LIVE records only. It means \"values you would get back\", and a record sitting in the trash would not bring its own.",
        "tags": [
          "Trash"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "items": [
                    {
                      "batch": "01a0169e-…",
                      "deletedAt": "2026-10-05T09:12:44.000Z",
                      "deletedBy": null,
                      "rows": [
                        {
                          "id": "01a0169f-…",
                          "title": "Rooftop shoot"
                        }
                      ]
                    }
                  ],
                  "columns": [
                    {
                      "id": "01a016a1-…",
                      "name": "Stage",
                      "type": "select",
                      "deletedAt": "2026-10-04T17:02:00.000Z",
                      "deletedBy": null,
                      "values": 412
                    }
                  ]
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      },
      "delete": {
        "operationId": "purge-trash",
        "summary": "Empty one table’s trash",
        "description": "Destroys the trashed records of this table, and the files attached to them.\n\nTHIS IS THE ONE PLACE A RECORD STOPS BEING RECOVERABLE, and the only place attachments are deleted. The reason they wait until here is worth knowing: a trashed record can be restored, a cell edit can be undone, and a record’s history shows what a cell used to hold — all three would find bytes that were gone if files went the moment a cell stopped naming them.\n\nIT DOES NOT TOUCH TRASHED COLUMNS. Emptying the record trash and dropping a column for good are different decisions, and a caller that wanted the second did not ask for it here.\n\nIT TAKES `write`, not `config`: it destroys CONTENTS and nothing else, which is the same reading `POST /v1/workspace/empty-trash` already has. Sorting one as `config` because it is dangerous would draw the line around danger rather than around meaning.",
        "tags": [
          "Trash"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "purged": 12
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/databases/{id}/trash/columns/{columnId}": {
      "post": {
        "operationId": "restore-column",
        "summary": "Put a dropped column back",
        "description": "Brings a deleted column back into the table — AND ITS VALUES COME WITH IT.\n\nTHAT IS WHY THIS MATTERS MORE THAN IT LOOKS. Dropping a column does not touch a single cell: it sets a flag on the column, and the data stays exactly where it was. So the workaround was never \"rebuild the column\" — the values were unreachable by any route at all, sitting behind the same flag.\n\nIT COMES BACK AT THE END, not where it was. Position is a live ordering and the columns either side have moved on; putting it back in the middle would reshuffle a table somebody has been using since.\n\nTHE ID COMES FROM THE TRASH LISTING AND ONLY FROM THERE. Every other column parameter in this API takes an id or a key, resolved against the table’s live columns — which by definition cannot see a trashed one.\n\nIT TAKES `config`, because a column is the shape of the table. The values arriving with it are a consequence, not the purpose.",
        "tags": [
          "Trash"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "columnId",
            "in": "path",
            "required": true,
            "description": "From `columns` on the trash read. An id, never a key.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "column": {
                    "id": "01a016a1-…",
                    "key": "f_7c1e4a90b2d1",
                    "name": "Stage",
                    "type": "select"
                  }
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/databases/{id}/archive": {
      "get": {
        "operationId": "read-archive",
        "summary": "What this table does with old records",
        "description": "The retention policy, the records already archived, the files that have left, and whether there is anywhere to put the next one.\n\nSTART HERE, AND NOT ONLY WHEN YOU CARE ABOUT ARCHIVING. Asking for a view of kind `archive` CREATES A POLICY if the table has none, and the default is not off: three months, by `updated`. So a table may be archiving on a schedule nobody chose, and this is the only way to find out.\n\nONE CALL, because it is one question. Three calls would let you act on a period read at one moment and a count read at another.\n\n`policy: null` MEANS NOTHING IS ARCHIVED EVER — which is a different state from a policy that exists and is switched off, and the difference matters: the first table has never had an archive view.\n\nIt carries `periods` and `bases` too, so nothing about writing a policy has to be guessed. Guessing at an enum whose wrong value archives by the wrong date is not a guess this API should ask for.",
        "tags": [
          "Archive"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "policy": {
                    "enabled": true,
                    "afterMonths": 3,
                    "basis": "updated",
                    "basisField": null,
                    "coldAfterMonths": null,
                    "coldFormat": "csv",
                    "lastSweptAt": null
                  },
                  "items": [
                    {
                      "id": "01a0169f-…",
                      "values": {
                        "Job": "Rooftop"
                      },
                      "archivedAt": "2026-07-01T00:00:00.000Z",
                      "rule": "after 3 months"
                    }
                  ],
                  "columns": [
                    {
                      "key": "f_052ba5ec071d",
                      "name": "Job",
                      "type": "text"
                    }
                  ],
                  "exports": [],
                  "storage": {
                    "ready": false,
                    "bucket": null
                  },
                  "periods": [
                    {
                      "months": 3,
                      "label": "3 months"
                    }
                  ],
                  "bases": [
                    {
                      "id": "updated",
                      "label": "Last edited"
                    }
                  ]
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      },
      "patch": {
        "operationId": "set-archive",
        "summary": "Change the retention policy",
        "description": "Switches archiving on or off for this table, and sets when a record is old.\n\nTHIS IS THE CLEAREST `config` IN THE WHOLE API. A retention policy is a standing instruction to remove records from every default read of a table, on a timer, with nobody present. A key minted to file invoices must not be able to write one — and the same scope that can write it is the one that can switch it off.\n\nA PATCH, so switching archiving off does not mean restating the period, the basis and the export format. Restating them is how a caller changes one by accident.\n\n`afterMonths: null` SWITCHES THE SWEEP OFF while leaving everything else alone, which is a different instruction from omitting the key.",
        "tags": [
          "Archive"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "enabled": {
                    "type": "boolean",
                    "description": "Whether records are archived at all."
                  },
                  "afterMonths": {
                    "type": "string",
                    "description": "How old is old. `null` means no period, which stops the sweep."
                  },
                  "basis": {
                    "type": "string",
                    "description": "Which date counts. The readable list is in `bases` on the read above."
                  },
                  "basisField": {
                    "type": "string",
                    "description": "A date column to measure from, by id or by key. Required when `basis` is `field`."
                  },
                  "coldAfterMonths": {
                    "type": "string",
                    "description": "When an archived record should leave for a file. `null` is never."
                  },
                  "coldFormat": {
                    "type": "string",
                    "description": "The file format for that second hop."
                  }
                }
              },
              "example": {
                "enabled": false
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "policy": {
                    "enabled": false,
                    "afterMonths": 3,
                    "basis": "updated"
                  }
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/databases/{id}/archive/sweep": {
      "post": {
        "operationId": "sweep-archive",
        "summary": "Apply the policy now",
        "description": "Archives everything the policy says is old, and says how much that was.\n\nTHERE IS NO SCHEDULER IN THIS APP. Deliveries and automations are drained by hand too, and saying that plainly beats a timer that silently never runs — so until now the policy sat live and inert, and this is what an integration can call on a cadence of its own.\n\nIT TAKES `config`, matching the policy it executes: this is the call that moves records out of every default read, so a key that may not write the policy must not be able to apply it either.\n\nA TABLE WITH NO POLICY ANSWERS `{ archived: 0 }`, not an error. The sweep ran and correctly did nothing, which is a true answer to \"apply the policy\".",
        "tags": [
          "Archive"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "archived": 12,
                  "undated": 0,
                  "cutoff": "2026-07-05T00:00:00.000Z"
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/databases/{id}/archive/restore": {
      "post": {
        "operationId": "restore-archive",
        "summary": "Put records back",
        "description": "Returns archived records to the table’s ordinary reads. Up to 500 at a time.\n\nTHE MISSING HALF OF A ONE-WAY DOOR. Archiving could be switched on from outside and nothing could be brought back.\n\nIT TAKES `write`, NOT `config`, and that is deliberate: THE RECORD NEVER STOPPED EXISTING, so asking for the power to re-shape the workspace in order to un-hide one is the wrong question. It is the same scope as editing that record — usually what you want it back for. It is also the reversal this endpoint exists to provide, so it is not behind the scope that caused the problem.",
        "tags": [
          "Archive"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "items": {
                    "type": "string",
                    "description": "The records to bring back. Up to 500."
                  }
                },
                "required": [
                  "items"
                ]
              },
              "example": {
                "items": [
                  "01a0169f-…"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "restored": 1
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/databases/{id}/archive/export": {
      "post": {
        "operationId": "export-archive",
        "summary": "Write archived records out to a file",
        "description": "The second hop: archived records leave the database for a gzipped file, and do not come back.\n\nTHIS IS THE ONE STEP IN THE FEATURE THAT IS NOT REVERSIBLE, which is why it takes `config` — the screen asks for the permission to delete things, and this is the nearest equivalent here.\n\n`force: true` IS HOW YOU SAY \"EVEN THOUGH NO COLD PERIOD IS SET\". Without it, a table whose `coldAfterMonths` is `null` answers `{ exportId: null }` rather than exporting everything — a missing period means nobody has decided when records should leave, and the sensible reading of that is \"not yet\", not \"immediately\".\n\nIf no storage is configured the file is kept in the database row instead; `storage.ready` on the read above says which it will be.",
        "tags": [
          "Archive"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "force": {
                    "type": "boolean",
                    "description": "Export even though no cold period is set."
                  }
                }
              },
              "example": {
                "force": true
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "exportId": "01a016a3-…",
                  "rows": 120,
                  "bytes": 8244,
                  "format": "csv"
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/databases/{id}/archive/exports/{exportId}": {
      "get": {
        "operationId": "download-export",
        "summary": "Fetch one of those files",
        "description": "The bytes, gzipped, named for the table and the day it was written.\n\nWHERE THE FILE ACTUALLY LIVES IS NOT YOUR PROBLEM: inline in the row when no storage was configured, in the workspace’s bucket when it was. The server resolves it either way — and a bucket that has since been disconnected is a sentence saying so rather than an empty file.\n\nThe column names are the CSV header, not the column keys, because the point of this file is being readable by something that is not Orbit years later.",
        "tags": [
          "Archive"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "exportId",
            "in": "path",
            "required": true,
            "description": "From `exports` on the archive read.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": "The gzipped file, as application/gzip."
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/databases/{id}/items/{itemId}/columns/{key}/set": {
      "post": {
        "operationId": "set-item-value",
        "summary": "Set a value, and say why",
        "description": "WHAT `PATCH …/items/{itemId}` CANNOT SAY. That writes the value and the history keeps “the API changed it”. This one carries the REASON, a note, and when it happened in the world — the three things a column is made dynamic to keep.\n\nWorks on any column. On a static one the value is written as usual and `reason`, `note` and `at` are refused, because there is nowhere to keep them.\n\n`at` is for something that happened EARLIER than now: never in the future, never before the column started recording, and — for a value that is not a sum — never before its latest change, because the cell and its timeline would then disagree about what it holds.",
        "tags": [
          "Dynamic columns"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "itemId",
            "in": "path",
            "required": true,
            "description": "The record.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "key",
            "in": "path",
            "required": true,
            "description": "The column’s key, as in an item’s `values`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "value": {
                    "type": "string",
                    "description": "The new value, in the column’s own shape. To move a number by an amount instead, use `/adjust`."
                  },
                  "reason": {
                    "type": "string",
                    "description": "One of the column’s quick reasons, by name (“Sale”) or id — kept as that reason, so a correction is still a correction — or your own words, up to 64 characters."
                  },
                  "note": {
                    "type": "string",
                    "description": "Up to 500 characters."
                  },
                  "at": {
                    "type": "string",
                    "description": "When it happened, if earlier than now."
                  }
                },
                "required": [
                  "value"
                ]
              },
              "example": {
                "value": "sold",
                "reason": "Sale",
                "note": "till 3",
                "at": "2026-09-22T09:00:00.000Z"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "item": {
                    "id": "01a02a70-b4c9-7d31-9f0e-2c5b8a1d4e77",
                    "values": {
                      "k_2b8c04": "sold"
                    }
                  }
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/databases/{id}/items/{itemId}/columns/{key}/adjust": {
      "post": {
        "operationId": "adjust-item",
        "summary": "Add to or take from a number",
        "description": "THE ONE A CHECKOUT CALLS. Moves a number column by `delta`, applied to the value as it is when the call lands — so two tills selling the last two at once both count, where two calls setting the value would lose one.\n\nOn a DYNAMIC column the change is kept in its history with `reason` (one of the column’s quick reasons by name or id, or your own words), `note`, and `at` when it happened earlier than now. On a static number it is simply an increment nobody can lose, and a reason is refused, because there is nowhere to keep it.\n\nNeeds a `write` key. A required reason is not asked of a key: a machine writing on its own has nobody to ask, so the change is recorded without one rather than lost.",
        "tags": [
          "Dynamic columns"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "itemId",
            "in": "path",
            "required": true,
            "description": "The record.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "key",
            "in": "path",
            "required": true,
            "description": "The number column’s key.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "delta": {
                    "type": "string",
                    "description": "The amount: `-1` for a sale, `12` for a restock."
                  },
                  "reason": {
                    "type": "string",
                    "description": "Why: one of the column’s quick reasons, by name (“Sale”) or id — kept as that reason, so a correction is still a correction — or your own words, up to 64 characters. Dynamic columns only."
                  },
                  "note": {
                    "type": "string",
                    "description": "Up to 500 characters. Dynamic columns only."
                  },
                  "at": {
                    "type": "string",
                    "description": "When it happened, if earlier than now — never before the column started recording, never in the future. Dynamic columns only."
                  }
                },
                "required": [
                  "delta"
                ]
              },
              "example": {
                "delta": -250,
                "reason": "Discount agreed on the call",
                "note": "Confirmed by Ada",
                "at": "2026-10-12T09:00:00.000Z"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "item": {
                    "id": "01a02a70-b4c9-7d31-9f0e-2c5b8a1d4e77",
                    "values": {
                      "k_quantity": 41
                    }
                  }
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/databases/{id}/items/{itemId}/columns/{key}/history": {
      "get": {
        "operationId": "get-item-history",
        "summary": "Read a dynamic column’s history",
        "description": "Every change to one item’s value in a DYNAMIC column, oldest first: when it happened (`at`), when it was recorded (`recordedAt`), what it became, why, and what made it. A column is dynamic when somebody switched on “Make dynamic” for it; a static column has no history of its own and is refused.\n\n`start` is what the value was when the range begins, so a graph can draw from its left edge.\n\nFOR A NUMBER, DRAW `running`, NOT `value`. `running` is the value at each change in the order the changes HAPPENED; `value` is what the cell held when the change was written. They differ when a change was entered late — a sale dated back to 09:00 but typed at 15:00.\n\nA `baseline` entry is where the history starts: the value when the column was made dynamic, or when the item was created. A `correct` entry fixes the record rather than describing something that happened.",
        "tags": [
          "Dynamic columns"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "itemId",
            "in": "path",
            "required": true,
            "description": "The record.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "key",
            "in": "path",
            "required": true,
            "description": "The column’s key, as in an item’s `values`.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "from",
            "in": "query",
            "required": false,
            "description": "Only changes that happened at or after this. Open-ended when absent.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "to",
            "in": "query",
            "required": false,
            "description": "Only changes that happened at or before this.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "column": "k_quantity",
                  "from": "2026-09-01T00:00:00.000Z",
                  "to": null,
                  "start": {
                    "value": 42,
                    "at": "2026-08-30T09:00:00.000Z"
                  },
                  "entries": [
                    {
                      "id": "01a02a70-b4c9-7d31-9f0e-2c5b8a1d4e77",
                      "at": "2026-09-02T09:00:00.000Z",
                      "recordedAt": "2026-09-02T15:10:00.000Z",
                      "kind": "adjust",
                      "value": 39,
                      "delta": -3,
                      "running": 39,
                      "reason": "sale",
                      "note": null,
                      "source": "person",
                      "actor": "dana@example.com"
                    }
                  ],
                  "truncated": false
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/databases/{id}/items/{itemId}/columns/{key}/at": {
      "get": {
        "operationId": "get-item-value-at",
        "summary": "Read what a dynamic column held at a moment",
        "description": "The value of one item’s dynamic column as of `date` — “how many were in stock on the 1st”. `known` is false before the column’s history began; the answer is then “not known”, never a guess.",
        "tags": [
          "Dynamic columns"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "itemId",
            "in": "path",
            "required": true,
            "description": "The record.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "key",
            "in": "path",
            "required": true,
            "description": "The column’s key.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "date",
            "in": "query",
            "required": false,
            "description": "The moment. Now when absent.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "column": "k_quantity",
                  "date": "2026-09-01T00:00:00.000Z",
                  "known": true,
                  "value": 42,
                  "since": "2026-08-30T09:00:00.000Z"
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/databases/{id}/columns/{columnId}/history": {
      "get": {
        "operationId": "get-column-history",
        "summary": "Read every item’s history at once",
        "description": "One dynamic column across the whole table: `items` is keyed by item id, each with `start` — what it held when the range begins — and its changes inside it.\n\nONE READ RATHER THAN ONE PER ITEM. Four hundred items would otherwise be four hundred calls, and the answer would be assembled from four hundred different moments rather than one.\n\nCapped at 20,000 changes; `truncated` says when the cap was reached, and a narrower range is the answer.",
        "tags": [
          "Dynamic columns"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "columnId",
            "in": "path",
            "required": true,
            "description": "The column’s id or its key — either is accepted here.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "from",
            "in": "query",
            "required": false,
            "description": "Only changes at or after this. Open-ended when absent.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "to",
            "in": "query",
            "required": false,
            "description": "Only changes at or before this.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "column": "k_quantity",
                  "from": "2026-09-01T00:00:00.000Z",
                  "to": null,
                  "items": {
                    "01a02a71-…": {
                      "start": {
                        "value": 42,
                        "at": "2026-08-30T09:00:00.000Z"
                      },
                      "entries": [
                        {
                          "id": "01a02a90-…",
                          "at": "2026-09-02T09:00:00.000Z",
                          "kind": "adjust",
                          "value": 39,
                          "delta": -3,
                          "running": 39,
                          "reason": "sale",
                          "source": "api"
                        }
                      ]
                    }
                  },
                  "truncated": false
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/databases/{id}/columns/{columnId}/dynamic/fill": {
      "post": {
        "operationId": "fill-dynamic",
        "summary": "Fill a dynamic column’s past in",
        "description": "A COLUMN SWITCHED ON TODAY HAS NO YESTERDAY. Every edit the app has recorded is in the audit trail, so the months before it became dynamic can be written in as history — marked `imported`, so a reconstruction never reads as something recorded as it happened.\n\nOnly edits BEFORE each item’s earliest entry are taken, an edit already carried is never written twice, and a past value that no longer fits the column’s type is skipped and counted. `gaps` counts the items whose reconstructed past does not arrive at the value recording started from — a change made outside the app, which nothing could have recorded.\n\nSafe to call twice: the second call finds nothing left to write.",
        "tags": [
          "Dynamic columns"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "columnId",
            "in": "path",
            "required": true,
            "description": "The column’s id or its key.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {}
              },
              "example": {}
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "filled": {
                    "count": 18,
                    "items": 6,
                    "from": "2026-06-02T08:15:00.000Z",
                    "skipped": 1,
                    "gaps": 0
                  },
                  "column": {
                    "id": "01a0169f-…",
                    "key": "k_quantity",
                    "name": "In stock",
                    "type": "number"
                  }
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      },
      "delete": {
        "operationId": "clear-dynamic-fill",
        "summary": "Take a filled-in past back out",
        "description": "The switch’s other way, and ONLY what the fill wrote: entries whose source is `imported`. Nothing recorded as it happened is touched, so this cannot lose a real change.",
        "tags": [
          "Dynamic columns"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "columnId",
            "in": "path",
            "required": true,
            "description": "The column’s id or its key.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "cleared": 18,
                  "column": {
                    "id": "01a0169f-…",
                    "key": "k_quantity",
                    "name": "In stock",
                    "type": "number"
                  }
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/databases/{id}/columns/{columnId}/plans": {
      "get": {
        "operationId": "list-plans",
        "summary": "What is still going to happen",
        "description": "Every change planned for this column, across every item, in time order, with the column’s loop. Only plans still waiting: one that has been applied is history, and is read from the column’s history instead.",
        "tags": [
          "Dynamic columns"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "columnId",
            "in": "path",
            "required": true,
            "description": "The column’s id or its key.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "from",
            "in": "query",
            "required": false,
            "description": "Only plans at or after this.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "to",
            "in": "query",
            "required": false,
            "description": "Only plans at or before this.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "column": "k_price",
                  "plans": [
                    {
                      "id": "01a02b10-…",
                      "itemId": "01a02a70-b4c9-7d31-9f0e-2c5b8a1d4e77",
                      "column": "k_price",
                      "at": "2026-10-01T00:00:00.000Z",
                      "value": 29,
                      "status": "planned",
                      "outcome": null
                    }
                  ],
                  "loop": {
                    "id": "01a02b20-…",
                    "column": "k_price",
                    "in": "2026-09-26T00:00:00.000Z",
                    "out": "2026-09-28T00:00:00.000Z",
                    "mode": "loop",
                    "until": null,
                    "appliedThrough": "2026-09-23T10:00:00.000Z"
                  }
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/databases/{id}/items/{itemId}/columns/{key}/plans": {
      "post": {
        "operationId": "create-plan",
        "summary": "Plan a change",
        "description": "A change with a date in the future. ORBIT APPLIES IT — nothing of yours has to be awake at the time — and it lands as an ordinary change, recorded with source `plan`, so automations and shares see it happen like any other.\n\n`at` is rounded to the minute and must be at least a minute ahead, except inside a looping region, where a pattern is allowed to sit in the past because it is a pattern and not a moment. The value is checked against the column’s type now, so a plan cannot fail at midnight for something you could have been told about at noon.\n\nIf Orbit is down when a plan comes due it is applied when it comes back, in order, dated to the moment it was meant for.",
        "tags": [
          "Dynamic columns"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "itemId",
            "in": "path",
            "required": true,
            "description": "The record.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "key",
            "in": "path",
            "required": true,
            "description": "The dynamic column’s key.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "at": {
                    "type": "string",
                    "description": "When it should happen."
                  },
                  "value": {
                    "type": "string",
                    "description": "What it becomes, in the column’s own shape. Absent means empty."
                  }
                },
                "required": [
                  "at"
                ]
              },
              "example": {
                "at": "2026-10-01T00:00:00.000Z",
                "value": 29
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "plan": {
                    "id": "01a02b10-…",
                    "itemId": "01a02a70-b4c9-7d31-9f0e-2c5b8a1d4e77",
                    "column": "k_price",
                    "at": "2026-10-01T00:00:00.000Z",
                    "value": 29,
                    "status": "planned",
                    "outcome": null
                  }
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/databases/{id}/plans/{planId}": {
      "patch": {
        "operationId": "update-plan",
        "summary": "Move a plan, or change what it does",
        "description": "ADDRESSED BY ITS OWN ID, like a view: once it exists it is a thing, and repeating the item and the column in the path would be two more ids that have to agree with it.\n\nA plan that has already been applied is no longer a plan — it is history — and is refused here rather than quietly changed.",
        "tags": [
          "Dynamic columns"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "planId",
            "in": "path",
            "required": true,
            "description": "The plan.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "at": {
                    "type": "string",
                    "description": "A new moment, under the same rules as making one."
                  },
                  "value": {
                    "type": "string",
                    "description": "A new value."
                  }
                }
              },
              "example": {
                "at": "2026-10-02T09:00:00.000Z"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "plan": {
                    "id": "01a02b10-…",
                    "itemId": "01a02a70-b4c9-7d31-9f0e-2c5b8a1d4e77",
                    "column": "k_price",
                    "at": "2026-10-02T09:00:00.000Z",
                    "value": 29,
                    "status": "planned",
                    "outcome": null
                  }
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      },
      "delete": {
        "operationId": "delete-plan",
        "summary": "Take a plan back",
        "description": "Only while it is still waiting. A plan already applied is a change in the history, and undoing it is another change — made with `set` or `adjust`, where it can say why.",
        "tags": [
          "Dynamic columns"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "planId",
            "in": "path",
            "required": true,
            "description": "The plan.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "removed": "01a02b10-…"
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/databases/{id}/columns/{columnId}/loop": {
      "put": {
        "operationId": "set-loop",
        "summary": "Repeat a column’s plans",
        "description": "THE PLANS BETWEEN `in` AND `out` BECOME A PATTERN, applied again every (out − in) until `until`, or forever — weekend pricing, a shift that repeats, a rate that steps through the day.\n\nROUNDS START WHEN THE LOOP STARTS LOOPING. A region drawn across last week does not replay last week; nothing floods when you switch it on.\n\n`mode: \"realtime\"` keeps the region without repeating it: the plans inside simply happen once, as the clock reaches them. LEAVING A LOOP DOES NOT FIRE THE PATTERN — pattern plans whose moment has passed are released as `skipped` and `released` counts them; the ones still ahead become ordinary plans.\n\nOne loop per column, replaced by sending another. At least a minute long, and `out` after `in`.",
        "tags": [
          "Dynamic columns"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "columnId",
            "in": "path",
            "required": true,
            "description": "The column’s id or its key.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "in": {
                    "type": "string",
                    "description": "Where the region starts. Required the first time; a later call may send only what it changes."
                  },
                  "out": {
                    "type": "string",
                    "description": "Where it ends — at least a minute after `in`. The plan exactly at `out` is outside the pattern: the region is up to, not including, it."
                  },
                  "mode": {
                    "type": "string",
                    "description": "`loop` to repeat, `realtime` to let it play once."
                  },
                  "until": {
                    "type": "string",
                    "description": "When repeating stops. `null` is forever."
                  }
                }
              },
              "example": {
                "in": "2026-09-26T00:00:00.000Z",
                "out": "2026-09-28T00:00:00.000Z",
                "mode": "loop",
                "until": null
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "loop": {
                    "id": "01a02b20-…",
                    "column": "k_price",
                    "in": "2026-09-26T00:00:00.000Z",
                    "out": "2026-09-28T00:00:00.000Z",
                    "mode": "loop",
                    "until": null,
                    "appliedThrough": "2026-09-23T10:00:00.000Z"
                  },
                  "released": 0
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      },
      "delete": {
        "operationId": "delete-loop",
        "summary": "Stop repeating",
        "description": "The loop goes; the plans stay. Pattern plans already past are released as `skipped` — `released` says how many — and the ones still ahead become ordinary plans, each happening once.",
        "tags": [
          "Dynamic columns"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "columnId",
            "in": "path",
            "required": true,
            "description": "The column’s id or its key.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "removed": true,
                  "released": 3
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/databases/{id}/sub-items": {
      "post": {
        "operationId": "enable-sub-items",
        "summary": "Let a table nest",
        "description": "SUB-ITEMS ARE A SELF-RELATION THE TABLE HAS NAMED, not a second hierarchy. Turning them on creates two real columns — “Sub-items”, holding many, and “Parent item”, holding one — and remembers which one means “underneath”.\n\nBoth are HIDDEN in every view, because once a table nests the indent says what they say. They are still real: filterable, and un-hideable from the column list.\n\nAlready on, the answer is `{\"column\": null}` rather than a second pair of columns.",
        "tags": [
          "Sub-items"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "column": {
                    "type": "string",
                    "format": "uuid",
                    "description": "Adopt a relation this table already has instead of making one. It must point at THIS table: nesting rows of another table inside this one is a tree whose levels are different kinds of thing, and it is refused."
                  }
                }
              },
              "example": {}
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "column": {
                    "id": "01a016a2-…",
                    "key": "k_sub_1c9f",
                    "name": "Sub-items",
                    "type": "relation",
                    "isPrimary": false
                  }
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      },
      "delete": {
        "operationId": "disable-sub-items",
        "summary": "Stop it nesting",
        "description": "CLEARS THE POINTER AND NOTHING ELSE. The columns stay, the links stay, and turning it back on through the same column puts the tree back exactly as it was. Deleting them here would make a toggle into a way to destroy data with no confirmation in front of it.",
        "tags": [
          "Sub-items"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "ok": true
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/databases/{id}/items/{itemId}/links": {
      "get": {
        "operationId": "list-links",
        "summary": "What a record points at",
        "description": "A RELATION’S VALUE IS NOT IN THE ROW. The links live in their own table, so a relation handed to `values` on a create or a patch is dropped — the record is made, the answer is 200, and the link is not there. These three routes are how an integration says what points at what.\n\nOne column at a time, because a record can point at several tables and answering for all of them would make the common question pay for the rare one.",
        "tags": [
          "Sub-items"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "itemId",
            "in": "path",
            "required": true,
            "description": "The record.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "column",
            "in": "query",
            "required": true,
            "description": "The relation column — its key or its id. A column that is not a relation is refused by name rather than answered with nothing.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "links": [
                    {
                      "id": "01a02ac0-…",
                      "rowId": "01a02a71-…",
                      "label": "GALON",
                      "icon": null,
                      "deleted": false,
                      "values": {},
                      "position": 0
                    }
                  ]
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      },
      "post": {
        "operationId": "add-link",
        "summary": "Point one record at another",
        "description": "A RELATION’S VALUE IS NOT IN THE ROW. The links live in their own table, so a relation handed to `values` on a create or a patch is dropped — the record is made, the answer is 200, and the link is not there. These three routes are how an integration says what points at what.\n\nThe cardinality, the far table and whether that record exists are all checked by the same code the app uses — so this endpoint cannot allow something the interface refuses.",
        "tags": [
          "Sub-items"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table the record is in.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "itemId",
            "in": "path",
            "required": true,
            "description": "The record doing the pointing.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "column": {
                    "type": "string",
                    "description": "The relation column — its key or its id."
                  },
                  "item": {
                    "type": "string",
                    "format": "uuid",
                    "description": "The record being pointed at, in the table that column targets."
                  }
                },
                "required": [
                  "column",
                  "item"
                ]
              },
              "example": {
                "column": "f_3fab64ee106f",
                "item": "01a03f32-de13-…"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "item": "01a0ee07-…",
                  "linked": "01a03f32-de13-…",
                  "column": "f_3fab64ee106f"
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/databases/{id}/items/{itemId}/links/{targetId}": {
      "patch": {
        "operationId": "set-link",
        "summary": "The link’s own cells",
        "description": "Sets the values that belong to the LINK rather than to either record — the \"3\" next to the product on a line item.\n\nAdd and remove had doors; CHANGE DID NOT, so a quantity could be set once and never corrected.\n\nADDRESSED BY THE FAR RECORD, not by a link id, which is the convention the other link endpoints already use: a caller knows which record it linked and has nowhere natural to have kept a link id.\n\nTHE COLUMN TRAVELS IN THE QUERY STRING, deliberately — the BODY IS THE LINK’S OWN CELLS, arbitrary keys that could perfectly well include one called `column`.\n\nA link that is not there is a 404 here, unlike removing one: \"change the quantity on a link that does not exist\" has no state that satisfies it.",
        "tags": [
          "Sub-items"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "itemId",
            "in": "path",
            "required": true,
            "description": "The record whose cell holds the link.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "targetId",
            "in": "path",
            "required": true,
            "description": "The record at the other end.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "column",
            "in": "query",
            "required": false,
            "description": "The relation column, by id or by key. Needed when the table has more than one.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {}
              },
              "example": {
                "Quantity": 3
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "link": {
                    "id": "l_7f3a9",
                    "rowId": "01a016a2-…",
                    "values": {
                      "Quantity": 3
                    }
                  }
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      },
      "delete": {
        "operationId": "remove-link",
        "summary": "Stop pointing at it",
        "description": "Removes the link and neither record. **Unlinking something that is already unlinked answers 200**, not 404: retrying a delivery must not fail for having already worked.",
        "tags": [
          "Sub-items"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "itemId",
            "in": "path",
            "required": true,
            "description": "The record doing the pointing.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "targetId",
            "in": "path",
            "required": true,
            "description": "The record it points at.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "column",
            "in": "query",
            "required": true,
            "description": "The relation column — its key or its id.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "ok": true
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/databases/{id}/items/{itemId}/links/{targetId}/position": {
      "post": {
        "operationId": "move-link",
        "summary": "Reorder the links in a cell",
        "description": "Moves one link to a place in the list, and renumbers the rest.\n\nAN INDEX, NOT A SORT KEY. The caller says which place it landed on; the server does the renumbering — so a caller cannot invent two the same.\n\nHere the column is in the BODY, where `position` already is and no collision with a link’s own cells can arise.",
        "tags": [
          "Sub-items"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "itemId",
            "in": "path",
            "required": true,
            "description": "The record whose cell holds the link.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "targetId",
            "in": "path",
            "required": true,
            "description": "The record at the other end.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "column": {
                    "type": "string",
                    "description": "The relation column, by id or by key."
                  },
                  "position": {
                    "type": "integer",
                    "description": "The place in the list, counting from 0."
                  }
                },
                "required": [
                  "position"
                ]
              },
              "example": {
                "column": "f_7c1e4a90b2d1",
                "position": 0
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "ok": true
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/databases/{id}/items/{itemId}/sub-items": {
      "get": {
        "operationId": "list-sub-items",
        "summary": "List an item’s sub-items",
        "description": "The records directly underneath this one, in the order they were put there. A table that has not been told to nest answers 409 with the endpoint that turns it on.",
        "tags": [
          "Sub-items"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "itemId",
            "in": "path",
            "required": true,
            "description": "The parent record.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "subItems": [
                    {
                      "id": "01a02ac0-…",
                      "rowId": "01a02a71-…",
                      "label": "Location scout",
                      "icon": null,
                      "deleted": false,
                      "values": {},
                      "position": 0
                    }
                  ]
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      },
      "post": {
        "operationId": "add-sub-item",
        "summary": "Put a record underneath",
        "description": "AN EXISTING RECORD, OR A NEW ONE. `{\"item\": \"…\"}` moves a record that already exists underneath this one; `{\"values\": {…}}` creates one there. Both are the same act from the tree’s point of view, and needing two calls for the second would leave a top-level record behind if the link failed.\n\nA CYCLE IS REFUSED. A record that is already above this one cannot become its child: a loop is a record that is its own ancestor, and everything that walks the tree — drawing it, counting it, deleting a branch — would run forever.",
        "tags": [
          "Sub-items"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "itemId",
            "in": "path",
            "required": true,
            "description": "The parent record.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "item": {
                    "type": "string",
                    "format": "uuid",
                    "description": "An existing record in this table to move underneath."
                  },
                  "values": {
                    "type": "object",
                    "description": "Or: create one, keyed by column key, as `POST …/items` takes it."
                  }
                }
              },
              "example": {
                "values": {
                  "k_7f3a91": "Location scout"
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "item": "01a02a71-…",
                  "parent": "01a02a70-b4c9-7d31-9f0e-2c5b8a1d4e77"
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    },
    "/databases/{id}/items/{itemId}/sub-items/{childId}": {
      "delete": {
        "operationId": "remove-sub-item",
        "summary": "Take a record out from under",
        "description": "AND NOT DELETE IT. The record stays in the table as a top-level one. Deleting it is `DELETE /v1/databases/{id}/items/{itemId}`, which is a different path because it is a different act — and one keystroke apart is exactly how often they would be confused if they were the same.",
        "tags": [
          "Sub-items"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The table.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "itemId",
            "in": "path",
            "required": true,
            "description": "The parent record.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "childId",
            "in": "path",
            "required": true,
            "description": "The record to lift out. One that is not underneath this one is a 409 that says so.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success.",
            "content": {
              "application/json": {
                "example": {
                  "ok": true
                }
              }
            }
          },
          "4XX": {
            "description": "A refusal, in the one envelope every error uses: `{\"error\": {\"code\": \"...\", \"message\": \"...\"}}`. The CODE is the contract; the message is for a person."
          }
        }
      }
    }
  }
}
