WaafipaydocsWaafiPay

Webhooks

Webhooks are HTTP callbacks that send real-time transaction notifications to your server. When a transaction completes, WaafiPay automatically sends the transaction details to your registered endpoint, eliminating the need for polling.

Current Support: Webhooks are available for authorization and refund transactions via the Hosted Payment Page (HPP). Failed deliveries are not automatically retried.

Best Practice: Implement proper logging and error handling for webhook events to ensure you don't miss critical transaction updates.


Quick Start

How It Works

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

Understanding the Payload

Every webhook has two key fields:

  • event: Transaction type (authorization, refund, or webhook.test)
  • payment.status: Outcome (APPROVED, FAILED, DECLINED, CANCELED, EXPIRED, TIMEOUT)
{
  "event": "authorization",
  "payment": {
    "status": "APPROVED",
    "amount": 100.50,
    "reference_id": "ORDER-123"
  }
}

1. Register a Webhook

You can only register one webhook URL to receive payment notifications. The provided URL should be publicly accessible.

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",
        "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.
urlStringRequiredURL endpoint for webhook to be sent to. Only one webhook endpoint is allowed per merchant.

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"
    }
}

Note: Store your secret securely—do not commit it to source control. We only provide it once during webhook registration. This secret is used to verify the authenticity of incoming webhook requests.


2. Get All Webhooks

Retrieve a list of all existing webhooks.

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",
                "merchantName": "WaafiPay Store"
            }
        ]
    }
}

3. Update Webhook

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

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.

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, WaafiPay automatically sends a test request to validate that your endpoint is reachable and responding correctly.

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: 5 seconds
  • Expected Response: 200 OK

Test Event Details

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

Note: Test webhooks (webhook.test) are not signed with HMAC signatures. They are sent only during webhook registration to verify endpoint availability. Your webhook handler should recognize this special 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') {
    console.log('Received webhook test ping');
    return res.status(200).send('OK');
  }
 
  // For real events, verify signature
  try {
    verifyWebhookSignature(req, process.env.WEBHOOK_SECRET);
 
    // Process authorization or refund events
    if (event === 'authorization') {
      await handleAuthorization(req.body);
    } else if (event === 'refund') {
      await handleRefund(req.body);
    }
 
    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 will receive transaction notifications. The webhook payload structure is simple:

  • The event field tells you the transaction type (authorization or refund)
  • The payment.status field tells you the transaction outcome (approved, failed, etc.)

Note: The payload structure may differ between authorization and refund events, and some fields may be optional. See the field reference table below for details on which fields are present in each event type.

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": "ORD-2024-001",
    "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 ORD-2024-001",
    "date": "2025-08-13 15:04:05"
  }
}

Refund Transaction Example

{
  "event": "refund",
  "merchant_id": 10165,
  "user_id": "400394",
  "merchant_uid": "M400394",
  "payment": {
    "transaction_id": "1314550",
    "transfer_code": "ISR0011314550",
    "amount": 50.25,
    "currency": "USD",
    "status": "APPROVED",
    "reference_id": "REFUND-2024-8901",
    "date": "2025-08-14 10:30:00"
  }
}

Webhook Fields Reference

FieldDescriptionExampleAuthorizationRefund
eventTransaction typeauthorization, refund
merchant_idMerchant ID (numeric)10165
merchant_uidMerchant UID (string)M400394
user_idUser ID400394
customer_identityCustomer phone/card number252612345678-
cardholder_nameCustomer nameJohn Doe✓ (optional)-
payment.transaction_idTransaction ID1303630
payment.order_idOrder IDORD-2024-001✓ (optional)-
payment.transfer_codeProvider transfer codeISR0011303630
payment.amountTransaction amount100.50
payment.currencyCurrency codeUSD, DJF
payment.payment_methodPayment methodMWALLET_ACCOUNT, CREDIT_CARD-
payment.statusTransaction outcomeAPPROVED, FAILED, etc.
payment.reference_idYour reference IDREF-2024-4567
payment.channelTransaction channelWEB, APP, POS-
payment.descriptionTransaction descriptionPayment for order...✓ (optional)-
payment.dateTransaction timestamp2025-08-13 15:04:05

