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 |
payment_amount_discrepancy | Payment settled for an amount other than the one invoiced |
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 amount discrepancies | payment_amount_discrepancy.underpayment, payment_amount_discrepancy.overpayment |
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",
"card_bin": "12345678",
"card_last4": "6441",
"card_expiry": "2025-12-26",
"event_ts": "2023-12-22T14:24:36+01:00",
"partner_reference": "partner-ref-1234",
"failure_reason": null,
"error_code": null,
"error_description": null,
"error_category": null,
"mandate_id": null
}
}
| Field | Type | Description |
|---|---|---|
intent_id | string (UUID) | ID of the payment intent this attempt belongs to |
reference_id | string | Your merchant-provided reference on the payment |
purchaser_id | string | Your unique customer identifier |
amount | integer | Payment amount in the smallest currency unit |
currency | string | Currency of the payment |
status | string | Current payment status — see Possible Statuses |
partner | string | The PSP that processed the attempt (for example adyen) |
method | string | Payment method used (for example card, eft, payshap) |
brand | string | null | Payment method brand, when applicable (for example visa, mastercard) |
issuer_country | string | null | ISO 3166-1 alpha-2 country of the card issuer, when known |
card_bin | string | null | Card bank identification number. Only present for card payments |
card_last4 | string | null | Last 4 digits of the card number. Only present for card payments |
card_expiry | string | null | Card expiry date (YYYY-MM-DD). Only present for card payments |
event_ts | string (RFC 3339) | When the PSP reported this status change |
partner_reference | string | null | Your own reference for the attempt |
failure_reason | string | null | Deprecated. Coarse-grained failure reason. Only set when status is failed or chargeback. Use error_code / error_category instead |
error_code | string | null | Error taxonomy code for the failure, normalized across PSPs. Only set when status is failed. See Error Taxonomy for details |
error_description | string | null | Human-readable description of error_code. Only set when status is failed |
error_category | string | null | Error taxonomy category. Only set when status is failed. See Error Taxonomy for details |
mandate_id | integer | null | NjiaPay mandate ID, set 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.Error Taxonomy
When status is failed, error_code and error_category give you a normalized, PSP-independent classification of the failure.
error_category tells you how to react to the failure:
| Category | Meaning |
|---|---|
soft | A transient or recoverable failure. Can be safely retried automatically — for example by reattempting the payment. |
hard | A permanent decline. Retrying with the same payment method will not succeed. |
technical | A failure on the PSP or gateway side unrelated to the payment method, or an internal error on NjiaPay's side while processing the payment. May succeed on retry once the underlying issue clears. |
fraud | The payment was blocked by fraud or risk screening. Do not retry without further review. |
error_code gives the granular reason within that category:
error_code | error_category | Description |
|---|---|---|
card-expired | soft | The cardholder's card has expired |
card-blocked | hard | The card has been blocked by the issuer |
card-invalid-number | hard | The card number is invalid |
card-non-sufficient-funds | soft | Insufficient funds on the card |
card-do-not-honor | hard | The issuer declined the transaction |
card-restricted | hard | The card is restricted for this type of transaction |
card-not-permitted | hard | This transaction is not permitted for the cardholder |
card-suspected-fraud | fraud | The issuer suspects fraud on this card |
card-cvc-failed | soft | The CVC/CVV check failed |
card-authentication-required | soft | Additional authentication is required |
card-invalid-amount | soft | The transaction amount is invalid for this card |
card-issuer-down | technical | The card issuer is temporarily unavailable |
card-3ds-failed | soft | 3D Secure authentication failed |
card-3ds-technical-error | technical | A technical error occurred during 3D Secure |
card-3ds-expired | soft | The 3D Secure session expired |
card-not-supported | technical | The card type is not supported by the issuer |
card-security-violation | fraud | A security violation was detected |
card-account-closed | hard | The cardholder's account has been closed |
card-mandate-revoked | hard | The payment mandate has been revoked |
card-stolen | hard | The card has been reported as stolen |
card-lost | hard | The card has been reported as lost |
card-daily-limit-exceeded | soft | The daily transaction limit has been exceeded |
card-pin-tries-exceeded | soft | Too many incorrect PIN attempts |
card-invalid-transaction | soft | The transaction type is invalid |
shopper-cvc-failed | soft | The shopper entered an incorrect CVC/CVV |
shopper-3ds-failed | soft | The shopper failed 3D Secure authentication |
shopper-session-expired | soft | The payment session expired |
shopper-cancelled | soft | The shopper cancelled the payment |
gateway-declined | hard | The payment was declined |
gateway-timeout | technical | The payment gateway timed out |
gateway-acquirer-error | technical | The acquirer returned an error |
gateway-technical-error | technical | A technical error occurred at the gateway |
gateway-invalid-payload | technical | The payment request was malformed |
gateway-blocked-risk-rules | fraud | The payment was blocked by risk rules |
gateway-cvc-required | soft | The gateway requires a CVC/CVV |
gateway-payment-method-unsupported | technical | The payment method is not supported |
gateway-invalid-amount | technical | The amount sent to the gateway is invalid |
gateway-cvc-invalid-length | soft | The CVC/CVV length is incorrect |
service-invalid-request | technical | An internal service configuration or request error occurred |
service-suspected-fraud | fraud | The transaction was flagged by internal fraud screening |
service-connection-timeout | technical | NjiaPay timed out establishing a connection to the PSP |
service-read-timeout | technical | NjiaPay timed out reading the PSP response |
service-technical-error | technical | An unexpected error occurred inside NjiaPay while talking to the PSP |
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"
}
}
| Field | Type | Description |
|---|---|---|
intent_id | string (UUID) | ID of the payment intent that was canceled |
reference_id | string | Your merchant-provided reference on the payment |
purchaser_id | string | Your unique customer identifier |
amount | integer | Payment amount in the smallest currency unit |
currency | string | Currency of the payment |
status | string | The intent status |
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"
}
}
card_* fields are only present if the payment method is a card.| Field | Type | Description |
|---|---|---|
intent_id | string (UUID) | ID of the payment intent the credential originated from |
reference_id | string | Your merchant-provided reference on the originating payment |
purchaser_id | string | Your unique customer identifier |
origin_attempt_id | integer | NjiaPay attempt ID that produced this credential |
credential_token | string | Opaque token — pass as the credential when calling /api/intents/auto-attempt |
display_name | string | Masked, human-readable label for the credential (for example *****441) |
allow_noninteractive | boolean | Whether the payment method supports non-interactive (MIT) use |
is_mit_compatible | boolean | Whether the payment method supports merchant-initiated transactions |
method | string | Payment method the credential was created for (for example card) |
status | string | Current credential status — see Credential Status Lifecycle |
brand | string | null | Payment method brand, when applicable (for example visa) |
allow_unscheduled_mit | boolean | null | Whether the purchaser opted in to unscheduled merchant-initiated transactions |
card_last4 | string | null | Last 4 digits of the card number. Only present for card payment methods |
card_expiry | string | null | Card expiry date (YYYY-MM-DD). Only present for card payment methods |
card_bin | string | null | Card bank identification number. Only present for card payment methods |
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 Refund Types |
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 Refund Statuses |
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 Mandate Statuses |
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 |
Event: Payment Amount Discrepancy
Triggers when a payment settles for an amount other than the one invoiced. This can happen on bank transfers, where the customer controls how much they send and can transfer too little or too much.
Two topics carry this event, so you can subscribe to only the case you handle:
| Topic | Meaning |
|---|---|
payment_amount_discrepancy.underpayment | The payer sent less than the invoiced amount |
payment_amount_discrepancy.overpayment | The payer sent more than the invoiced amount |
An underpayment leaves the payment pending, because it is not fully funded. No payment.success follows. An overpayment still settles as payment.success, because the invoice was covered. The surplus is what needs resolving with your customer.
Both topics deliver body type payment_amount_discrepancy. Use content.discrepancy to tell them apart, the same way you use content.status for a status_change.
Example Payload
An underpayment of 300.00 NGN against a 500.00 NGN invoice:
{
"type": "payment_amount_discrepancy",
"notification_id": "b8c9d0e1-c5d6-e7f8-0910-111213141516",
"created": "2025-07-01T08:00:00Z",
"content": {
"discrepancy": "underpayment",
"intent_id": "550e8400-e29b-41d4-a716-446655440000",
"reference_id": "order-9912",
"purchaser_id": "customer-4567",
"currency": "NGN",
"expected_amount": 50000,
"received_amount": 20000,
"shortfall_amount": 30000,
"partner": "start_button",
"partner_reference": "a1b2c3d4e5f6",
"original_partner_reference": "f6e5d4c3b2a1",
"event_ts": "2025-07-01T07:59:58Z"
}
}
An overpayment carries discrepancy: "overpayment", and surplus_amount in place of shortfall_amount:
{
"type": "payment_amount_discrepancy",
"notification_id": "c9d0e1f2-d6e7-f809-1011-121314151617",
"created": "2025-07-01T08:00:00Z",
"content": {
"discrepancy": "overpayment",
"intent_id": "550e8400-e29b-41d4-a716-446655440000",
"reference_id": "order-9912",
"purchaser_id": "customer-4567",
"currency": "NGN",
"expected_amount": 50000,
"received_amount": 60000,
"surplus_amount": 10000,
"partner": "start_button",
"partner_reference": "b2c3d4e5f6a1",
"original_partner_reference": "f6e5d4c3b2a1",
"event_ts": "2025-07-01T07:59:58Z"
}
}
| Field | Type | Description |
|---|---|---|
discrepancy | string | underpayment or overpayment, always equal to the topic suffix |
intent_id | string (UUID) | ID of the payment intent |
reference_id | string | Your merchant-provided reference |
purchaser_id | string | Your unique customer identifier |
currency | string | ISO 4217 code for every amount in the payload |
expected_amount | integer | The invoiced amount, in minor units |
received_amount | integer | What the payer actually sent, in minor units |
shortfall_amount | integer | Underpayments only: how far short the payment fell, in minor units |
surplus_amount | integer | Overpayments only: how far over the payment went, in minor units |
partner | string | The PSP that reported the discrepancy |
partner_reference | string | The PSP reference of the transaction carrying the discrepancy |
original_partner_reference | string, null | The PSP reference of the original charge, when the PSP books the discrepancy as its own transaction |
event_ts | string (ISO) | When the PSP recorded the discrepancy |
partner_reference identifies that surplus transaction and original_partner_reference points back to the original charge. You normally need the original reference to raise a refund.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");
}
});
5. Handle Schema Changes Defensively
The webhook body can change over time: new fields are added, and existing fields may be deprecated and removed. Ignore fields you don't recognize instead of rejecting the payload.
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.