Integration Guides

Webhooks

Set up real-time payment notifications with webhooks

Webhooks enable you to listen for payment status updates and created payment credentials. Typically, you'll monitor these events on your webhook URL. A POST endpoint that must process JSON requests and respond with a 200 OK status.

You can configure your webhook URL in the merchant portal under the Settings > Webhooks section.

Why Use Webhooks?

Webhooks are the recommended way to handle payment notifications because:

Real-time Updates - Get notified instantly when payments complete ✅ Reliable Delivery - Automatic retries for up to 7 days ✅ Asynchronous - Don't rely on customers returning to your site ✅ Secure - Server-to-server communication ✅ Comprehensive - Receive all payment lifecycle events

Delivery Guarantee

We implement a robust webhook delivery system that guarantees:

  • Reliable delivery with automated retries for up to 7 days
  • Exponential backoff between retry attempts (starting at 10s, increasing up to 10 minutes)
  • Multiple delivery attempts for each webhook event
  • Events are delivered in the order they occurred (per event type)

If your endpoint is unavailable or returns a non-200 response, we'll continue retry attempts throughout the 7-day retention period.

Supported Events

NjiaPay sends the following webhook events:

Event TypeDescription
status_changePayment intent status changed
cancelationPayment intent was canceled
payment_credentialPayment credential created (for MIT)
refundRefund initiated or completed
mandateMandate status changed
mandate_amendmentMandate amendment status changed

Setting Up Webhooks

1. Create a Webhook Endpoint

Your webhook endpoint must:

  • Accept POST requests
  • Process JSON payloads
  • Return HTTP 200 status code
  • Respond quickly (< 5 seconds recommended)

Example endpoint:

app.post("/webhook", express.json(), async (req, res) => {
  const event = req.body;

  try {
    // Process event
    await handleWebhookEvent(event);

    // Return 200 immediately
    res.status(200).send("OK");
  } catch (error) {
    console.error("Webhook error:", error);
    res.status(500).send("Error");
  }
});

2. Configure Webhook URL

  1. Log into the merchant portal
  2. Navigate to Settings > Webhooks
  3. Enter your webhook URL (must be HTTPS)
  4. Save configuration
Your webhook URL must be publicly accessible via HTTPS. For local development, use tools like ngrok to create a public tunnel.

3. Test Webhook Delivery

After configuration:

  1. Create a test payment in sandbox
  2. Verify webhook is received at your endpoint

Event: status_change

Triggers when the status of a payment intent changes.

Possible Statuses

  • initiated: The initial status of a payment intent
  • pending: The payment is still processing
  • authorized: The payment has been authorized, but not yet captured (only for delayed capture methods)
  • success: The payment was successful, you can now deliver the product or service
  • failed: The payment failed, no other payment methods available
  • chargeback: The payment was charged back

The transitions in the state diagram for payment processing (payment succeeds and payment fails) are all a direct result of responses from the payment service provider.

Example Payload

{
  "type": "status_change",
  "created": "2024-09-01T00:00:00Z",
  "content": {
    "intent_id": "018c91b1-b36-791w-134j-a87164cf2f73",
    "reference_id": "<reference-id>",
    "purchaser_id": "<purchaser-id>",
    "amount": 20000,
    "currency": "ZAR",
    "status": "success",
    "partner": "adyen",
    "method": "card",
    "brand": "mastercard",
    "issuer_country": "ZA",
    "event_ts": "2023-12-22T14:24:36+01:00",
    "failure_reason": null,
    "mandate_id": null
  }
}
For instalment collections against a mandate, mandate_id is set to the NjiaPay mandate ID. This lets you identify which mandate the payment belongs to.

Event: Cancelation

Triggered when a payment is canceled.

Example Payload

{
  "type": "cancelation",
  "created": "2024-09-01T00:00:00Z",
  "content": {
    "intent_id": "018c91b1-7d36-7019-1948-0afcd0a61a7b",
    "reference_id": "<reference-id>",
    "purchaser_id": "<purchaser-id>",
    "amount": 750,
    "currency": "ZAR",
    "status": "canceled"
  }
}

Event: payment_credential

Triggers when a payment credential is created or changes status. You will receive one event when a new credential becomes ready to use, and a second event only if it is later deactivated.

Important: Store this credential token securely — it's required for making future auto-payment attempts with the /api/intents/auto-attempt endpoint. Only initiate auto-payments after receiving a payment_credential event with status: "active".

