Message format.
One JSON object per change, the same shape for every destination. Destinations that need structure — search, cache, EventBridge — read op, table and data; the rest pass the bytes through.
The envelope
{
"op": "update",
"table": "public.orders",
"data": { "id": 42, "status": "shipped", "total": 19.99 },
"old": { "id": 42, "status": "paid", "total": 19.99 },
"seq": 137,
"idempotency_key": "9f1c3d2e-6b4a-4c7e-9d0f-2a1b3c4d5e6f",
"commit_lsn": "0/1A2B3C8"
}
| Field | Type | Meaning |
|---|---|---|
op | string | insert, update, delete, or read for a row delivered by a backfill |
table | string | The table, as schema.table |
data | object or null | The row after the change, with JSON-native values. null on delete. |
old | object or null | The row before the change. With the default replica identity it holds only the key columns on update and delete; with REPLICA IDENTITY FULL, the whole previous row. null on insert. |
seq | integer | A per-pipeline sequence number that only goes up, in commit order |
idempotency_key | string | A UUID unique to this message. Use it to drop duplicates. |
commit_lsn | string | The WAL position of the committing transaction, e.g. 0/1A2B3C8. 0/0 on a backfilled row, which has no WAL position. |
The ordering key
Every message also carries an ordering key: schema.table:pk, for example public.orders:42 (composite keys are joined with commas). It is not inside the JSON. Instead it travels the way each destination understands order: the Kafka record key, the SQS FIFO message group, the Pub/Sub ordering key, the Service Bus session id, the RabbitMQ routing key, a Redis Streams field, a NATS header, the Redis cache key, and the document id for search sinks (sanitised to [A-Za-z0-9_-], so public_orders_42). Each destination page says exactly where.
Examples
Insert
{"op":"insert","table":"public.orders","data":{"id":42,"status":"paid","total":19.99},"old":null,"seq":136,"idempotency_key":"…","commit_lsn":"0/1A2B3A0"}
Update, default replica identity
{"op":"update","table":"public.orders","data":{"id":42,"status":"shipped","total":19.99},"old":{"id":42},"seq":137,"idempotency_key":"…","commit_lsn":"0/1A2B3C8"}
Delete, with REPLICA IDENTITY FULL
{"op":"delete","table":"public.orders","data":null,"old":{"id":42,"status":"shipped","total":19.99},"seq":138,"idempotency_key":"…","commit_lsn":"0/1A2B3F0"}
Read, from a backfill
{"op":"read","table":"public.orders","data":{"id":42,"status":"paid","total":19.99},"old":null,"seq":139,"idempotency_key":"…","commit_lsn":"0/0"}
How values are encoded
Column values become JSON-native types: numbers, strings, booleans, null, and JSON columns as nested JSON. Timestamps and other types with no JSON equivalent arrive as their Postgres text form.
Backfilled rows
A backfill delivers rows that exist now rather than changes as they happen. Each row comes as op: "read" with the whole row in data, no old, and commit_lsn of 0/0. Values are encoded exactly as on the live stream and the ordering key is the same, so a backfilled row and later changes to it arrive in the right order. Treat a read as an upsert.
Schema changes
A column you add shows up in data from the first change after you add it; a column you drop disappears the same way. Waltail records each schema change and lists them on the pipeline in the console, because your consumers usually want to know.
Two destinations wrap the envelope
The webhook sends {"messages":[…]} per batch, and Cloudflare Queues puts the envelope under body.payload next to the keys. Search sinks index only data; the Redis cache stores the whole envelope; EventBridge puts it in detail.