How do I replay webhooks after fixing a bug?
Replay the stored raw payloads from the window the bug was live, oldest first, through the fixed code. Find the first and last event the bug could have touched, deploy the fix, replay exactly that window, and keep the handler idempotent so events that were processed correctly the first time are harmless the second time. If you did not keep the raw payloads, there is nothing to replay; you can only re-fetch current state from the sender's API.
Replay a window
// Replay stored raw events from the window a bug was live, oldest first, through the fixed handler.
// listStored(from, to) -> [{ id, receivedAt, payload }]; pass handle: replayHandler(...) below
export async function replayWindow ({ listStored, handle, from, to }) {
const events = (await listStored(from, to))
.filter((e) => e.receivedAt >= from && e.receivedAt < to)
.sort((a, b) => a.receivedAt - b.receivedAt)
const failed = []
for (const e of events) {
try { await handle(e.payload) } catch (err) { failed.push({ id: e.id, error: String(err) }) }
}
return { replayed: events.length - failed.length, failed }
}
// The handler a replay needs. It does not check "already processed": the buggy run wrote that receipt.
// transform(payload) -> { objectId, version, ...fields }
// writeUnlessNewer(row) compares and writes in ONE atomic step: replace the row stored under row.objectId
// unless its version is greater. In SQL, a single upsert:
// INSERT INTO rows AS stored ... ON CONFLICT (object_id) DO UPDATE SET ... WHERE stored.version <= EXCLUDED.version
export function replayHandler ({ transform, writeUnlessNewer }) {
return async (payload) => {
// overwrite, never add: a second replay writes the same row again, and a newer stored version wins
await writeUnlessNewer(transform(payload))
}
}Why oldest first
Replaying in the order events arrived keeps them close to the order they happened, but it does not guarantee it. Senders deliver out of order, and a replayed event can be older than state that reached you after the window closed. Idempotency only makes a second run of the same event harmless; it does not stop an older version of an object overwriting a newer one. That takes a check in the handler: compare the payload's version or updated-at time with the one you store, and skip the payload if it is older. replayHandler does that, and lets an equal version through, because the row the buggy run wrote carries the same version as the payload that fixes it.
The comparison has to happen inside the write, not before it. Your live handler keeps running during the replay, so if the replay reads the stored row, sees version 2, and then writes, a live version 3 can land between the read and the write and be overwritten. A single conditional upsert, which writes only WHERE stored.version <= EXCLUDED.version, leaves no such gap: whichever write lands second, the row ends at version 3.
Why the handler ignores "already processed"
A live handler usually records each event's id and skips any id it has seen, so a sender's retry cannot apply an event twice (see My webhook fired. Why is there no data in my database?). Every event in the window has such a receipt, written by the buggy code, so replaying through that handler skips them all: it reports success and the wrong rows stay wrong. If the bug stored an amount of 2500 where the payload said 25, the replay has to run the fixed transform and write 25 over the 2500.
What keeps that safe is the write, not the receipt. Write the whole row by object id and never add to what is there, so replaying the window twice leaves the same rows. The version check, done in that same write, stops a payload older than the stored row from replacing it. Effects that must happen once per event, such as an email or a charge, keep their receipt; a replay should rebuild your data, not repeat them.
Why raw payloads
Replaying rows you already transformed re-runs the bug's output, not the sender's input. Only the payload exactly as it arrived can go through the fixed code and come out right.
In Slashbin
In Slashbin you pick what to re-run — one entity by its primary-key value, a time range, or a whole source — and it re-fires the stored payloads through your latest transform. It re-sends every payload in the window, even one a newer version has already replaced, so your receiver still needs to overwrite by object id and skip stale versions. See What's the best way to replay webhooks?
Need somewhere for the next project's webhooks to land? Slashbin's Free plan takes an account, not a card.
Start Free
(opens in a new tab)