Webhook payloads: the reference
One envelope, four triggers. A real captured payload for each — created, updated, matches, deleted — plus the test shape, what every field means, and how to survive a delivery arriving twice.
Updated August 2026
Every delivery is one JSON object with the same envelope; the trigger decides which parts are filled. The payloads below are real — captured from a receiver wired to the demo database's Orders table, quoted as delivered.
One thing to unlearn first: property order in the delivered JSON carries no meaning and is not guaranteed. Read fields by name, and verify the signature on the raw bytes, never on a re-serialized object.
record.created
A new order, complete and verbatim:
{
"test": false,
"after": {
"fields": {
"Name": "ORD-1050",
"Paid": false,
"Items": 1,
"Notes": "Guide walkthrough order.",
"Total": 45,
"Placed": "2026-08-09T14:30:00.000Z",
"Plants": [{ "id": "rec_y4crk8w9hc3e", "display": "Monstera deliciosa" }],
"Status": "New",
"Customer": [{ "id": "rec_gs8683t5vvcn", "display": "Nour El-Sayed" }],
"Discount": null,
"Deliver by": "2026-08-12",
"To collect": null,
"Assigned to": null,
"Send invoice": null,
"Customer email": "nour.elsayed@example.com",
"Delivery window": null
}
},
"event": "record.created",
"table": { "id": "tbl_sf6ir2p5y3uc", "name": "Orders" },
"before": null,
"database": { "id": "db_rci9p7iggin5", "name": "Sakkara Plants" },
"field_ids": {
"Name": "fld_rura8c4k4hvy",
"Paid": "fld_54jth3ba5mqx",
"Items": "fld_682w7f8s4mji",
"Notes": "fld_n3ixit8erdw4",
"Total": "fld_hxccfbm9g77r",
"Placed": "fld_6v55cun3tdkw",
"Plants": "fld_q6vgq8ntevpm",
"Status": "fld_t2xzarddguef",
"Customer": "fld_fwhs3ksx7i9y",
"Discount": "fld_2cds8jyptifn",
"Deliver by": "fld_fje32cs9basz",
"To collect": "fld_z8gcp9z6dzfh",
"Assigned to": "fld_6mmcfuicsn9z",
"Send invoice": "fld_7c7rctin9qxm",
"Customer email": "fld_bftdjdcgku7d",
"Delivery window": "fld_ycbcnc3iegc6"
},
"record_id": "rec_vk2563xwk2d6",
"delivery_id": "dlv_k6d7adu95w49",
"occurred_at": "2026-08-09T23:00:54.533Z",
"changed_field_ids": []
}
The envelope, field by field
delivery_id— this delivery's unique id, and the key to idempotency — the last section of this page.event—record.created,record.updated,record.matches,record.deleted,webhook.test, orbutton.pressed(from a button field wired to the webhook).occurred_at— when the change happened, ISO 8601 UTC. Not when this attempt was sent — a retried delivery keeps its originaloccurred_at.database,table—{ "id", "name" }each. On a whole-database webhook,tabletells you which table this event came from.record_id— the record,rec_…. Stable across the record's life;nullonly on a test.before,after— full snapshots of the record's fields, ornullwhere the state doesn't exist:beforeisnullon a create,afterisnullon a delete. Values arrive exactly as the API returns them — select cells as choice names, dates as ISO 8601 (bare dates for date-only fields), checkboxes as booleans, linked records as[{ "id", "display" }], members as emails, formulas and rollups as their computed values.changed_field_ids— the fields this edit wrote, as field ids (always ids, whatever the cell keying). Empty on create, delete, test, and clock-fired matches. "Wrote" is literal: writing a value equal to the old one still lists the field.field_ids— the name → id map for every field, so a consumer keying by name can still translate to rename-proof ids.test—falsefor real events,truefor Send a test.
Cell keying. By default cells are keyed by field name, with field_ids
alongside, as above. Flip the webhook's Cells keyed by toggle to Field
id and cells arrive keyed by fld_… instead, with the inverse map —
field_names — alongside. Ids survive renames; anything long-lived should
bind to them. Either way, both vocabularies ride in every delivery.
record.updated
The same order, Status edited from New to Preparing and an assignee set —
two fields written, both listed:
{
"test": false,
"event": "record.updated",
"table": { "id": "tbl_sf6ir2p5y3uc", "name": "Orders" },
"database": { "id": "db_rci9p7iggin5", "name": "Sakkara Plants" },
"record_id": "rec_vk2563xwk2d6",
"delivery_id": "dlv_8hqvge9kp3xe",
"occurred_at": "2026-08-09T23:02:13.877Z",
"changed_field_ids": ["fld_t2xzarddguef", "fld_6mmcfuicsn9z"],
"before": { "fields": { "Status": "New", "Assigned to": null, "…": "…" } },
"after": { "fields": { "Status": "Preparing", "Assigned to": "omar@sakkaraplants.com", "…": "…" } }
}
(The "…" rows stand for the other thirteen fields — both snapshots always
carry the whole record, as in the create above; clipped here for length.
field_ids rides along too.)
before plus changed_field_ids is what saves the round trip: "Status
became Preparing" and "someone edited the notes of an order that was
already Preparing" are different automations, and both are answerable from
the one delivery.
record.matches
Fired by the transition into matching —
the semantics — with the state that crossed the
line. The webhook here had the condition Status is Out for delivery:
{
"event": "record.matches",
"record_id": "rec_vk2563xwk2d6",
"delivery_id": "dlv_3tjk3625de4r",
"occurred_at": "2026-08-09T23:02:17.923Z",
"changed_field_ids": ["fld_t2xzarddguef"],
"before": { "fields": { "Status": "Preparing", "…": "…" } },
"after": { "fields": { "Status": "Out for delivery", "…": "…" } }
}
When time — not an edit — makes a record start matching (a condition naming
today or now), there is no edit to describe: the delivery arrives with
"before": null and "changed_field_ids": [], and the record's state in
after. Captured live from a Placed on or before now webhook:
"event": "record.matches", "before": null, "changed_field_ids": [],
"occurred_at": "2026-08-09T23:09:19.803Z".
record.deleted
The record's final state, in before; after is null:
{
"test": false,
"after": null,
"event": "record.deleted",
"table": { "id": "tbl_sf6ir2p5y3uc", "name": "Orders" },
"before": {
"fields": {
"Name": "ORD-1050",
"Paid": true,
"Status": "Preparing",
"Notes": "Left with the doorman.",
"…": "…"
}
},
"database": { "id": "db_rci9p7iggin5", "name": "Sakkara Plants" },
"record_id": "rec_vk2563xwk2d6",
"delivery_id": "dlv_ptxbjezxsz8a",
"occurred_at": "2026-08-09T23:03:12.078Z",
"changed_field_ids": []
}
Deleted records sit in the trash for 7 days.
record.deleted fires once, at trash time; purging — one row, Empty
trash, or the 7-day sweep — fires nothing more. A restored record
announces itself as a fresh record.created, before: null, same
record_id.
webhook.test
Send a test delivers the same envelope with "test": true, the event
webhook.test, "record_id": null, and after.fields filled with sample
values — but your table's real field names (or ids), so an automation
tool's "determine data structure" step learns the exact shape real deliveries
will have. Branch on test early in any handler that does real work.
Assume it arrives twice
Delivery is at-least-once. A delivery that times out on your side may still
have been processed by your server; Tabla can't know, so it retries — same
delivery_id, next attempt number (the X-Tabla-Delivery header is
dlv_….2 on the second try). Two habits make this a non-event:
- Record each
delivery_idyou've processed and drop repeats. - Or make handlers naturally repeatable — "set Status to Confirmed" survives a duplicate; "append a row" doesn't.
A replay is deliberately a new delivery — the
same payload under a fresh delivery_id — because you asked for it again.
Two more boundaries worth knowing: writes touching 50 or more records at once fire no webhooks, and one delivery describes exactly one record — there is no batch payload today.