TrustWixDocs
Handle results

Webhook events

The event catalogue, every payload shape, and which ones you actually need to subscribe to

Subscribe only to what you act on. Most integrations need exactly one event, and adding the rest mainly adds traffic you then have to filter.

Two names in the catalogue are marked reserved: aml.case.created and aml.case.updated. They have a defined meaning and a defined payload, and nothing sends them yet. They are listed so the catalogue is complete and so a name never changes meaning later, but you cannot subscribe to one. The dashboard does not offer them and the API refuses them, rather than accepting a subscription that would stay silent forever. Everything else on this page is live.

A refused selection comes back as INVALID_EVENT with a sentence saying which of the three mistakes it was:

What you sentWhat comes back
A name we do not recogniseUnknown event "verification.done".
A reserved name"aml.case.created" is reserved and nothing sends it yet, so subscribing to it would deliver nothing.
An empty listChoose at least one event. An endpoint with no events subscribed receives nothing.

The middle one exists so that asking for something real but unfinished does not send you hunting for a spelling mistake that is not there.

The first two reach you in the same words wherever you hit them, since the API and both consoles ask the same validator. The third is what your dashboard says. Over the API, events is a required field that must name at least one event, so an empty list is refused by schema validation as INVALID_BODY before the catalogue is ever consulted.

The envelope

Every event has the same envelope: an event id, the event name, a createdAt timestamp and a data object whose contents depend on the event. Only data differs between the events below, and every example on this page is a complete payload as it arrives.

{
  "id": "evt_9c2f1b7a",
  "event": "verification.approved",
  "createdAt": "2026-07-26T16:30:25.032Z",
  "data": {
    "sessionId": "vs_2f8a1c9b4e7d",
    "verdict": "approve",
    "declineType": null,
    "status": "approved",
    "reasonCodes": [],
    "changeReason": "engine"
  }
}

Prop

Type

Signature verification, headers and delivery behaviour are in Webhooks. Sending yourself one of these payloads without running a verification is covered in Testing your endpoint.

Two things decide whether an event reaches you

Subscribing to an event is one filter. Which verifications the endpoint covers is a separate one, and both have to pass before anything is delivered.

An endpoint covers every API key on your account unless you narrow it with api_key_ids, which is described in Webhooks. An endpoint narrowed to a named set of keys receives only the verifications those keys created, so a verification created from your dashboard, which carries no key, reaches only your unscoped endpoints. Minting a new key adds it to nothing, so its traffic reaches your unscoped endpoints and no narrowed one until you say otherwise.

If you are debugging an event that never arrives, check both. A correct subscription on an endpoint that does not cover the key which created the verification looks exactly like a subscription that was never made.

Defaults

There is no default over the API. events is required on POST /v1/webhooks and must name at least one event, so an endpoint you register from your backend carries exactly the list you sent and nothing you did not ask for. An endpoint whose subscription you never chose is one you will not think to check when it goes quiet, which is why omitting the field is refused rather than filled in for you.

Your dashboard does start you somewhere. The create dialog opens with the four terminal outcomes ticked, verification.approved, verification.rejected, verification.review and verification.expired, plus verification.data_updated, and you change them before saving. That is a starting point, not a fallback.

verification.completed is deliberately not among those. It fires alongside the verdict-specific event, so a preselection carrying both would deliver every approval twice to an integration that never asked for duplicates. If you want the single-event shape instead, subscribe to verification.completed on its own and untick the rest.

Verification lifecycle

EventWhen it fires
verification.createdA verification session was created.
verification.pendingThe applicant opened the hosted flow, and the session is waiting on them.
verification.awaiting_serviceSubmissions are in and the checks are queued.
verification.completedThe session reached a terminal state. Fires for every outcome.
verification.approvedThe verdict is approve.
verification.rejectedThe verdict is reject.
verification.reviewThe verdict is review and a human needs to look at it.
verification.expiredThe applicant never completed the session in time.
verification.correctedAn outcome we already sent for this session was wrong, and has been corrected.
verification.data_updatedSomeone on your team edited this verification's applicant data. Fetch it again for the new values.
verification.already_verifiedA new verification was attempted for someone you have already approved, and was refused, or an open review case was closed because the same person was approved.

The first three are progress, not decisions. They carry no verdict and nothing about them is final, so they are for driving your own UI, measuring drop-off between "created" and "opened", and alerting when sessions stop arriving. Never grant or refuse anything on one.

The next five are outcomes and share a single payload shape, and verification.corrected carries that same shape too. The last two are not outcomes and each has a payload of its own: verification.data_updated under Applicant data updated and verification.already_verified under Already verified.

