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 sent | What comes back |
|---|---|
| A name we do not recognise | Unknown 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 list | Choose 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
| Event | When it fires |
|---|---|
verification.created | A verification session was created. |
verification.pending | The applicant opened the hosted flow, and the session is waiting on them. |
verification.awaiting_service | Submissions are in and the checks are queued. |
verification.completed | The session reached a terminal state. Fires for every outcome. |
verification.approved | The verdict is approve. |
verification.rejected | The verdict is reject. |
verification.review | The verdict is review and a human needs to look at it. |
verification.expired | The applicant never completed the session in time. |
verification.corrected | An outcome we already sent for this session was wrong, and has been corrected. |
verification.data_updated | Someone on your team edited this verification's applicant data. Fetch it again for the new values. |
verification.already_verified | A 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
{
"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"
}
}{
"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
{
"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"
}
}{
"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.
{
"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
| Event | When it fires |
|---|---|
check.completed | An 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
| Event | When it fires |
|---|---|
action.pending | A case is waiting on a reviewer. |
action.reviewed | A reviewer reached a decision. |
action.on_hold | A 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.
{
"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"
}
}{
"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"
}
}{
"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
| Event | When it fires |
|---|---|
aml.case.created | A screening check opened a case that needs attention. Reserved. |
aml.case.updated | An 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
| Event | When it fires |
|---|---|
fraud_network.created | A cross-merchant fraud signal was raised. |
fraud_network.modified | An 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
| Event | When it fires |
|---|---|
verification.tag_added | One of your own labels was put on a verification. |
verification.tag_removed | One 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.