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

# Payment Webhooks

Payment webhook events track the lifecycle of a payment from initiation to completion or failure.

## Events

| Event | Description |
|-------|-------------|
| `payment.success` | Payment completed successfully |
| `payment.failed` | Payment failed |
| `payment.pending` | Payment is pending |
| `payment.cancelled` | Payment was cancelled |

## Payload

```json
{
  "event_type": "payment.success",
  "payment_data": {
    "merchant_id": "m_abc123",
    "payment_id": "pi_xyz789",
    "transaction_id": "txn_def456",
    "order_id": "pi_xyz789",
    "amount": 100.00,
    "currency": "USD",
    "status": "succeeded",
    "payment_method": "credit_card",
    "processed_at": "2026-02-28T10:30:00.000Z"
  }
}
```

For failed payments, an additional `failure_reason` field is included in
`payment_data` along with an operational `code` (see [Operational Errors](../../rest-api/errors#operational-errors))
that maps to a friendly customer-facing message.

```json
{
  "event_type": "payment.failed",
  "payment_data": {
    "merchant_id": "m_abc123",
    "payment_id": "pi_xyz789",
    "transaction_id": "txn_def456",
    "amount": 100.00,
    "currency": "USD",
    "status": "failed",
    "code": "TSP_PAYMENT_FAILED",
    "failure_reason": "Card declined by issuer",
    "processed_at": "2026-02-28T10:30:00.000Z"
  }
}
```

## Payment Callback Lifecycle

Treat payment events as a state machine:

- `payment.pending` means the payment is still in progress.
- `payment.success` is the final success signal.
- `payment.failed` is an explicit final failure signal.

:::important Incomplete payments can remain pending
Some payment channels keep incomplete orders in `processing`/`pending` until a terminal success is received. In these cases, your server may receive repeated or unchanged pending updates and may not receive an automatic failure callback.

Your integration should:
- keep the order as pending while waiting for a terminal event,
- mark success only on `payment.success`,
- mark failed only when `payment.failed` is explicitly delivered.
:::

:::note Recommended merchant handling
If no terminal callback arrives yet, continue showing the payment as pending/in progress. Do not auto-fail based only on timeout unless your own business timeout policy requires it.
:::

## Real-time WebSocket events

If you embed the ZenPays checkout (or subscribe to its `/payment-status`
namespace directly), you'll also see these socket events alongside the HTTP
webhooks above. They drive the live status UI in the customer's browser.

| Event | When fired | Notable fields |
|-------|------------|----------------|
| `payment-update` | Generic status transition | `status`, optionally `rampOrderStatus`, `kycStatus` |
| `payment-success` | Intent moved to `succeeded` | `transactionId`, `timestamp` |
| `payment-failed` | Intent moved to terminal failed state | `code`, `error`, `timestamp` — the `code` is one of the [operational error codes](../../rest-api/errors#operational-errors) (e.g., `TSP_PAYMENT_FAILED`, `PROVIDER_UNAVAILABLE`, `PAYMENT_STUCK`). Use it to pick a friendly message; never display the raw `error` string. |
| `payment-stuck` | Intent has been in `processing` past ~90s with no transitions | `code`, `message`, `status: 'processing'` — **informational only**. The intent is still alive. Use this to swap a generic spinner for a "this is taking longer than usual" message; do NOT treat it as a terminal failure. |

### Example: handling `payment-failed` with codes

```typescript
socket.on('payment-failed', (data: { intentId: string; code?: string; error?: string }) => {
  switch (data.code) {
    case 'NO_TSP_AVAILABLE':
    case 'UNSUPPORTED_REGION':
      showMessage("This payment route isn't available right now.");
      break;
    case 'UNSUPPORTED_CURRENCY_PAIR':
      showMessage("This currency combination isn't supported.");
      break;
    case 'TSP_PAYMENT_FAILED':
      showMessage("Your payment couldn't be completed. Please try a different method.");
      break;
    case 'PAYMENT_STUCK':
      // Hard timeout — friendly but explicit
      showMessage("We tried but couldn't complete this payment. Please try again later.");
      break;
    default:
      showMessage("Something went wrong. Please try again or contact support.");
  }
});
```

### Example: handling `payment-stuck`

```typescript
socket.on('payment-stuck', (data: { intentId: string; code: string; message: string }) => {
  // Swap the generic spinner for a friendlier "still working" UI.
  // Do NOT mark the payment as failed — it's still in progress.
  showWaitingCard({
    title: "This is taking longer than usual",
    description: "We're still trying. Feel free to wait or check back in your dashboard.",
  });
});
```
