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 Type | Description |
|---|---|
status_change | Payment intent status changed |
cancelation | Payment intent was canceled |
payment_credential | Payment credential created (for MIT) |
refund | Refund initiated or completed |
mandate | Mandate status changed |
mandate_amendment | Mandate 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");
}
});
@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. Configure Webhook URL
- Log into the merchant portal
- Navigate to Settings > Webhooks
- Enter your webhook URL (must be HTTPS)
- Save configuration
3. Test Webhook Delivery
After configuration:
- Create a test payment in sandbox
- 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 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",
"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",
"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.
/api/intents/auto-attempt endpoint. Only initiate auto-payments after receiving a payment_credential event with status: "active".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",
"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 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",
"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
| 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",
"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",
"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
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:
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:
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:
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)
# Configure this URL + /webhook in merchant portal
Using webhook.site
For quick testing without code:
- Go to webhook.site
- Copy your unique URL
- Configure in merchant portal
- Create test payment
- View webhook payload 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,
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.