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.
A delivery is one attempt to forward a captured webhook to the endpoint's destination. Retries and replays are attempts too. For every attempt WebhookVault keeps the full record: the request as it went over the wire, after transformation, and the response as it came back. When a destination misbehaves, the evidence is already there.
What is recorded
Sent
- method and the exact URL
- every header that went out, including headers set by transformation rules and the
X-WebhookVault-Forwarded: truemarker - the body, as text, or base64 for a binary capture
Received
- the status code and the content type
- every response header
- the response body, as text (base64 with a
base64:prefix when it is not valid UTF-8)
Outcome and rules
- state:
Succeeded,Failed(non-2xx),Errored(unreachable),DeadLetter(the attempt that exhausted the retry budget),Pending(in flight, no result yet), orInterrupted(the attempt was cut short before any outcome was known, and the delivery was re-queued); the error text; timing - what the transformation rules did, per rule and version
Bodies are stored up to 256 KB each way and flagged bodyTruncated when cut; the full
body still went over the wire. Attempts made before this record existed show the outcome only
(hasDetail: false).
Where it shows
- Endpoint page, Deliveries tab: every attempt on the endpoint, newest first, filterable by outcome and by request id. Pick one for its Sent, Received and Rules panes. "Open request" jumps to the capture it came from.
- Request detail: each attempt in the delivery history has a details link that opens the same record.
- Deep link:
/Endpoint/{id}?delivery={attemptId}opens an attempt directly, and?tab=deliveriesopens the tab.
Signing deliveries
A destination can carry a signing secret. When it does, every delivery leaves with a
wv-signature header, so the receiver can prove the request came from WebhookVault and that
nothing changed it on the way. Set it on the endpoint's Forwarding tab, or with
forwardSigningSecret on the endpoint API. It is stored protected and never returned; the
API reports only whether it is set and when.
Do not confuse it with the endpoint's own signing secret, which is the sender's secret and is used to verify what arrives. This one signs what leaves.
The header looks like this:
wv-signature: t=1757439000,v1=6f1c...a9t is the Unix time the delivery was signed. v1 is the lowercase hex HMAC-SHA256 of
t, a full stop, and the raw request body, keyed with the secret. The timestamp is inside the
signed material, so it cannot be edited to make an old delivery look recent.
Verify it against the raw body bytes, before any JSON parsing. A reserialised body is a different byte sequence and will not match.
const crypto = require('crypto');
function verify(header, rawBody, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(',').map(p => p.split('=')));
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(parts.t));
if (!Number.isFinite(age) || age > toleranceSeconds) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(parts.t + '.')
.update(rawBody)
.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1 ?? '');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}import hashlib, hmac, time
def verify(header, raw_body, secret, tolerance_seconds=300):
parts = dict(p.split("=", 1) for p in header.split(","))
if abs(int(time.time()) - int(parts["t"])) > tolerance_seconds:
return False
expected = hmac.new(
secret.encode(),
parts["t"].encode() + b"." + raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))Compare in constant time, and reject anything older than your tolerance so a captured delivery cannot be replayed later.
Three things worth knowing:
- The signature covers the body after transformation rules run, because that is the body the receiver is handed.
- A
wv-signaturethat arrived on the original webhook is never passed through. On a signed destination it is replaced with ours; on an unsigned one it is dropped, so the receiver is never given a signature that cannot verify. - The same scheme signs alert webhooks, so a receiver writes the verification once.
API
| Call | Purpose |
|---|---|
GET /api/v1/endpoints/{id}/deliveries | a page of attempts (page, pageSize up to 100, state, requestId) |
GET /api/v1/endpoints/{id}/deliveries/{attemptId} | one attempt in full: sent, received, transformNote |
GET /api/v1/endpoints/{id}/requests/{requestId}/attempts | the attempts of one request (outcome only, with hasDetail) |
Response bodies from destinations are stored as they were received. Treat them as data from a third party when you read them back.
Transformations
Shape what a forward destination receives with a declarative JSON rule: keep, remove, rename, redact, set, template. No code, previewable, and written by any LLM from the published spec.
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.