Credential Status Lifecycle

StatusMeaning
activeCredential confirmed and ready — use this token for auto-payment attempts.
inactiveCredential has been deactivated — card expired, removed via the API, or replaced by re-enrollment. Do not use for MIT.

Credential created (active)

Fires once the payment is confirmed by the payment provider. Store this token — it is immediately ready for auto-payment attempts.

{
  "type": "payment_credential",
  "created": "2024-09-01T00:00:00Z",
  "content": {
    "intent_id": "018c91b1-b36-791w-134j-a87164cf2f73",
    "reference_id": "<reference-id>",
    "purchaser_id": "<purchaser-id>",
    "origin_attempt_id": 718956,
    "credential_token": "cred_token_abc123xyz",
    "display_name": "*****441",
    "allow_noninteractive": true,
    "is_mit_compatible": true,
    "method": "card",
    "status": "active",
    "brand": "visa",
    "allow_unscheduled_mit": false,
    "card_last4": "6441",
    "card_expiry": "2025-12-26",
    "card_bin": "12345678"
  }
}

Credential deactivated (inactive)

Fires if the credential is later deactivated — for example due to card expiry, deletion via the API, or re-enrollment with updated settings. Mark it as unusable in your system.

{
  "type": "payment_credential",
  "created": "2024-09-01T01:00:00Z",
  "content": {
    "intent_id": "018c91b1-b36-791w-134j-a87164cf2f73",
    "reference_id": "<reference-id>",
    "purchaser_id": "<purchaser-id>",
    "origin_attempt_id": 718956,
    "credential_token": "cred_token_abc123xyz",
    "display_name": "*****441",
    "allow_noninteractive": true,
    "is_mit_compatible": true,
    "method": "card",
    "status": "inactive",
    "brand": "visa",
    "allow_unscheduled_mit": false,
    "card_last4": "6441",
    "card_expiry": "2025-12-26",
    "card_bin": "12345678"
  }
}

The card_* fields are only present if the payment method is a card.

Event: Refund

Triggers when a refund is initiated, processed, completed, or fails for a payment intent. This event allows you to track the lifecycle of refunds and update your systems accordingly.

Refund Types

  • full: The entire payment amount is being refunded
  • partial: Only a portion of the payment amount is being refunded

Refund Statuses

  • initiated: The refund has been created in our system
  • pending: The refund is being processed by the payment provider
  • success: The refund has been successfully processed and funds are being returned
  • failed: The refund attempt failed
  • canceled: The refund was canceled

Example Payload

{
  "type": "refund",
  "created": "2024-09-01T00:00:00Z",
  "content": {
    "type": "full",
    "intent_id": "018c91b1-b36-791w-134j-a87164cf2f73",
    "reference_id": "<reference-id>",
    "purchaser_id": "<purchaser-id>",
    "amount": 20000,
    "currency": "ZAR",
    "status": "success"
  }
}

Event: mandate

Triggers whenever the status of a mandate changes. Use this event to track the signing lifecycle and know when a mandate becomes active and collections can begin.

Mandate Statuses

StatusDescription
initiatedMandate created; customer not yet redirected to sign
pendingSigning in progress at the provider
authorizedSuccessfully signed by the customer
activeApproved by the bank and ready for collections
canceledCancelled by the customer or the merchant
failedSigning or authorisation failed
suspendedSuspended at the bank register; can be reactivated by the bank

Example Payload

{
  "type": "mandate",
  "created": "2025-06-01T10:00:00Z",
  "content": {
    "mandate_id": 42,
    "intent_id": "550e8400-e29b-41d4-a716-446655440000",
    "reference_id": "sub-001",
    "purchaser_id": "customer-4567",
    "status": "active"
  }
}
FieldTypeDescription
mandate_idintegerNjiaPay internal mandate ID — use this with the Mandates API
intent_idstring (UUID)ID of the payment intent used to sign the mandate
reference_idstringYour merchant-provided reference
purchaser_idstringYour unique customer identifier
statusstringCurrent mandate status — see table above

Event: mandate_amendment

Triggers when the status of a mandate amendment changes. Amendments are initiated via the Amend Mandate endpoint and must be approved by the bank before they take effect.

Amendment Statuses

StatusDescription
pendingAmendment submitted and awaiting bank approval
successAmendment approved and applied to the mandate
failedAmendment was rejected by the bank

Example Payload

