WaafipaydocsWaafiPay

Webhooks

Webhooks are HTTP callbacks that send real-time transaction notifications to your server. When a payment reaches a final outcome, WaafiPay posts the transaction details to your registered endpoint, so you do not need to poll.

Current Support: Webhooks are sent for Hosted Payment Page (HPP) payments, direct mobile-wallet API purchases, refunds, and purchase reversals. Deliveries that do not get a 2xx response are retried automatically (see Delivery and Retries).

Best Practice: Verify the signature, ignore repeated deliveries using X-Webhook-Event-Id, respond 200 OK quickly, and process the event afterwards.

Note: This page was updated in October 2026 to match current behaviour. If you integrated against an earlier version of this page, read What changed on this page first.


Quick Start

How It Works

  1. Register a webhook URL → 2. Receive test ping → 3. Transaction reaches a final outcome → 4. Receive webhook → 5. Verify signature → 6. Check event + payment.status → 7. Take action → 8. Return 200 OK

Understanding the Payload

Every webhook has two key fields:

  • event: What happened (authorization, declined, refund, void, or webhook.test)
  • payment.status: The transaction outcome (APPROVED, DECLINED, FAILED, CANCELLED)
{
  "event": "authorization",
  "payment": {
    "status": "APPROVED",
    "amount": 100.50,
    "reference_id": "ORDER-123"
  }
}

1. Register a Webhook

You can register a default webhook URL to receive all events. Optionally, you can register a separate URL for authorization or refund events using the eventType parameter. A merchant can have at most two webhooks, and only one per event type (the default webhook counts as one).

The URL must be a public HTTPS address with a domain name.

In all API requests, you must include the HPP credentials: merchantUid, storeId, hppKey.

Request:

POST /asm
{
    "schemaVersion": "1.0",
    "requestId": "{{$guid}}",
    "timestamp": "{{$timestamp}}",
    "channelName": "WEB",
    "serviceName": "WEBHOOK_REGISTER",
    "serviceParams": {
        "merchantUid": {{MERCHANT_UID}},
        "storeId": {{STORE_ID}},
        "hppKey": {{HPP_KEY}},
        "url": "https://api.example.com/webhook",
        "eventType": "refund",
        "description": "a description"
    }
}

Request Parameters

ParameterData TypePresenceDescription
schemaVersionStringConstantAPI schema version (e.g., "1.0").
requestIdUUID StringRequiredUnique request identifier (e.g., UUID).
timestampStringRequiredDate and time of the request.
channelNameStringConstantThe channel through which the request was made.
serviceNameStringConstantThe name of the service being called (e.g., WEBHOOK_REGISTER).
merchantUidStringRequiredUnique identifier provided by WaafiPay upon merchant account setup.
storeIdStringRequiredAPI store/user identifier for the merchant.
hppKeyStringRequiredAPI key for request authentication.
partnerUidStringOptionalWAAFI Merchant Number (PartnerUID).
descriptionStringOptionalDescription of your webhook usage.
urlStringRequiredHTTPS URL the webhooks are posted to. Maximum 255 characters.
eventTypeStringOptionalauthorization or refund to send only that event to this URL. Leave empty for the default webhook, which receives every other event.

Response:

{
    "schemaVersion": "1.0",
    "timestamp": "2024-02-08 04:38:42.938",
    "responseId": "{{$guid}}",
    "responseCode": "2001",
    "errorCode": "0",
    "responseMsg": "Webhook created successfully",
    "params": {
        "secret": "your_secret",
        "webhookId": 25,
        "eventType": "refund",
        "message": "New webhook created successfully"
    }
}

Note: Store your secret securely—do not commit it to source control. It is returned only when the webhook is created, and again only if you later change the URL. This secret is used to verify the authenticity of incoming webhook requests.

Registration fails if the test ping (see Test Your Webhook) does not get an HTTP 200 from your URL, or if a webhook already exists for the same event type.

Registering Event-Specific Webhooks

Each event is sent to the webhook registered for that event name. If there is none, it is sent to the default webhook. If you register only a refund webhook and no default webhook, authorization, declined and void events are not delivered.

Example: Register default webhook (all events)

