Skip to main content

Payload Versions

Every endpoint declares which payload shape it receives. There are two, and they are stable contracts, not a rolling format:

VersionWhat you receiveSuccess ruleDefault for
LEGACY_V1Byte-for-byte the payload Zyphe sent before organization-level endpoints existedStrictly 200Endpoints created by the migration
V2A common envelope naming the event, the API version, and both ends of the deliveryAny 2xxEvery 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_V1 endpoint never receives nextStep, even where the platform now computes it.
  • A LEGACY_V1 endpoint cannot subscribe to events that have no legacy shape, because there are no legacy bytes to reproduce. Today those are flow.completed and kyc_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:

PartQuestion it answersNotes
rootWhat delivery is this?id, type, apiVersion, createdAt, recipientOrganizationId, source
flowWhich run, and where it standsPresent for every event that has a flow. Absent for Transaction Monitoring alerts.
dataWhat happenedThe product result under its own name (dv, poa, kyb, aml, …), or the notification under its own name
FieldMeaning
idIdentifier of the event. The same event delivered to three endpoints carries the same id. Use it for idempotency.
typeThe canonical event type — the single place the event is named.
apiVersionThe envelope contract, dated. It changes only on a breaking envelope change. Current value: 2026-08-26.
createdAtWhen the event occurred, not when this attempt was sent.
recipientOrganizationIdWho this delivery is for. Differs from source.organizationId for KYC Passport deliveries.
sourceorganizationId, plus flowId / flowResultId when the event is flow-bound.
flow.statusThe run's status, in the same vocabulary as the legacy flowStatus. Omitted when the producer did not observe the run.
flow.slugThe flow's slug, so acting on an event does not require resolving source.flowId through the API first.
flow.customDataThe flow result's custom data — the legacy top-level custom.
flow.nextStepThe 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>.customDataThe verification request's custom data. A different scope from flow.customData, and now visibly so.

What moved, compared with LEGACY_V1

LEGACY_V1V2Why
event (COMPLETED, NOTIFICATION, …)(gone) — use typetype already names the event, canonically
resultIdsource.flowResultIdIdentical 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
customflow.customDataThe name now says whose custom data it is
flowStatusflow.statusRead together with the run, so stored with it
nextStepflow.nextStepLEGACY_V1 never carries it at all
version: 0 inside each payload objectapiVersion on the envelopeOne 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 eventdata keyflow block
verification.document.expireddocumentExpiredPresent, including status — its producer holds the flow result
verification.kyc.expiredkycResultExpiredPresent, without status
flow.risk.reevaluatedflowRiskReevaluatedPresent, without status
transaction.alert.createdtransactionAlertAbsent — 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 from flowStatus on 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, and transaction.alert.created are separate type values you subscribe to individually, instead of one NOTIFICATION event 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.

  1. Make your handler accept both. A V2 body has a top-level type and apiVersion; a LEGACY_V1 body 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.

  2. Accept any 2xx on your side — or keep returning 200, which satisfies both rules.

  3. Switch the endpoint, in sandbox first:

    PATCH /organizations/{organizationId}/webhooks/{endpointId}
    { "payloadVersion": "V2" }
  4. Subscribe to the new events you now have access to, such as flow.completed.

  5. 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.

Idempotency

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.