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
payment_amount_discrepancyPayment settled for an amount other than the one invoiced

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
Payment amount discrepanciespayment_amount_discrepancy.underpayment, payment_amount_discrepancy.overpayment
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",
    "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
  }
}
FieldTypeDescription
intent_idstring (UUID)ID of the payment intent this attempt belongs to
reference_idstringYour merchant-provided reference on the payment
purchaser_idstringYour unique customer identifier
amountintegerPayment amount in the smallest currency unit
currencystringCurrency of the payment
statusstringCurrent payment status — see Possible Statuses
partnerstringThe PSP that processed the attempt (for example adyen)
methodstringPayment method used (for example card, eft, payshap)
brandstring | nullPayment method brand, when applicable (for example visa, mastercard)
issuer_countrystring | nullISO 3166-1 alpha-2 country of the card issuer, when known
card_binstring | nullCard bank identification number. Only present for card payments
card_last4string | nullLast 4 digits of the card number. Only present for card payments
card_expirystring | nullCard expiry date (YYYY-MM-DD). Only present for card payments
event_tsstring (RFC 3339)When the PSP reported this status change
partner_referencestring | nullYour own reference for the attempt
failure_reasonstring | nullDeprecated. Coarse-grained failure reason. Only set when status is failed or chargeback. Use error_code / error_category instead
error_codestring | nullError taxonomy code for the failure, normalized across PSPs. Only set when status is failed. See Error Taxonomy for details
error_descriptionstring | nullHuman-readable description of error_code. Only set when status is failed
error_categorystring | nullError taxonomy category. Only set when status is failed. See Error Taxonomy for details
mandate_idinteger | nullNjiaPay mandate ID, set for instalment collections against a mandate
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:

CategoryMeaning
softA transient or recoverable failure. Can be safely retried automatically — for example by reattempting the payment.
hardA permanent decline. Retrying with the same payment method will not succeed.
technicalA 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.
fraudThe payment was blocked by fraud or risk screening. Do not retry without further review.

error_code gives the granular reason within that category:

error_codeerror_categoryDescription
card-expiredsoftThe cardholder's card has expired
card-blockedhardThe card has been blocked by the issuer
card-invalid-numberhardThe card number is invalid
card-non-sufficient-fundssoftInsufficient funds on the card
card-do-not-honorhardThe issuer declined the transaction
card-restrictedhardThe card is restricted for this type of transaction
card-not-permittedhardThis transaction is not permitted for the cardholder
card-suspected-fraudfraudThe issuer suspects fraud on this card
card-cvc-failedsoftThe CVC/CVV check failed
card-authentication-requiredsoftAdditional authentication is required
card-invalid-amountsoftThe transaction amount is invalid for this card
card-issuer-downtechnicalThe card issuer is temporarily unavailable
card-3ds-failedsoft3D Secure authentication failed
card-3ds-technical-errortechnicalA technical error occurred during 3D Secure
card-3ds-expiredsoftThe 3D Secure session expired
card-not-supportedtechnicalThe card type is not supported by the issuer
card-security-violationfraudA security violation was detected
card-account-closedhardThe cardholder's account has been closed
card-mandate-revokedhardThe payment mandate has been revoked
card-stolenhardThe card has been reported as stolen
card-losthardThe card has been reported as lost
card-daily-limit-exceededsoftThe daily transaction limit has been exceeded
card-pin-tries-exceededsoftToo many incorrect PIN attempts
card-invalid-transactionsoftThe transaction type is invalid
shopper-cvc-failedsoftThe shopper entered an incorrect CVC/CVV
shopper-3ds-failedsoftThe shopper failed 3D Secure authentication
shopper-session-expiredsoftThe payment session expired
shopper-cancelledsoftThe shopper cancelled the payment
gateway-declinedhardThe payment was declined
gateway-timeouttechnicalThe payment gateway timed out
gateway-acquirer-errortechnicalThe acquirer returned an error
gateway-technical-errortechnicalA technical error occurred at the gateway
gateway-invalid-payloadtechnicalThe payment request was malformed
gateway-blocked-risk-rulesfraudThe payment was blocked by risk rules
gateway-cvc-requiredsoftThe gateway requires a CVC/CVV
gateway-payment-method-unsupportedtechnicalThe payment method is not supported
gateway-invalid-amounttechnicalThe amount sent to the gateway is invalid
gateway-cvc-invalid-lengthsoftThe CVC/CVV length is incorrect
service-invalid-requesttechnicalAn internal service configuration or request error occurred
service-suspected-fraudfraudThe transaction was flagged by internal fraud screening
service-connection-timeouttechnicalNjiaPay timed out establishing a connection to the PSP
service-read-timeouttechnicalNjiaPay timed out reading the PSP response
service-technical-errortechnicalAn 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"
  }
}
FieldTypeDescription
intent_idstring (UUID)ID of the payment intent that was canceled
reference_idstringYour merchant-provided reference on the payment
purchaser_idstringYour unique customer identifier
amountintegerPayment amount in the smallest currency unit
currencystringCurrency of the payment
statusstringThe intent status

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.
FieldTypeDescription
intent_idstring (UUID)ID of the payment intent the credential originated from
reference_idstringYour merchant-provided reference on the originating payment
purchaser_idstringYour unique customer identifier
origin_attempt_idintegerNjiaPay attempt ID that produced this credential
credential_tokenstringOpaque token — pass as the credential when calling /api/intents/auto-attempt
display_namestringMasked, human-readable label for the credential (for example *****441)
allow_noninteractivebooleanWhether the payment method supports non-interactive (MIT) use
is_mit_compatiblebooleanWhether the payment method supports merchant-initiated transactions
methodstringPayment method the credential was created for (for example card)
statusstringCurrent credential status — see Credential Status Lifecycle
brandstring | nullPayment method brand, when applicable (for example visa)
allow_unscheduled_mitboolean | nullWhether the purchaser opted in to unscheduled merchant-initiated transactions
card_last4string | nullLast 4 digits of the card number. Only present for card payment methods
card_expirystring | nullCard expiry date (YYYY-MM-DD). Only present for card payment methods
card_binstring | nullCard 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 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 Refund Types
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 Refund Statuses
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 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

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

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:

TopicMeaning
payment_amount_discrepancy.underpaymentThe payer sent less than the invoiced amount
payment_amount_discrepancy.overpaymentThe 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"
  }
}
FieldTypeDescription
discrepancystringunderpayment or overpayment, always equal to the topic suffix
intent_idstring (UUID)ID of the payment intent
reference_idstringYour merchant-provided reference
purchaser_idstringYour unique customer identifier
currencystringISO 4217 code for every amount in the payload
expected_amountintegerThe invoiced amount, in minor units
received_amountintegerWhat the payer actually sent, in minor units
shortfall_amountintegerUnderpayments only: how far short the payment fell, in minor units
surplus_amountintegerOverpayments only: how far over the payment went, in minor units
partnerstringThe PSP that reported the discrepancy
partner_referencestringThe PSP reference of the transaction carrying the discrepancy
original_partner_referencestring, nullThe PSP reference of the original charge, when the PSP books the discrepancy as its own transaction
event_tsstring (ISO)When the PSP recorded the discrepancy
Some PSPs book an overpayment's surplus as a separate transaction with its own reference. When they do, 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:

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");
  }
});

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

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