{
  "type": "mandate_amendment",
  "created": "2025-07-01T08:00:00Z",
  "content": {
    "mandate_id": 42,
    "intent_id": "550e8400-e29b-41d4-a716-446655440000",
    "reference_id": "sub-001",
    "purchaser_id": "customer-4567",
    "amendment_status": "success"
  }
}
FieldTypeDescription
mandate_idintegerNjiaPay internal mandate ID
intent_idstring (UUID)ID of the payment intent used to sign the mandate
reference_idstringYour merchant-provided reference
purchaser_idstringYour unique customer identifier
amendment_statusstringCurrent amendment status — see table above

Implementation Best Practices

1. Respond Quickly

Always return 200 OK immediately, then process the webhook asynchronously:

handler.js
app.post("/webhook", async (req, res) => {
  const event = req.body;

  // Return 200 immediately
  res.status(200).send("OK");

  // Process asynchronously
  processWebhookAsync(event).catch((err) => {
    console.error("Async webhook error:", err);
  });
});

async function processWebhookAsync(event) {
  // Your long-running processing here
  await handleWebhookEvent(event);
}

2. Implement Idempotency

Events may be delivered multiple times due to retry logic. Use notification_id as your dedup key — it is a globally unique integer assigned to each event across the entire platform:

handler.js
async function handleWebhookEvent(event) {
  const notificationId = event.notification_id;

  // Check if already processed
  if (await isEventProcessed(notificationId)) {
    console.log("Event already processed:", notificationId);
    return;
  }

  // Process event
  await processEvent(event);

  // Mark as processed
  await markEventProcessed(notificationId);
}

3. Verify Event Ordering

Use the created timestamp to ensure events are processed in order:

handler.js
async function processEvent(event) {
  const lastEventTime = await getLastEventTime(event.content.intent_id);

  if (event.created < lastEventTime) {
    console.log("Skipping out-of-order event");
    return;
  }

  // Process event
  await handleEvent(event);

  // Update last event time
  await setLastEventTime(event.content.intent_id, event.created);
}

4. Handle Errors Gracefully

If processing fails, return a non-200 status to trigger retry:

handler.js
app.post("/webhook", async (req, res) => {
  try {
    await handleWebhookEvent(req.body);
    res.status(200).send("OK");
  } catch (error) {
    console.error("Webhook processing failed:", error);
    // Return 500 to trigger retry
    res.status(500).send("Error");
  }
});

Local Development

For local testing, use a tunneling service to expose your local server:

Using ngrok

Terminal
# Start your local server
npm start

# In another terminal, create tunnel
ngrok http 3000

# Copy the HTTPS URL (e.g., https://abc123.ngrok.io)
# Configure this URL + /webhook in merchant portal

Using webhook.site

For quick testing without code:

  1. Go to webhook.site
  2. Copy your unique URL
  3. Configure in merchant portal
  4. Create test payment
  5. View webhook payload on webhook.site

In Your Application

Log all webhook events for debugging:

handler.js
app.post("/webhook", async (req, res) => {
  const event = req.body;

  // Log incoming webhook
  await logWebhook({
    type: event.type,
    intent_id: event.content.intent_id,
    received_at: new Date(),
    payload: event,
  });

  // Process...

  res.status(200).send("OK");
});

Security Considerations

✅ DO

  • Use HTTPS for webhook URLs
  • Validate webhook payload structure
  • Implement idempotency
  • Log all webhook events
  • Monitor webhook failures
  • Return 200 only after successful processing
  • Process webhooks asynchronously

❌ DON'T

  • Expose webhook endpoints publicly without authentication (consider IP whitelisting)
  • Trust webhook data without validation
  • Process webhooks synchronously if slow
  • Ignore duplicate events
  • Return 200 before validating payload

Troubleshooting

Webhooks Not Received

Possible causes:

  • Webhook URL not configured
  • URL not publicly accessible
  • Firewall blocking requests
  • SSL certificate issues
  • Endpoint returning non-200 status

Solutions:

  • Verify URL in merchant portal
  • Test URL with curl/Postman
  • Check server logs for errors
  • Use ngrok for local testing

Duplicate Events

Issue: Same event received multiple times

Solution: This is expected behavior due to retry logic. Use notification_id as your dedup key — it is stable across retries of the same event.

Out-of-Order Events

Issue: Events arrive in unexpected order

Solution: Use created timestamp to detect and handle out-of-order events appropriately.

Auto Payments

Use credential tokens from webhooks

Status Lifecycle

Understand status transitions

Refunds

Handle refund events