Payload Versions
Every endpoint declares which payload shape it receives. There are two, and they are stable contracts, not a rolling format:
| Version | What you receive | Success rule | Default for |
|---|---|---|---|
LEGACY_V1 | Byte-for-byte the payload Zyphe sent before organization-level endpoints existed | Strictly 200 | Endpoints created by the migration |
V2 | A common envelope naming the event, the API version, and both ends of the delivery | Any 2xx | Every endpoint you create |
The success rule differs on purpose: migrating an endpoint to V2 must never silently change which of your responses count as delivered, so the strict rule stays attached to the legacy shape.
LEGACY_V1
Exactly the payload documented in Receiving Webhooks — the result envelope with resultId, event, data, custom, and flowStatus, or the notification envelope with eventData. Nothing was added to it and nothing was removed from it, including the fields a V2 endpoint receives that this one does not.
Two consequences:
- A
LEGACY_V1endpoint never receivesnextStep, even where the platform now computes it. - A
LEGACY_V1endpoint cannot subscribe to events that have no legacy shape, because there are no legacy bytes to reproduce. Today those areflow.completedandkyc_passport.verification.updated. The API refuses the subscription when you write it rather than dropping the delivery later.
If you have an integration that works today, doing nothing is a supported choice.
V2
V2 is not the legacy body with an envelope on top: it states each fact once, at one address.
{
"id": "0f0dcb6c-6b6a-4f4a-a4a6-2b4a19e2e9c1",
"type": "verification.dv.completed",
"apiVersion": "2026-08-26",
"createdAt": "2026-08-26T10:12:44.192Z",
"recipientOrganizationId": "6b0f6a2e-1f5c-4a70-9d6b-2b1a0e4c9d33",
"source": {
"organizationId": "6b0f6a2e-1f5c-4a70-9d6b-2b1a0e4c9d33",
"flowId": "2d8285d7-f4ba-42df-ab3f-681d9870d37a",
"flowResultId": "936f35a8-4921-430e-b016-15be48968886"
},
"flow": {
"status": "PROCESSING",
"slug": "onboarding",
"customData": { "uid": "123" },
"nextStep": {
"id": "03559a0f-be8e-4ab7-964d-1ff4745d92a4",
"slug": "liveness",
"name": "Liveness",
"type": "LIVENESS"
}
},
"data": {
"dv": {
"id": "d65fa9a0-596e-4d7e-afd1-59a23bfb5a74",
"verificationRequestId": "cf52e18e-28d1-4a2f-8304-c04ef5a75d0f",
"flowId": "2d8285d7-f4ba-42df-ab3f-681d9870d37a",
"flowStepId": "03559a0f-be8e-4ab7-964d-1ff4745d92a4",
"createdAt": "2026-08-26T10:12:40.100Z",
"customData": {},
"status": "PASSED",
"reasons": [],
"identityEmail": "applicant01@example.com"
}
}
}
The envelope has three parts, and they answer three different questions:
| Part | Question it answers | Notes |
|---|---|---|
| root | What delivery is this? | id, type, apiVersion, createdAt, recipientOrganizationId, source |
flow | Which run, and where it stands | Present for every event that has a flow. Absent for Transaction Monitoring alerts. |
data | What happened | The product result under its own name (dv, poa, kyb, aml, …), or the notification under its own name |
| Field | Meaning |
|---|---|
id | Identifier of the event. The same event delivered to three endpoints carries the same id. Use it for idempotency. |
type | The canonical event type — the single place the event is named. |
apiVersion | The envelope contract, dated. It changes only on a breaking envelope change. Current value: 2026-08-26. |
createdAt | When the event occurred, not when this attempt was sent. |
recipientOrganizationId | Who this delivery is for. Differs from source.organizationId for KYC Passport deliveries. |
source | organizationId, plus flowId / flowResultId when the event is flow-bound. |
flow.status | The run's status, in the same vocabulary as the legacy flowStatus. Omitted when the producer did not observe the run. |
flow.slug | The flow's slug, so acting on an event does not require resolving source.flowId through the API first. |
flow.customData | The flow result's custom data — the legacy top-level custom. |
flow.nextStep | The step the run is waiting on (id, slug, name, type), or absent when it is waiting on nothing. |
data.<product> | The result object. Only products that are actually present are sent. |
data.<product>.customData | The verification request's custom data. A different scope from flow.customData, and now visibly so. |
What moved, compared with LEGACY_V1
LEGACY_V1 | V2 | Why |
|---|---|---|
event (COMPLETED, NOTIFICATION, …) | (gone) — use type | type already names the event, canonically |
resultId | source.flowResultId | Identical value on every standard payload |
data.data.{dv,poa,…} | data.{dv,poa,…} | The extra nesting carried no information |
Ten null product keys on every delivery | (gone) | Absence says what null said |
custom | flow.customData | The name now says whose custom data it is |
flowStatus | flow.status | Read together with the run, so stored with it |
nextStep | flow.nextStep | LEGACY_V1 never carries it at all |
version: 0 inside each payload object | apiVersion on the envelope | One version, in one place |
Nothing was summarised or dropped: every value the legacy body carried is still on the wire, at a stated address. Migrating is a mechanical re-pathing.
Notifications
A notification's data goes under its own name, and the legacy eventData.type discriminant is gone — the envelope's type already names it:
{
"type": "flow.risk.reevaluated",
"apiVersion": "2026-08-26",
"source": { "organizationId": "…", "flowId": "…", "flowResultId": "…" },
"flow": { "slug": "onboarding", "customData": { "uid": "123" } },
"data": {
"flowRiskReevaluated": {
"flowResultId": "936f35a8-4921-430e-b016-15be48968886",
"scoreBefore": 10,
"scoreAfter": 70,
"statusBefore": "COMPLETED",
"statusAfter": "REQUIRES_MANUAL_REVIEW"
}
}
}
| Canonical event | data key | flow block |
|---|---|---|
verification.document.expired | documentExpired | Present, including status — its producer holds the flow result |
verification.kyc.expired | kycResultExpired | Present, without status |
flow.risk.reevaluated | flowRiskReevaluated | Present, without status |
transaction.alert.created | transactionAlert | Absent — a TM alert is about a transaction, not about a run |
A producer that did not observe the run's state omits flow.status rather than guessing it.
What only V2 carries
-
flow.nextStep, the step the user is expected to reach next. -
flow.slug, on every event that has a run. -
flow.completed, a registry-native event fired when every step the flow asks for has a passing result. Before it existed, completion had to be inferred fromflowStatuson the last step's payload, which is a snapshot taken before completion was evaluated. Its whole subject is the run, so the run is where it reports:{"type": "flow.completed","apiVersion": "2026-08-26","source": { "organizationId": "…", "flowId": "…", "flowResultId": "…" },"flow": {"status": "COMPLETED","slug": "onboarding","customData": { "uid": "123" },"nextStep": null},"data": { "identityId": "3f7a1c62-9f0d-4b58-b1d2-6b8a9c0e5d41" }} -
Distinct notification events.
verification.document.expired,verification.kyc.expired,flow.risk.reevaluated, andtransaction.alert.createdare separatetypevalues you subscribe to individually, instead of oneNOTIFICATIONevent routed on a buried discriminant.
A change both versions see
Step webhooks now evaluate the flow before sending, so the run's status describes the state after the result being reported, not a snapshot taken before it was applied. That applies to LEGACY_V1 deliveries too — the field is the same flowStatus, its value is simply more current. nextStep and flowSlug are stripped from LEGACY_V1 bodies, which stay byte-identical to the pre-registry format.
Body encoding
The request body is canonical JSON, so object keys arrive in a deterministic order. Do not depend on that order when parsing, and always verify the signature against the raw bytes you received, before parsing. See Webhook Signature.
Migrating an endpoint to V2
Switching is a single field, and it is reversible.
-
Make your handler accept both. A
V2body has a top-leveltypeandapiVersion; aLEGACY_V1body does not.function normalize(body: any) {if ('apiVersion' in body) {return {eventId: body.id,eventType: body.type,flowResultId: body.source.flowResultId,flowStatus: body.flow?.status,custom: body.flow?.customData ?? {},results: body.data, // { dv } | { kyb } | { documentExpired } | …}}return {eventId: null,eventType: null,flowResultId: body.resultId,flowStatus: body.flowStatus,custom: body.custom ?? {},results: body.event === 'NOTIFICATION' ? { [body.eventData.type]: body.eventData } : body.data,}}Use the field mapping table to re-path the rest.
-
Accept any
2xxon your side — or keep returning200, which satisfies both rules. -
Switch the endpoint, in sandbox first:
PATCH /organizations/{organizationId}/webhooks/{endpointId}{ "payloadVersion": "V2" } -
Subscribe to the new events you now have access to, such as
flow.completed. -
Once you trust the new handler, drop the legacy branch.
If you would rather not touch a working endpoint at all, create a second endpoint pointing at a new URL with payloadVersion: "V2", run both in parallel, and delete the old one when you are done. Both receive the same events; each is signed and retried independently.
On V2, id is the event identifier and is stable across retries and across endpoints — the natural idempotency key. On LEGACY_V1 there is no such field: build a key from the result type and its identifier, as described in Receiving Webhooks.