verification.completed fires alongside the verdict-specific event, so a single subscription to it covers all four outcomes and you branch on data.status yourself. Subscribing to both verification.completed and verification.approved means you receive two events for the same approval, which is fine as long as your handler is idempotent.

The one-event integration

If you only want to subscribe to a single event, choose verification.completed and switch on data.status.

A lifecycle event can arrive after the outcome

The progress events are informational and are not ordered against the terminal ones. A session decided the instant it is submitted, a sandbox magic value or a flow with little to capture, can deliver verification.approved before verification.awaiting_service reaches you. The same is true of the review events below. Treat the terminal event as authoritative, order by the envelope createdAt when you need the sequence, and never hold an outcome waiting for a progress event that may already be behind it.

Progress payloads

verification.created
{
  "id": "evt_4b19c07e",
  "event": "verification.created",
  "createdAt": "2026-07-26T16:22:04.118Z",
  "data": {
    "sessionId": "vs_2f8a1c9b4e7d",
    "status": "pending",
    "flow": "flw_8d31c0b2e6a94f77",
    "source": "campaign-fr"
  }
}
verification.awaiting_service
{
  "id": "evt_7ad3f9c1",
  "event": "verification.awaiting_service",
  "createdAt": "2026-07-26T16:29:51.702Z",
  "data": {
    "sessionId": "vs_2f8a1c9b4e7d",
    "status": "processing",
    "checks": ["document", "face_match"]
  }
}

Prop

Type

Outcome payloads

verification.rejected
{
  "id": "evt_2c7e5a91",
  "event": "verification.rejected",
  "createdAt": "2026-07-26T16:30:25.032Z",
  "data": {
    "sessionId": "vs_2f8a1c9b4e7d",
    "verdict": "reject",
    "declineType": "final",
    "status": "rejected",
    "reasonCodes": ["EXPIRATION_DATE"],
    "changeReason": "engine"
  }
}
verification.approved, settled by a reviewer
{
  "id": "evt_e08b4d63",
  "event": "verification.approved",
  "createdAt": "2026-07-26T18:04:11.914Z",
  "data": {
    "sessionId": "vs_2f8a1c9b4e7d",
    "verdict": "approve",
    "declineType": null,
    "status": "approved",
    "reasonCodes": [],
    "changeReason": "reviewer",
    "reviewedAt": "2026-07-26T18:04:11.900Z",
    "reviewMessage": "Document reissued, matches the selfie"
  }
}

Prop

Type

reasonCodes is the same list you get on the REST resource, where it is spelled reason_codes, and the codes themselves are in Reason codes.

reviewedAt and reviewMessage are not merely null on an outcome the engine decided on its own, they are absent: the keys do not appear in data at all. Both appear the moment the case is settled in your review queue, and either can be null even then, so test for the key rather than for a value.

One correction worth reading if you use that key to mean "a person looked at this": auto-decide carries it too. When your own automatic policy settles a case it goes through the same path a reviewer does and is stamped the same way, because it decided on your behalf. changeReason is what separates them, reviewer against auto_policy, and it is on every outcome that carries a verdict.

change_reason on the REST resource answers the same question for a verification you READ rather than one we pushed. It carries only the settled-state values (engine, reviewer, auto_policy) and is null until the verification is terminal, because the delivery-only values say why an event was sent and mean nothing on a resource. Use it when reconciling after an ALREADY_VERIFIED refusal.

When an answer we gave you was wrong

changeReason is on every decided outcome and is the field to branch on when you need to tell a new decision from a restatement of an old one. replay means nothing changed and we are simply delivering again. correction means the opposite: an outcome you have already received and may have acted on was wrong, and the payload it arrives in is the right one.

A correction also fires verification.corrected, after verification.completed and the verdict event, so a merchant who would rather subscribe than branch can do that instead. It carries the same payload. An integration that ignores the name entirely still reaches the correct state from the two events before it.

reviewedAt is not a substitute for this. A correction that restores a reviewer's decision carries that reviewer's ORIGINAL timestamp, because that is when the person decided; stamping the moment of the correction would assert a review that never took place.

verification.expired is the odd one: verdict and declineType are both null, status is expired, and there is no changeReason. A receiver that keys off verdict alone sees nothing and does nothing, which is the correct outcome, because the applicant dropped out rather than failing.

Applicant data updated

verification.data_updated fires when someone on your team edits the applicant data of a verification in your dashboard: a name the document reader got wrong, a document number it missed. An edit can be made while the case is in review or after it was decided, and every edit sends one event.

An edit never changes status or verdict. It corrects the record, it does not re-decide the case, and no outcome event is sent alongside it. If your team also wants the decision changed, that is a separate action that arrives as verification.corrected.

The payload names the fields that changed and never their values, so personal data is never stored or retried in a delivery. Fetch the verification again to read them:

