Webhooks
Delivery, signature verification and retries, the source of truth for every result
Webhooks are how results reach you. Nothing else is authoritative: not the redirect the user lands on, not anything the client-side flow reports. Those are useful for driving your UI. The webhook is the statement of fact.
Registering an endpoint
Create an endpoint through /v1/webhooks or in your dashboard. You give a URL and choose the events
you want. Each endpoint gets its own signing secret, prefixed whsec_, shown once at creation.
Register separate endpoints for sandbox and live so a sandbox event can never be mistaken for a real
one.
Unlike flows and keys, this one is genuinely yours to automate: webhook:manage is in the default
permission set for both environments, so a live key can register its own endpoints on deploy.
Choosing which verifications reach it
By default an endpoint receives every verification your account runs in its environment, whichever API key created it. That is the right answer for most integrations and it is what you get if you never touch this.
If you run more than one product under a single account, one API key each, you can point each
product's results at its own backend instead. Pass api_key_ids when you register the endpoint, or
PATCH it later, and the endpoint will only receive verifications created by the keys you name. The
identifier is the id field on a key from GET /v1/api-keys, not its key_prefix. Setting
description at the same time is worth the two seconds, because two endpoints pointing at the same
host are otherwise told apart only by opening both and comparing key lists.
An empty api_key_ids means every key, and it always will. It is the default rather than an
unfinished configuration, so there is nothing to migrate and no reason to fill it in unless you
actually want to narrow the endpoint. Listing keys only ever narrows one.
Three consequences are worth knowing before you scope anything:
A verification created outside an API key, from your dashboard rather than your backend, carries no
key at all. It reaches only endpoints with an empty api_key_ids. If you scope every endpoint you
have, nobody receives those, and there is no error anywhere to tell you: the verifications complete
normally and the deliveries are simply never created. Keep one unscoped endpoint if your team
creates verifications by hand. Your dashboard warns you when a change would leave you without one.
Minting a new key does not subscribe it to anything. A new key's traffic reaches your unscoped endpoints and no scoped one, until you add it to the endpoints that should have it. That is deliberate, because the alternative is a new key quietly delivering to a receiver nobody pointed it at, but it does mean adding a key is two steps rather than one.
Keys and endpoints belong to the same environment. A sandbox endpoint can only be scoped to sandbox
keys, and naming a live key on it is a 400 telling you which key, rather than a setting that
stores and never fires.
Changing one afterwards
PATCH /v1/webhooks/{id} changes the URL, the subscribed events, the keys it covers, or whether the
endpoint is active. Send only the fields you are changing. The endpoint keeps its id and its
delivery history, which is the reason to prefer this over deleting and re-registering. The dashboard
offers the same edit.
Omitting a field is not the same as sending an empty value for it, and api_key_ids is where that
bites. Omit it and the endpoint's routing is left exactly as it is. Send [] and you have cleared
the filter, so an endpoint you had narrowed to one product starts receiving everything again. Build
the request body from the fields you are actually changing rather than spreading a partial object
with ?? [] defaults, or a rename will quietly unfilter the endpoint. Sending api_key_ids
replaces the list outright, so to add a key, send the full list you want rather than the one key.
The environment is the one thing that cannot change. It is fixed when the endpoint is registered, because every delivery already recorded is labelled with it, so moving the endpoint would retroactively mislabel your own history. Register a second endpoint in the other environment instead.
Rotating a signing secret
POST /v1/webhooks/{id}/rotate_secret issues a new secret on the same endpoint and returns it once.
The cutover is immediate and there is no overlap window. The previous secret stops signing as soon as the call responds, so any delivery arriving before you deploy the new one will fail verification on your side. Treat it as a coordinated change: take the new secret, deploy it, and expect the failed deliveries in between to arrive on the retry ladder. Rotating from the dashboard behaves identically and asks for your authenticator code first.
The payload
{
"id": "evt_9c2f1b7a",
"event": "verification.approved",
"createdAt": "2026-07-26T16:30:25.032Z",
"data": {
"sessionId": "vs_2f8a1c9b4e7d",
"verdict": "approve",
"status": "approved",
"reasonCodes": []
}
}id is the event id and your deduplication key. data.sessionId is the verification id and your
reconciliation key. Only data changes between event types, and the full catalogue with every
payload shape is in Webhook events.
Payload fields are camelCase, where the REST resources are snake_case. reasonCodes on a webhook is
reason_codes on GET /v1/verifications/{id}, and it is the same list.
A test delivery carries one extra envelope field, test: true. Real events never carry the field at
all, so a receiver that ignores properties it does not know about needs no change for this, and one
that wants to branch has something to branch on. See Testing your
endpoint.
Headers
These names are fixed strings. Match them exactly, and do not derive them from anything.
| Header | Purpose |
|---|---|
Verifisere-Signature | t=<unix seconds>,v1=<hex hmac> over the timestamp and the raw body. |
Verifisere-Event | The event name, so you can route before parsing. |
Idempotency-Key | The event id, repeated for convenience. |
X-Payload-Digest | Hex HMAC-SHA256 of the raw body alone. |
X-Payload-Digest-Alg | Always HMAC_SHA256_HEX. |
Verifying the signature
Split the header on the comma to get t and v1. Build the signed payload by joining the timestamp
and the exact raw request body with a full stop. Compute HMAC-SHA256 with your endpoint secret, and
compare against v1 in constant time. Reject anything whose timestamp is more than five minutes
away from now, which stops replay of a captured request.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(rawBody, header, secret, toleranceSeconds = 300) {
// A request without the header is invalid, not an error worth a 500 and a retry.
if (typeof header !== "string") return false;
const parts = Object.fromEntries(
header.split(",").map((p) => {
const i = p.indexOf("=");
return [p.slice(0, i), p.slice(i + 1)];
}),
);
const timestamp = Number.parseInt(parts.t, 10);
if (!Number.isFinite(timestamp)) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(parts.v1 ?? "", "hex");
return a.length === b.length && timingSafeEqual(a, b);
}Verify against the raw bytes
Sign and compare the exact body we sent, before any JSON parsing and before any re-serialization.
Round-tripping through a JSON parser changes whitespace and key order, and the signature will
never match again. In Express, use express.raw() on the webhook route. In Next.js, read
await request.text().
Responding
Return a 2xx as soon as you have verified the signature and safely stored the event. Do the real
work afterwards, out of band. You have ten seconds to answer, so do not verify a signature, update a
user, send an email and then reply.
Retries
A delivery that fails is retried with exponential backoff. There are up to ten attempts in total, the first retry about ten seconds after the failure and each following wait doubling, so the ladder runs for roughly an hour and a half before we give up. That is deliberately longer than a deploy or a gateway restart at your end. Every event type uses the same ladder, so a review decision is not chased any harder than a status change.
Anything other than a 2xx, and a response that has not arrived within ten seconds, counts as a
failure and consumes an attempt. After the last one the delivery is marked failed and stops on its
own. Its full history, every attempt with the status we got back and the body you returned, stays in
the delivery log on your dashboard.
To recover a window where your endpoint was down longer than the ladder lasts, open the delivery log
and press Retry on a failed delivery. It sends the same event again, under the original
Idempotency-Key, so a receiver that did process it can recognise the repeat and drop it. A retry is
a single attempt with you watching rather than a new ladder, so if your endpoint is still refusing
you see the status it returned and can press again once it is fixed. The verification itself can also
always be read back with GET /v1/verifications/{id}.
Asking for more time
Two answers mean "not now" rather than "this failed". Reply 429 or 503 and the delivery is
deferred without consuming an attempt, so a rate limit you enforce on yourself, or a deploy that
takes your receiver out for a minute, does not eat into the ten.
Send a Retry-After header and we wait what you asked for, up to a ceiling of six hours per
deferral. Without one we choose the wait ourselves. A delivery can be held this way for at most
twenty-four hours from the first deferral; past that it stops being treated as a request for time
and fails like any other, on the ladder above.
This is the honest way to shed load. A 500 to make us back off costs you an attempt every time,
and the early ones are gone in a couple of minutes.
Idempotency
Assume every event can arrive more than once, both because of retries and because a verification's final state can legitimately change after a manual review concludes.
Deduplicate on the event id, and make the underlying state transition safe to repeat, keyed on the
verification id. Ordering is not guaranteed either, so treat the state in the payload as the current
truth rather than assuming events arrive in the order they were created. The progress events in
particular can land after the outcome they preceded, which is covered in Webhook
events.
Testing your endpoint
You do not need a verification to put a payload on your receiver. Any endpoint can be sent a test event, from your dashboard or over the API.
From your dashboard, "Send test event" on the endpoint makes one immediate attempt and shows you the result inline, including the status code your receiver answered with. Nothing is queued and nothing is retried, so a failure there is the answer rather than something that may quietly fix itself a minute later.
Over the API, POST /v1/webhooks/{id}/test queues the delivery, and from that point it is
treated exactly like a real one, retry ladder included. Name the event in the body, or send nothing
and get verification.completed.
curl -X POST https://api.trustwix.com/v1/webhooks/whe_.../test \
-H "Authorization: Bearer $TRUSTWIX_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{"event": "verification.rejected"}'The call answers with the queued delivery rather than with your receiver's reply, because your receiver has not been called yet.
Either way the attempt is recorded in your delivery log next to real events, which is where you go to see what happened and to resend it. Both paths also need the endpoint to be active: a disabled endpoint is refused rather than sent to, because "delivered nothing" is what a disabled endpoint is for.
The retry behaviour is the only real difference between the two, and it decides how long you wait to find out. The dashboard answers immediately. An API test against a receiver that is down works through the whole ladder before it settles, which is about five minutes. The payload is identical either way, so pick the dashboard when the question is whether your URL is reachable, and the API when you want to watch your own retry handling do its job.
You may name an event the endpoint is not subscribed to, which is deliberate: it lets you write and prove a handler before you commit a production endpoint to receiving that traffic. Reserved events are the exception, because there is no live shape to imitate.
What a test payload looks like
It is signed with the endpoint's real secret and carries the same headers as anything else, so your verification code runs unchanged. Two things mark it:
The envelope has test: true. Real deliveries never carry the field, so checking for it is the
reliable test, and checking that it is absent is how you decide something is real.
Every resource id inside data is obviously fake and constant: vs_test000000000000000000 for the
session, tag_test000000000 for a tag. There is nothing behind them, so a handler that fetches the
verification back to enrich it will not find one. That is worth exercising rather than working
around, because it is the same path you take when a real event arrives before your own record exists.
The envelope id is the exception. It is a real, freshly minted evt_ id on every send, because it
is also the Idempotency-Key, and two test sends of the same event must not look like a duplicate
to your deduplication.
A test goes wherever you aim it
Test events are delivered to the endpoint you name, including a live one carrying real traffic.
Branch on test before you act, or aim at a sandbox endpoint.
Testing with real verifications
Sandbox verifications fire the same events with the same signature scheme, to the endpoints you registered with a sandbox key. You can also resend any past delivery from your dashboard, which is the quickest way to reproduce a bug against a payload that really happened.