Writing records over the API: create, update, upsert, delete
Batches of up to 100, all-or-nothing — including what a failed batch leaves behind (nothing). Upsert semantics spelled out, delete-to-trash, and the exact value shape for every tricky field type.
Updated August 2026
Four verbs on one endpoint, /api/tables/:id/records: POST creates,
PATCH updates by id, PUT upserts, DELETE trashes. Each call carries up
to 100 records and is all-or-nothing — one transaction, so a batch that fails
on record 97 leaves records 1 through 96 unwritten too.
Create, and what a failed batch leaves
POST takes {"records":[{"fields":{…}},…]}. Send three plants where the
third names a select choice that doesn't exist:
curl -X POST "https://tabladb.com/api/tables/tbl_jukzu86nxtcj/records" \
-H "Authorization: Bearer key_xxxx.your-secret" \
-H "Content-Type: application/json" \
-d '{"records":[{"fields":{"Name":"Spider plant","Price":14}},
{"fields":{"Name":"Jade plant","Price":16}},
{"fields":{"Name":"Calathea","Price":34,"Light":"Shade"}}]}'
{"error":{"code":"bad_request","message":"\"Shade\" is not a valid choice for field \"Light\"."}}
The two valid rows did not land. Filtering for them right after returns
{"records":[]} — captured, not assumed. Fix the batch, resend the whole
thing; there is no partial state to reconcile.
Update by id
PATCH takes the same shape plus each record's id. Only the fields you
send change. An optional if_updated_at per record makes the write refuse
with a 409 if the record changed since you loaded it — the conflict shape is
in errors and limits.
Upsert: match, then update or create
PUT adds merge_on — the field or fields that identify a record:
curl -X PUT "https://tabladb.com/api/tables/tbl_jukzu86nxtcj/records" \
-H "Authorization: Bearer key_xxxx.your-secret" \
-H "Content-Type: application/json" \
-d '{"merge_on":["Name"],"records":[
{"fields":{"Name":"Fiddle-leaf fig","In stock":9}},
{"fields":{"Name":"Calathea orbifolia","Price":34,"Light":"Low light","In stock":5}}]}'
Each incoming record is matched on its merge_on values against the table:
exactly one match is updated, no match is created. The response says which
was which, twice over — on each record, and grouped as ids. Here the fig
existed and the calathea didn't:
{"records": [
{"id": "rec_utf767j36rpj", "created": false, "fields": {"Name": "Fiddle-leaf fig", "…": "…"}},
{"id": "rec_janiuq4ei9up", "created": true, "fields": {"Name": "Calathea orbifolia", "…": "…"}}],
"created_records": ["rec_janiuq4ei9up"],
"updated_records": ["rec_utf767j36rpj"]}
Reach for created on the record when you're looping — an automation tool
holding one record can read its own flag, where finding that same id in a
sibling array means a search it usually can't express. The two arrays are the
same fact for anything that would rather have it grouped.
One asymmetry worth knowing: an attachment cell on a record the upsert
matched is appended to, not replaced. An upsert is usually a repeated
sync, and one that deleted the files already on a record it merely re-saw would
be the more expensive surprise. It's the only place upsert's cell semantics
differ from PATCH's — to clear or shorten an attachment cell, use PATCH.
The honest edges, all refused with nothing written:
- Two existing records already share the key:
2 records already have Name="Fiddle-leaf fig", so this can't identify one.The key failed at its one job, so no guess is made. - Two records in the batch share a key:
Two records in this batch have the same Name="Calathea orbifolia". - A record omits a
merge_onfield entirely — a missing key can't identify anything.
merge_on takes any single-valued stored field (text, number, date,
checkbox, single select — a member field counts as text). A link, a
multi-select, an attachment or anything computed can't identify a record and
is refused by name.
Delete is trash
curl -X DELETE "https://tabladb.com/api/tables/tbl_jukzu86nxtcj/records" \
-H "Authorization: Bearer key_xxxx.your-secret" \
-H "Content-Type: application/json" \
-d '{"records":["rec_gjb32pptrttb"]}'
{"records":[{"id":"rec_gjb32pptrttb","deleted":true}]}
Deleted records sit in the trash for 7 days —
POST /api/tables/:id/trash/restore brings them back, links and attachments
included. Clients that can't send a DELETE body may use
?records=rec_a,rec_b instead.
Cell shapes for the tricky types
Every shape below was verified with a real write and read-back.
- Linked records — write a list of
rec_…ids:"Customer": ["rec_ugnu6fgyhpsw"]. It reads back enriched:"Customer": [{"id": "rec_ugnu6fgyhpsw", "display": "Dina Aziz"}]— and a PATCH may send that object form back unchanged. A link field set to a single record enforces it: two ids answerField "Customer" links a single record; got 2. - Selects — write the choice name (
"Status": "New") or its id; either resolves, the id is what's stored, names come back on reads (cell_format=idsfor ids). - Attachments — a list whose items may be an
att_…id, anhttps://URL, or{"url": "…", "filename": "…"}, mixed freely and kept in the order you send. A URL is fetched by Tabla during the write, so a file you have a link to needs no upload call and no second request:{"Photos": ["https://example.com/fig.png"]}works on create, update and upsert alike. If you have raw bytes instead of a link, upload them first —POST /api/tables/:id/attachments(multipart:file,field_id,record_id, and the record must already exist) returns{"id": "att_s86mde8pwzay", "filename": "fig.png", "size_bytes": 69, …}— then write that id into the cell. An id you didn't upload is refused:Unknown attachment id(s): att_000000000000. Upload the file first, or send its URL and Tabla will fetch it.The fetch is anonymous, so the link has to be publicly reachable; 20 URLs per request, 30s each and 60s in total; and if one of them fails the whole batch fails and every file already fetched for it is deleted again. One thing to know before you point a retry at it: fetching is not idempotent, so a call that succeeds twice stores the file twice. A retry after a failure is safe — a failed batch keeps nothing. - Member — write the person's email:
"Assigned to": "omar@sakkaraplants.com". The API stores the address as sent and does not check it against the member list; an address that belongs to no member shows in the grid as the bare address, with no name or photo to resolve to. - Dates —
"2026-08-12", or a full timestamp for date-and-time fields. Checkbox —true/false. Duration — seconds, or"1:30". - Formula, lookup, rollup, button — computed, not written:
"To collect" is a formula — it's computed automatically and can't be written to directly.
null clears any cell. The full field-by-field reference is at
/docs/api; when a mapping must survive renames, key everything
by id (names and ids).