curl "https://api.trustwix.com/v1/verifications/vs_2f8a1c9b4e7d?include=applicant" \
  -H "Authorization: Bearer TWK_..."

That call needs the applicant:read permission, described in Applicant data. The event reaches you without it, and tells you which fields to refresh.

verification.data_updated
{
  "id": "evt_0d4a7e19",
  "event": "verification.data_updated",
  "createdAt": "2026-09-23T10:14:02.518Z",
  "data": {
    "sessionId": "vs_2f8a1c9b4e7d",
    "version": 2,
    "changedFields": ["last_name", "full_name"],
    "reason": "misread",
    "editedBy": { "type": "member", "role": "owner" },
    "revertedFromVersion": null,
    "status": "approved"
  }
}

Prop

Type

Deliveries can arrive out of order, and a delivery can arrive twice. Keep the highest version you have applied per verification and ignore any delivery whose version is not higher. When you fetch, compare applicant.version in the response with the version you were sent: a lower number means the read was served before the edit reached it, so fetch again.

Nothing is ever deleted by an edit. Every version is kept, the original read included, so an edit can always be traced and restored. A restore is itself a new version, with revertedFromVersion naming the one it brought back.

Every webhook endpoint that already existed when this event was introduced was subscribed to it automatically, so you receive it without changing anything. Unsubscribe in the dashboard or with PATCH /v1/webhooks/{id} if you do not want it. An endpoint you register over the API receives it only if you name it in events.

Already verified

verification.already_verified fires when someone you have already approved is put through verification again and we refuse it. Refusing repeats is on by default, per flow, and the whole control is described in Check existing applicants.

It is deliberately not a replay of verification.completed. A handler that provisions on a completion, or that opens a verification of its own, cannot tell a replay from something new having happened, and an integration retrying in a loop would drive that handler forever. This event carries the whole original outcome, so it reconciles your records just as well, under a name that can only mean one thing.

{
  "id": "evt_...",
  "event": "verification.already_verified",
  "createdAt": "2026-08-26T14:04:11.284Z",
  "data": {
    "sessionId": "vs_...",
    "approvedSessionId": "vs_...",
    "approvedStatus": "approved",
    "approvedVerdict": "approve",
    "approvalValidUntil": "2029-03-04T23:59:59.999Z",
    "attemptedSessionId": null,
    "matchedOn": "reference",
    "gate": "create",
    "attemptedAt": "2026-08-26T14:04:11.201Z"
  }
}

approvedSessionId is the verification you already hold, and it is the field to reconcile against. sessionId is whichever session the event is about, which differs by gate and is why the approval is always named separately: a refusal at create has no new session, so the two are the same value, while a refusal partway through a session you created names that session and sets attemptedSessionId to it as well.

matchedOn is reference, email or document, and it answers the only question worth asking when a refusal looks wrong: reference means your own applicant.reference_id is already approved, document means somebody presented a document that is. approvalValidUntil is when the approval stops refusing anything, or null when nothing will lapse it.

gate says where the refusal happened: create before any session existed, document when the document was read during capture, or processing when the number was only found after the applicant submitted. The last two both name the session in attemptedSessionId and are otherwise identical.

gate can also be superseded, which is not a refusal of a new attempt. It means the session named in attemptedSessionId was an older case of the same person still waiting on manual review, and an approval on another verification took it off the queue. That session moves to expired with ALREADY_VERIFIED added to its reason codes, and verification.completed and verification.expired follow for it. Nobody decided the case, so there is no verification.approved or verification.rejected for it, and the applicant is not emailed about it. Treat approvedSessionId as the person's verification and stop waiting on the closed one.

verification.expired has one more cause worth knowing: a session refused because the same person already has a case waiting on review carries the reason code REVIEW_IN_PROGRESS. No verification.already_verified is sent for that one, because nobody has been approved yet. The answer arrives on the open case when a reviewer decides it.

Repeats are coalesced to at most one event per approval per hour, so an integration stuck in a retry loop cannot flood your endpoint.

Checks

EventWhen it fires
check.completedAn individual check inside a verification finished.

Useful for progress indicators and for internal analytics on which step people fail. Do not build your access decision on it, because an individual check result is not the verdict.

{
  "id": "evt_91f0a2bc",
  "event": "check.completed",
  "createdAt": "2026-07-26T16:30:12.441Z",
  "data": {
    "sessionId": "vs_2f8a1c9b4e7d",
    "checkType": "document",
    "verdict": "approve",
    "score": 0.98,
    "reasonCodes": []
  }
}

Prop

Type

Review actions

EventWhen it fires
action.pendingA case is waiting on a reviewer.
action.reviewedA reviewer reached a decision.
action.on_holdA case was escalated out of the working queue.