Understanding Events and Status

Events (Transaction Types)

Webhooks are sent for the following events:

EventDescriptionWhen It's SentHMAC Signed
webhook.testWebhook validation testWhen you register or update a webhook URLNo
authorizationPayment authorization transactionWhen a customer completes (or fails to complete) a payment authorization via HPPYes
refundRefund transactionWhen a refund is processed for a previous authorizationYes

Status Values (Transaction Outcomes)

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

StatusMeaningApplies ToWhat To Do
APPROVEDTransaction succeededAuthorization, RefundFulfill order / Update accounting
FAILEDTransaction failed due to technical errorAuthorization, RefundCancel order / Log error and notify support
DECLINEDTransaction declined by payment provider/bankAuthorization, RefundCancel order and notify customer / Log refund failure
CANCELEDCustomer canceled the transactionAuthorizationCancel order
EXPIREDTransaction link/session expired before completionAuthorizationMark as abandoned, optionally resend payment link
TIMEOUTTransaction timed out without completionAuthorization, RefundMark as abandoned / Log and retry refund

Handling Webhooks

// Handle based on event type and status
if (event === 'authorization') {
  if (status === 'APPROVED') fulfillOrder(payment.order_id);
  else if (['DECLINED', 'FAILED'].includes(status)) cancelOrder(payment.order_id);
  else if (['EXPIRED', 'TIMEOUT', 'CANCELED'].includes(status)) markAbandoned(payment.order_id);
}
 
if (event === 'refund') {
  if (status === 'APPROVED') processRefund(payment.transaction_id);
  else if (['DECLINED', 'FAILED', 'TIMEOUT'].includes(status)) handleRefundFailure(payment.transaction_id);
}

Always check both event and payment.status to determine the correct action.


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) to prevent replay attacks
X-Webhook-Event-IdUnique per event; use for replay protection
X-Webhook-Signature-AlgAlways HMAC-SHA256
X-Webhook-SignatureEncoded as lowercase hex digest of HMAC-SHA256 over the signing string
Content-TypeAlways application/json

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)
  6. Check event ID (reject if already processed)
  7. Return 200 OK quickly (process async 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: 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": "ORD-2024-001",
        "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 ORD-2024-001",
        "date": "2025-08-12 17:59:15"
    }
}

Example 2: Refund Webhook

Headers

X-Webhook-Timestamp: 1729587478
X-Webhook-Event-Id: 1152
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",
        "transfer_code": "ISR0011314550",
        "amount": 30.5,
        "currency": "USD",
        "status": "APPROVED",
        "reference_id": "REFUND-2024-8901",
        "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 (!crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expectedSignature)
  )) {
    throw new Error('Invalid signature');
  }
 
  return true;
}
 
// Track processed events to prevent duplicates
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
    if (processedEvents.has(eventId)) {
      return res.status(200).send('OK');
    }
 
    // 4. Process based on event and status
    if (event === 'authorization') {
      if (payment.status === 'APPROVED') fulfillOrder(payment.order_id);
      else if (['DECLINED', 'FAILED'].includes(payment.status)) cancelOrder(payment.order_id);
      else if (['EXPIRED', 'TIMEOUT', 'CANCELED'].includes(payment.status)) markAbandoned(payment.order_id);
    } else if (event === 'refund') {
      if (payment.status === 'APPROVED') processRefund(payment.transaction_id);
      else if (['DECLINED', 'FAILED', 'TIMEOUT'].includes(payment.status)) handleRefundFailure(payment.transaction_id);
    }
 
    // 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
  • Store secret securely: Use environment variables, never commit to code
  • Return 200 quickly: Respond within 5 seconds, process async if needed
  • Log everything: Keep webhook logs for debugging
  • Implement idempotency: Use event IDs to prevent duplicate processing
  • Handle test events: Respond to webhook.test without signature verification

Common Issues

IssueSolution
Signature verification failsCapture raw HTTP body before parsing JSON
Webhooks not receivedEnsure URL is publicly accessible
Duplicate processingTrack event IDs using X-Webhook-Event-Id
Timeout errorsReturn 200 OK immediately, queue for async processing
Replay attacksValidate timestamp (reject if >5 minutes old)