{
    "serviceParams": {
        "merchantUid": {{MERCHANT_UID}},
        "storeId": {{STORE_ID}},
        "hppKey": {{HPP_KEY}},
        "url": "https://api.example.com/webhook"
    }
}

Example: Register refund-specific webhook

{
    "serviceParams": {
        "merchantUid": {{MERCHANT_UID}},
        "storeId": {{STORE_ID}},
        "hppKey": {{HPP_KEY}},
        "url": "https://api.example.com/refund-webhook",
        "eventType": "refund"
    }
}

2. Get All Webhooks

Retrieve a list of all existing webhooks. The secret is never returned by this call.

POST Request

POST /asm
{
    "schemaVersion": "1.0",
    "requestId": "{{$guid}}",
    "timestamp": "{{$timestamp}}",
    "channelName": "WEB",
    "serviceName": "WEBHOOK_LIST",
    "serviceParams": {
        "merchantUid": "{{MERCHANT_UID}}",
        "storeId": {{STORE_ID}},
        "hppKey": "{{HPP_KEY}}"
    }
}

Response

{
    "params": {
        "data": [
            {
                "id": "25",
                "url": "https://app.sample.com/webhook",
                "description": "testing",
                "merchantId": "10165",
                "partnerUid": "400394"
            }
        ]
    }
}

3. Update Webhook

You can update the url and description of an existing webhook.

When the URL changes, the new URL receives a test ping and a new secret is generated and returned in params.secret. The old secret stops working immediately.

POST Request

POST /asm
{
    "schemaVersion": "1.0",
    "requestId": "{{$guid}}",
    "timestamp": "{{$timestamp}}",
    "channelName": "WEB",
    "serviceName": "WEBHOOK_UPDATE",
    "serviceParams": {
        "merchantUid": "{{MERCHANT_UID}}",
        "storeId": {{STORE_ID}},
        "hppKey": "{{HPP_KEY}}",
        "url": "https://api.sample.so/webhook/v2",
        "webhookId": 10,
        "description": "another description"
    }
}

4. Delete Webhook

Remove an existing webhook. Its events are then sent to your default webhook, if you have one; otherwise they are no longer delivered.

POST Request

POST /asm
{
    "schemaVersion": "1.0",
    "requestId": "{{$guid}}",
    "timestamp": "{{$timestamp}}",
    "channelName": "WEB",
    "serviceName": "WEBHOOK_DELETE",
    "serviceParams": {
        "merchantUid": "{{MERCHANT_UID}}",
        "storeId": {{STORE_ID}},
        "hppKey": "{{HPP_KEY}}",
        "webhookId": 10
    }
}

5. Test Your Webhook

When you register a webhook, or update its URL, WaafiPay sends a test request to validate that your endpoint is reachable and responding correctly. Registration or update fails if the ping does not succeed.

Test Webhook Payload

{
  "event": "webhook.test",
  "message": "WaafiPay webhook validation ping",
  "merchant_uid": "your-merchant-uid"
}

What to Expect

  • User-Agent: WPNotifyService-Test
  • Content-Type: application/json
  • Timeout: 1 second. Respond within 1 second.
  • Expected Response: exactly HTTP 200. Redirects count as a failure.

Test Event Details

FieldDescriptionExample
eventSpecial test event typewebhook.test
messageDescriptive message indicating this is a testWaafiPay webhook validation ping
merchant_uidYour merchant UIDM400394

Note: Test webhooks (webhook.test) are not signed and carry none of the X-Webhook-* headers. They are sent only during webhook registration and URL updates. Your webhook handler should recognize this event type and respond with 200 OK without requiring signature verification.

Example Implementation

app.post('/webhook', async (req, res) => {
  const { event } = req.body;
 
  // Handle test event
  if (event === 'webhook.test') {
    return res.status(200).send('OK');
  }
 
  // For real events, verify signature
  try {
    verifyWebhookSignature(req, process.env.WEBHOOK_SECRET);
    await handleEvent(req.body); // see "Handling Webhooks" below
    res.status(200).send('OK');
  } catch (error) {
    console.error('Webhook error:', error);
    res.status(400).send('Invalid webhook');
  }
});

Receiving a Webhook on Your Server

