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
{
"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)
that maps to a friendly customer-facing message.
{
"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.pendingmeans the payment is still in progress.payment.successis the final success signal.payment.failedis an explicit final failure signal.
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.failedis explicitly delivered.
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 (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
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
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.",
});
});