KYC Webhooks
KYC webhook events notify you when a customer's ZenPays KYC verification status changes. These are fired for customers going through identity verification in the ramp checkout widget.
Events
| Event | Description |
|---|---|
kyc.approved | Customer's identity verification passed |
kyc.declined | Customer's identity verification was rejected |
kyc.in_review | Verification flagged for manual review |
kyc.expired | Previous verification has expired |
Intermediate states like pending, in_progress, and resubmitted are not delivered as webhooks — they would be noise for most integrations. If you need that granularity, poll GET /ramp-intents/:id/kyc-status instead.
Payload
{
"event_type": "kyc.approved",
"user_id": "usr_abc123",
"kyc_status": "approved",
"aml_status": "clear",
"decline_reason": null,
"merchant_id": "mer_abc123",
"affected_intent_ids": ["ri_1710345678000_a1b2c3d4e5f6g7h8"],
"didit_session_id": "8a9b2c1d-e3f4-...",
"occurred_at": "2026-03-16T14:30:00.000Z"
}
Payload Fields
| Field | Type | Description |
|---|---|---|
event_type | string | The KYC event: kyc.approved, kyc.declined, kyc.in_review, or kyc.expired |
user_id | string | The userId you passed when creating the ramp intent |
kyc_status | string | Verification state: approved, declined, in_review, or expired |
aml_status | string | null | AML screening result: clear, flagged, rejected, pending_review, or null if not yet evaluated |
decline_reason | string | null | Reason code when terminal: kyc_rejected, kyc_expired, or a provider-specific string. null for non-decline events. |
merchant_id | string | Your merchant ID |
affected_intent_ids | string[] | Ramp intents that this status change applied to. May be empty on late approvals — see "Multi-merchant fan-out & late approval" below. |
didit_session_id | string | null | The underlying Didit verification session ID, for cross-referencing with our support team. |
occurred_at | string | ISO 8601 timestamp of the status change |
Multi-merchant fan-out & late approval
Two behaviors that surprise integrators if you don't model them:
-
Multi-merchant fan-out. A single customer can have ramp intents across multiple merchants. When their KYC status changes, every merchant that has any intent for that user receives the event independently. Each receives only the
affected_intent_idsbelonging to their merchant. -
Late approval after a prior decline. If a user is declined today and approved tomorrow (e.g., after manual re-review), the original failed intents stay terminally
cancelled— we do not auto-resume them. The merchant still receiveskyc.approved, but withaffected_intent_ids: []. Treat this as an instruction to update your user verification cache so future intents proceed without re-KYC, not as an event about a specific transaction.
Usage
KYC webhooks are useful for:
- Updating user records — mark a user as KYC-verified in your system when
kyc.approvedis received, so future ramp intents can passkycVerified: true. - Handling rejections — notify the customer or trigger support flows on
kyc.declined. Thedecline_reasonfield tells you whether it was a hard rejection (kyc_rejected) or a session expiry (kyc_expired). - Monitoring review queues — track
kyc.in_reviewevents for compliance reporting; pair withaml_statusto know whether identity was approved but AML flagged for review.
Example
function handleWebhookEvent(event: any) {
switch (event.event_type) {
case 'kyc.approved':
// Mark user as KYC verified in your database for future ramp intents
await updateUserKycStatus(event.user_id, 'verified')
if (event.affected_intent_ids.length > 0) {
// The user had active intents that just unblocked — surface success
// in any UI showing those intents.
await notifyIntentsUnblocked(event.affected_intent_ids)
} else {
// Late approval after a prior decline — no active intent to resume.
// Merchant may want to invite the customer to start a new transaction.
await inviteRetry(event.user_id)
}
break
case 'kyc.declined':
// Hard rejection — notify the customer, offer support
await notifyCustomer(event.user_id, 'KYC verification was not successful', {
reason: event.decline_reason,
affectedIntents: event.affected_intent_ids,
})
break
case 'kyc.in_review':
// Identity OK but flagged for review (often AML-driven)
await flagForReview(event.user_id, {
amlStatus: event.aml_status,
affectedIntents: event.affected_intent_ids,
})
break
case 'kyc.expired':
// User will need to re-verify on the next ramp intent
await updateUserKycStatus(event.user_id, 'expired')
break
}
}
Once you receive kyc.approved for a user, store that status in your system. For subsequent ramp intents, pass kycVerified: true so the customer doesn't have to verify again.