Integration Guides

Webhooks

Set up real-time payment notifications with webhooks

Webhooks let you listen for payment status updates and created payment credentials. You register one or more webhook destinations, and NjiaPay sends each event to your endpoint as an HTTP POST with a JSON body. Your endpoint must process the request and respond with a 200 OK.

Every request is signed with an HMAC secret so you can verify it came from NjiaPay, and undelivered events are retried automatically over a 24-hour window.

You manage your webhook destinations in the merchant portal under Settings > Webhooks.

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 over 24 hours ✅ Asynchronous - Don't rely on customers returning to your site ✅ Secure - Every request is HMAC-signed so you can verify authenticity ✅ Comprehensive - Subscribe to exactly the events you care about

Delivery & Retries

Delivery is at-least-once. If your endpoint is unavailable or returns a non-2xx response, the event is retried on a fixed schedule. Because delivery is at-least-once, you must deduplicate events using the event id.

Each event is attempted once immediately, then retried up to 30 times across a 24-hour window before it is given up on. The delay before each retry (in seconds) is:

30, 60, 120, 240, 480, 960, 1920, 3600, then 3600 every hour until 24h elapses

The intervals start at 30 seconds and double up to one hour, then repeat hourly. The final attempt lands at exactly 24 hours after the first delivery. After the last attempt the event is no longer retried.

Ordering is not guaranteed. Events may arrive out of order, especially after a retry. Do not assume that the order in which requests hit your endpoint matches the order the events occurred. Use the created timestamp in the body (or event_ts where present) to reconcile ordering. See Verify Event Ordering.

Topics & Events

Each event has two related identifiers:

  • type — the event category, carried in the JSON body (for example status_change). This is what your handler switches on.
  • topic — the routing key you subscribe a destination to, in the form <resource>.<status> (for example payment.success). This is only used when configuring a destination.

When you create a destination you choose which topics it receives. Subscribe to * to receive every event, or pick specific topics.

Event types

Event Type (type)Description
status_changePayment intent status changed
cancelationPayment intent was canceled
payment_credentialPayment credential created or deactivated (MIT)
refundRefund initiated or completed
mandateMandate status changed
mandate_amendmentMandate amendment status changed

Topics

GroupTopics
Paymentspayment.initiated, payment.pending, payment.authorized, payment.success, payment.failed, payment.chargeback, payment.canceled
Refundsrefund.initiated, refund.pending, refund.success, refund.failed, refund.canceled
Credentialscredential.active, credential.inactive, credential.deleted
Mandatesmandate.initiated, mandate.pending, mandate.authorized, mandate.active, mandate.canceled, mandate.failed, mandate.suspended
Mandate amendmentsmandate_amendment.pending, mandate_amendment.success, mandate_amendment.failed
A cancelation is delivered on the payment.canceled topic with body typecancelation.

Setting Up Webhooks

1. Create a Webhook Endpoint

Your webhook endpoint must:

  • Accept POST requests
  • Process JSON payloads
  • Return an 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. Create a Webhook Destination

  1. Log into the merchant portal
  2. Navigate to Settings > Webhooks
  3. Add a destination with your endpoint URL (must be HTTPS)
  4. Select the topics you want to receive, or * for all events
  5. Copy the signing secret — it is shown only once. Store it securely; you need it to verify signatures.

You can create multiple destinations, each with its own topics and signing secret.

Your webhook URL must be publicly accessible via HTTPS. For local development, use a tunnel such as ngrok to expose your local server.

3. Test Webhook Delivery

After configuration:

  1. Create a test payment in sandbox
  2. Verify the webhook is received at your endpoint
  3. Confirm your signature verification passes

Request Headers

Each delivery includes these headers (all prefixed with x-njiapay-):

HeaderDescription
x-njiapay-event-idUnique event id (UUID). Equal to notification_id in the body. Use it to deduplicate.
x-njiapay-topicThe topic the event was routed on (for example payment.success)
x-njiapay-timestampDelivery timestamp (RFC 3339)
x-njiapay-signatureHMAC signature of the body — see Verifying Signatures

Example request:

POST /webhook HTTP/1.1
Content-Type: application/json
x-njiapay-event-id: a1b2c3d4-5e6f-7081-92a3-b4c5d6e7f809
x-njiapay-topic: payment.success
x-njiapay-timestamp: 2024-09-01T00:00:00Z
x-njiapay-signature: v0=6f8b...c1a2

{"type":"status_change","notification_id":"a1b2c3d4-5e6f-7081-92a3-b4c5d6e7f809","created":"2024-09-01T00:00:00Z","content":{ ... }}

Verifying Signatures

Every request carries an x-njiapay-signature header so you can confirm it was sent by NjiaPay and was not tampered with. The signature is an HMAC-SHA256 of the raw request body, hex-encoded, using your destination's signing secret as the key:

HMAC-SHA256(signing_secret, raw_body)

The header value is prefixed with v0=:

x-njiapay-signature: v0=<hex-signature>

To verify:

  1. Read the raw request body bytes — do not re-serialize the parsed JSON, as any change to whitespace or key order breaks the signature.
  2. Take the x-njiapay-signature header and strip the v0= prefix. During secret rotation the header may contain multiple comma-separated signatures.
  3. Compute HMAC-SHA256(signing_secret, raw_body) and hex-encode it.
  4. Accept the request if your computed signature matches any signature in the header, using a constant-time comparison.
  5. Optionally reject requests whose x-njiapay-timestamp is too old to mitigate replay attacks.
import crypto from "node:crypto";

