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.
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 examplestatus_change). This is what your handler switches on.- topic — the routing key you subscribe a destination to, in the form
<resource>.<status>(for examplepayment.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_change | Payment intent status changed |
cancelation | Payment intent was canceled |
payment_credential | Payment credential created or deactivated (MIT) |
refund | Refund initiated or completed |
mandate | Mandate status changed |
mandate_amendment | Mandate amendment status changed |
Topics
| Group | Topics |
|---|---|
| Payments | payment.initiated, payment.pending, payment.authorized, payment.success, payment.failed, payment.chargeback, payment.canceled |
| Refunds | refund.initiated, refund.pending, refund.success, refund.failed, refund.canceled |
| Credentials | credential.active, credential.inactive, credential.deleted |
| Mandates | mandate.initiated, mandate.pending, mandate.authorized, mandate.active, mandate.canceled, mandate.failed, mandate.suspended |
| Mandate amendments | mandate_amendment.pending, mandate_amendment.success, mandate_amendment.failed |
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
200status 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");
}
});
@app.route('/webhook', methods=['POST'])
def webhook():
event = request.json
try:
# Process event
handle_webhook_event(event)
# Return 200 immediately
return 'OK', 200
except Exception as e:
print(f'Webhook error: {e}')
return 'Error', 500
<?php
$event = json_decode(file_get_contents('php://input'), true);
try {
// Process event
handleWebhookEvent($event);
// Return 200 immediately
http_response_code(200);
echo 'OK';
} catch (Exception $e) {
error_log('Webhook error: ' . $e->getMessage());
http_response_code(500);
echo 'Error';
}
2. Create a Webhook Destination
- Log into the merchant portal
- Navigate to Settings > Webhooks
- Add a destination with your endpoint URL (must be HTTPS)
- Select the topics you want to receive, or
*for all events - 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.
3. Test Webhook Delivery
After configuration:
- Create a test payment in sandbox
- Verify the webhook is received at your endpoint
- Confirm your signature verification passes
Request Headers
Each delivery includes these headers (all prefixed with x-njiapay-):
| Header | Description |
|---|---|
x-njiapay-event-id | Unique event id (UUID). Equal to notification_id in the body. Use it to deduplicate. |
x-njiapay-topic | The topic the event was routed on (for example payment.success) |
x-njiapay-timestamp | Delivery timestamp (RFC 3339) |
x-njiapay-signature | HMAC 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:
- Read the raw request body bytes — do not re-serialize the parsed JSON, as any change to whitespace or key order breaks the signature.
- Take the
x-njiapay-signatureheader and strip thev0=prefix. During secret rotation the header may contain multiple comma-separated signatures. - Compute
HMAC-SHA256(signing_secret, raw_body)and hex-encode it. - Accept the request if your computed signature matches any signature in the header, using a constant-time comparison.
- Optionally reject requests whose
x-njiapay-timestampis 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;
}
import hashlib
import hmac
def verify_signature(raw_body: bytes, signature_header: str, secrets: list[str]) -> bool:
received = [s.strip().removeprefix("v0=") for s in signature_header.split(",")]
for secret in secrets:
expected = hmac.new(
secret.encode(), raw_body, hashlib.sha256
).hexdigest()
for sig in received:
if hmac.compare_digest(sig, 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:
- A new signing secret is generated and shown once — store it.
- The previous secret stays valid for a 24-hour grace period.
- During the grace period, the
x-njiapay-signatureheader contains one signature per valid secret, current first:v0=<current>,<previous>. - 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 intentpending: The payment is still processingauthorized: 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 servicefailed: The payment failed, no other payment methods availablechargeback: 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
}
}
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_mitorrequest_unscheduled_mitcombined 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.
/api/intents/auto-attempt endpoint. Only initiate auto-payments after receiving a payment_credential event with status: "active"./api/intents/auto-attempt endpoint for the purchaser.Credential Status Lifecycle
| Status | Meaning |
|---|---|
active | Credential confirmed and ready — use this token for auto-payment attempts. |
inactive | Credential 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 refundedpartial: Only a portion of the payment amount is being refunded
Refund Statuses
initiated: The refund has been created in our systempending: The refund is being processed by the payment providersuccess: The refund has been successfully processed and funds are being returnedfailed: The refund attempt failedcanceled: 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"
}
}
| Field | Type | Description |
|---|---|---|
refund_id | integer | NjiaPay refund ID — matches the id returned by Create Refund |
type | string | full or partial — see above |
intent_id | string (UUID) | ID of the payment intent being refunded |
reference_id | string | Your merchant-provided reference on the original payment |
purchaser_id | string | Your unique customer identifier |
amount | integer | Refunded amount in the smallest currency unit |
currency | string | Currency of the original payment |
status | string | Current refund status — see above |
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
| Status | Description |
|---|---|
initiated | Mandate created; customer not yet redirected to sign |
pending | Signing in progress at the provider |
authorized | Successfully signed by the customer |
active | Approved by the bank and ready for collections |
canceled | Cancelled by the customer or the merchant |
failed | Signing or authorisation failed |
suspended | Suspended 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"
}
}
| Field | Type | Description |
|---|---|---|
mandate_id | integer | NjiaPay internal mandate ID — use this with the Mandates API |
intent_id | string (UUID) | ID of the payment intent used to sign the mandate |
reference_id | string | Your merchant-provided reference |
purchaser_id | string | Your unique customer identifier |
status | string | Current 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
| Status | Description |
|---|---|
pending | Amendment submitted and awaiting bank approval |
success | Amendment approved and applied to the mandate |
failed | Amendment 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"
}
}
| Field | Type | Description |
|---|---|---|
mandate_id | integer | NjiaPay internal mandate ID |
intent_id | string (UUID) | ID of the payment intent used to sign the mandate |
reference_id | string | Your merchant-provided reference |
purchaser_id | string | Your unique customer identifier |
amendment_status | string | Current amendment status — see table above |
Implementation Best Practices
1. Respond Quickly
Always return 200 OK immediately, then process the webhook asynchronously:
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.
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:
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:
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
# 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:
- Go to webhook.site
- Copy your unique URL
- Add it as a webhook destination in the merchant portal
- Create a test payment
- View the webhook payload and headers on webhook.site
In Your Application
Log all webhook events for debugging:
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-signatureheader 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.