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

# Webhook

> Receive payment event notifications

## Overview

Webhooks allow you to receive real-time notifications for payment events such as completion, failure, and refunds.
ZAFA PAY sends HTTP POST requests to your registered webhook endpoints when payment status changes.

<Note>
  Webhook endpoints can be managed from the **Webhooks** tab in the merchant dashboard ([https://app.zafapay.com](https://app.zafapay.com)). Each endpoint has its own Webhook Secret for signature verification.
</Note>

## Webhook Endpoints

You can register multiple webhook endpoints to receive event notifications. Each endpoint has its own URL, secret, and optional event filter.

| Feature              | Description                                                                                            |
| -------------------- | ------------------------------------------------------------------------------------------------------ |
| Multiple endpoints   | Register up to 16 webhook endpoints per merchant                                                       |
| Event filtering      | Choose which event types each endpoint receives. If no filter is set, the endpoint receives all events |
| Per-endpoint secrets | Each endpoint has its own Webhook Secret (`whsec_xxx` format) for signature verification               |
| Enable/disable       | Endpoints can be individually activated or deactivated without deletion                                |

Webhook endpoints can be created and managed from the **Webhooks** tab in the merchant dashboard.

## Event Types

| Event                | Description                                                                                                                                                              |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `payment.succeeded`  | Payment completed successfully                                                                                                                                           |
| `payment.failed`     | Payment failed                                                                                                                                                           |
| `payment.canceled`   | Payment canceled — either voided by the merchant (`POST /v1/payments/{id}/void`) or canceled by the customer during 3D Secure authentication on the hosted checkout page |
| `payment.refunded`   | Refund completed                                                                                                                                                         |
| `payment.chargeback` | Chargeback occurred                                                                                                                                                      |

## Payload

<ResponseField name="event" type="string">
  Event type (e.g., `payment.succeeded`)
</ResponseField>

<ResponseField name="transaction_id" type="string">
  Transaction ID
</ResponseField>

<ResponseField name="merchant_id" type="string">
  Merchant ID
</ResponseField>

<ResponseField name="merchant_name" type="string">
  Merchant name
</ResponseField>

<ResponseField name="status" type="string">
  Payment status (`succeeded`, `failed`, `canceled`, `refunded`, `chargeback`)
</ResponseField>

<ResponseField name="amount" type="string">
  Payment amount (string format, e.g., `"100.00"`)
</ResponseField>

<ResponseField name="currency" type="string">
  Currency code
</ResponseField>

<ResponseField name="payment_method" type="string">
  Payment method (`card`, `depot`, etc.)
</ResponseField>

<ResponseField name="payment_method_id" type="string">
  Saved card ID (`pmi_xxx` format). Only included for recurring payments or when `save_card` was used
</ResponseField>

<ResponseField name="is_recurring" type="boolean">
  `true` for recurring (subscription) payments
</ResponseField>

<ResponseField name="save_card" type="boolean">
  Only included as `true` when the card was saved for future use
</ResponseField>

<ResponseField name="external_id" type="string">
  Merchant's order ID (the value specified when creating the payment)
</ResponseField>

<ResponseField name="product_name" type="string">
  Product name (the value specified when creating the payment)
</ResponseField>

<ResponseField name="customer_id" type="string">
  Customer ID (the value specified when creating the payment)
</ResponseField>

<ResponseField name="email" type="string">
  Customer's email address (the value specified when creating the payment)
</ResponseField>

<ResponseField name="tel" type="string">
  Customer's phone number (the value specified when creating the payment)
</ResponseField>

<ResponseField name="amount_refunded" type="string">
  Refunded amount (string format. `payment.refunded` event only)
</ResponseField>

<ResponseField name="error" type="object">
  Error details (`payment.failed` event only)

  <Expandable title="error properties">
    <ResponseField name="code" type="string">
      Unified error code (e.g., `card_declined`, `expired_card`, `insufficient_funds`)
    </ResponseField>

    <ResponseField name="category" type="string">
      Error category: `authentication`, `soft_decline`, `hard_decline`, `gateway_error`
    </ResponseField>

    <ResponseField name="message" type="string">
      Human-readable error message
    </ResponseField>

    <ResponseField name="recommended_action" type="string">
      Recommended action: `retry`, `contact_customer`, `use_different_card`, `none`
    </ResponseField>
  </Expandable>
</ResponseField>

<ResponseField name="card_brand" type="string">
  Card brand (`visa`, `mastercard`, `amex`, `jcb`, etc.)
</ResponseField>

<ResponseField name="card_last4" type="string">
  Last 4 digits of the card number
</ResponseField>

<ResponseField name="cardholder_name" type="string">
  Cardholder name
</ResponseField>

<ResponseField name="card_exp_month" type="number">
  Card expiration month
</ResponseField>

<ResponseField name="card_exp_year" type="number">
  Card expiration year
</ResponseField>

<ResponseField name="card_country" type="string">
  Card issuing country (ISO 3166-1 alpha-2 code, e.g., `US`, `JP`, `SG`). Only included when BIN lookup data is available.
</ResponseField>

<ResponseField name="card_funding" type="string">
  Card funding type (`credit`, `debit`, `prepaid`). Only included when BIN lookup data is available.
</ResponseField>

<ResponseField name="metadata" type="object">
  Metadata specified when creating the payment
</ResponseField>

<ResponseField name="created_at" type="string">
  Transaction creation timestamp (ISO 8601 format)
</ResponseField>

<ResponseField name="timestamp" type="string">
  Webhook sent timestamp (ISO 8601 format)
</ResponseField>

## Payload Examples

### payment.succeeded (Payment Successful)

```json theme={null}
{
  "event": "payment.succeeded",
  "transaction_id": "tx_abc123",
  "merchant_id": "acct_12345",
  "merchant_name": "Sample Store",
  "status": "succeeded",
  "amount": "100.00",
  "currency": "usd",
  "payment_method": "card",
  "payment_method_id": "pmi_abc123",
  "is_recurring": false,
  "save_card": true,
  "external_id": "order_12345",
  "product_name": "Premium Plan",
  "customer_id": "cust_12345",
  "email": "customer@example.com",
  "tel": "09012345678",
  "card_brand": "visa",
  "card_last4": "4242",
  "cardholder_name": "TARO YAMADA",
  "card_exp_month": 12,
  "card_exp_year": 2028,
  "card_country": "US",
  "card_funding": "credit",
  "metadata": {},
  "created_at": "2024-01-15T10:30:00Z",
  "timestamp": "2024-01-15T10:31:00Z"
}
```

<Note>
  * `payment_method_id` is only included when `save_card` was used or for recurring payments
  * `save_card` is only included as `true` when the card was saved in the initial payment
  * `is_recurring` is `true` for recurring payments
</Note>

### payment.failed (Payment Failed)

```json theme={null}
{
  "event": "payment.failed",
  "transaction_id": "tx_abc123",
  "merchant_id": "acct_12345",
  "merchant_name": "Sample Store",
  "status": "failed",
  "amount": "100.00",
  "currency": "usd",
  "payment_method": "card",
  "is_recurring": false,
  "external_id": "order_12345",
  "product_name": "Premium Plan",
  "customer_id": "cust_12345",
  "email": "customer@example.com",
  "tel": "09012345678",
  "error": {
    "code": "card_declined",
    "category": "soft_decline",
    "message": "Your card was declined.",
    "recommended_action": "use_different_card"
  },
  "card_brand": "visa",
  "card_last4": "4242",
  "cardholder_name": "TARO YAMADA",
  "card_exp_month": 12,
  "card_exp_year": 2028,
  "card_country": "US",
  "card_funding": "credit",
  "metadata": {},
  "created_at": "2024-01-15T10:30:00Z",
  "timestamp": "2024-01-15T10:31:00Z"
}
```

<Note>
  The `error` object is only included in `payment.failed` events. Use `recommended_action` to determine the appropriate response (`retry`, `contact_customer`, `use_different_card`, or `none`).
</Note>

### payment.canceled (Payment Canceled)

```json theme={null}
{
  "event": "payment.canceled",
  "transaction_id": "tx_abc123",
  "merchant_id": "acct_12345",
  "merchant_name": "Sample Store",
  "status": "canceled",
  "amount": "100.00",
  "currency": "usd",
  "payment_method": "card",
  "is_recurring": false,
  "external_id": "order_12345",
  "product_name": "Premium Plan",
  "customer_id": "cust_12345",
  "email": "customer@example.com",
  "tel": "09012345678",
  "card_brand": "visa",
  "card_last4": "4242",
  "cardholder_name": "TARO YAMADA",
  "card_exp_month": 12,
  "card_exp_year": 2028,
  "card_country": "US",
  "card_funding": "credit",
  "metadata": {},
  "created_at": "2024-01-15T10:30:00Z",
  "timestamp": "2024-01-15T10:32:00Z"
}
```

<Note>
  `payment.canceled` fires in two cases:

  1. **Merchant void** — `POST /v1/payments/{id}/void` was called to void an authorized/captured payment before settlement.
  2. **Customer cancel during 3DS** — the customer clicked "Cancel Payment" on the hosted 3DS challenge page. The checkout link becomes immediately reusable; the customer can retry with a different card using the same payment link.

  Both cases send the same event shape. If you need to distinguish them, use `GET /v1/payments/{id}` and inspect the transaction's prior state, or rely on your own state tracking.
</Note>

### payment.refunded (Refund Completed)

```json theme={null}
{
  "event": "payment.refunded",
  "transaction_id": "tx_abc123",
  "merchant_id": "acct_12345",
  "merchant_name": "Sample Store",
  "status": "refunded",
  "amount": "100.00",
  "currency": "usd",
  "payment_method": "card",
  "is_recurring": false,
  "external_id": "order_12345",
  "product_name": "Premium Plan",
  "customer_id": "cust_12345",
  "email": "customer@example.com",
  "tel": "09012345678",
  "amount_refunded": "50.00",
  "card_brand": "visa",
  "card_last4": "4242",
  "cardholder_name": "TARO YAMADA",
  "card_exp_month": 12,
  "card_exp_year": 2028,
  "card_country": "US",
  "card_funding": "credit",
  "metadata": {},
  "created_at": "2024-01-15T10:30:00Z",
  "timestamp": "2024-01-20T14:00:00Z"
}
```

<Note>
  The `amount_refunded` field is only included in `payment.refunded` events. For partial refunds, it shows the refunded amount.
</Note>

### payment.chargeback (Chargeback Occurred)

```json theme={null}
{
  "event": "payment.chargeback",
  "transaction_id": "tx_abc123",
  "merchant_id": "acct_12345",
  "merchant_name": "Sample Store",
  "status": "chargeback",
  "amount": "100.00",
  "currency": "usd",
  "payment_method": "card",
  "is_recurring": false,
  "external_id": "order_12345",
  "product_name": "Premium Plan",
  "customer_id": "cust_12345",
  "email": "customer@example.com",
  "tel": "09012345678",
  "card_brand": "visa",
  "card_last4": "4242",
  "cardholder_name": "TARO YAMADA",
  "card_exp_month": 12,
  "card_exp_year": 2028,
  "card_country": "US",
  "card_funding": "credit",
  "metadata": {},
  "created_at": "2024-01-15T10:30:00Z",
  "timestamp": "2024-02-01T09:00:00Z"
}
```

<Warning>
  When a chargeback occurs, the transaction amount may be debited from the merchant. Please respond promptly.
</Warning>

## Signature Verification

Webhook requests include a signature header. Verify this signature using the endpoint's Webhook Secret to confirm the request was sent by ZAFA PAY.

### Signature Header

| Environment | Header Name                   |
| ----------- | ----------------------------- |
| Sandbox     | `X-Zafapay-Signature-Sandbox` |
| Production  | `X-Zafapay-Signature`         |

### Verification Method

The signature is an HMAC-SHA256 hash of the request body (JSON string), using the endpoint's Webhook Secret as the key.

```javascript Node.js theme={null}
const crypto = require('crypto');

function verifyWebhookSignature(payload, signature, secret) {
  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(JSON.stringify(payload))
    .digest('hex');

  return signature === expectedSignature;
}

// Express.js example
app.post('/webhooks/zafapay', express.json(), (req, res) => {
  const signature = req.headers['x-zafapay-signature-sandbox'];
  // Use the Webhook Secret for this endpoint (whsec_xxx format)
  const secret = process.env.ZAFAPAY_WEBHOOK_SECRET;

  if (!verifyWebhookSignature(req.body, signature, secret)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  // Process event
  const { event, transaction_id, status } = req.body;

  switch (event) {
    case 'payment.succeeded':
      // Handle successful payment
      break;
    case 'payment.failed':
      // Handle failed payment
      break;
    case 'payment.refunded':
      // Handle refund
      break;
  }

  res.json({ received: true });
});
```

## Response

Return HTTP status code `2xx` when the webhook is successfully received.

```json theme={null}
{
  "received": true
}
```

## Retry

ZAFA PAY automatically retries webhooks in the following cases:

* HTTP status code other than `2xx` is returned
* Connection timeout occurs (10 seconds)

### Retry Schedule

| Attempt | Delay After Failure |
| ------- | ------------------- |
| 1st     | Immediate           |
| 2nd     | 1 minute later      |
| 3rd     | 5 minutes later     |
| 4th     | 30 minutes later    |
| 5th     | 2 hours later       |
| 6th     | 6 hours later       |

A total of 6 retries are executed over approximately 9 hours. If all retries fail, the event is moved to a Dead Letter Queue (DLQ) for manual intervention.

<Tip>
  To prevent duplicate notifications from retries, ensure idempotency using `transaction_id` as the key.
</Tip>

## Best Practices

<Steps>
  <Step title="Always Verify Signature">
    Signature verification is essential to prevent unauthorized requests
  </Step>

  <Step title="Ensure Idempotency">
    The same event may be sent multiple times. Use `transaction_id` as a key to prevent duplicate processing
  </Step>

  <Step title="Return Response Quickly">
    Process webhooks asynchronously and return `200` immediately. Long processing times will cause timeout retries
  </Step>

  <Step title="Log Errors">
    Log received payloads and processing results for debugging
  </Step>
</Steps>
