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 fromProtection onProtection off
The dashboardThe replay dialog names each earlier delivery and asks first. Replay anyway sends.Sent in one click.
The API, with guard=trueRefused with 409 and code: already_delivered, listing each earlier delivery. Nothing is sent. Repeat with allowDuplicates=true to send.Sent.
The API, without guardSent, as it always has been, and named in warnings.Sent.
The MCP serverRefused 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.

HeaderValueThe same acrossChanges on
X-WebhookVault-Request-Idreq_ and the captured request's id, as the dashboard shows itautomatic retries and every replaynever
X-WebhookVault-Delivery-Idthe delivery's id: the jobId the API and the MCP server return for its attemptsautomatic retries of one deliveryeach replay
X-WebhookVault-Attemptthe attempt number within the delivery, from 1each retry
X-WebhookVault-Replaytrue, 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-Id treats retries and replays of one captured request as the same event.
  • X-WebhookVault-Delivery-Id treats 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 200

A 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.status equals ok.

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) carry jobId, origin (capture or replay) and responseCheck.
  • The MCP server reports the same fields, and the error class response_check for a delivery the receiver answered without meeting its action's check.
  • A replay's already_delivered refusal lists each earlier delivery's jobId, the same value the receiver saw as X-WebhookVault-Delivery-Id.

On this page