Skip to content
Open beta — everything’s free right now, and your rate is locked when it ends.

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.

Every request
curl https://tabladb.com/api/databases \
  -H "Authorization: Bearer key_xxxxxxxxxxxx.your-secret"
Two endpoints stand outside this. 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.

viewer
Reads records, fields, views and webhooks. A webhook's signing secret is redacted below editor. Any change is refused with 403.
editor
Creates and edits records, fields, tables, views and webhooks, and deletes a field, a view or a webhook. Trashes, restores and purges only records it created itself — naming another member's record refuses with 403 and nothing is deleted.
owner
Everything an editor can do, plus deleting a table, archiving or deleting the database, managing members, and acting on any record regardless of who created it.
A resource in a database the account is not a member of answers 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.

GET /api/meta/whoami
{
  "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.

A missing or unverifiable key — 401
{"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.

600 / minute
Per key — the published cap. Charged only after the secret has been verified.
3,000 / minute
Per calling address, charged before any credential is read, whenever a bearer header is present. Several keys behind one address each keep their own 600.
60 / minute
Per calling address, for requests carrying no bearer header.

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.

GET /api/tables/:id/records
List records — filter, sort, search, page.
POST /api/tables/:id/records/list
The same lister, parameters in a JSON body, for filters too long for a web address.
GET /api/tables/:id/records/:rid
One record, every field.
POST /api/tables/:id/records
Create up to 100 records.
PATCH /api/tables/:id/records
Update up to 100 records.
PUT /api/tables/:id/records
Upsert up to 100 records against merge_on.
DELETE /api/tables/:id/records
Move up to 100 records to the trash.
POST /api/tables/:id/records/:rid/press
Press a button field on one record — { "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.

filter
The filter language, as URL-encoded JSON on GET or the object itself in a POST body. Invalid JSON, or JSON that is not an object, is 400. A condition naming a field that no longer exists is 400 — a stale filter fails loudly rather than quietly returning nothing.
sort
Comma list of field ids or names, applied in order, with the record id always appended as the final tiebreak. -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.
search
Case-insensitive substring match across every text and long-text column of the table. A literal % or _ in the term is escaped, not treated as a wildcard.
fields
Comma list of field ids or names narrowing which cells each record carries. A filter or sort may still name a field left out.
page_size
Default 100, maximum 1000. Anything outside that is 400.
bookmark
The previous response's bookmark, passed back verbatim. Opaque; continue a page with the same sort it was started with.
cell_format
names (default) or ids — how select cells read. Any other value is 400. No effect on member, link, or attachment cells.
key_by
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.
link_display_cap
GET only. Return display text for at most this many linked records per cell; every id still comes back. A value that is not a whole number in 1…1000 is ignored rather than refused, and means no cap.
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.
GET /api/tables/tbl_…/records
{
  "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.

text, long text (email, phone, url)
A string.
number (currency, percent, rating)
A number. A rating is rounded to whole stars and clamped to its maximum; 0 or less clears the cell.
duration
Reads as a number of seconds. A write accepts "1:30" or a count of seconds; negatives are refused.
checkbox
true or false.
date, no time
"2026-08-02" — a plain calendar day, never a timestamp. An impossible date is 400.
date with time
An ISO 8601 instant in universal time, e.g. "2026-07-22T15:30:20.026Z". A write accepts a bare date (midnight) or a timestamp carrying at least hours and minutes.
single select
The choice's name, or its stored id with cell_format=ids. A write accepts either.
multi select
A list of names (or ids). A write accepts either.
member / multi-member
An email address / a list of email addresses, in both directions.
link
[{ "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.
attachment
A list of objects: 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.
formula, lookup, rollup
Read-only, in the shape of whatever kind the value resolves to. A write is refused with 400.
button
Always 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.
An attachment is a genuine two-step: 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.

The four write bodies
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.

A write of 50 records or more deliberately enqueues no webhook deliveries — a bulk load fires a flood of per-record events otherwise. Smaller batches and single-record calls always fire.

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.

409 — PATCH with a stale if_updated_at
{ "error": {
    "code": "conflict",
    "message": "1 record changed since it was loaded.",
    "records": [ { "id": "rec_hfsd6hs3nfhu", "updated_at": "…", "fields": { … } } ]
} }
A link write bumps 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.

Usable in merge_on
text, long text, number, date, checkbox, single select. A link, attachment, multi-select, formula, lookup, rollup or button is refused — none can identify a record.
Every record must carry every merge field
A key that is absent cannot identify anything, so the batch is refused rather than matching every blank-keyed row.
Two records in one batch sharing a key
Refused, naming the collision. Nothing is written.
Two stored records sharing a key
Refused — the key cannot identify one record, and a guess is not made. Nothing is written.
Response
records (every affected record, in the order sent), plus created_records and updated_records — the ids in each group.
An upserted record fires the event that actually happened to it: 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.

Trash routes
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
Role rules apply to every trash operation: an editor deletes, restores and purges only records they created themselves; an owner acts on any. A refusal is 403, and nothing is changed. The one exception in shape, not in rule: { "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 field
name or fld_…
sort term
name or fld_…, with - or :desc for descending
fields= projection (the two list routes)
comma list of names or fld_…
cell keys on write (fields object)
name or fld_…, per key
merge_on keys (upsert)
name or fld_…
a webhook's watched_field_ids
fld_… only
The id is tried first, then the exact name. A reference matching no field is refused with 400 naming it — never a silently dropped condition.

On 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.

GET /api/tables/tbl_xhxth3zynfwi/records?key_by=id&cell_format=ids&fields=fld_yrhs7u9v4jd7,fld_twa9psesrjx9,fld_2exr2qahjthk
{
  "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.

Before the rename — filtering by name
GET /api/tables/tbl_xhxth3zynfwi/records
  ?filter={"join":"and","conditions":[{"field":"Nights","op":"greater_or_equal","value":3}]}

200 OK — one record
After "Nights" is renamed to "Nights stayed" — the identical request
HTTP 400
{"error":{"code":"bad_request","message":"Unknown field \"Nights\"."}}
The same filter written against the id — unaffected by the rename
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.

Cells on a create, update or upsert response are always keyed by field name, and those responses carry no companion map — 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.

Two things a rename repairs on its own. A formula's expression is the one surface that names fields by name only — written {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.

The filter object
{
  "join": "and",
  "conditions": [
    { "field": "Status", "op": "is", "value": "Confirmed" },
    { "field": "fld_2vf9y4dn7g2t", "op": "greater", "value": 1000 }
  ]
}
join
"and" or "or". Absent or null means "and". Any other value is refused.
conditions
A list whose members are each either a condition — {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.

text, long_text
is · is_not · contains · not_contains · empty · not_empty
number
is · is_not · greater · greater_or_equal · less · less_or_equal · empty · not_empty
date
is · before · after · on_or_before · on_or_after · empty · not_empty
checkbox
is (the only operator)
single_select
is · is_not · any_of · none_of · empty · not_empty
multi_select
has_any · has_all · has_none · empty · not_empty
link
has_any · has_all · has_none · empty · not_empty
attachment
empty · not_empty

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.

text, long_text
A string.
number
A finite number. A numeric string is refused.
checkbox
true or false.
date
"YYYY-MM-DD", a full timestamp for a date-with-time field, or a relative object (below).
single_select
A choice id or a choice name. any_of / none_of take a list of them.
multi_select
A list of choice ids or names.
link
A list of rec_… ids. Anything else is refused.
empty, not_empty
No value at all — omit it.

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.

A relative date value
{ "field": "Check-in", "op": "on_or_after", "value": { "relative": "today" } }

{ "field": "Check-in", "op": "after",
  "value": { "relative": "today", "offset": -7, "unit": "day" } }
relative
"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.
offset
A whole number, positive or negative, at most 366 either way. Defaults to 0.
unit
"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.

The two request forms
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
}
The REST list compiles a filter strictly: a condition naming an unknown or deleted field, or an operator the field's kind does not take, is refused with a message naming it — never a quietly empty result. The one exception is a select value that no longer resolves to a live choice: rather than fail, it resolves to a sentinel no cell can hold, so 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.

Sortable
text · long_text · number · date · checkbox · single_select
Not sortable
multi_select · link · attachment · button · every lookup and rollup

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.

A multi-member field reuses multi_select storage but stores email addresses, not choice ids, so its 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.

The tail of a page (captured)
"bookmark": "eyJ2IjpbXSwiaWQiOiJyZWNfaGZzZDZoczNuZmh1In0"
page_size
A whole number from 1 to 1000. Default 100. Anything else is refused.
bookmark
Pass the previous response's value back verbatim. It is opaque — do not construct one.
End of results
bookmark is absent from the response, not null. It appears only when a further page exists.
Changing sort mid-page
A bookmark carries one value per sort term; continuing with a different number of sort terms is refused. A different sort with the same number of terms is not detected — it seeks on values that no longer mean anything. Continue a page with the sort it was started with.
A bookmark that will not decode
Refused as invalid. A well-formed bookmark from a different list is not detected; it simply seeks with the values it carries.

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.

Create a field (request body)
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.

single_line_text
text — one line
long_text
long_text — many lines; rich renders markdown
email
text — no server-side format validation
phone
text — no server-side format validation
url
text — no server-side format validation
number
number
currency
number
percent
number
rating
number
duration
number holding SECONDS; writable as "1:30" or a number, always read back as seconds
date
date — a date column, or timestamptz when include_time is true
checkbox
checkbox
member
text holding one member's email address
multi_member
multi_select — text[] of member emails
single_select
single_select — the chosen choice's id
multi_select
multi_select — text[] of choice ids
link
link — no column; a connector table
attachment
attachment — text[] of att_… ids
button
text — no column, no value; configuration only
formula_number
number — a generated column
formula_text
text — a generated column
formula_date
date — a generated column
lookup_text
text — no column, computed read-time
lookup_number
number — no column, computed read-time
lookup_date
date — no column, computed read-time
lookup_checkbox
checkbox — no column, computed read-time
rollup_number
number — no column, aggregated read-time
rollup_date
date — no column, aggregated read-time

Three 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.

single_line_text, email, phone, url
unique
long_text
rich (boolean)
number
decimals, separators, unique
currency
symbol, symbol_position ("after" renders 12 $; otherwise, including when omitted, $12), decimals, separators, unique
percent
decimals, separators, unique
rating
max (integer), shape, color
duration
duration_format"h:mm" (default) or "h:mm:ss"
date
include_time, date_format"iso" (the fallback), "us", "eu", "friendly", "long"time_format ("12h" or "24h"), unique
checkbox
icon, checked_color, unchecked_color
member, multi_member, attachment
none
single_select, multi_select
choices; single_select also reads unique
link
target_table_id (required), allow_multiple, reverse_field_name, conditions, picker_field_ids
button
label, color, action, webhook_id, url_template, condition
formula_number, formula_text, formula_date
expression
lookup_*
link_field_id, target_field_id, conditions
rollup_number, rollup_date
link_field_id, aggregation, target_field_id, conditions, decimals, separators

Select 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)
It fixed the real column type — date or timestamptz. A PATCH that changes it is refused.
unique
The partial UNIQUE index is built only at creation. An options 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.
Link wiring
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_name
Create-only — it names the mirror field, which afterwards is renamed like any other field. Sent on a later edit it is ignored, not refused.
Costume changes
Free within one storage kind (and textlong_text). Converted with a real cast: text ↔ number, checkbox → text, single_selectmulti_select. Refused by name: multi_selectsingle_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.

text, long_text
is · is_not · contains · not_contains · empty · not_empty
number
is · is_not · greater · greater_or_equal · less · less_or_equal · empty · not_empty
date
is · before · after · on_or_before · on_or_after · empty · not_empty
checkbox
is
single_select
is · is_not · any_of · none_of · empty · not_empty
multi_select
has_any · has_all · has_none · empty · not_empty
link
has_any · has_all · has_none · empty · not_empty
attachment
empty · not_empty
Two cases return an empty 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.

There is no GET for a single database, a single field, or a single view. Those paths carry only PATCH and DELETE; read them through their parent listing (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.

Databases owned per account
100 — counted over databases the account owns; a database shared with it does not count
Tables per database
200
Fields per table
500 — a link is checked against the cap on both tables, since it lands a mirror on the target
Choices per select field
1000, each name at most 500 characters

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/views
The table's views, ordered by position 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/:id
name and/or config, at least one. config replaces the stored object wholesale — this route never merges.
DELETE /api/views/:id
Deletes the view. No records are touched. Refused if it is the table's only view.

View 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_fields
Field ids hidden in this view.
field_order, col_widths
Field ids in display order; { fieldId: pixels }.
row_height
A name, not a number ("short" is the fallback).
color_rules, summaries
Conditional row colouring; the per-column footer aggregate.
Per-type keys
calendar_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
A record is created. Payload event: record.created.
record_updated
A record is updated. Payload event: record.updated.
record_matches
A record starts matching the conditions. Payload event: record.matches.
record_deleted
A record is moved to the trash. Payload event: 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.

Conditions need a specific 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.
A stored condition that can no longer be evaluated — its field deleted, its choice renamed away — counts as not matching rather than aborting the write it was evaluated inside. A record write always commits.

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

"name" (default)
Cells in before.fields/after.fields are keyed by field name. The payload carries field_ids (name → id).
"id"
Cells are keyed by 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.

One exception: a 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

record.updated — a real delivery (signatures elided)
{
  "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
}
delivery_id
dlv_…. Stable across every retry of the same delivery. Use it to deduplicate.
event
record.created · record.updated · record.matches · record.deleted · button.pressed · webhook.test.
occurred_at
When the delivery was enqueued, ISO 8601 in UTC. Not the attempt time.
database, table
{id, name} each.
record_id
rec_…, or null on a test delivery.
changed_field_ids
The field ids the write supplied. Empty on create, on delete, on a clock-driven record.matches, and on a button press.
field_ids / field_names
The map matching payload_key, described above.
before
{fields}, or null when there was no prior state (create, test, button press, clock-driven match).
after
{fields}, or null on record.deleted — the record's last known state arrives in before.
test
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

Content-Type
application/json
X-Tabla-Delivery
<delivery_id>.<attempt number>, attempts counting from 1.
X-Tabla-Timestamp
Unix seconds, regenerated on every attempt.
X-Tabla-Signature
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.

Verifying a delivery in Node
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.

Success
Any 2xx response. Redirects are not followed — a 3xx counts as a failure.
Request timeout
10 seconds per attempt.
Retry schedule
10s, then 60s, then 5min — 3 retries, 4 attempts in all.
Response recorded
Status, duration, and the first 4000 characters of the body, in the delivery log.
Auto-pause
20 consecutive exhausted deliveries. A delivery that recovers within its own retries never counts.
Resuming
PATCH /api/webhooks/:id with {"resume": true}. The failure streak resets to zero.
Log retention
Finished deliveries older than 30 days are pruned, always keeping the most recent 200 per webhook.

A write of 50 records or more enqueues nothing. Bulk imports and large batch writes are deliberately not a flood of per-record deliveries.

Endpoint URLs are checked when saved and again at send time: the hostname must resolve to a public address, and the delivery connects to the address that was just checked. An endpoint that resolves to a private or link-local address fails like any other failed delivery.

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

GET /api/databases/:id/webhooks
List a database's webhooks. Viewer and up.
POST /api/databases/:id/webhooks
Create one: url and trigger_type required; name, table_id, conditions, watched_field_ids, payload_key optional. Editor and up.
GET /api/webhooks/:id
One webhook's full configuration. Viewer and up.
PATCH /api/webhooks/:id
Field updates, {"is_active": false} to switch off, {"resume": true} to clear an auto-pause. Editor and up.
DELETE /api/webhooks/:id
Delete the webhook. Editor and up.
POST /api/webhooks/:id/secret
Regenerate the signing secret. Editor and up.
POST /api/webhooks/:id/test
Send one signed request with a sample payload. A webhook bound to a table gets that table's real field names; a whole-database one gets a placeholder pair. One attempt only, never retried, never counted toward the failure streak; the response reports that attempt's status, body, and duration. Editor and up.
GET /api/webhooks/:id/deliveries
The 50 most recent deliveries, newest first. Viewer and up.
POST /api/webhooks/:id/deliveries/:deliveryId/replay
Requeue the payload exactly as it was, under a new delivery_id, signed with the current secret. Full retry and auto-pause semantics apply. Editor and up.
Switching a webhook off or back on, changing its table, its trigger type, or its conditions clears what a time-driven webhook remembers as already matching, and the next pass re-records silently. Resuming a webhook that was off for a day does not open by delivering everything that came due meanwhile.

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 two is not optional. An integration that stops after the upload leaves an orphaned file that no screen and no API response will ever show.

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.

file
The file itself. Its filename is stored verbatim, Arabic and all other UTF-8 included; a sanitised copy is used only inside the storage key.
field_id
The attachment field (fld_…). Must be on this table and must be an attachment field.
record_id
The record the file belongs to (rec_…). Must already exist.
POST /api/tables/tbl_…/attachments — response
{
  "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.

PATCH /api/tables/tbl_…/records
{"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.

Per-file cap
100 MB, checked on the parsed file. A declared Content-Length above the cap plus 1 MB of multipart slack is rejected before the body is buffered.
Per-database cap
5 GB of original attachment bytes across every table. Both caps answer 400, not 429.
Upload rate
60 uploads per account per 60 seconds, also a 400.
Role
Editor and up. A viewer is refused.

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.

ExportGET /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.

manifest.json
The structure: 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.
<table>.csv
One file per table, UTF-8 with a byte-order mark so Excel opens it correctly. Header row of id then every field name in grid order. Trashed records are absent. Two tables whose names collide take a (2), (3) suffix.
attachments.csv
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.
attachments/
The real file bytes, named <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.
README.txt
Plain-language description of the zip, plus any gaps: files missing from the locker, and files skipped over the export ceiling.

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.

ImportPOST /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.

Preview
Parses the file and sniffs each column. Returns 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.
target
JSON. {"mode":"new_table","name":"…"} or {"mode":"existing_table","tableId":"tbl_…"}. An existing table must belong to this database.
plan
JSON list of {"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.
Run result
{"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.

File cap
20 MB of raw bytes, checked before parsing. A declared Content-Length above 22 MB is rejected before buffering.
Row cap
50,000 data rows per run, refused at preview time as well as at run time.
Choice cap
A select column resolving to more than 100 distinct values is refused by name.
A run validates everything before it writes anything: the plan, every target field, and every cell of every row. A bad value on the last row aborts with nothing created. Two failures come too late for that ordering to pre-empt: a clash on a field that requires unique values, which Postgres discovers only mid-write, and a failure while the plan's new fields are being created, which leaves the fields already added on an existing table. A 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.

401 — no key, or a key that does not resolve
{"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.

409 — PATCH with a stale if_updated_at
{
  "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" }]
        }
      }
    ]
  }
}
Branch on the status and the 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.
400 bad_request
A malformed body, an unknown field or operator, a value a field cannot hold, a duplicate in a field that requires unique values, a batch over 100, or a ceiling reached. A record write is all-or-nothing, so nothing was written; an import is the exception and says so in its message.
401 unauthorized
No bearer token, a token that does not resolve to a live key, or a suspended account. Sessions and keys stop working in the same instant.
403 forbidden
A member of the database whose role is too low: "Viewers can't make changes.", "Only an owner can do that.", "Support access is read-only." Also an editor reaching a record someone else created — trashing, restoring and purging are scoped to a member's own records unless the role is owner.
404 not_found
The id does not exist — or it belongs to a database this key's owner is not a member of. Both carry the same status and the same code.
409 conflict
A field name already taken, a self-link needing a distinct reverse name, or a stale if_updated_at (the case that also carries records).
429 rate_limited
Over a cap. Carries a Retry-After header in whole seconds.
500 internal
An unhandled fault. The error object is always {"code":"internal","message":"Something went wrong."}; the detail is logged, never returned.
503
Only 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.

Per API key
600 requests a minute, charged only once the key's secret is verified
Per address, no credential
60 requests a minute
Per address, with a bearer
3,000 requests a minute — a shield in front of the credential check, not the published cap. Several keys behind one address each keep their own 600.
Attachment uploads, per account
60 a minute — refused as a 400 with the wait in its message, not a 429
429
retry-after: 13

{"error":{"code":"rate_limited","message":"Too many requests. Retry in about 13s."}}
The counters live in the running application's memory and reset when it restarts. That is honest abuse limiting, not an accounting meter.
Records per write batch
100 — all-or-nothing; one bad row rejects the batch and nothing is written
page_size on a read
1 to 1,000, default 100
One attachment file
100 MB
Attachment storage per database
5 GB, counted from the stored original bytes
Import file
20 MB
Import rows per run
50,000
Databases per account
100
Tables per database
200
Fields per table
500

Reaching 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.

GET /api/health
{"ok":true,"name":"tabla","database":true}

Signing in rewrites every request example on this page with your own database, table and field ids. It’s free while Tabla is in open beta — start free, or read the overview first.