<!-- ZenPays documentation · https://docs.zenpayz.com/docs/guides/webhooks/kyc-webhooks -->

# 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 |

:::note
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`](../../rest-api/endpoints/ramp/get-kyc-status) instead.
:::

## Payload

```json
{
  "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:

1. **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_ids` belonging to *their* merchant.

2. **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 receives `kyc.approved`, but with `affected_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.approved` is received, so future ramp intents can pass `kycVerified: true`.
- **Handling rejections** — notify the customer or trigger support flows on `kyc.declined`. The `decline_reason` field tells you whether it was a hard rejection (`kyc_rejected`) or a session expiry (`kyc_expired`).
- **Monitoring review queues** — track `kyc.in_review` events for compliance reporting; pair with `aml_status` to know whether identity was approved but AML flagged for review.

### Example

```typescript
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
  }
}
```

:::tip Skip KYC on repeat transactions
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.
:::
