Webhook Events
ZoPay sends webhook notifications for 6 different event types. This page describes each event in detail, including when they fire and what data they contain.
Required Events: All 6 events listed below must be subscribed to when registering a webhook endpoint. You cannot subscribe to a subset of events.
Event Structure
All webhook events follow the same structure:
1{
2 "event": "payment.succeeded",
3 "data": {
4 // Event-specific data
5 },
6 "timestamp": "2026-01-18T23:30:00.000Z"
7}Event Types
1. payment.succeeded
Fired when a collection transaction completes successfully. This means money has been received from the customer.
When It Fires
- Collection transaction status changes to
SUCCESS - Payment is confirmed by the gateway
- Funds are available in merchant wallet
Payload
1{
2 "event": "payment.succeeded",
3 "data": {
4 "transaction_id": "550e8400-e29b-41d4-a716-446655440000",
5 "merchant_id": "merchant-uuid",
6 "amount": "10000",
7 "currency": "XAF",
8 "gateway": "MTN_MOMO",
9 "customer_msisdn": "237670000000",
10 "status": "SUCCESS",
11 "fees": "250",
12 "net_amount": "9750",
13 "created_at": "2026-01-18T23:30:00.000Z"
14 },
15 "timestamp": "2026-01-18T23:30:00.000Z"
16}Data Fields
| Field | Type | Description |
|---|---|---|
transaction_id | string | Unique transaction identifier |
merchant_id | string | Your merchant ID |
amount | string | Transaction amount (in smallest currency unit) |
currency | string | Currency code (e.g., "XAF") |
gateway | string | Payment gateway used (e.g., "MTN_MOMO") |
customer_msisdn | string | Customer phone number (E.164 format) |
fees | string | Total fees charged |
net_amount | string | Net amount after fees |
2. payment.failed
Fired when a collection transaction fails. This could be due to insufficient funds, network issues, or other gateway errors.
When It Fires
- Collection transaction status changes to
FAILED - Gateway rejects the payment
- Payment timeout occurs
Payload
1{
2 "event": "payment.failed",
3 "data": {
4 "transaction_id": "550e8400-e29b-41d4-a716-446655440000",
5 "merchant_id": "merchant-uuid",
6 "amount": "10000",
7 "currency": "XAF",
8 "gateway": "MTN_MOMO",
9 "customer_msisdn": "237670000000",
10 "status": "FAILED",
11 "failure_reason": "Insufficient funds",
12 "created_at": "2026-01-18T23:30:00.000Z"
13 },
14 "timestamp": "2026-01-18T23:30:00.000Z"
15}Additional Fields
failure_reason- Human-readable reason for failure
3. payout.completed
Fired when a disbursement/payout transaction completes successfully. This means money has been sent to the recipient.
When It Fires
- Payout transaction status changes to
SUCCESS - Funds are successfully sent to recipient
- Gateway confirms the payout
Payload
1{
2 "event": "payout.completed",
3 "data": {
4 "payout_id": "660e8400-e29b-41d4-a716-446655440001",
5 "merchant_id": "merchant-uuid",
6 "amount": "5000",
7 "currency": "XAF",
8 "gateway": "MTN_MOMO",
9 "recipient_msisdn": "237671111111",
10 "status": "SUCCESS",
11 "fees": "100",
12 "total_deducted": "5100",
13 "created_at": "2026-01-18T23:30:00.000Z"
14 },
15 "timestamp": "2026-01-18T23:30:00.000Z"
16}Data Fields
payout_id- Unique payout identifierrecipient_msisdn- Recipient phone numbertotal_deducted- Total amount deducted from wallet (amount + fees)
4. payout.failed
Fired when a disbursement/payout transaction fails. This could be due to invalid recipient, network issues, or insufficient wallet balance.
When It Fires
- Payout transaction status changes to
FAILED - Gateway rejects the payout
- Insufficient wallet balance
Payload
1{
2 "event": "payout.failed",
3 "data": {
4 "payout_id": "660e8400-e29b-41d4-a716-446655440001",
5 "merchant_id": "merchant-uuid",
6 "amount": "5000",
7 "currency": "XAF",
8 "gateway": "MTN_MOMO",
9 "recipient_msisdn": "237671111111",
10 "status": "FAILED",
11 "failure_reason": "Invalid recipient",
12 "created_at": "2026-01-18T23:30:00.000Z"
13 },
14 "timestamp": "2026-01-18T23:30:00.000Z"
15}5. refund.completed
Fired when a refund is successfully processed. This means money has been returned to the customer.
When It Fires
- Refund transaction status changes to
SUCCESS - Refund is confirmed by the gateway
- Funds are returned to customer
Payload
1{
2 "event": "refund.completed",
3 "data": {
4 "refund_id": "770e8400-e29b-41d4-a716-446655440002",
5 "transaction_id": "550e8400-e29b-41d4-a716-446655440000",
6 "merchant_id": "merchant-uuid",
7 "amount": "10000",
8 "currency": "XAF",
9 "status": "SUCCESS",
10 "created_at": "2026-01-18T23:30:00.000Z"
11 },
12 "timestamp": "2026-01-18T23:30:00.000Z"
13}Data Fields
refund_id- Unique refund identifiertransaction_id- Original transaction that was refunded
6. settlement.generated
Fired when a new settlement is generated. Settlements represent periodic transfers of funds to merchants.
When It Fires
- A new settlement is created
- Settlement period ends
- Settlement is ready for processing
Payload
1{
2 "event": "settlement.generated",
3 "data": {
4 "settlement_id": "880e8400-e29b-41d4-a716-446655440003",
5 "merchant_id": "merchant-uuid",
6 "amount": "50000",
7 "currency": "XAF",
8 "period_start": "2026-01-01T00:00:00.000Z",
9 "period_end": "2026-01-07T23:59:59.999Z",
10 "transaction_count": 125,
11 "created_at": "2026-01-18T23:30:00.000Z"
12 },
13 "timestamp": "2026-01-18T23:30:00.000Z"
14}Data Fields
settlement_id- Unique settlement identifierperiod_start- Start of settlement periodperiod_end- End of settlement periodtransaction_count- Number of transactions in settlement
HTTP Headers
All webhook requests include the following headers:
| Header | Description | Example |
|---|---|---|
X-Zo-Event | The event type | payment.succeeded |
X-Zo-Delivery-Id | Unique delivery ID (for idempotency) | delivery-uuid-123 |
X-Zo-Timestamp | Timestamp in milliseconds | 1769164241000 |
X-Zo-Signature | HMAC-SHA256 signature (hex only, no prefix) | a1b2c3d4e5f6... |
X-Zo-Replay | "true" if replayed (optional) | true |
Content-Type | Always application/json | application/json |
Handling Events
Your webhook handler should follow these steps:
- Verify the webhook signature using your webhook secret (see Security)
- Check the timestamp to prevent replay attacks (reject if older than 5 minutes)
- Check delivery ID for idempotency (prevent duplicate processing)
- Parse the event type from
X-Zo-Eventheader oreventfield - Process the event data based on type
- Return HTTP 200 within 30 seconds (process asynchronously if needed)
- Handle errors gracefully - don't let processing errors crash your server
Critical: Always Verify Signature First
Never process a webhook without verifying the signature first. This ensures the request is from ZoPay and hasn't been tampered with.
Event Processing Example
Here's a simple example of processing webhook events:
1app.post('/webhooks/zopay', async (req, res) => {
2 // 1. Verify signature (see Security page)
3 if (!verifySignature(req)) {
4 return res.status(401).send('Invalid signature');
5 }
6
7 const { event, data } = req.body;
8
9 // 2. Process based on event type
10 switch (event) {
11 case 'payment.succeeded':
12 await handlePaymentSuccess(data);
13 break;
14 case 'payment.failed':
15 await handlePaymentFailure(data);
16 break;
17 case 'payout.completed':
18 await handlePayoutSuccess(data);
19 break;
20 case 'payout.failed':
21 await handlePayoutFailure(data);
22 break;
23 case 'refund.completed':
24 await handleRefundCompleted(data);
25 break;
26 case 'settlement.generated':
27 await handleSettlementGenerated(data);
28 break;
29 default:
30 console.log('Unknown event:', event);
31 }
32
33 // 3. Respond quickly
34 res.status(200).send('OK');
35});
36
37async function handlePaymentSuccess(data) {
38 // Update order status in database
39 await db.orders.update({
40 where: { transactionId: data.transaction_id },
41 data: { status: 'paid' }
42 });
43
44 // Send confirmation email
45 await sendEmail(data.customer_msisdn, 'Payment confirmed');
46}Idempotency
Webhooks may be delivered more than once. Always make your handlers idempotent by checking if you've already processed the event:
1async function handlePaymentSuccess(data) {
2 // Check if already processed
3 const existing = await db.webhookLogs.findUnique({
4 where: { transactionId: data.transaction_id }
5 });
6
7 if (existing) {
8 console.log('Already processed:', data.transaction_id);
9 return; // Skip duplicate
10 }
11
12 // Process the event
13 await db.orders.update({
14 where: { transactionId: data.transaction_id },
15 data: { status: 'paid' }
16 });
17
18 // Log the webhook
19 await db.webhookLogs.create({
20 data: { transactionId: data.transaction_id }
21 });
22}Next Steps
- Learn how to Register a Webhook Endpoint
- Understand Webhook Security and signature verification
- View Webhooks Overview for general information