Once registered, your webhook URL receives transaction notifications. The payload has a fixed shape:

  • The event field tells you what happened (authorization, declined, refund, void)
  • The payment.status field tells you the transaction outcome (APPROVED, DECLINED, FAILED, CANCELLED)

Note: Optional fields are omitted from the JSON when they have no value rather than sent as null. Do not fail if an optional field is missing. Unknown fields may be added in the future; ignore what you do not use.

Authorization Transaction Example

{
  "event": "authorization",
  "customer_identity": "252612345678",
  "cardholder_name": "John Doe",
  "merchant_id": 10165,
  "merchant_uid": "M400394",
  "user_id": "400394",
  "payment": {
    "transaction_id": "1303630",
    "order_id": "6482913",
    "transfer_code": "ISR0011303630",
    "amount": 100.50,
    "currency": "USD",
    "payment_method": "MWALLET_ACCOUNT",
    "status": "APPROVED",
    "reference_id": "REF-2024-4567",
    "channel": "WEB",
    "description": "Payment for order REF-2024-4567",
    "date": "2025-08-13 15:04:05"
  }
}

Declined Transaction Example (customer cancelled on the payment page)

{
  "event": "declined",
  "merchant_id": 10165,
  "merchant_uid": "M400394",
  "user_id": "400394",
  "payment": {
    "transaction_id": "0",
    "order_id": "6482920",
    "amount": 100.50,
    "currency": "USD",
    "payment_method": "MWALLET_ACCOUNT",
    "status": "CANCELLED",
    "reference_id": "REF-2024-4570",
    "channel": "WEB",
    "description": "Payment for order REF-2024-4570",
    "date": "2025-08-13 15:09:41"
  }
}

Refund Transaction Example

{
  "event": "refund",
  "merchant_id": 10165,
  "user_id": "400394",
  "merchant_uid": "M400394",
  "payment": {
    "transaction_id": "1314550",
    "original_transaction_id": "1303630",
    "order_id": "6482913",
    "transfer_code": "ISR0011303630",
    "amount": 50.25,
    "original_amount": 100.50,
    "is_partial": true,
    "currency": "USD",
    "status": "APPROVED",
    "reference_id": "REFUND-2024-8901",
    "channel": "WEB",
    "description": "Refund of Tran: 1303630",
    "date": "2025-08-14 10:30:00"
  }
}

Void Transaction Example (purchase reversed)

{
  "event": "void",
  "merchant_id": 10165,
  "user_id": "400394",
  "merchant_uid": "M400394",
  "payment": {
    "transaction_id": "1314560",
    "original_transaction_id": "1303630",
    "order_id": "6482913",
    "transfer_code": "ISR0011303630",
    "amount": 100.50,
    "currency": "USD",
    "status": "CANCELLED",
    "reference_id": "REF-2024-4567",
    "channel": "WEB",
    "description": "Cancellation: 1303630",
    "date": "2025-08-14 11:02:17"
  }
}

Webhook Fields Reference

FieldDescriptionExamplePresence
eventWhat happenedauthorization, declined, refund, voidAlways
merchant_idMerchant ID (number)10165Always
merchant_uidMerchant UID (string)M400394Always
user_idWaafiPay user ID of the account that owns the transaction, as a string"400394"Optional
request_idYour requestId from the API call that triggered the eventb0c1...Optional; refunds only
customer_identityMasked card number or customer wallet number252612345678, 411111******1111Optional
cardholder_nameCustomer or cardholder nameJohn DoeOptional
payment.transaction_idWaafiPay transaction ID, as a string. For refund and void it is the ID of the refund or reversal. "0" when no transaction was created, for example a cancelled payment page or a declined refund."1303630"Always
payment.original_transaction_idFor refund and void: the transaction that was refunded or reversed"1303630"refund, void
payment.order_idWaafiPay HPP order ID (number as a string). This is not your reference; see reference_id."6482913"Optional
payment.transfer_codePayment provider reference for the transactionISR0011303630Optional
payment.amountTransaction amount (number). For refund it is the refunded amount.100.50Always
payment.original_amountFor refund: the amount of the original transaction100.50refund
payment.is_partialFor refund: true when the refund is for less than the original amounttruerefund
payment.currencyISO currency codeUSD, DJFAlways
payment.payment_methodPayment method usedMWALLET_ACCOUNT, MWALLET_BANKACCOUNT, CREDIT_CARD, DEBIT_CARDOptional
payment.feesFees charged on the transaction (number). Omitted when zero.2.50Optional
payment.statusTransaction outcomeAPPROVED, DECLINED, FAILED, CANCELLEDAlways
payment.reference_idYour reference ID (referenceId you sent when creating the payment or refund)REF-2024-4567Optional
payment.channelChannel the payment was made onWEB (HPP), APIOptional
payment.descriptionTransaction descriptionPayment for order...Optional
payment.dateTransaction time, YYYY-MM-DD HH:MM:SS, East Africa Time (UTC+3), no time zone suffix2025-08-13 15:04:05Always
payment.token_referenceRarely present. Can be ignored.12345Rare