These track the human side of a case, and they answer a question the outcome events cannot: how long something sat, and whether it is still sitting. action.pending fires when a case enters the review queue, and again every time it returns to the queue, so a case that is escalated and later handed back gives you a second one rather than going quiet.

action.reviewed fires alongside verification.completed and the verdict-specific event, all three describing the same decision. Subscribe to it when you want the reviewer's decision as its own fact, with the review id to reconcile against; keep using the outcome events for the state change itself.

action.on_hold means the case left the working queue for escalation. It is not a decision and the verification stays in manual_review, so nothing about your applicant's state has changed yet.

action.pending
{
  "id": "evt_5ba2c308",
  "event": "action.pending",
  "createdAt": "2026-07-26T16:30:26.004Z",
  "data": {
    "sessionId": "vs_2f8a1c9b4e7d",
    "reviewId": "b7c1e9a4-3f52-4c8e-9d10-6a2f5b0d7e31",
    "status": "manual_review"
  }
}
action.reviewed
{
  "id": "evt_c41d76ef",
  "event": "action.reviewed",
  "createdAt": "2026-07-26T18:04:11.914Z",
  "data": {
    "sessionId": "vs_2f8a1c9b4e7d",
    "reviewId": "b7c1e9a4-3f52-4c8e-9d10-6a2f5b0d7e31",
    "decision": "approve",
    "reviewedAt": "2026-07-26T18:04:11.900Z"
  }
}
action.on_hold
{
  "id": "evt_38e9b5c2",
  "event": "action.on_hold",
  "createdAt": "2026-07-26T17:12:40.556Z",
  "data": {
    "sessionId": "vs_2f8a1c9b4e7d",
    "reviewId": "b7c1e9a4-3f52-4c8e-9d10-6a2f5b0d7e31",
    "reason": "escalated"
  }
}

Prop

Type

No reviewer identity is ever on the wire. Who decided a case is visible in your console and stays there.

Screening

EventWhen it fires
aml.case.createdA screening check opened a case that needs attention. Reserved.
aml.case.updatedAn existing screening case changed. Reserved.

These two are the whole reserved list. The names and their payloads are fixed, nothing emits them yet, and subscribing to one is refused rather than accepted and left silent. Screening results reach you today through the ordinary outcome events and their reason codes.

Fraud network

EventWhen it fires
fraud_network.createdA cross-merchant fraud signal was raised.
fraud_network.modifiedAn existing fraud signal changed.

These payloads deliberately carry no personal data. They tell you that a signal exists against a verification you own, not who else was involved.

{
  "id": "evt_6f2b8d40",
  "event": "fraud_network.created",
  "createdAt": "2026-07-26T16:30:27.318Z",
  "data": {
    "sessionId": "vs_2f8a1c9b4e7d",
    "linked": true,
    "reason": "face_match",
    "risk": "high",
    "crossMerchant": true,
    "links": 2,
    "distinctNames": 2,
    "knownFraud": false
  }
}

Prop

Type

Counts and a reason are as specific as this gets, on purpose. distinctNames: 3 on a face link tells you what you need to act, and telling you the names would mean handing you another business's applicant data.

Tags

EventWhen it fires
verification.tag_addedOne of your own labels was put on a verification.
verification.tag_removedOne of your own labels was taken off.

Unlike every other event here, these report something YOU concluded rather than something we did. They exist so a decision your team records in the console reaches your own systems: a reviewer marking somebody as high risk is a fact your fraud tooling wants, and without these it would live in our console and nowhere else. Tags themselves are covered in Tags.

{
  "id": "evt_a17c3e58",
  "event": "verification.tag_added",
  "createdAt": "2026-07-26T18:06:02.771Z",
  "data": {
    "sessionId": "vs_2f8a1c9b4e7d",
    "tag": { "id": "tag_9f2c1a7b40de", "name": "High Risk", "color": "red" },
    "source": "console"
  }
}

Prop

Type

Check source if you both apply tags over the API and subscribe to these events, or you will process your own writes as though they were somebody else's, and a handler that reacts by tagging again is a loop.

One event is sent per verification per tag. Tagging fifty cases at once in the console delivers fifty events, not one, so size your receiver for the bulk action rather than for the single one.

Tagging an applicant does not fire an event. Webhook endpoints are registered per environment and an applicant belongs to no environment, so there is no honest way to decide which endpoint should receive it.

Adding events

The catalogue is additive. New event types can appear, and new fields can appear inside data, so ignore events you do not recognise instead of failing on them, and do not use a strict object mapper that throws on unknown properties.

A reserved name becoming live is an addition of exactly this kind: the name and payload are already published, so it starts arriving at endpoints that subscribe to it from that point on. Nothing is back-filled and no existing subscription changes meaning.

On this page