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

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.
  • eventrecord.created, record.updated, record.matches, record.deleted, webhook.test, or button.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 original occurred_at.
  • database, table{ "id", "name" } each. On a whole-database webhook, table tells you which table this event came from.
  • record_id — the record, rec_…. Stable across the record's life; null only on a test.
  • before, after — full snapshots of the record's fields, or null where the state doesn't exist: before is null on a create, after is null on 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.
  • testfalse for real events, true for 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_id you'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.

Tabla is the database we build these on.

A no-code database with real Postgres underneath: every feature on every plan, a million records per database, and your whole database back out in one file, any day.

More guides