Understanding Events and Status

Events

EventDescriptionWhen It's SentHMAC Signed
webhook.testWebhook validation testWhen you register a webhook or change its URLNo
authorizationResult of a purchaseWhen a customer completes a payment via HPP (card or mobile wallet), or when a direct API mobile-wallet purchase is approvedYes
declinedA purchase did not completeWhen a direct API mobile-wallet purchase is declined by the payment provider (DECLINED), or when the customer cancels on the hosted payment page (CANCELLED)Yes
refundResult of a refundWhen a full or partial refund is processed (APPROVED) or declined (DECLINED)Yes
voidA purchase was reversedWhen an approved purchase is reversed (CANCELLED)Yes

Approved payments and processed refunds always produce a webhook. Other outcomes do not always produce one: a decline or a cancellation on the payment page may not send a webhook, and a closed browser, an expired payment link or a timed-out payment page never does. If you have not received an authorization event for an order, query its status with HPP_GETTRANINFO (see HPP API). New event names may be added in the future. Return 200 OK for any event you do not recognise.

Status Values (Transaction Outcomes)

The payment.status field tells you what happened with the transaction:

StatusMeaningEventsWhat To Do
APPROVEDTransaction succeededauthorization, refundFulfil order / record the refund
DECLINEDDeclined by the payment provider or bank; or refund declinedauthorization, declined, refundCancel order and notify customer / log refund failure
FAILEDTransaction failed due to a technical errorauthorizationCancel order; log and contact support if it repeats
CANCELLEDCustomer cancelled on the payment page (declined), or an approved purchase was reversed (void)declined, voidCancel order / release goods and record the reversal

Spelling and case. The status is CANCELLED with two Ls. Compare status values case-insensitively. Treat anything other than APPROVED as not approved.

Handling Webhooks

function handleEvent(body) {
  const { event, payment } = body;
  const status = (payment?.status || '').toUpperCase();
 
  switch (event) {
    case 'authorization':
      if (status === 'APPROVED') fulfillOrder(payment.reference_id, payment.transaction_id);
      else cancelOrder(payment.reference_id); // DECLINED, FAILED
      break;
    case 'declined':
      cancelOrder(payment.reference_id); // DECLINED or CANCELLED
      break;
    case 'refund':
      if (status === 'APPROVED') recordRefund(payment.original_transaction_id, payment.amount, payment.is_partial);
      else handleRefundFailure(payment.original_transaction_id);
      break;
    case 'void':
      recordReversal(payment.original_transaction_id);
      break;
    default:
      // Unknown or future event: acknowledge and ignore
      break;
  }
}

Always check both event and payment.status to determine the correct action, and key your own records on payment.reference_id (your reference) or payment.transaction_id, not on payment.order_id.


Delivery and Retries

  • Success is any 2xx response. Any other response, a connection failure or a timeout counts as a failed attempt.
  • Timeouts: 5 seconds to connect, 10 seconds to start responding, 30 seconds in total.
  • Retries: after a failed attempt we retry about 5 minutes later, then with increasing delays of up to 1 hour, up to 8 retries over about 2.5 to 5 hours. Retry timing is approximate. Every retry carries the same X-Webhook-Event-Id with a new X-Webhook-Timestamp and X-Webhook-Signature. After the last retry, delivery stops; contact support to have an event re-sent.
  • Events are not guaranteed to arrive in order. For example, a refund may arrive before a retried authorization.

