# Orbit Office — the HTTP API Orbit is a workspace of pages: documents, and databases with views, columns and records. This API covers 188 endpoints over 24 topics; 173 of them are reachable with a workspace API key, which is nearly all of it. READ THIS FILE FIRST AND YOU CAN MAKE A CORRECT SECOND CALL. It is the part that is not guessable from the endpoint list: the credentials, the scopes, the error envelope, and the handful of rules that cost an afternoon each if you meet them by being refused. | | | |---|---| | `/llms.txt` | this file: the orientation, and what else there is | | `/llms-full.txt` | every endpoint in full — parameters, examples, and the whole of each description | | `/openapi.json` | OpenAPI 3.1, generated from the same source. Examples rather than invented schemas | | `/docs` | the same reference as a web page, for a person | --- 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. ## Base URL Everything 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. THERE 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. ## Authentication Two credentials exist, and they are not interchangeable. A SESSION is what the app uses: an access token from this workspace’s own auth server, sent as `Authorization: Bearer `. It acts as a person and is subject to that person’s page grants. THE 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. An 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_…`. A key authenticates everything under `/v1`, which is nearly all of this reference. 11 entries are marked "session", and they divide in two. NINE 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. THE 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. A 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. ## Scopes A 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. WHAT 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. SO 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. AND 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. ## Errors Failures 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. 401 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. A 404 and a "not yours" are deliberately the same answer: telling somebody a record exists but belongs to another workspace is itself a disclosure. ## Rate limits 300 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"`. ## Records are addressed by column KEY A 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. So 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. ## And a value that is not one is narrowed, not refused A 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. THIS 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. WHAT 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. The shape each type takes is on its entry under “Column types”. ## Lists are cursor-paged `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. Pass 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. ## A frozen page refuses writes A 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`. A 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. ## Ask what your key can do `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. Without it the only way to find out a scope is absent is to be refused by the route that needed it. ## A machine-readable copy THREE OTHER COPIES OF THIS REFERENCE EXIST, all generated from it rather than written beside it, so none of them can describe a different API: `/llms.txt` is the orientation — this introduction and an index of the topics, a few kilobytes, and the one to hand an agent first. `/llms-full.txt` is every endpoint in plain text: parameters, examples, and the whole of each description. `/openapi.json` is OpenAPI 3.1. THE 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. AND 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. ALL 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. --- ## The four jobs, as sequences Every step of every one of these is executed against a real server on each build, so a sequence that stopped working is a failed build rather than a paragraph somebody trusts. ### Build a table somebody can work in The table with its columns, the choice ids, a board to see it by stage, a record, and a read back. **What it needs** A key carrying `config`, `write`, `read`. 5 calls. **1 · POST /v1/databases** { "title": "Shoots", "icon": "📸", "columns": [ { "name": "Job", "type": "text" }, { "name": "Stage", "type": "select", "options": { "choices": [ "Booked", "Shot", "Delivered" ] } }, { "name": "Shoot on", "type": "date" }, { "name": "Fee", "type": "number" }, { "name": "Confirmed", "type": "checkbox" } ] } ONE CALL MAKES THE WHOLE TABLE. Not a page and then five columns — the columns are part of it, everything is validated before anything is written, and a default view called Table comes with it. Needs `config`, because this is the workspace’s SHAPE. THE ANSWER CARRIES THE COLUMN KEYS, which is the thing you need next and the reason this is not two requests. Send choices as plain names and the ids come back; a cell holds a choice ID, not its label, so that renaming an option does not rewrite every row. **2 · GET /v1/databases/01a0169e-1586-776f-b92a-133830f16c4e/columns** THE CHOICE IDS LIVE HERE. The call above hands back the columns, but a select’s own choices are in its `options` — so this is where `Booked` becomes the `c_…` you write into a cell. A program that already holds the keys can skip this; one that is picking up a table somebody else made cannot. **3 · POST /v1/databases/01a0169e-1586-776f-b92a-133830f16c4e/views** { "name": "By stage", "kind": "board", "config": { "groupBy": "k_2b8c04" } } A BOARD IS A VIEW WITH A COLUMN TO GROUP BY, and `groupBy` is a column KEY. The table already had a Table view; this adds the lane-per-stage one somebody will actually work in. **4 · POST /v1/databases/01a0169e-1586-776f-b92a-133830f16c4e/items** { "values": { "k_7f3a91": "Rooftop shoot", "k_2b8c04": "c_booked" } } VALUES ARE KEYED BY COLUMN KEY. `{"Job": "Rooftop shoot"}` would succeed and write NOTHING — a column name is silently dropped, which is the one mistake here that does not announce itself. Needs `write`. **5 · GET /v1/databases/01a0169e-1586-776f-b92a-133830f16c4e/items?limit=50** AND READ IT BACK, which is worth doing once: the answer carries the COLUMNS beside the records, so a program that kept neither can still read what it just wrote. `nextCursor` is on every page and is `null` on the last — it is not omitted when there is no more, so a loop that stops when a page is shorter than `limit` is wrong the moment anybody passes one. **Watch out for** • A column NAME in `values` is dropped without a word. Always write keys. • This table is `config` work. An integration that only files records wants `read` and `write` and should not be able to reshape anything. ### File records in from another system, twice, without duplicating them The shape every inbound integration actually needs: look first, then update or add. No `config` anywhere. **What it needs** A key carrying `read`, `write`. 3 calls. **1 · POST /v1/databases/01a0169e-1586-776f-b92a-133830f16c4e/items/query** { "filter": { "match": "all", "rules": [ { "key": "k_7f3a91", "op": "is", "value": "Rooftop shoot" } ] }, "limit": 1 } LOOK BEFORE YOU WRITE. There is no upsert here, deliberately: what counts as "the same record" is your business rule and not the server’s. `filter` IS A GROUP, NOT A LIST: `{ match, rules }`, where `match` is `"all"`, `"any"` or a whole number meaning "at least this many", and each rule is `{key, op, value}`. Sending the list on its own is a 400 — worth knowing, because the first draft of this recipe did exactly that, with the reference open, which had it right in three places at once. Which `op` applies depends on the column type. EVERYTHING THAT CANNOT BE ANSWERED IS REFUSED rather than ignored — an unknown key, an op that does not apply, a word where a number belongs. Each of those would otherwise match every row, and you would read the whole table as your filtered set, with a 200. **2 · PATCH /v1/databases/01a0169e-1586-776f-b92a-133830f16c4e/items/01a016f0-1586-776f-b92a-133830f16c51** { "values": { "k_9ac3f2": 1200, "k_2b8c04": "c_shot" } } IT EXISTED, SO CHANGE IT. A PATCH touches only the keys you send; leaving a column out leaves it alone. Had the query come back empty, this step is `POST …/items` with the same `values` instead — which is the whole of the branch. **3 · GET /v1/databases/01a0169e-1586-776f-b92a-133830f16c4e/items/01a016f0-1586-776f-b92a-133830f16c51/page** READ THE ONE RECORD BACK — and note the path. THERE IS NO BARE `GET /items/{id}`: a record is addressed by id for a PATCH and a DELETE, and the single-record READ is its page. Nothing is missing, but a program that assumes the obvious URL gets a 404, and `POST /items/query` with a filter is the other way to fetch exactly one. The answer carries the table’s whole `fields` list beside the record, for the same reason a page of records carries the columns: values are keyed, and a caller holding neither cannot read them. **Watch out for** • No upsert exists on purpose. Query, then write — and filter on a column you control, not on the record’s title. • A filter naming a column that no longer exists is REFUSED, not skipped. That is the safe direction: skipping would widen your query to the whole table. • There is no `GET /items/{id}`. Use `…/items/{id}/page`, or a query with a filter. ### Tell another system when a record appears An automation, a signed webhook, and the delivery log that proves it arrived. Three of the calls exist only because you want it NOW rather than within the tick. **What it needs** A key carrying `config`, `write`, `read`. 6 calls. **1 · POST /v1/automations** { "name": "Tell the courier", "table": "01a0169e-1586-776f-b92a-133830f16c4e", "trigger": { "type": "event", "config": { "verbs": [ "db.row_created" ] } }, "actions": [ { "type": "send_webhook" } ], "enabled": true } A RULE BELONGS TO A TABLE, and `db.row_created` is the verb. `enabled: true` from the start, because a rule nobody switched on is the commonest reason "the webhook never fired". **2 · PUT /v1/automations/01a01700-1586-776f-b92a-133830f16c62/transport** { "url": "https://couriers.example.com/hook", "method": "POST" } WHERE IT GOES, AND THE SECRET COMES BACK ONCE. The answer carries `signingSecret` — keep it: every delivery is signed with it, and a receiver that does not check the signature is a URL anybody can post to. It is readable again at `POST /v1/automations/{id}/transport/secret`, which is a POST for a read on purpose, because a GET that answers with a credential is a GET something will one day log. **3 · POST /v1/databases/01a0169e-1586-776f-b92a-133830f16c4e/items** { "values": { "k_7f3a91": "Courier test", "k_2b8c04": "c_booked" } } THE THING THAT HAPPENS. Creating a record is what `db.row_created` means. **4 · POST /v1/automations/run-queued** IN PRODUCTION YOU DO NOT NEED THIS. The scheduler ticks `queueRuns`, `runQueued` and the delivery pump every pass, so rules fire and webhooks send themselves. This verb and the next are "now, rather than within the tick" — which is exactly what an integration that has just written a row and wants its webhook out does want, and what a test needs in order not to sleep. **5 · POST /v1/deliveries/run** DRAIN THE QUEUE. Same reasoning: immediacy, not necessity. **6 · GET /v1/deliveries?automation=01a01700-1586-776f-b92a-133830f16c62** DID IT ARRIVE? THIS IS THE ONLY PLACE THAT ANSWERS. Each entry carries the status the receiver gave, the attempt count and the response body — and `POST /v1/deliveries/{id}/replay` sends one again after you have fixed the receiver. A retry policy nobody can see is a retry policy nobody trusts. **Watch out for** • The signing secret is shown once when you set the transport. Read it back with the POST, never a GET. • A delivery that failed is still IN the log. Absence from the log means the rule never fired, which is a different bug entirely — look at `enabled` and the trigger verb. ### Give another system exactly one table, and only the columns it needs The receiver from goal 2 of docs/ORBIT-OFFICE.md: *write into one table only*. Two of the calls are refusals, and those are the point. **What it needs** A key carrying `config`. 5 calls. Some steps are made by a SECOND key — the one being handed the table — and say so. **1 · GET /v1/key (as the integration’s key)** FIRST, THE KEY ASKS WHAT IT IS. A KEY CANNOT MINT ITSELF — the owner creates it in Automation ▸ Api Keys, where the secret is shown once and stored as a hash, and that is the one step of this recipe that is not an API call. (A key that could mint keys would be a privilege escalation with no ceiling.) So an integration starts by being handed a secret, and this call is how it finds out what that secret can do. IT NEEDS NO SCOPE, deliberately: scopes are flat, so 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. **2 · PUT /v1/api-keys/01a01710-1586-776f-b92a-133830f16c73/grants** { "grants": [ { "pageId": "01a0169e-1586-776f-b92a-133830f16c4e", "scopes": [ "read", "write" ], "columns": [ "k_7f3a91", "k_2b8c04", "k_44e1c7" ], "writeColumns": [ "k_2b8c04" ] } ] } NOW NARROW IT, as the owner’s key. Three narrowings in one grant: which PAGES it may reach, which COLUMNS of them it may see, and which of those it may change. PUT 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. **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. THE LIST MUST INCLUDE THE NAME COLUMN. Every record answer carries its title, which is that column’s value. **3 · GET /v1/databases/01a0169e-1586-776f-b92a-133830f16c4e/items?limit=5 (as the integration’s key)** AND NOW IT SEES THREE COLUMNS. `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, and from every revision — and a query may not filter or sort by one, because ordering by a column reveals it one comparison at a time. **4 · GET /v1/pages (as the integration’s key) → 403 page_not_granted** EVERYTHING ELSE IS REFUSED, INCLUDING LOOKING AROUND. A confined key cannot list the pages, read the member roster, see the other credentials, or touch the share links. It is not a filtered view of the workspace — it is one table and nothing else. **5 · PATCH /v1/databases/01a0169e-1586-776f-b92a-133830f16c4e/items/01a016f0-1586-776f-b92a-133830f16c51 (as the integration’s key) → 403 column_not_granted** { "values": { "k_2b8c04": "c_shot", "k_9ac3f2": 5 } } AND A WRITE OUTSIDE `writeColumns` CHANGES NOTHING AT ALL. Both columns are refused, not just the ungranted one: a partial write that reported success would be the worst of the three possible answers. Send only `Stage` and it goes through. **Watch out for** • A grant cannot outrank the key. Scopes in a grant are INTERSECTED with the key’s own, so naming `write` on a read-only key stores nothing. • A confined key cannot call the grants endpoint — this route is workspace-wide, so the confinement refuses it on the way in. Narrow a key and it cannot widen itself again; that is reversible from the app. • A formula column sends what it READS. Exposing a margin exposes the cost behind it, even though nobody granted the cost column. ### Hand a Hebrew reader an English table The job docs/core/LANGUAGES.md was written for: publish a language, write the second names by hand, and send somebody a link that opens in theirs. Ten calls, and the last one passes no parameter at all. **What it needs** A key carrying `config`, `write`, `read`. 10 calls. **1 · POST /v1/workspace/languages** { "code": "he" } THE OWNER’S LIST IS THE DOOR. Nothing can be translated into a language this workspace does not publish — a foreign key refuses it underneath, and the endpoints refuse it with a sentence — so this call comes first and is the only one that is a decision rather than typing. The NAME and the DIRECTION come from the catalogue: `he` is עברית and reads right to left, and a reader gets both without anybody typing them. `PATCH /v1/workspace/languages/{code}` overrides either. Nothing here translates anything. There is no model and no suggestion: every second name below is typed by a person who speaks the language. **2 · GET /v1/databases/01a0169e-1586-776f-b92a-133830f16c4e/columns** AND NOW THE ONE THING WORTH KNOWING BEFORE YOU WRITE A TRANSLATION: a column is named by its **id** here, and a record’s values are keyed by its **key**. A translation of a header takes the id, because an id survives a rename and outliving a rename is the whole point; a translation of a VALUE takes the key, because that is what the record’s `values` are keyed by and the reader drawing a cell already holds it. Both are in this answer, which is why this step exists. **3 · PUT /v1/pages/01a0169e-1586-776f-b92a-133830f16c4e/translations** { "subject": "column", "id": "01a016a0-1586-776f-b92a-133830f16c50", "path": "name", "lang": "he", "value": "עבודה" } THE HEADER. `config`, because a column’s second name is part of the column the way its type is — a key that files records in is not renaming things in any language. The answer carries `origin`: what the column said when this was written. Rename the column later and the Hebrew is marked `stale` and still shown, because it is usually still right and throwing away somebody’s work over a typo fix would be worse than a mark. **4 · PUT /v1/pages/01a0169e-1586-776f-b92a-133830f16c4e/translations** { "subject": "column", "id": "01a016a1-1586-776f-b92a-133830f16c52", "path": "choice:c_booked", "lang": "he", "value": "הוזמן" } A SELECT’S WORDS ARE ITS CHOICES, AND THEY ARE TRANSLATED ONCE. Not per record: a cell holds a choice id, so forty records sharing `Booked` share one Hebrew word — and translating them per cell would let two of them read differently, which is a data defect with a screen in front of it. **5 · PATCH /v1/databases/01a0169e-1586-776f-b92a-133830f16c4e/columns/01a016a0-1586-776f-b92a-133830f16c50** { "options": { "translated": true } } VALUES ARE OPT-IN, AND THIS IS THE SWITCH. A record’s NAME is always translatable; every other text column has to be asked for, because most text columns in most tables are references, codes and addresses-by-another-name, and offering a Hebrew box on every one of them would bury the two that matter. It is refused on every type where it would mean nothing — a number is FORMATTED by the reader’s locale, a date likewise, a select is its choices, a person’s name is their own, a URL would stop working — and the refusal carries the reason (`cannot_translate_values`). **6 · PUT /v1/pages/01a0169e-1586-776f-b92a-133830f16c4e/items/01a016f0-1586-776f-b92a-133830f16c51/translations** { "path": "cell:k_7f3a91", "lang": "he", "value": "צילומי גג" } A RECORD’S VALUE, AT ITS OWN ROUTE AND ITS OWN SCOPE. `write`, not `config`: translating a record is filing data about that record, and a key built to fill a table in two languages should not also be able to change a column’s type. The subject and the id are the route, so the body is three fields. The length limit is THE COLUMN’S OWN — 250 characters for a short text column, 10,000 for a long one — because a translation gets the same room as the original. **7 · GET /v1/databases/01a0169e-1586-776f-b92a-133830f16c4e/items?lang=he&limit=5** AND HERE IS THE SHAPE TO READ TWICE. The column HEADERS come back translated in place. The VALUES do not: `values` always holds what somebody typed, and the translated ones arrive in `translated` beside the records — `translated[itemId].values[columnKey]`. THE REASON IS THAT A VALUE ROUND-TRIPS. The same bag you read is the bag you send back, so a translation substituted into it would be written over the original by the first integration that read a record and patched it. A column name cannot do that: it is read here and written through a rename. EVERYTHING THAT IS LOGIC READS THE ORIGINAL. `POST /items/query` filtering `contains: "Rooftop"` still matches this record; filtering on the Hebrew matches nothing. One set of rules for everybody, whatever they are reading. **8 · POST /v1/pages/01a0169e-1586-776f-b92a-133830f16c4e/shares** { "mode": "public", "level": "view", "label": "Hebrew reader" } NOW THE LINK. `token` comes back once and is the whole credential — append it to `/share/`. It is named `token` rather than `url` because this install does not know the host it is served from. **9 · PUT /v1/shares/01a016a2-1586-776f-b92a-133830f16c53/rules** { "element": "table", "tab": "v:01a0169f-1586-776f-b92a-133830f16c4f", "visible": true, "columns": [ "k_7f3a91", "k_2b8c04" ] } WHAT THE LINK SENDS, AND IT IS A LIST YOU WRITE. A table page’s single element is `table` and a tab is `v:`. `columns` governs what comes back, not only what may be changed — and a visitor is sent the TRANSLATIONS of exactly these columns and no others, because a translation of a value is that value in another language. **10 · GET /share/shr_7f3a91c4e0d2/elements/table/tabs/v%3A01a0169f-1586-776f-b92a-133830f16c4f/rows** AND THE READER PASSES NOTHING AT ALL. This is the only step in any of these recipes that is not a program: it is a browser asking, and `Accept-Language` is what it already sends. The link honours it — headers in Hebrew, the records’ second values beside them — and answers `Vary: accept-language` so no cache can hand one visitor’s language to the next. THIS IS THE OPPOSITE OF `/v1`, DELIBERATELY. A program’s answer never changes until it asks (`?lang=`), because an integration written last year must keep working after somebody adds Hebrew. A person’s page always speaks their language, because nobody can add a parameter to a link they were sent. `?lang=` still works here too, for a reader whose browser says one thing and who wants another — and for the owner checking their own Hebrew. **Watch out for** • A translation into the language the workspace is WRITTEN in is refused (`is_default`). It would be a second copy of the same name with nothing keeping the two in step. • A record’s value is a different route and a different scope from a column’s name — `write` against `config`. Sending `subject: "row"` to the page route is refused rather than quietly accepted, because a scope distinction undone by a field in a body is not a distinction. • Filters, sorts and formulas read the ORIGIN, always. A reader searching their own language on a share link does find it — that one match is origin OR translation — but nothing else in the product looks at a translation. • A page body read with `?lang=` must not be sent back: `PATCH /v1/pages/{id}` would store the translation as the original. Read without it when you intend to write. --- ## What is in the full reference - **Getting started** — 7 endpoints - **Workspace** — 20 endpoints - **Variables** — 4 endpoints - **Members** — 10 endpoints - **Pages** — 10 endpoints - **Synced blocks** — 5 endpoints - **Backups** — 4 endpoints - **Who may see what** — 5 endpoints - **Shares** — 15 endpoints - **Formulas** — 4 endpoints - **Spending & lookups** — 6 endpoints - **Automations** — 10 endpoints - **Elements** — 5 endpoints - **Tables** — 4 endpoints - **Views** — 4 endpoints - **Columns** — 9 endpoints - **Items** — 20 endpoints - **Item links & presets** — 6 endpoints - **Deliveries** — 4 endpoints - **Trash** — 3 endpoints - **Archive** — 6 endpoints - **Dynamic columns** — 13 endpoints - **Sub-items** — 10 endpoints - **Automation** — 4 endpoints Each is in `/llms-full.txt`, in this order, with every parameter and an example of what it answers. --- GENERATED from the same source the web reference renders, and checked against the server’s real route table on every build (`verify-api-docs.ts`, `guard-openapi.mjs`). If an endpoint is described here, it is registered; if it is registered under `/v1`, it is described here.