API reference
Everything the screen does runs through this API — the screen is just another client of it. Every response below is a real capture from a running Tabla, not a sketch of one.
OpenAPI specThe machine-readable twin of this page: OpenAPI 3.1, 31 paths, 50 operations — the source most client generators expect.
The sample database
Every example below is written against this table — and every response on the page was captured from it, so each curl really did produce the answer printed under it. Sign in and this section becomes your own databases, with the examples rewritten to your table and field ids.
Authentication and keys
Almost every call to the public API carries an API key. Mint one from the account menu (top right → API keys…). The full value is shown once, at creation — it cannot be shown again, only replaced with a new key.
A key reads <key_id>.<secret> and travels in the Authorization header. The id half is a plain lookup key; only the secret is verified, against a stored hash.
curl https://tabladb.com/api/databases \ -H "Authorization: Bearer key_xxxxxxxxxxxx.your-secret"
GET /api/health takes no credential at all, and GET /api/share/:token/records — the read behind a shared view link — is authenticated by the token in its own address.A key acts as the account that owns it — it is not a separate identity with its own permissions. Whatever that account may touch through the screen, the key may touch through the API, and nothing more. Access is decided per resource, per request, from the account's role in the database that resource belongs to. A role is per database, so one account can be an owner in one database and a viewer in another.
404 not_found — identical to an id that never existed, on purpose. 403 forbidden means the opposite: membership is real, the role is too low.GET /api/meta/whoami is the call to make when a key is first pasted in. It needs no database or table id, so it works before anything else is configured. There is no valid flag — reaching 200 at all is the proof, because the shared gate verified the secret first.
{ "user": { "id": "usr_7k2mnpq4rstv", "email": "tarek@example.com", "name": "Tarek Shalaby" }, "key": { "id": "key_3n7pqrs2tuvw", "name": "Automation platform", "created_at": "2026-07-11T09:14:02.118Z", "last_used_at": "2026-07-22T15:31:44.902Z", "access": "read_write", "databases": "all" } }
last_used_at is stamped as each request comes in. The stamp is best-effort and is not waited on, so the value read back here is either this call's moment or the one before it. No roles or memberships are reported: a blanket permission would be a second answer that could disagree with the per-resource one. access and databases are a different thing — the key's own limits, read back from the row that sets them. databases is either "all", meaning every database on the account including ones made later, or the exact list this key was narrowed to. This is the call to make when an integration starts being refused and nobody remembers how the key was set.
{"error":{"code":"unauthorized","message":"Missing or invalid API key. Expected: Bearer <id>.<secret>."}}
Rate limits are counted in two stages, so a key id that leaks into a config file or a support paste cannot be spent by anyone but its holder.
Over a limit the response is 429 with code rate_limited, a message naming the wait ("Too many requests. Retry in about 12s."), and a Retry-After header in whole seconds, never below 1. That header is the earliest moment a retry can succeed.
You don't have to wait for the 429 to see it coming. Every response to a request carrying an API key — successes and errors alike, not only refusals — includes X-RateLimit-Limit (600, that key's budget for the minute) and X-RateLimit-Remaining (what is left of it). Read the second one and ease off before you are refused rather than after.
X-RateLimit-Limit: 600 X-RateLimit-Remaining: 599 X-RateLimit-Limit: 600 X-RateLimit-Remaining: 598
X-RateLimit-Remaining is what is genuinely free rather than a flat zero: a call that spends several units at once can be refused while slots remain.Revoking a key from the same screen deletes it outright — the next request carrying it gets the 401 above — and a suspended account's keys stop resolving in the same instant.
Records
A record is one row of a table. Every record carries an id (rec_…), created_at, updated_at, and a fields object holding its cells.
merge_on.{ "field_id": "fld_…" }. A link button answers { action: "link", url }; a webhook button enqueues one delivery and answers { action: "webhook", delivered: true, webhook_id }. The record is never changed.The list query surface. Every parameter below is optional.
viw_…) or by name — read the table through it, so you get its filter, its sort, and its visible fields in its column order. Anything you also state explicitly wins: sort replaces the view's ordering, fields replaces its columns, and filter is ANDed with the view's rather than replacing it — a view's filter is what makes it that view. A name shared by two views on the table is 400; a view id belonging to another table is 404, the same as one that doesn't exist.-Field or Field:desc sorts descending. Sortable storage kinds only: text, long text, number, date, checkbox, single select. A multi-select, link, attachment, button, lookup or rollup is refused with 400. A single select sorts by its choices' defined order. Ascending puts empty cells last, descending puts them first.% or _ in the term is escaped, not treated as a wildcard.100, maximum 1000. Anything outside that is 400.bookmark, passed back verbatim. Opaque, and bound to the ordering that produced it: continuing it under a different sort — or a different view, which carries its own — is refused with 400 rather than answered with a page that quietly skips and repeats rows. Start again without it to change the order.names (default) or ids — how select cells read. Any other value is 400. No effect on member, link, or attachment cells.name (default) or id — how each record's cells are keyed. Any other value is 400. Ids survive a field being renamed, so anything automated should ask for them.true spreads each record's cells across the record itself instead of nesting them under fields — for a mapping UI that shows one level, where <q>fields › Nightly rate</q> is a step nobody wants. The record's own id, created_at and updated_at are written last and so win a collision with a field of the same name; pair flat with key_by=id and no collision is possible, since every cell key is then a fld_…. Accepts true/1 or false/0; anything else is 400. The companion map and bookmark sit beside the records as usual.POST …/records/list reads every parameter above from the body except link_display_cap, which it does not read at all — link cells there always carry every linked record's display text. view, cell_format, key_by and flat come from the body on that route, not the query string.{ "records": [ { "id": "rec_hfsd6hs3nfhu", "created_at": "2026-07-22T15:30:20.026Z", "updated_at": "2026-07-22T15:30:20.026Z", "fields": { "Reference": "CM-1041", "Nights": 3, "Nightly rate": 1450, "Check-in": "2026-08-02", "Status": "Confirmed", "Guest": [{ "id": "rec_vkim6gua34pp", "display": "Layla Hassan" }] } } ], "bookmark": "eyJ2IjpbXSwiaWQiOiJyZWNfaGZzZDZoczNuZmh1In0", "field_ids": { "Reference": "fld_yrhs7u9v4jd7", "Nights": "fld_twa9psesrjx9", "Nightly rate": "fld_2vf9y4dn7g2t", "Check-in": "fld_279mx4k477yz", "Status": "fld_2exr2qahjthk", "Guest": "fld_3e8wmizyypxc" } }
bookmark is absent, not null, on the last page. field_ids maps name → id for exactly the fields the response carries, so a fields= projection narrows the map too; with key_by=id the cells key by fld_… and the map inverts to field_names. A single-record read carries the same map beside its record.Cell shapes. What a cell reads as, and what a write accepts.
"1:30" or a count of seconds; negatives are refused.true or false."2026-08-02" — a plain calendar day, never a timestamp. An impossible date is 400."2026-07-22T15:30:20.026Z". A write accepts a bare date (midnight) or a timestamp carrying at least hours and minutes.cell_format=ids. A write accepts either.[{ "id": "rec_…", "display": "…" }] — always a list, never a bare id. A write sends a list of rec_… ids, or the same { id, … } objects a read returns, so a record can be read and written back unchanged. A link field holding a single record refuses a list of more than one.id, filename, size_bytes, content_type, url, thumb_url, card_url. A write sends a list whose items may each be an att_… id already on that record and field, an https:// URL for Tabla to fetch, or { "url", "filename" } — mixed freely, order preserved.null on a read — a button renders from its field configuration and holds no per-record value. A write is refused with 400; press it instead.POST /api/tables/:id/attachments is a genuine two-step — it stores the file and returns its att_… id but does not put it in the cell, so a following PATCH writing that id into the field is what makes the file appear, and the record has to exist first. A link: put the URL straight in the cell on any create, update or upsert, and Tabla fetches it during the write — one call, no id to carry, and it works on a create because the id is minted against the record as it is made. The url, thumb_url and card_url in a read are temporary signed links, valid about an hour from the response that carried them.POST /api/tables/tbl_xhxth3zynfwi/records { "records": [{ "fields": { "Reference": "CM-1043", "Photos": [ "https://example.com/terrace.png", { "url": "https://example.com/receipt?id=88", "filename": "receipt.pdf" } ] } }] }
The reply carries the cell in its ordinary read shape, so the minted ids arrive with the same call. Items may be mixed — ["att_a1b2c3d4e5f6", "https://…/new.png"] keeps a file and adds one, in that order — and an object carrying both an id and a url resolves to its id, so a record read and sent back unchanged does not re-download every file it already has.
filename, else the server's Content-Disposition, else the URL's last path segment, else file with an extension guessed from the content type. Fetching is anonymous — no credentials are ever sent, so the URL has to be publicly reachable; a link that needs a signature must carry it in the URL. Private, loopback, link-local and cloud-metadata addresses are refused, re-checked on every redirect.Writes. Batches are capped at 100 records and are all-or-nothing: one bad row aborts the whole batch and nothing is written. A PATCH naming the same record id twice is refused; a DELETE listing the same id twice simply takes it once. Field keys may be names or fld_… ids, mixed freely, and the same field named twice in one record is refused. ?cell_format=ids on a write route controls how select cells read in the response it sends back.
POST { "records": [ { "fields": { "Reference": "CM-1042" } } ] } PATCH { "records": [ { "id": "rec_…", "fields": { "Nights": 4 } } ] } PUT { "merge_on": ["Reference"], "records": [ { "fields": { … } } ] } DELETE { "records": ["rec_…"] } or ?records=rec_a,rec_b
Create and update answer with { "records": [ … ] } — the records as they now stand, read back inside the same transaction. Delete answers with { "records": [ { "id": "rec_…", "deleted": true } ] }. Updating a record that sits in the trash is 400, and one that never existed is 404; either way nothing in the batch is changed.
All three writes take cell_format, key_by and flat as query parameters, meaning on the reply exactly what they mean on a read — so an automation that writes by field id can read its own result back by field id, and a rename moves nothing at either end.
Optimistic concurrency. An update item may carry if_updated_at — the updated_at the caller last saw. Any mismatch aborts the entire batch with 409, and the error carries the stale records as they are <i>now</i>, so a stale client can resolve without a second fetch. Omitting the token keeps last-write-wins.
{ "error": { "code": "conflict", "message": "1 record changed since it was loaded.", "records": [ { "id": "rec_hfsd6hs3nfhu", "updated_at": "…", "fields": { … } } ] } }
updated_at on the records at the other end of the link too, so a token held across a link goes stale honestly.Upsert. merge_on is a non-empty list of fields (ids or names) that identify a record. Each record is matched on those fields: a match is updated, no match is created. Matching and both write loops share one transaction.
records (every affected record, in the order sent), each carrying "created": true|false for which half of the upsert it landed in, plus created_records and updated_records — the same fact as the ids in each group.created is on each record as well as in the envelope arrays because a mapping language that iterates records — Make's and n8n's both — cannot look sideways at a sibling array to find out what happened to the row it is holding. Under flat=true it is written last, so it wins over a field of the same name, exactly as id, created_at and updated_at do.record.created for a new row, record.updated for one that already existed.The trash. A delete moves records to the trash, where they stay restorable for seven days; attachments stay in the locker. Restore fires record.created; a purge fires nothing, because record.deleted already fired when the record was trashed. Ids are capped at 100 per call here too.
GET /api/tables/:id/trash → { records: [ … deleted_at … ], bookmark? } ?page_size= ?bookmark= newest first POST /api/tables/:id/trash/restore { "ids": ["rec_…"] } → { records: [ … ] } refuses if an id is not in the trash POST /api/tables/:id/trash/purge { "ids": ["rec_…"] } → { records: [ { id, deleted: true } ] } POST /api/tables/:id/trash/purge { "all": true } → { deleted: <count> } gone for good, attachment bytes included
{ "all": true } empties the table's trash. A viewer is refused with a 403, and nothing is changed.Names, ids, and surviving a rename
Every field carries two references: a human name, unique within its table and editable at any time, and a stable id — fld_ plus 12 characters — minted once and never changed. Names are for reading. Ids are for anything that must keep working.
filter condition fieldfld_…sort termfld_…, with - or :desc for descendingfields= projection (the two list routes)fld_…fields object)fld_…, per keymerge_on keys (upsert)fld_…watched_field_idsfld_… onlyOn a read, key_by=id keys each record's cells by field id instead of field name. The records list, its POST …/records/list twin and the single-record read each carry a companion map, so the two vocabularies can be translated without a second request: field_ids (name → id) by default, field_names (id → name) when key_by=id. The map is built from the visible field set, so a fields= projection's map describes exactly what the payload carries and nothing more. The trash listing takes neither parameter: its records are always keyed by name and carry no map.
{ "records": [ { "id": "rec_hfsd6hs3nfhu", "created_at": "2026-07-22T15:30:20.026Z", "updated_at": "2026-07-22T16:05:57.069Z", "fields": { "fld_yrhs7u9v4jd7": "CM-1041", "fld_twa9psesrjx9": 3, "fld_2exr2qahjthk": "ft7i3kkj" } }, { "id": "rec_vwvpf6wkuehg", "created_at": "2026-07-22T15:30:20.026Z", "updated_at": "2026-07-22T15:30:20.026Z", "fields": { "fld_yrhs7u9v4jd7": "CM-1042", "fld_twa9psesrjx9": 1, "fld_2exr2qahjthk": "3mbkebzw" } } ], "field_names": { "fld_yrhs7u9v4jd7": "Reference", "fld_twa9psesrjx9": "Nights", "fld_2exr2qahjthk": "Status" } }
A choice inside a select field has its own id, minted once and kept through a rename. cell_format=ids reads select cells as those stored ids — "ft7i3kkj" above, where the default cell_format=names reads "Confirmed". It reaches single-select cells, multi-select cells, and a lookup that surfaces one of those. It does not reach anything else: member and multi-member cells read as the stored email addresses either way, and link and attachment cells keep their own object shapes, a link's display text staying a name.
Writing a select cell accepts either the choice's name or its id, and the id is what gets stored. Renaming a choice therefore touches zero records.
What a rename breaks. The records list compiles a filter strictly: a condition naming a field that no longer answers to that name is refused with 400 naming the reference. It is not quietly dropped and the page is not quietly empty — an automation that reads "no matches today" and carries on is the worse failure.
GET /api/tables/tbl_xhxth3zynfwi/records ?filter={"join":"and","conditions":[{"field":"Nights","op":"greater_or_equal","value":3}]} 200 OK — one record
HTTP 400 {"error":{"code":"bad_request","message":"Unknown field \"Nights\"."}}
GET /api/tables/tbl_xhxth3zynfwi/records ?filter={"join":"and","conditions":[{"field":"fld_twa9psesrjx9","op":"greater_or_equal","value":3}]} 200 OK — one record
The same holds for a write. A fields object keyed by fld_… survives a rename; one keyed by names stops resolving the moment someone edits a label in the app. Naming the same field twice in one record — once by id, once by name — is 400.
key_by shapes a write's reply exactly as it shapes a read's: POST, PATCH and PUT all take it, both key their cells the way you asked, and both carry the companion map. One difference worth knowing: a write has no fields= projection, so its map covers every field on the table rather than only the ones in the payload. A delete response carries no cells at all: {"id": "rec_…", "deleted": true} per record.A webhook keys its delivered cells the same two ways, through its own payload_key: name (the default) keys before.fields/after.fields by name and adds a field_ids map; id keys them by fld_… and adds the inverse field_names map. A receiver whose mapping is written once should ask for id.
{Field name}, with no id form — so every dependent formula in that table has its stored text rewritten (unless the new name carries a brace or a leading or trailing space, in which case the expression is left as it was rather than rewritten into text that no longer parses). And a link cell's display text follows its target's display field. Neither is a caller's input, so neither is affected by which vocabulary the caller uses.Filtering, sorting, searching
One filter language serves every reader — the REST list, a saved view, a webhook's conditions, a lookup's own "only matching linked records" filter. A filter is a JSON object with two keys.
{ "join": "and", "conditions": [ { "field": "Status", "op": "is", "value": "Confirmed" }, { "field": "fld_2vf9y4dn7g2t", "op": "greater", "value": 1000 } ] }
"and" or "or". Absent or null means "and". Any other value is refused.{field, op, value} — or a nested group with its own join and conditions. Absent, null, or empty adds nothing to the query. Anything that is not a list is refused.Groups nest one level. A member of conditions may be a group carrying its own join and conditions, so mixed logic — A and (B or C) — is expressible in one filter. Two levels is the limit; deeper is refused with 400. A group with an empty conditions constrains nothing rather than excluding everything. A filter holds at most 200 nodes in total, counting groups alongside conditions; past that it is refused with 400 rather than compiled.
field names a field either by its stable id (fld_…) or by its exact name, in any language. The id is tried first, then the name. An unknown reference is refused; so is a name matching two fields, with a message asking for the id instead. Ids survive a rename, so anything long-lived should use them.
Which operators a condition may use is decided by the field's storage kind — the underlying column — not by its costume. A currency field and a rating field are both number.
Every field carries its own operators array in GET /api/tables/:id/fields (and in the table's own field list), computed from the same table the query builder checks against. That array is the authoritative per-field answer — a button field and a lookup that surfaces a select, link or attachment advertise an empty list, because neither can be filtered at all.
true or false."YYYY-MM-DD", a full timestamp for a date-with-time field, or a relative object (below).any_of / none_of take a list of them.rec_… ids. Anything else is refused.Behaviour worth knowing: contains / not_contains are case-insensitive and match Arabic as readily as Latin; a literal % or _ in the value is escaped, never a wildcard. is_not and not_contains also match records whose cell is empty. Text empty covers both an unset cell and an empty string. is false on a checkbox matches never-set cells as well as explicit false. has_all and is_exactly de-duplicate the ids sent; is_exactly asks whether the cell holds that set and nothing else, compared as a set, so the order never matters and an empty cell never matches it. A link filter excludes trashed linked records, so the filter and the rendered cell agree.
A date-with-time field compared against a bare "YYYY-MM-DD" is compared against that whole day in the database's own timezone; given a full timestamp it compares instant to instant. A date-only field is timezone-independent and compares verbatim.
A date condition's value may name a moment instead of a literal date. It is resolved to the literal an ordinary condition would carry before anything is compared, once per request, so two conditions in one filter can never land on opposite sides of midnight.
{ "field": "Check-in", "op": "on_or_after", "value": { "relative": "today" } } { "field": "Check-in", "op": "after", "value": { "relative": "today", "offset": -7, "unit": "day" } }
"today" or "now". Required. "today" resolves to a calendar day in the database's timezone; "now" resolves to the instant itself, degrading to the calendar day against a field that stores no time."day", "week" or "month". Defaults to "day". A month step clamps into a shorter month — 31 January plus one month is 28 February.No other key is allowed inside a relative value, and a relative value is only understood on a date field.
The filter travels two ways. On GET /api/tables/:id/records it is URL-encoded JSON in filter=. On POST /api/tables/:id/records/list — the same lister, for filters too long to survive a web address — it is a real object in the request body (a JSON string is still accepted there). Both are the same language and return the same page shape.
GET /api/tables/tbl_xhxth3zynfwi/records?filter=%7B%22conditions%22%3A%5B%7B%22field%22%3A%22Status%22%2C%22op%22%3A%22is%22%2C%22value%22%3A%22Confirmed%22%7D%5D%7D&sort=-Check-in&page_size=50 POST /api/tables/tbl_xhxth3zynfwi/records/list { "filter": { "join": "or", "conditions": [ { "field": "Status", "op": "is", "value": "Confirmed" }, { "field": "Nights", "op": "greater_or_equal", "value": 3 } ] }, "sort": "Status,Nightly rate:desc", "page_size": 50 }
is / any_of / has_any / has_all match no record and is_not / none_of / has_none match every record. Renaming or deleting a choice therefore cannot break a filter, but it can silently widen a negated one.sort is a comma-separated list of terms applied in order. Each term is a field id or name, descending as either -Field or Field:desc; Field:asc is accepted and is the default, and the suffix is read case-insensitively. The two forms do not combine — -Name:desc is read as the field Name:desc and refused. Ascending places records with no value last; descending places them first. An empty text string is a value, not a blank, so it is not grouped with them. A single select sorts by the order its choices are defined in, not alphabetically.
Be exact about the computed fields. A rollup can always be filtered, on its single aggregate value. A lookup can be filtered when it surfaces a plain scalar — text, long text, number, date or checkbox. A positive operator matches a record when at least one linked record satisfies it; is_not, not_contains and empty are the exact negation of that, so they match when no linked record satisfies the positive form. A lookup that surfaces a select, a link or an attachment reads as display text with no column to compare, and cannot be filtered; it advertises no operators. No lookup or rollup can ever be sorted, because neither has a column to order by. A button field holds no value, so it can be neither filtered nor sorted.
has_any / has_all / has_none values cannot resolve: the first two match no records and the third matches every record. Only empty and not_empty behave meaningfully on it. A single member field stores text and filters as text.search is a case-insensitive substring match across every text and long-text column of the table at once, ORed together, Arabic included — hidden columns and columns left out of fields= included too. Email, phone, url, member and text-formula fields all wear text storage, so all of them are searched. A literal % or _ is escaped. Lookup, rollup and button fields are excluded, and select cells are not searched — they store choice ids. A blank term is ignored entirely; a table with no text column at all returns nothing for any term. A search and a filter both present are combined with and.
Paging is keyset, not offset. Results are ordered by the requested sorts and then always by record id, so the order is total and stable; the bookmark carries the last row's sort values plus that id, and the next page resumes strictly after it. Pages therefore never skip or repeat a record as records are inserted or deleted between calls. A record whose own sort value is edited mid-walk moves within the order, so it may still be missed or seen twice.
"bookmark": "eyJ2IjpbXSwiaWQiOiJyZWNfaGZzZDZoczNuZmh1In0"
bookmark is absent from the response, not null. It appears only when a further page exists.Fields, views and the catalog
A field is a storage kind plus a costume. The storage kind is what the real Postgres column holds, and for an ordinary field it alone decides how a cell is stored, filtered and sorted — there are nine. The costume is the entry in the field-type menu, and it is what type takes when a field is created. Several costumes share one storage kind: email, phone, url, member, button, formula_text and lookup_text are all text.
Create a field with POST /api/tables/:id/fields, body { "name": …, "type": …, "options": { … } }. name is required, must be a non-empty string, and is trimmed before it is stored; it must be unique within the table (a collision is 409, not 400). The one exception is a lookup or rollup, where name may be omitted entirely: the field is then named after what it surfaces — Status (from Projects), Sum of Nights (from Bookings), Count of Bookings — and a name derived that way is de-duplicated rather than refused, so it never 409s. Editing is PATCH /api/fields/:id with any of name, type, options.
POST /api/tables/tbl_xhxth3zynfwi/fields { "name": "Nightly rate", "type": "currency", "options": { "symbol": "EGP", "symbol_position": "after", "decimals": 2 } }
The costumes. Twenty-eight stored costumes, listed here with the storage kind each wears. Three of them have no real column: link lives in a connector table shared with its mirror, button holds no per-record value at all, and every lookup_*/rollup_* is computed at read time across a link. The formula_* costumes are real GENERATED ALWAYS AS (…) STORED columns.
rich renders markdown"1:30" or a number, always read back as secondsdate column, or timestamptz when include_time is truetext[] of member emailstext[] of choice idstext[] of att_… idsThree one-word doors. "formula", "lookup" and "rollup" are also accepted as type on create, and the concrete costume is inferred server-side — from the expression's own result type, from the storage kind of the surfaced field, or from the aggregation. The stored costume is always one of the concrete names above. These three are not accepted by PATCH, and they are not printed in the "Unknown field type" error.
The settings pouch. options is one flat object with a single known-key set shared by every costume — deliberately not a per-costume shape, because a pouch outlives the costume that wrote it (a currency field changed to text legitimately still carries symbol). A create may only introduce a key from the known list, and so may an edit of an ordinary scalar or select field; a key already stored on the field is tolerated forever. An unknown new key is refused with 400 that names it and prints the whole valid list. A link, button, formula, lookup or rollup edit takes a different path — each re-resolves its own configuration and never runs this check. Below is what each costume actually reads.
uniquerich (boolean)decimals, separators, uniquesymbol, symbol_position ("after" renders 12 $; otherwise, including when omitted, $12), decimals, separators, uniquedecimals, separators, uniquemax (integer), shape, colorduration_format — "h:mm" (default), "h:mm:ss" or "mm:ss"include_time, date_format — "iso" (the fallback), "us", "eu", "friendly", "long" — time_format ("12h" or "24h"), uniqueicon, checked_color, unchecked_colorchoices; single_select also reads uniquetarget_table_id (required), allow_multiple, reverse_field_name, conditions, picker_field_idslabel, color, action, webhook_id, url_template, conditionexpression, display_as — and whichever settings that display reads. display_as changes only how the result is DRAWN; the column, the compiled expression and the stored value are identical with it and without it. A number result may wear "currency" (symbol, symbol_position), "percent" or "duration" (duration_format), alongside decimals and separators. A text result may wear "single_select", which takes choices matched against the value by NAME (a formula produces the name, where a real select column stores an opaque id) — the choice order is the order this field sorts and groups in, and a value the list does not name still reads, uncoloured, sorting last. A costume the result type cannot wear is refused by name, and an edit that changes what the expression works out to clears the formatting that belonged to the old type.link_field_id, target_field_id, conditionslink_field_id, aggregation, target_field_id, conditions, decimals, separatorsSelect choices. options.choices is a list of { id, name, color }. A cell stores the choice's id, never its name, so renaming a choice touches zero records. An entry sent without an id is minted one (8 characters, no prefix); an entry that carries an id keeps it. Names are trimmed, capped at 500 characters, at most 1000 per field, and may not be __tabla_no_such_choice__ — a reserved sentinel. A new id-less entry may not duplicate a name already seen. On a PATCH that omits choices entirely the stored list is preserved; an explicit [] clears it.
Link target. options.target_table_id is required at creation and must name a table in the same database that the caller can edit. Creating a link writes two field rows — the forward field on this table and its mirror on the target, named by options.reverse_field_name (defaulting to this table's own name; a collision there is 409). The response returns only the forward field; the mirror's id is in its options.pair_field_id. options.conditions is a §9 filter over the target table constraining which records the picker offers; options.picker_field_ids names up to four fields of the linked table to show on picker cards, or three plus one attachment field as the card thumbnail — never a button and never the linked table's display field.
Formula expression. options.expression references fields as {Field Name} and is compiled into a Postgres generated column, so Postgres itself proves the expression before the field can exist. What is stored back is the canonical, fully braced form of what was sent, plus a server-written referenced_field_ids. Clock-based expressions are impossible: a generated column forbids them.
Lookup and rollup. link_field_id must name a link field on this table; target_field_id a field on the linked table that is not itself a lookup or rollup. The concrete costume follows the surfaced field's storage kind — text, long_text, single_select, multi_select, link and attachment all surface as display text (lookup_text); number, date and checkbox keep their own kind. For a rollup, aggregation is one of sum, count, min, max, avg; sum and avg need a number field, min and max a number or date field and keep its kind, and count reads no target field at all (one sent alongside it is stored back as null). The server also writes source_presentation, a snapshot of the surfaced field's costume and options, so a currency lookup still reads as currency.
Frozen after creation. Everything below is enforced server-side, not only in the workspace.
include_time (date)date or timestamptz. A PATCH that changes it is refused.uniqueoptions edit can neither add nor remove it: the stored flag is carried forward on the eligible costumes and stripped from the pouch on any other.target_table_id, direction, pair_field_id and allow_multiple are locked; sending a different value for any of them is refused with 400. Only conditions and picker_field_ids are read from a link edit — every other key in the payload is ignored.reverse_field_nametext ↔ long_text). Converted with a real cast: text ↔ number, checkbox → text, single_select → multi_select. Refused by name: multi_select → single_select, anything into or out of link, attachment, formula, lookup, rollup or button, and every other cross-storage pair.options.unique is honoured only for single_line_text, email, phone, url, number, currency, percent, date and single_select. Sent on any other costume it is stored in the pouch and enforces nothing.A formula's expression, a lookup or rollup's whole configuration, and a button's configuration are not frozen — each is re-resolved and replaced on a PATCH of options. A lookup/rollup edit must carry a complete configuration, not a partial one, and a formula edit must contain expression or it is refused.
The <code>operators</code> property. Every field returned by GET /api/tables/:id/fields and by GET /api/tables/:id carries operators: the filter operators that field actually accepts, derived at response time from the same table the query builder itself reads. Building a filter from this list means never hardcoding the mapping and never sending an operator the engine would refuse.
Building a write form. GET /api/tables/:id/fields?writable=true answers with only the fields a write can actually touch — formula, lookup, rollup, button and attachment fields are left out, the same set encodeWrite refuses — so a client offering someone a form doesn't have to learn that rejection list for itself. It also gives every single-select and multi-select choice label and value aliases of its name and id, which is the shape a dropdown builder wants, alongside the originals; anything already reading name/id is unaffected. Any value other than exactly true is ignored and the whole list comes back.
operators list rather than their storage kind's operators: a button, which holds no value, and a lookup that surfaces a select, link or attachment field, which reads as display text with no column to compare. Every rollup, and every lookup surfacing a plain scalar, is filterable. The field returned by PATCH /api/fields/:id is the stored catalog row and does not carry operators — re-list the table's fields if the vocabulary is needed after an edit.Databases. GET /api/databases lists every database the credential is a member of. POST /api/databases takes { "name": … }. PATCH /api/databases/:id takes name and/or color — at least one — where color is one of the twelve pigments (pomegranate, henna, saffron, ochre, olive, emerald, verdigris, nile, lapis, indigo, violet, rose) or null. DELETE /api/databases/:id removes the database and everything under it, and needs the owner role — an editor is refused with 403 and "Only an owner can delete a database."
Tables. GET /api/databases/:id/tables lists the database's tables, each with its fields. POST /api/databases/:id/tables takes { "name": … } and returns a table with an empty field list. GET /api/tables/:id returns one table with its fields — the same field shape, operators included, that the fields endpoint hands out. PATCH /api/tables/:id takes a required name. DELETE /api/tables/:id drops the table, its real Postgres table, and its connectors; like a database delete it needs the owner role. Creating, renaming, and every field write need editor.
GET /api/databases, GET /api/tables/:id/fields, GET /api/tables/:id/views). A database's icon and timezone are not settable over REST — the PATCH route accepts name and color only.Display field. A table's display_field_id is the value that represents a record in link chips, search results and the record panel's title. The first field created that can name a record takes it automatically; link, attachment, checkbox, button and lookup/rollup costumes cannot, so a table whose first field is one of those stays without a display field until an ordinary one is added.
Views. A view is saved view-bar state — filter, sort, hidden fields, row height, colour rules, and the per-view-type settings. It is never data and never a permission boundary: a field hidden in a view is still readable through the records endpoints. A table created over the API starts with no views — Tabla's own workspace creates a first "Grid view" when the table is opened there — but once a table has views, deleting the last remaining one is refused with 400.
GET /api/tables/:id/viewsposition then created_at.POST /api/tables/:id/views{ "name": …, "config": { … } }. name is required and non-empty; it is not unique-checked. config is optional and defaults to {}.PATCH /api/views/:idname and/or config, at least one. config replaces the stored object wholesale — this route never merges.DELETE /api/views/:idView writes need the editor role; a viewer is refused with 403 and the message "Viewers can't change views." Listing views is open to any member of the database.
What a view's config holds. The column is a free-form JSON object stored verbatim. The server imposes no schema on it: unknown keys are kept, and a filter naming a deleted field is kept, because the display surfaces tolerate stale state rather than break a whole view. The one thing PATCH does validate is any relative-date value nested inside — { relative, offset, unit } — down to eight levels. The keys below are the ones Tabla's own workspace writes and reads: a convention of the product, not a contract the API enforces.
type"calendar", "gallery" or "kanban". Anything else, including absent, means a grid.filters{ join, conditions: [{ fieldId, op, value }] } — note fieldId, not the field key the §9 filter uses.sorts[{ fieldId, dir }], serialised to the records endpoint's field:dir string.hidden_fieldsfield_order, col_widths{ fieldId: pixels }.row_height"short" is the fallback).color_rules, summariescalendar_field, calendar_end_field_id, calendar_range, calendar_custom_n, calendar_custom_unit, calendar_show_weekends, calendar_cover_field_id; gallery_card_size, gallery_cover_aspect, gallery_show_labels; kanban_cover_mode, kanban_cover_aspect, kanban_show_labels, kanban_show_uncategorized; group_field_id, group_unit, group_sort (and group_field_id_2, group_unit_2, group_sort_2 for a second grouping level), group_collapsed, group_color_rules, group_cover_field_id.filters in a view config is not the same object the records endpoints take. The workspace converts it before sending — renaming fieldId to field and dropping rows whose field is gone. Sending a view's filters straight to a records call will be refused.Webhooks
A webhook is an outbound POST Tabla sends when records change, and the endpoint a button field presses. It belongs to a database, optionally narrows to one table, and carries exactly one trigger type — not a set of independent flags.
The delivery row is written in the same database transaction as the record change itself. A write that rolls back leaves no delivery behind, and a crash the instant after commit cannot lose one.
Trigger types — the exact strings trigger_type accepts:
record.created.record.updated.record.matches.record.deleted.record_matches is a transition, not a state: it fires when the record did not match before the write and does after. Creation counts as a transition from nonexistence, so a record created already matching fires once. A record that keeps matching across further edits does not fire again.
record_deleted fires when a record is moved to the trash. A permanent purge sends nothing. Restoring a record from the trash fires record.created again.Conditions
A webhook's conditions are the same filter grammar a saved view uses — the same {join, conditions:[{field, op, value}]} object, the same operator vocabulary, the same field references (fld_… or a field name). This is deliberate and load-bearing: the trigger evaluator is ported one-to-one from the SQL filter builder, so a grid filter and a webhook condition can never quietly disagree about what contains or empty means for the same field. Conditions are compiled against the table's real fields when saved, so an unknown field or an operator the field's storage kind does not allow is refused at save time rather than misfiring later.
A condition set is the same §9 filter the records API reads, including nested groups and the same 200-node ceiling. No conditions means every event of that type fires. A set the evaluator will not read — deeper than two levels, or wider than the ceiling — does not fire the trigger; unlike the records API it says nothing about it, because this runs inside the record write and a webhook may never fail somebody's write.
table_id. A webhook targeting the whole database has no single field set, so conditions on one are refused. Lookup and rollup fields are refused as condition fields: a saved view filters those through a correlated subquery, which a trigger evaluated in memory against one record's decoded cells has no way to run.watched_field_ids
For record_updated only: a list of fld_… ids whose change makes the webhook fire. null or an empty list watches every field, and every other trigger type ignores the list entirely. Conditions still apply on top — a watched field must be touched and the conditions must match.
Formula, lookup, rollup and button fields are refused here. Their values are derived or absent, so they never appear among a write's changed fields, and watching one would leave a webhook that silently never fires. Watched fields also need a specific table.
payload_key
before.fields/after.fields are keyed by field name. The payload carries field_ids (name → id).fld_…. The payload carries field_names (id → name) instead.Field names are editable; field ids are not. An integration that binds a mapping once and lives with it wants "id", so renaming a field in the workspace does not break the receiver. A companion map always ships, whichever keying is chosen, so a receiver can translate either way. Test deliveries and button presses use the same keying as real deliveries from that webhook.
record.matches delivery raised by the clock rather than by a write is always name-keyed and always carries field_ids, whatever payload_key says. A receiver bound to "id" on a webhook whose conditions name a moment has to handle both shapes.The payload
{ "delivery_id": "dlv_s43ic2cv5z7i", "event": "record.updated", "occurred_at": "2026-07-22T16:20:13.950Z", "database": { "id": "db_awjakb4hxcbx", "name": "Casa Mayouie" }, "table": { "id": "tbl_xhxth3zynfwi", "name": "Bookings" }, "record_id": "rec_hfsd6hs3nfhu", "changed_field_ids": [ "fld_twa9psesrjx9", "fld_2exr2qahjthk" ], "field_ids": { "Guest": "fld_3e8wmizyypxc", "Photo": "fld_h9y9q9nh8q68", "Nights": "fld_twa9psesrjx9", "Status": "fld_2exr2qahjthk", "Check-in": "fld_279mx4k477yz", "Reference": "fld_yrhs7u9v4jd7", "Nightly rate": "fld_2vf9y4dn7g2t" }, "before": { "fields": { "Guest": [ { "id": "rec_vkim6gua34pp", "display": "Layla Hassan" } ], "Photo": [ { "id": "att_gbztm3r73q4p", "url": "https://…signed…", "filename": "terrace.png", "size_bytes": 208, "content_type": "image/png" } ], "Nights": 3, "Status": "Confirmed", "Check-in": "2026-08-02", "Reference": "CM-1041", "Nightly rate": 1450 } }, "after": { "fields": { "Guest": [ { "id": "rec_vkim6gua34pp", "display": "Layla Hassan" } ], "Photo": [ { "id": "att_gbztm3r73q4p", "url": "https://…signed…", "filename": "terrace.png", "size_bytes": 208, "content_type": "image/png" } ], "Nights": 4, "Status": "Confirmed", "Check-in": "2026-08-02", "Reference": "CM-1041", "Nightly rate": 1450 } }, "test": false }
dlv_…. Stable across every retry of the same delivery. Use it to deduplicate.record.created · record.updated · record.matches · record.deleted · button.pressed · webhook.test.{id, name} each.rec_…, or null on a test delivery.record.matches, and on a button press.payload_key, described above.{fields}, or null when there was no prior state (create, test, button press, clock-driven match).{fields}, or null on record.deleted — the record's last known state arrives in before.true only for a delivery sent from the test endpoint.changed_field_ids lists the fields the write named, not a value diff. A field written with the value it already had still appears there.Cells carry the same shapes the records API returns: a link cell is a list of {id, display}, a date-only cell is "2026-08-02", an unset cell is null. A button press adds trigger: "button", button: {field_id, label}, pressed_by and pressed_at, and carries the full record in after.
Headers and signing
application/json<delivery_id>.<attempt number>, attempts counting from 1.sha256=<hex> — see below.The signature is HMAC-SHA256, keyed with the webhook's secret, over the string <X-Tabla-Timestamp>.<raw request body> — the timestamp, a single full stop, then the body bytes exactly as sent. The digest is lowercase hex, prefixed with sha256=. Verify against the raw body, before any JSON parsing: re-serialising the payload changes the bytes and the signature will not match.
const crypto = require("node:crypto"); // raw — the exact request body, as a string or Buffer, unparsed. function verifyTablaDelivery(raw, headers, secret) { const timestamp = headers["x-tabla-timestamp"]; const expected = "sha256=" + crypto .createHmac("sha256", secret) .update(`${timestamp}.${raw}`) .digest("hex"); const a = Buffer.from(expected); const b = Buffer.from(headers["x-tabla-signature"] ?? ""); return a.length === b.length && crypto.timingSafeEqual(a, b); }
The secret is 48 hex characters, minted with the webhook. POST /api/webhooks/:id/secret regenerates it; the old secret stops verifying immediately. The secret is returned blank to members below the editor role.
Delivery, retries, and pausing
Delivery is at least once. A receiver that times out after doing its work, or a network failure on the way back, produces a second attempt of the same delivery. Deduplicate on delivery_id, which does not change across retries, and make the handler idempotent.
PATCH /api/webhooks/:id with {"resume": true}. The failure streak resets to zero.A write of 50 records or more enqueues nothing. Bulk imports and large batch writes are deliberately not a flood of per-record deliveries.
Triggers driven by time
A condition's value may name a moment instead of a fixed date: {"relative": "today"}, {"relative": "today", "offset": -1, "unit": "day"}, {"relative": "now"}. Anchors are today and now; units are day, week, month; offset is a whole number up to 366 either way.
Such a condition becomes true because the clock moved, with nothing written. A record_matches webhook whose conditions name a moment is therefore also checked on a clock, and fires on the same transition — so "when Renewal date is on or before today" delivers on the day it arrives, with no external scheduler.
The first pass after such a webhook is created — and after its table, trigger type, or conditions change, or it is switched off or back on, or resumed — records what already matches without delivering any of it — a new webhook on a table with a year of history does not open by firing for all of it. Only records that begin matching afterwards deliver. "Today" is resolved in the database's own timezone, the same zone the grid filter uses.
Endpoints
url and trigger_type required; name, table_id, conditions, watched_field_ids, payload_key optional. Editor and up.{"is_active": false} to switch off, {"resume": true} to clear an auto-pause. Editor and up.delivery_id, signed with the current secret. Full retry and auto-pause semantics apply. Editor and up.What a create answers with. The webhook is wrapped under a webhook key, not returned at the top level — so its id is at webhook.id. Worth stating plainly because an integration that subscribes and later unsubscribes has to store that id, and reading it from the wrong place fails silently: nothing errors, the id is simply never saved, and the subscription is still live after teardown. The signing secret is shown here and never again — GET /api/webhooks/:id does not return it. Regenerate it with POST /api/webhooks/:id/secret if it is lost.
{ "webhook": { "id": "whk_ntdbgfuyu8ya", "database_id": "db_d2dbgdm3r8y3", "table_id": "tbl_6xie9dkuw2sa", "name": "Bookings changed", "url": "https://example.com/hook", "trigger_type": "record_created", "payload_key": "id", "secret": "whsec_…", "is_active": true, "conditions": null, "watched_field_ids": null, "consecutive_failures": 0, "last_status": null, "last_attempt_at": null, "paused_at": null, "created_at": "2026-08-17T20:29:12.004Z" } }
Attachments, import and export
Uploading raw bytes takes two calls. POST /api/tables/:id/attachments stores the bytes and registers the file, then returns its att_… id. It does not put the file in the record's cell. Until a second call writes that id into the attachment field, the cell still reads [] and the file is invisible in every read, every export, and every webhook payload.
Step one — a standard multipart/form-data upload. The record must already exist and must not be in the trash; a trashed record reads as not_found.
fld_…). Must be on this table and must be an attachment field.rec_…). Must already exist.{ "id": "att_gbztm3r73q4p", "filename": "terrace.png", "size_bytes": 208, "content_type": "image/png", "url": "http://…/gadwal/tbl_…/att_…/terrace.png?X-Amz-Expires=3600&…", "thumb_url": "…", "card_url": "…" }
Step two — write the returned id into the cell. An attachment field holds an ordered list of ids, and a PATCH replaces the whole list, so an existing cell's ids must be resent alongside the new one to keep them.
{"records":[{"id":"rec_…","fields":{"Photo":["att_gbztm3r73q4p"]}}]}
An id may only be written into the exact table, field and record it was uploaded into — anything else is a bad_request. That also means an attachment cannot be set on POST (record creation): the record must exist before the upload, so the order is always create, upload, patch.
Dropping an id from the cell is a real delete. The bytes and the registry row leave the locker on the next PATCH; attachments are never shared between cells, so there is no detach-but-keep.
Content-Length above the cap plus 1 MB of multipart slack is rejected before the body is buffered.400, not 429.400.The download link is temporary. url, thumb_url and card_url are signed for 3600 seconds (one hour) from the moment they are signed — not from the moment they are read. Signatures are reused from an in-process cache for up to 15 minutes, so a link returned by a read always carries at least 45 minutes of remaining validity. A second read inside that window returns the same signature rather than a new one; past it, the next read mints a fresh link. There is no route that lists a table's attachments; the record read is the listing.
Images get two WebP derivatives during the same upload — 256px for grid rows, 1024px for cards, each the longest edge, never enlarged past the source — and url is the untouched original. Thumbnail generation is best-effort: for a non-image, an SVG (deliberately never rasterised), a source above 50 MB or 100 megapixels, or bytes the decoder cannot read, thumb_url and card_url fall back to exactly url, and the upload still succeeds.
Export — GET /api/databases/:id/export returns the whole database as a zip (Content-Type: application/zip), not JSON. Any member of the database may export it. This is the one route here that accepts a session cookie as well as a Bearer key, so it also refuses any request whose Sec-Fetch-Site header reads cross-site or same-site — a browser-only guard; a client that sends no such header is unaffected.
exported_at, the database's id and name, and per table its id, name, display_field_id, every field's id, name, storage_kind, costume and options, and every saved view's id, name, type and whole config — filters, sorts, hidden fields, column order and widths, row height, colour rules and the group field.id then every field name in grid order. Trashed records are absent. Two tables whose names collide take a (2), (3) suffix.id, table, record_id, author, author_id, created_at, edited, body, oldest first. Bodies carry their @[usr_…] mention tokens as typed, which is why author_id is a column. Written at editor rank and above — a viewer cannot read comments in the app, so their zip does not contain them either.id, record_id, filename, size_bytes, path, built from the registry with no trash filter — it can name files whose records are no longer in the table CSVs.<att id>-<filename> with the filename stripped of characters no filesystem tolerates. Files are written oldest first until the export's byte ceiling is reached — which is the account's own storage ceiling, so an account inside its storage limit exports every file it has. Anything past it exports metadata-only, with a blank path and a note in the README.Cell shapes in the CSV differ from the API's. A link cell writes the linked records' display text (falling back to the rec_… id), comma-joined; an attachment cell writes comma-joined att_… ids; any other multi-value cell joins with comma-space, the delimiter the importer splits on; a duration writes in the field's own format — h:mm, h:mm:ss or mm:ss — never raw seconds. Date-and-time columns are UTC ISO instants — the same value the API and webhooks carry, which is not what the app shows when the database's time zone is not UTC. A value beginning =, @, tab or carriage return is prefixed with a single quote so a spreadsheet opens it as text; so is one beginning + or - unless the whole string is a number.
Import — POST /api/databases/:id/import is one multipart route with two modes, chosen by which parts arrive. file alone previews. file plus target plus plan runs. Sending exactly one of target/plan is a bad_request.
rowCount, extraSheetsIgnored, and per column its columnIndex, sourceHeader, guessedCostume, guessedOptions and up to three samples. Nothing is written — and the database in the path is not read on this branch, so no membership check runs until a run.{"mode":"new_table","name":"…"} or {"mode":"existing_table","tableId":"tbl_…"}. An existing table must belong to this database.{"columnIndex":0,"sourceHeader":"…","action":{…}}. An action is {"kind":"skip"}, {"kind":"map","targetFieldId":"fld_…"}, or {"kind":"create","name":"…","costume":"…","options":{…}}. A column with no entry is not imported.{"tableId":"tbl_…","created":N}. Editor and up.The preview is not cached — a run re-parses the same bytes, so the file is sent twice. Columns are identified by columnIndex, never by header text, so duplicate or blank headers cannot collapse into each other. The parser is chosen by the file part's filename extension, not by sniffing bytes: .xlsx/.xls read the first worksheet only and count the rest in extraSheetsIgnored; everything else is parsed as CSV.
Only the fourteen plain costumes are importable — single line text, long text, email, phone, url, number, currency, percent, rating, duration, date, checkbox, single select, multi select. Link, attachment, and every formula, lookup and rollup costume are refused as both create and map targets. Mapping onto an existing select field adds any choice the file contains that the field lacks, preserving existing choice ids and colours.
Content-Length above 22 MB is rejected before buffering.new_table run deletes the table it created; an existing_table run writes in batches of 100, each its own transaction, and reports how many rows were already added and remain.The Airtable importer is a workspace feature, not part of this API — the REST route reads CSV and XLSX only.
Errors, limits and stability
Every failure answers with one envelope: an error object carrying a stable machine code and a human message. The HTTP status and the code always agree.
{"error":{"code":"unauthorized","message":"Missing or invalid API key. Expected: Bearer <id>.<secret>."}}
There is exactly one documented exception to that shape. The 409 raised by the optimistic-concurrency check — the if_updated_at guard on a write — merges a records array into the same error object. It carries each stale record as it is now, read back inside the write's own transaction, so a client holding an old copy can heal without a second request. One upsert case raises the same 409 with an empty records array: a record the batch matched on was removed while the batch was being written.
{ "error": { "code": "conflict", "message": "1 record changed since it was loaded.", "records": [ { "id": "rec_hfsd6hs3nfhu", "created_at": "2026-07-22T15:30:20.026Z", "updated_at": "2026-07-22T15:30:20.026Z", "fields": { "Reference": "CM-1041", "Nights": 3, "Nightly rate": 1450, "Check-in": "2026-08-02", "Status": "Confirmed", "Guest": [{ "id": "rec_vkim6gua34pp", "display": "Layla Hassan" }] } } ] } }
code. The message names the offending field, record or operator where the engine knows it, and is written to be read by a person — its wording is not part of the interface.bad_requestunauthorizedforbiddennot_foundcode.conflictif_updated_at (the case that also carries records).rate_limitedRetry-After header in whole seconds.internal{"code":"internal","message":"Something went wrong."}; the detail is logged, never returned.GET /api/health, and only while the database does not answer. That route answers with its own body, not the error envelope.The 403 and the 404 are deliberately different refusals. A member already knows the resource exists, so a role that is too low reads 403 with a plain sentence. Anyone outside the database reads 404, for every resource kind — the same status and the same code a made-up id gets, so probing tells an outsider nothing that branching on the code could reveal. Branch on the code, not the sentence: the wording of message does vary between the two.
Rate limits are counted in a sliding window. Every 429 carries a Retry-After header naming the earliest second a retry can succeed, and every response to a key-carrying request carries X-RateLimit-Limit and X-RateLimit-Remaining for the per-key budget. The attachment-upload cap is the one that answers differently — see the last row.
X-RateLimit-* headers describe.retry-after: 13 {"error":{"code":"rate_limited","message":"Too many requests. Retry in about 13s."}}
page_size on a readReaching any of these is a 400 whose message names the ceiling reached. Two paths refuse less bluntly, and say so in what they return: an import that fails after its first batch reports how many rows landed and stay (a run that created its own table drops that table instead), and a whole-database export past its byte ceiling writes the files it could not fit as metadata rows with a blank path, keeping the ones it did.
Stability. Ids are opaque and permanent — db_, tbl_, fld_, viw_, rec_, att_, whk_ and twelve characters. A rename changes a name, never an id, so anything automated should reference tables and fields by id and ask for key_by=id on reads.
The path carries no version number. Response objects may grow new keys over time: a client must ignore keys it does not recognise and must not depend on key order. Removing a key, changing a value's type, or repurposing an error code counts as a break. Shipped changes are listed newest first at /changelog.
GET /api/health takes no key. It reports whether the service is up and its database answering — 200 while Postgres replies, 503 while it does not.
{"ok":true,"name":"tabla","database":true}