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, 30 paths, 49 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" } }
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.
{"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.
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.
-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; continue a page with the same sort it was started with.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.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. cell_format and key_by 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 of att_… ids already uploaded into that same record and field.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 stores the file and returns its att_… id, but does not put it in the cell. A following PATCH writing that id into the field is what makes the file appear — which is also why a create refuses attachment ids outright, the record it would belong to not existing yet. The url, thumb_url and card_url are temporary signed links, valid about an hour from the response that carried them.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.
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), plus created_records and updated_records — the ids in each group.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 an editor's own trashed records and leaves everyone else's, rather than refusing.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 is read-only, though cell_format does shape a write's reply. 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.
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 de-duplicates the ids sent. 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). 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) or "h: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, conditionexpressionlink_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.
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 a flat AND/OR — the filter language has no nested groups. No conditions means every event of that type fires.
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.Attachments, import and export
Uploading an attachment 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 50 minutes, so a link returned by a read can carry as little as ten 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, and every field's id, name, storage_kind, costume and options.id then every field name in grid order. Trashed records are absent. Two tables whose names collide take a (2), (3) suffix.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, while the database's total attachment bytes are at or below 500 MB. Above that ceiling every file 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 or h: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. The attachment-upload cap is the one that answers differently — see the last row.
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 whose attachments total more than 500 MB writes every file as a metadata row with a blank path rather than risk a broken zip.
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}