// Register the route with a raw body parser so you get the exact bytes:
// app.post("/webhook", express.raw({ type: "application/json" }), handler)
function verifySignature(rawBody, signatureHeader, secrets) {
  const received = signatureHeader
    .split(",")
    .map((s) => s.trim().replace(/^v0=/, ""));

  for (const secret of secrets) {
    const expected = crypto
      .createHmac("sha256", secret)
      .update(rawBody)
      .digest("hex");

    for (const sig of received) {
      if (
        sig.length === expected.length &&
        crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
      ) {
        return true;
      }
    }
  }
  return false;
}

Pass a list of secrets so verification keeps working during rotation: include both your current and previous secret while the previous one is still valid.

Secret Rotation

You can rotate a destination's signing secret from Settings > Webhooks without downtime. When you rotate:

  1. A new signing secret is generated and shown once — store it.
  2. The previous secret stays valid for a 24-hour grace period.
  3. During the grace period, the x-njiapay-signature header contains one signature per valid secret, current first: v0=<current>,<previous>.
  4. After the grace period, the header returns to a single signature computed with the new secret.

To rotate safely: save the new secret, add it to your accepted-secrets list alongside the old one, deploy, and keep both until the grace period ends. The verification snippets above already accept a list of secrets.

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",
  "notification_id": "a1b2c3d4-5e6f-7081-92a3-b4c5d6e7f809",
  "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",
  "notification_id": "b2c3d4e5-6f70-8192-a3b4-c5d6e7f80910",
  "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 based on a Payment Intent with require_unscheduled_mit or request_unscheduled_mit combined with explicit opting in from the purchaser. This is the only scenario in which a credential can be used by a merchant and thus the only creation scenario for which we send a webhook.
  • 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".
Important: Multiple credentials can be created for the same purchaser. Make sure to not discard or overwrite credentials in your system unless our webhooks informed you that the credential has been deleted from our system. Otherwise you can no longer initiate payments through the /api/intents/auto-attempt endpoint for the purchaser.

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",
  "notification_id": "c3d4e5f6-7081-92a3-b4c5-d6e7f8091011",
  "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",
  "notification_id": "d4e5f6a7-8192-a3b4-c5d6-e7f809101112",
  "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",
  "notification_id": "e5f6a7b8-92a3-b4c5-d6e7-f80910111213",
  "created": "2024-09-01T00:00:00Z",
  "content": {
    "refund_id": 789,
    "type": "full",
    "intent_id": "018c91b1-b36-791w-134j-a87164cf2f73",
    "reference_id": "<reference-id>",
    "purchaser_id": "<purchaser-id>",
    "amount": 20000,
    "currency": "ZAR",
    "status": "success"
  }
}
FieldTypeDescription
refund_idintegerNjiaPay refund ID — matches the id returned by Create Refund
typestringfull or partial — see above
intent_idstring (UUID)ID of the payment intent being refunded
reference_idstringYour merchant-provided reference on the original payment
purchaser_idstringYour unique customer identifier
amountintegerRefunded amount in the smallest currency unit
currencystringCurrency of the original payment
statusstringCurrent refund status — see above
Correlate on refund_id, not intent_id. A payment intent can have several refunds, and you receive a notification on every refund status transition, so intent_id alone does not identify which refund an event refers to. See Implement Idempotency.

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",
  "notification_id": "f6a7b8c9-a3b4-c5d6-e7f8-091011121314",
  "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",
  "notification_id": "a7b8c9d0-b4c5-d6e7-f809-101112131415",
  "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

Delivery is at-least-once, so the same event may arrive more than once. Deduplicate using the event id: notification_id in the body, which is a UUID and matches the x-njiapay-event-id header. The same id is reused across every retry of an event.

handler.js
async function handleWebhookEvent(event) {
  const eventId = event.notification_id; // same value as x-njiapay-event-id

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

  // Process event
  await processEvent(event);

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

3. Verify Event Ordering

Ordering is not guaranteed. Use the created timestamp to ignore stale events:

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 a 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)
# Add it as a webhook destination in the merchant portal

Using webhook.site

For quick testing without code:

  1. Go to webhook.site
  2. Copy your unique URL
  3. Add it as a webhook destination in the merchant portal
  4. Create a test payment
  5. View the webhook payload and headers 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,
    event_id: event.notification_id,
    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
  • Verify the x-njiapay-signature header on every request before processing
  • Compare signatures with a constant-time function
  • Implement idempotency
  • Log all webhook events
  • Monitor webhook failures
  • Process webhooks asynchronously

❌ DON'T

  • Process a webhook before verifying its signature
  • Re-serialize the parsed body for signature verification — always use the raw bytes
  • Trust webhook data without validation
  • Ignore duplicate events
  • Discard your signing secret — it is shown only once

Troubleshooting

Webhooks Not Received

Possible causes:

  • No webhook destination configured, or the destination is not subscribed to the relevant topic
  • URL not publicly accessible
  • Firewall blocking requests
  • SSL certificate issues
  • Endpoint returning a non-200 status

Solutions:

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

Signature Verification Fails

Issue: Computed signature does not match x-njiapay-signature

Solutions:

  • Sign the raw request body bytes, not the re-serialized JSON
  • Confirm you are using the correct signing secret for that destination
  • During a rotation window, accept both the current and previous secret
  • Split the header on commas and match against each v0= signature

Duplicate Events

Issue: Same event received multiple times

Solution: This is expected under at-least-once delivery. Deduplicate using notification_id (equivalently the x-njiapay-event-id header) — it is stable across retries.

Out-of-Order Events

Issue: Events arrive in unexpected order

Solution: Ordering is not guaranteed. Use the created timestamp to detect and handle out-of-order events.

Auto Payments

Use credential tokens from webhooks

Status Lifecycle

Understand status transitions

Refunds

Handle refund events