Security: HMAC Signature Verification

All webhooks (except webhook.test) include an HMAC-SHA256 signature for verification. You must verify this signature to prevent fake webhook attacks.

How It Works

Signing String Format: {timestamp}.{event_id}.{raw_body_bytes}

  1. We compute HMAC-SHA256 of the signing string using your secret key
  2. We send the signature in the X-Webhook-Signature header
  3. You recompute the signature and compare it

Headers We Send

HeaderPurpose
User-Agent: WPNotifyServiceIdentifies the source of the webhook request
Content-Type: application/jsonIndicates the media type of the resource
X-Webhook-TimestampUnix timestamp (seconds) of this delivery attempt. Use it to reject old requests.
X-Webhook-Event-IdUnique per event and the same on every retry. Use it to ignore repeated deliveries.
X-Webhook-Signature-AlgAlways HMAC-SHA256
X-Webhook-SignatureLowercase hex digest of HMAC-SHA256 over the signing string
X-Trace-IDOptional delivery identifier. Include it when you report a delivery problem to support.

Verification Steps

  1. Capture raw body before JSON parsing
  2. Build signing string: {timestamp}.{event_id}.{raw_body_bytes}
  3. Compute HMAC-SHA256 using your secret key
  4. Compare signatures using constant-time comparison
  5. Validate timestamp (reject if >5 minutes old; retries always carry a new timestamp)
  6. Check event ID (if already processed, return 200 OK without processing again)
  7. Return 200 OK quickly (process the event afterwards if needed)

Critical: Always use the raw HTTP body bytes, not parsed JSON. Any whitespace changes will break verification.

Example Webhooks

Example 1: Authorization Webhook

Headers

X-Webhook-Timestamp: 1755045838
X-Webhook-Event-Id: 1151
X-Webhook-Signature-Alg: HMAC-SHA256
X-Webhook-Signature: 19797b9fe0eaaa99e4ad509d53f3c91d1bb16d56de20b2252a21260a31913838
Content-Type: application/json
User-Agent: WPNotifyService

Body

{
    "event": "authorization",
    "customer_identity": "252612345678",
    "cardholder_name": "Ahmed Hassan",
    "merchant_id": 10165,
    "merchant_uid": "M400394",
    "user_id": "400394",
    "payment": {
        "transaction_id": "1303630",
        "order_id": "6482913",
        "transfer_code": "ISR0011303630",
        "amount": 60.2,
        "currency": "USD",
        "payment_method": "MWALLET_ACCOUNT",
        "status": "APPROVED",
        "reference_id": "REF-2024-4567",
        "channel": "WEB",
        "description": "Payment for order REF-2024-4567",
        "date": "2025-08-12 17:59:15"
    }
}

Example 2: Refund Webhook

Headers

X-Webhook-Timestamp: 1729587478
X-Webhook-Event-Id: 1152
X-Webhook-Signature-Alg: HMAC-SHA256
X-Webhook-Signature: a3b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9
Content-Type: application/json
User-Agent: WPNotifyService

Body

{
    "event": "refund",
    "merchant_id": 10165,
    "user_id": "400394",
    "merchant_uid": "M400394",
    "payment": {
        "transaction_id": "1314550",
        "original_transaction_id": "1303630",
        "order_id": "6482913",
        "transfer_code": "ISR0011303630",
        "amount": 30.5,
        "original_amount": 60.2,
        "is_partial": true,
        "currency": "USD",
        "status": "APPROVED",
        "reference_id": "REFUND-2024-8901",
        "channel": "WEB",
        "description": "Refund of Tran: 1303630",
        "date": "2025-08-14 10:30:00"
    }
}

Implementation Guide

Complete Example

Here's a production-ready webhook handler:

const crypto = require('crypto');
 
function verifyWebhook(request, secret) {
  const timestamp = request.headers['x-webhook-timestamp'];
  const eventId = request.headers['x-webhook-event-id'];
  const signature = request.headers['x-webhook-signature'];
  const rawBody = request.rawBody; // Must be raw bytes, not parsed JSON
 
  // Check timestamp (reject if older than 5 minutes)
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - timestamp) > 300) {
    throw new Error('Webhook timestamp too old');
  }
 
  // Build signing string
  const signingString = `${timestamp}.${eventId}.${rawBody}`;
 
  // Compute HMAC
  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(signingString)
    .digest('hex');
 
  // Compare using constant-time comparison
  if (signature.length !== expectedSignature.length ||
      !crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature))) {
    throw new Error('Invalid signature');
  }
 
  return true;
}
 
