> ## Documentation Index
> Fetch the complete documentation index at: https://docs.zapyd.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Receive, verify and deduplicate signed events for every status change.

Webhooks push real-time updates to your server when objects in Zapyd change state. Use them instead of polling. They are the recommended way to drive your integration logic.

## Quick setup

<Steps>
  <Step title="Register your URL" icon="link">
    Call [`POST /org/api/v1/organizations/api-webhooks`](/api-reference-exchange/endpoint/organizations/api-webhooks) with `webhook_url`. It must be public HTTPS. One URL receives every event type.
  </Step>

  <Step title="Verify every event" icon="shield-check">
    Check `X-TIMESTAMP` and `X-SIGNATURE` before you read the body. See [Signature validation](#signature-validation).
  </Step>

  <Step title="Acknowledge fast" icon="bolt">
    Return `2xx` within a few seconds, and do the work asynchronously. Zapyd treats any other response as a failed delivery.
  </Step>

  <Step title="Deduplicate" icon="clone">
    The same event can arrive more than once. Store `id` + `event`, and skip events you've already processed.
  </Step>
</Steps>

## Event envelope

Every event has the same shape:

```json theme={"theme":{"light":"css-variables","dark":"css-variables"}}
{
  "type": "PAYOUT",
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "event": "SUCCESS",
  "timestamp": "2026-09-30T10:00:00Z",
  "metadata": { "client_reference_id": "payout-001", "utr": "RATN12341234XYZ" }
}
```

| Field | Meaning |
| - | - |
| `type` | The object that changed: `CUSTOMER`, `BANK`, `PAYIN`, `PAYOUT` or `EDD` |
| `id` | The object's Zapyd ID (customer, bank account, payin, payout or EDD) |
| `event` | The status the object moved to |
| `timestamp` | When the change happened (ISO 8601) |
| `metadata` | Extra context for that event. Often empty on success |

Webhooks tell you that something changed. For the full object, fetch it by `id`.

## Signature validation

Every request includes two headers:

```
X-TIMESTAMP: 1752670745
X-SIGNATURE: ohmVyfuNup6eKWOdOZBBZut5CMPdPTfFSB/zDT/eXQo=
```

* `X-TIMESTAMP` — Unix timestamp when the webhook was generated
* `X-SIGNATURE` — HMAC-SHA256 of the request, Base64 encoded

Validate both on every incoming request. Reject anything with a stale timestamp or a signature mismatch.

Webhooks are signed exactly like API requests, using **your** API key and secret: `Base64(HMAC-SHA256(secret, apiKey + "|" + X-TIMESTAMP + "|" + canonicalBody))`. See [Authentication](/guides/development-and-testing/authentication#signature-algorithm) for the canonical body rules.

<Tabs>
  <Tab title="Python">
    ```python theme={"theme":{"light":"css-variables","dark":"css-variables"}}
    import base64
    import hmac
    import hashlib
    import json
    import time

    def generate_hmac_signature(api_key, api_secret, timestamp, body=None):
        body = json.dumps(body or {}, sort_keys=True, separators=(",", ":"))
        message = f"{api_key}|{timestamp}|{body}".encode()
        signature = hmac.new(api_secret.encode(), message, hashlib.sha256).digest()
        return base64.b64encode(signature).decode()

    def verify_hmac_signature(api_key, timestamp, signature, body, api_secret):
        if abs(time.time() - int(timestamp)) > 300:
            return False  # stale or replayed
        expected = generate_hmac_signature(api_key, api_secret, timestamp, body)
        return hmac.compare_digest(expected, signature)
    ```

    **Usage:**

    ```python theme={"theme":{"light":"css-variables","dark":"css-variables"}}
    is_valid = verify_hmac_signature(
        api_key="your_api_key",
        api_secret="your_api_secret",
        timestamp=received_timestamp,
        signature=received_signature,
        body=webhook_body_dict,
    )

    if not is_valid:
        return 401  # reject
    ```
  </Tab>

  <Tab title="Node.js">
    ```javascript theme={"theme":{"light":"css-variables","dark":"css-variables"}}
    const crypto = require('crypto');

    // Same canonical JSON as request signing: sorted keys at every level, no whitespace, non-ASCII escaped
    function canonicalJson(value) {
      if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
      if (value && typeof value === 'object') {
        return `{${Object.keys(value).sort()
          .map((k) => `${canonicalJson(k)}:${canonicalJson(value[k])}`)
          .join(',')}}`;
      }
      return JSON.stringify(value).replace(
        /[\u007f-\uffff]/g,
        (c) => '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0')
      );
    }

    function verifyWebhook(apiKey, apiSecret, timestamp, signature, body) {
      if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
      const expected = crypto
        .createHmac('sha256', apiSecret)
        .update(`${apiKey}|${timestamp}|${canonicalJson(body)}`)
        .digest('base64');
      const a = Buffer.from(expected);
      const b = Buffer.from(signature || '');
      return a.length === b.length && crypto.timingSafeEqual(a, b);
    }

    // Express: app.post('/webhook/zapyd', express.json(), (req, res) => {
    //   const ok = verifyWebhook(API_KEY, API_SECRET, req.get('X-TIMESTAMP'), req.get('X-SIGNATURE'), req.body);
    //   if (!ok) return res.sendStatus(401);
    //   ...
    // });
    ```
  </Tab>
</Tabs>

<Warning>
  Always validate the signature before processing any webhook payload. Never trust the payload without verification.
</Warning>

## Event catalog

### Customer events

Fired when a customer's KYC status changes.

| Event | Description |
| - | - |
| `VERIFIED` | KYC passed. Customer can link banks and transact |
| `FAILED` | KYC failed or account suspended |

**Metadata:**

* `failure_reason` — present on `FAILED`. Values: `DOCUMENT_VERIFICATION_FAILED`, `TAX_VERIFICATION_FAILED`, `ADDITIONAL_INFO_VERIFICATION_FAILED`, `KYC_FAILED`

```json theme={"theme":{"light":"css-variables","dark":"css-variables"}}
{
  "type": "CUSTOMER",
  "event": "FAILED",
  "id": "12348400-e29b-41d4-a716-446655440000",
  "timestamp": "2024-03-13T10:00:00Z",
  "metadata": {
    "failure_reason": "TAX_VERIFICATION_FAILED"
  }
}
```

### Bank events

Fired when a bank account or UPI ID verification resolves.

| Event | Description |
| - | - |
| `VERIFIED` | Bank account or UPI ID ready for orders |
| `FAILED` | Verification failed |

**Metadata:**

* `failure_reason` — present on `FAILED`. Values: `ACCOUNT_TYPE_NRE`, `PENNY_DROP_FAILED`, `NAME_MISMATCH`, `BANK_RISK_CHECK_FAILED`

```json theme={"theme":{"light":"css-variables","dark":"css-variables"}}
{
  "type": "BANK",
  "event": "VERIFIED",
  "id": "12348400-e29b-41d4-a716-446655440000",
  "timestamp": "2024-03-13T10:00:00Z",
  "metadata": {}
}
```

### Payin events

Fired when a payin order changes state.

| Event | Description |
| - | - |
| `SUCCESS` | Payment received, crypto released |
| `FAILED` | Payin failed |
| `REFUND_INITIATED` | Refund process started |
| `REFUNDED` | Fiat returned to source |
| `ON_HOLD` | Payment flagged and held for review |

**Metadata:**

* `transaction_reference_id` — present on `SUCCESS`. The bank reference (UTR for INR) of the customer's payment
* `transaction_hash` — present on `SUCCESS`. The on-chain transaction that delivered the crypto
* `failure_reason` — present on `FAILED`. Values: `INCORRECT_UTR`, `PAYMENT_NOT_RECEIVED`, and others. See [Failure Reason Reference](/api-reference-exchange/appendix/failure-reason-reference)
* `refund_reason` — present on `REFUND_INITIATED` and `REFUNDED`. Values: `INCORRECT_AMOUNT`, `PAYMENT_FROM_NON_WHITELISTED_ACCOUNT`, `THIRD_PARTY_PAYMENT`

```json theme={"theme":{"light":"css-variables","dark":"css-variables"}}
{
  "type": "PAYIN",
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "event": "SUCCESS",
  "timestamp": "2024-03-13T10:00:00Z",
  "metadata": {
    "transaction_reference_id": "412345678901",
    "transaction_hash": "0x9f2c3b1a..."
  }
}
```

```json theme={"theme":{"light":"css-variables","dark":"css-variables"}}
{
  "type": "PAYIN",
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "event": "FAILED",
  "timestamp": "2024-03-13T10:00:00Z",
  "metadata": {
    "failure_reason": "INCORRECT_UTR"
  }
}
```

```json theme={"theme":{"light":"css-variables","dark":"css-variables"}}
{
  "type": "PAYIN",
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "event": "REFUND_INITIATED",
  "timestamp": "2024-03-13T10:00:00Z",
  "metadata": {
    "refund_reason": "INCORRECT_AMOUNT"
  }
}
```

### Payout events

Fired when a payout order changes state.

| Event | Description |
| - | - |
| `SUCCESS` | Funds delivered to beneficiary |
| `FAILED` | Payout failed |
| `REFUNDED` | Payout amount returned |
| `IN_REVIEW` | Payout held for compliance review or a request for information |

**Metadata:**

* `client_reference_id` — present on every payout event. Your reference from Create Payout, or `null`
* `utr` — present on `SUCCESS`. The bank's transfer reference (the UTR in India), usable for reconciliation
* `failure_reason` — present on `FAILED`. Always the generic `Payout failed`; the detailed reason is not exposed
* `refund_reason` — present on `REFUNDED`
* `reason` — present on `IN_REVIEW`. Either `Request for information` (with an `rfi_link` the customer must complete) or `Under compliance review`

`PROCESSING` is not sent as a webhook. Poll [Fetch Payout](/api-reference-exchange/endpoint/payout/order/\{payout_id}) if you need it.

```json theme={"theme":{"light":"css-variables","dark":"css-variables"}}
{
  "type": "PAYOUT",
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "event": "SUCCESS",
  "timestamp": "2024-03-13T10:00:00Z",
  "metadata": {
    "client_reference_id": "payout-ref-001",
    "utr": "RATN12341234XYZ"
  }
}
```

```json theme={"theme":{"light":"css-variables","dark":"css-variables"}}
{
  "type": "PAYOUT",
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "event": "IN_REVIEW",
  "timestamp": "2024-03-13T10:00:00Z",
  "metadata": {
    "client_reference_id": "payout-ref-001",
    "reason": "Request for information",
    "rfi_link": "https://..."
  }
}
```

### EDD events

Fired when Enhanced Due Diligence verification resolves.

| Event | Description |
| - | - |
| `VERIFIED` | EDD passed. The customer gets higher limits |
| `FAILED` | EDD failed. Standard limits remain |

```json theme={"theme":{"light":"css-variables","dark":"css-variables"}}
{
  "type": "EDD",
  "event": "VERIFIED",
  "id": "12348400-e29b-41d4-a716-446655440000",
  "timestamp": "2024-03-13T10:00:00Z",
  "metadata": {}
}
```


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.