TrustWixDocs
Handle results

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.

HeaderPurpose
Verifisere-Signaturet=<unix seconds>,v1=<hex hmac> over the timestamp and the raw body.
Verifisere-EventThe event name, so you can route before parsing.
Idempotency-KeyThe event id, repeated for convenience.
X-Payload-DigestHex HMAC-SHA256 of the raw body alone.
X-Payload-Digest-AlgAlways 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.

Send a test event
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.

On this page