// Track processed events to prevent duplicates (use a database in production)
const processedEvents = new Set();
 
app.post('/webhook', async (req, res) => {
  const { event, payment } = req.body;
  const eventId = req.headers['x-webhook-event-id'];
 
  try {
    // 1. Handle test events
    if (event === 'webhook.test') {
      return res.status(200).send('OK');
    }
 
    // 2. Verify signature
    verifyWebhook(req, process.env.WEBHOOK_SECRET);
 
    // 3. Check for duplicates (retries reuse the same event ID)
    if (processedEvents.has(eventId)) {
      return res.status(200).send('OK');
    }
 
    // 4. Process based on event and status
    const status = (payment?.status || '').toUpperCase();
    switch (event) {
      case 'authorization':
        if (status === 'APPROVED') await fulfillOrder(payment.reference_id, payment.transaction_id);
        else await cancelOrder(payment.reference_id);
        break;
      case 'declined':
        await cancelOrder(payment.reference_id);
        break;
      case 'refund':
        if (status === 'APPROVED') await recordRefund(payment.original_transaction_id, payment.amount, payment.is_partial);
        else await handleRefundFailure(payment.original_transaction_id);
        break;
      case 'void':
        await recordReversal(payment.original_transaction_id);
        break;
      default:
        break; // unknown event: acknowledge and ignore
    }
 
    // 5. Mark as processed
    processedEvents.add(eventId);
 
    // 6. Return success
    res.status(200).send('OK');
  } catch (error) {
    console.error('Webhook error:', error);
    res.status(400).send('Invalid webhook');
  }
});

Best Practices

  • Use HTTPS: Webhook URL must use HTTPS on a domain name
  • Store secret securely: Use environment variables, never commit to code
  • Return 200 quickly: Respond within a few seconds and process the event afterwards
  • Log everything: Keep webhook logs, including X-Webhook-Event-Id and X-Trace-ID
  • Prevent duplicate processing: Use event IDs; retries reuse the same ID
  • Handle test events: Respond to webhook.test within 1 second without signature verification
  • Ignore what you do not recognise: Ignore unknown events and fields, and do not fail when an optional field is missing
  • Do not rely on webhooks alone: Query HPP_GETTRANINFO for orders that never received an event

Common Issues

IssueSolution
Signature verification failsCapture raw HTTP body before parsing JSON
Webhook registration failsYour URL must answer the webhook.test ping with HTTP 200 within 1 second, over HTTPS, without a redirect
Webhooks not receivedEnsure URL is publicly accessible; check that a default webhook exists for events without a dedicated URL
Duplicate processingTrack event IDs using X-Webhook-Event-Id
Timeout errorsReturn 200 OK immediately and process the event afterwards
Replay attacksValidate timestamp (reject if >5 minutes old)
No webhook for an abandoned paymentExpected; query HPP_GETTRANINFO

What changed on this page

The previous version of this page described some values that WaafiPay never sent. The behaviour has not changed for the items below; the documentation was corrected.

TopicPreviously documentedActual behaviour
Cancelled statusCANCELEDCANCELLED (two Ls). WaafiPay has always sent CANCELLED.
EXPIRED and TIMEOUT statusesListed as webhook statusesNever sent. Expired or timed-out payments produce no webhook; query HPP_GETTRANINFO.
Eventsauthorization, refund onlydeclined and void are also sent (see Events). Unknown events must be acknowledged with 200 OK.
Retries"Failed deliveries are not automatically retried"Failed deliveries are retried up to 8 times over several hours with the same X-Webhook-Event-Id.
Test ping timeout5 seconds1 second
payment.order_idShown as your order referenceWaafiPay HPP order ID. Your reference is payment.reference_id.
Refund fieldsNot documentedoriginal_transaction_id, original_amount, is_partial
Update webhookOnly url/descriptionChanging the URL also rotates the secret, which is returned in the response