Idempotency and safe replays
How replays avoid sending a request twice, how a receiver recognises a repeat delivery from the X-WebhookVault identity headers, how to de-duplicate safely, and how an action decides that a response means delivered.
A webhook can reach a receiver more than once. WebhookVault retries a delivery that failed, a person or an agent can replay a stored request, and a receiver can process a delivery and still fail to answer in time, which looks like a failure from outside. Exactly-once delivery is not possible over HTTP. Two things keep a repeat harmless: replays that check what each action already received before sending, and a receiver that recognises a repeat when one does arrive. This page covers both.
Safe replays
Before a replay sends, WebhookVault looks at each chosen action's earlier deliveries of that request:
- delivered: an earlier delivery succeeded;
- in progress: a delivery is still pending or retrying (it has the request queued, so a replay now would send it twice);
- a delivery that was cancelled, or that ran out of retries (a dead letter), does not count.
An action with Duplicate protection switched off is never checked. Turn it off only for a
receiver that ignores repeats, for example one that de-duplicates on X-WebhookVault-Request-Id
(below).
What happens when a chosen action already has the request:
| Where the replay comes from | Protection on | Protection off |
|---|---|---|
| The dashboard | The replay dialog names each earlier delivery and asks first. Replay anyway sends. | Sent in one click. |
The API, with guard=true | Refused with 409 and code: already_delivered, listing each earlier delivery. Nothing is sent. Repeat with allowDuplicates=true to send. | Sent. |
The API, without guard | Sent, as it always has been, and named in warnings. | Sent. |
| The MCP server | Refused with already_delivered, with each earlier delivery under error.details. An agent may pass allowDuplicate only after the person says yes. | Sent. |
The refusal is all or nothing: when one chosen action already has the request, nothing is sent
to the others either. A bulk replay is never refused as a whole. In the dashboard it skips the
request and action pairs that were already delivered unless you choose Send them again. Over
the API, send skipDelivered: true to skip them, and the response counts what was skipped.
Include in replays. Each action says whether a replay that names no actions reaches it. A new forward is included; a new notify or sub-request is not, so replaying an order does not post the same message to a channel twice. Actions that existed before this setting are all included until you untick it. Name an action explicitly and it is reached either way. In the dashboard the replay dialog shows every action, ticked when it is included.
Both settings are on the action, under Replays in the action editor, and on the
actions API as includeInReplays and
warnBeforeDuplicate. Turning protection off asks first and is recorded in the audit log.
Every replay writes one audit row: who replayed, which actions it reached, and what the duplicate
check found (passed, skipped, or overridden when a confirmed or unguarded replay sent to an
action that already had the request).
See replayRequest and
bulkReplay for the parameters and the 409 body.
Delivery identity headers
Every forward carries four headers, set by WebhookVault alone. A header of the same name on the captured request, or one written by a transformation rule, is removed before these are added.
| Header | Value | The same across | Changes on |
|---|---|---|---|
X-WebhookVault-Request-Id | req_ and the captured request's id, as the dashboard shows it | automatic retries and every replay | never |
X-WebhookVault-Delivery-Id | the delivery's id: the jobId the API and the MCP server return for its attempts | automatic retries of one delivery | each replay |
X-WebhookVault-Attempt | the attempt number within the delivery, from 1 | each retry | |
X-WebhookVault-Replay | true, only on a delivery a replay queued |
They are on forwards only. A sub-request calls a third-party API whose header rules belong to that API, and a notify sends through an alert channel, so neither carries them.
wv-signature (see Signing deliveries) signs the
timestamp and the body, not these headers. Use them to de-duplicate and to trace, never to decide
whether a request is genuine.
De-duplicating in a receiver
Pick the key by what should count as a repeat:
- The provider's own event id, when the payload has one (
evt_..., a delivery id, a message id). It is the same however the event reached you, so it is the first choice. X-WebhookVault-Request-Idtreats retries and replays of one captured request as the same event.X-WebhookVault-Delivery-Idtreats only automatic retries as repeats, so a replay is processed again.
Then claim the key under a unique constraint in the same transaction as the work, and answer 2xx when it was already claimed. A key committed on its own, before the work, loses the event if the work then fails; a key checked without a constraint lets two concurrent deliveries both pass.
// processed_webhooks(key text primary key)
app.post('/webhooks', async (req, res) => {
const key = req.body.id ?? req.get('X-WebhookVault-Request-Id');
const db = await pool.connect();
try {
await db.query('BEGIN');
const claim = await db.query(
'INSERT INTO processed_webhooks (key) VALUES ($1) ON CONFLICT (key) DO NOTHING', [key]);
if (claim.rowCount === 0) {
await db.query('ROLLBACK');
return res.sendStatus(200); // already processed: success, so it is not retried
}
await applyEvent(db, req.body); // the business write, in the same transaction
await db.query('COMMIT');
res.sendStatus(200);
} catch (err) {
await db.query('ROLLBACK');
res.sendStatus(500); // nothing was kept, so a retry can succeed
} finally {
db.release();
}
});# processed_webhooks(key text primary key)
def handle(payload, headers):
key = payload.get("id") or headers["X-WebhookVault-Request-Id"]
with conn.transaction():
claim = conn.execute(
"INSERT INTO processed_webhooks (key) VALUES (%s) ON CONFLICT (key) DO NOTHING",
(key,),
)
if claim.rowcount == 0:
return 200 # already processed: success, so it is not retried
apply_event(conn, payload) # the business write, in the same transaction
return 200A receiver that prefers to answer a repeat with 409 Conflict can, and the action can then
count 409 as delivered (below), so a repeat is not retried.
Response checks
By default an action counts any 2xx answer as delivered. Some receivers answer 200 with an error
in the body, or use a specific status for success. An action can say what success looks like, and
a response that does not match is a failed delivery: retried on the normal schedule, parked as
a dead letter when retries run out, and alerted like any other failure.
Set it in the action editor under How does this receiver say success?, or with
successStatusCodes and successCondition on the
actions API. Forwards and sub-requests have it; a notify
has no receiver response of its own.
Accepted status codes. A comma list of exact codes and classes from 200 to 599: 201,
200, 202, 2xx, 409. Empty (or 2xx alone) means any 2xx. A listed non-2xx code counts as
delivered too, which is how a receiver's "already processed" 409 stops being retried.
A condition on the response. The editor offers three plain choices:
- Body contains text: the body, read as text, contains a value.
- JSON key exists: the body is JSON and has the key, for example
body.result.id. - JSON key equals value: for example
body.statusequalsok.
Advanced condition opens the same builder as Only run when, over the response's body
and headers only (a response has no method, path or query). The grammar is the one conditions
and transformation rules use.
How edge cases are judged. A check that reads the JSON body (any path below body) needs a JSON
body read in full, whatever the operator: not, notEquals and any included.
- A response larger than the part kept for checking cannot have its JSON checked, so a JSON check fails and says so. A text check runs on the part that was read, and a failure says so.
- A JSON check on a response that is not JSON fails, naming the content type it had.
- A JSON check on an empty response (a
204) fails, saying there was no body. - A check that reads the body fails on a binary body, or on one that could not be read.
- A check that cannot be read fails rather than passing: a delivery is never counted on a check that did not run.
- When nothing comes back (a timeout, a refused connection), there is nothing to check. The
delivery fails for that reason and the check reads
skipped.
A sub-request's response is recorded with the value of any credential-named JSON key (for example
access_token) replaced by [redacted], after the check has run on the real response.
A failed check's reason is the attempt's error, and starts with Response check failed:. It quotes
only the value found at the checked path, at most 80 characters, with card numbers masked; a value
at a path named like a credential (a token, a key, a signature) is never quoted. Every delivery
attempt reports responseCheck: passed, failed, skipped or notConfigured.
Check against the last response, in the editor, runs the check you are editing against what the action last received, before you save it.
In the dashboard and the API
- The request detail and the Deliveries tab show a failed check as code, check failed, and mark deliveries a replay sent.
- Delivery attempt reads (
listDeliveries,getDelivery,listAttempts) carryjobId,origin(captureorreplay) andresponseCheck. - The MCP server reports the same fields, and the error class
response_checkfor a delivery the receiver answered without meeting its action's check. - A replay's
already_deliveredrefusal lists each earlier delivery'sjobId, the same value the receiver saw asX-WebhookVault-Delivery-Id.
Deliveries
Every forward attempt with exactly what left the vault and exactly what came back: method, URL, headers and body both ways, status, timing, and what the transformation rules did.
Watchdogs
A dead-man's switch per webhook stream: expect a matching capture at least every N with G of grace, and alarm through your alert channels when the window passes in silence.