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 four types of webhook events:
| Event Type | Description |
|---|---|
StatusChange | Payment intent status changed |
Cancelation | Payment intent was canceled |
PaymentCredential | Payment credential created (for MIT) |
Refund | Refund initiated or completed |
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: StatusChange
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": "StatusChange",
"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
}
}
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: PaymentCredential
Triggers when a payment credential is created (e.g. a card is saved for auto payments). This event provides the credential token required for initiating auto payments.
/api/intents/auto-attempt endpoint.Example Payload
{
"type": "PaymentCredential",
"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": "luUOJ4pNipy3vdGPSD",
"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"
}
}
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. Use the created timestamp or event content to detect duplicates:
async function handleWebhookEvent(event) {
const eventId = `${event.type}_${event.content.intent_id}_${event.created}`;
// 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
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. Implement idempotency handling using event IDs or timestamps.
Out-of-Order Events
Issue: Events arrive in unexpected order
Solution: Use created timestamp to detect and handle out-of-order events appropriately.