Integration Guides

Webhooks

Set up real-time payment notifications with 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 TypeDescription
StatusChangePayment intent status changed
CancelationPayment intent was canceled
PaymentCredentialPayment credential created (for MIT)
RefundRefund 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");
  }
});

2. Configure Webhook URL

  1. Log into the merchant portal
  2. Navigate to Settings > Webhooks
  3. Enter your webhook URL (must be HTTPS)
  4. Save configuration
Your webhook URL must be publicly accessible via HTTPS. For local development, use tools like ngrok to create a public tunnel.

3. Test Webhook Delivery

After configuration:

  1. Create a test payment in sandbox
  2. 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 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": "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.

Important: Store this credential token securely - it's required for making future auto-payment attempts with the /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 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",
  "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:

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

Events may be delivered multiple times. Use the created timestamp or event content to detect duplicates:

handler.js
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:

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

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)
# Configure this URL + /webhook in merchant portal

Using webhook.site

For quick testing without code:

  1. Go to webhook.site
  2. Copy your unique URL
  3. Configure in merchant portal
  4. Create test payment
  5. View webhook payload 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,
    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.

Auto Payments

Use credential tokens from webhooks

Status Lifecycle

Understand status transitions

Refunds

Handle refund events