Skip to content

Webhooks

The purchase response tells you what happened so far. The webhook tells you the final outcome. For any purchase that returns 300, the webhook is how you learn whether it was delivered or failed.


Registering a webhook URL

Set a public HTTPS URL on the API Keys page, or over the portal API:

# Sandbox
curl -X POST https://sandbox.instapaybill.com/api/v1/sandbox/keys/webhook \
  -H "Authorization: Bearer $IPB_SANDBOX_KEY" \
  -H "Content-Type: application/json" \
  -d '{"webhookUrl":"https://your-server.com/webhooks/instapaybill"}'

# Production
curl -X POST https://sandbox.instapaybill.com/api/v1/sandbox/live-keys/webhook \
  -H "Cookie: __Secure-ipb_developer=..." \
  -H "Origin: https://developer.instapaybill.com" \
  -H "Content-Type: application/json" \
  -d '{"webhook":"https://your-server.com/webhooks/instapaybill"}'

The URL must be a public HTTPS URL, at most 2048 characters:

{
  "error": [
    {
      "msg": "Use a public HTTPS webhook URL.",
      "path": "webhookUrl"
    }
  ]
}

Your endpoint must be reachable from the public internet. Private IPs, loopback addresses and hostnames that resolve to them are rejected — this is an SSRF guard, and it is enforced by resolving the hostname and checking every A/AAAA record.


Live webhooks

Live webhooks fire when a transaction is resolved — which may be immediately after the purchase, or much later if the purchase was held at 300 and the provider confirmed afterwards.

Payload

{
  "code": 200,
  "type": "airtime",
  "reference": "IP|TN|A|123|20260918120000_ASDFG",
  "description": "MTN Airtime Purchase",
  "message": "Airtime Purchase Successful"
}
Field Type Description
code number The final verdict: 200 delivered, 300 still pending, 400 failed.
type string Service: airtime, data, electricity, cables, education.
reference string The platform's internal reference (item_id).
description string What was bought.
message string The outcome text.

Three optional fields are added when the transaction carries them:

Field Present for Source
data_pin Education The raw PIN payload from the provider.
token Electricity The prepaid meter token.
unit Electricity The kilowatt-hours purchased.

The signature header

Every live webhook carries:

x-webhook-signature: <hex hmac>

The HMAC is SHA-512, keyed with your live key, over the exact raw request body:

const hash = crypto
  .createHmac('sha512', live_key)
  .update(JSON.stringify(body))
  .digest('hex');

Verify the signature over the raw body, not a re-serialised object

The signature is computed over JSON.stringify(body) — the platform's own serialisation. If you parse the JSON and re-serialise it, key order or number formatting can differ and the HMAC will not match. Read the raw bytes from the request.

Verifying in Node.js

import crypto from 'node:crypto';

export function verifyWebhook(rawBody, signatureHeader, liveKey) {
  const expected = crypto
    .createHmac('sha512', liveKey)
    .update(rawBody)              // raw bytes, not JSON.parse(rawBody)
    .digest('hex');

  const a = Buffer.from(expected, 'utf8');
  const b = Buffer.from(String(signatureHeader ?? ''), 'utf8');
  if (a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}
app.post('/webhooks/instapaybill', express.raw({ type: 'application/json' }), (req, res) => {
  if (!verifyWebhook(req.body, req.get('x-webhook-signature'), process.env.IPB_LIVE_KEY)) {
    return res.status(401).end();
  }
  const event = JSON.parse(req.body);
  // …handle the event…
  res.status(200).end();
});

Use the live key, not the test key

The signing key is always live_key, even if the transaction was created with a test key. There is one webhook secret per account: the live key.

Verifying in Python

import hmac, hashlib

def verify(raw_body: bytes, signature: str | None, live_key: str) -> bool:
    if not signature:
        return False
    expected = hmac.new(live_key.encode(), raw_body, hashlib.sha512).hexdigest()
    return hmac.compare_digest(expected, signature)

Verifying in PHP

$expected = hash_hmac('sha512', $rawBody, $liveKey);
$valid = hash_equals($expected, $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '');

Always use a constant-time comparison. A plain === leaks timing information.

Delivery guarantees

Property Behaviour
Trigger When the transaction resolves — by provider callback, by the pending-transaction cron, or by an admin action.
Scope Only for transactions created on the api channel. App and web purchases never trigger your webhook.
Timeout 10 seconds.
Redirects Disabled. A 301/302 is not followed.
Retries Up to 6 attempts total. Failed deliveries are re-sent by the resend-webhooks cron.
Body limit 1 MB.
Proxies Disabled.
DNS The target is resolved once and the IP is pinned — no second lookup, so DNS rebinding cannot redirect the call.

Respond 2xx fast

The platform does not wait for your processing to finish. Acknowledge with a 200 and do the work asynchronously, or you will burn the 10-second timeout and trigger a retry.

Idempotency

Because deliveries are retried, your handler must be idempotent. The reference field is stable across retries — use it as your idempotency key and store it before doing any work.


Sandbox webhooks

Sandbox webhooks are fired on every sandbox transaction, immediately after it is recorded.

Payload

{
  "event": "sandbox.transaction",
  "reference": "SBTEST-ABCD1234EF",
  "service": "airtime",
  "code": 200,
  "data": {
    "code": 200,
    "reference": "SBTEST-ABCD1234EF",
    "description": "MTN Airtime Purchase",
    "message": "Top-up of ₦500 to 08012345678 delivered successfully.",
    "type": "airtime"
  },
  "timestamp": "2026-10-19T12:00:00.000Z"
}
Field Type Description
event string Always sandbox.transaction.
reference string The sandbox transaction reference.
service string airtime, data, electricity, cables, education, startimes.
code number The simulated outcome: 200, 300 or 400.
data object The full simulated response body.
timestamp string ISO-8601 timestamp.

Headers

Content-Type: application/json
X-InstaPayBill-Sandbox: 1

The sandbox header is your discriminator

X-InstaPayBill-Sandbox: 1 is always present on sandbox deliveries and never on live ones. Use it to route test traffic away from your production order pipeline.

No signature

Sandbox webhooks are not signed. The delivery is HTTPS-only with redirects disabled, but there is no HMAC to check. Do not let sandbox traffic drive real fulfilment logic — check the header first.

Delivery status

The platform tracks the last delivery on the sandbox key record:

{
  "webhookUrl": "https://your-server.com/webhooks/instapaybill",
  "webhookLastDeliveredAt": "2026-10-19T12:00:00.000Z",
  "webhookLastStatus": 200
}

Available from GET /api/v1/sandbox/keys.


Inbound webhooks (provider → InstaPayBill)

These are for providers and partners, not for your integration. They are listed so you can recognise them if you see them in logs.

Method Path Provider Verification
POST /api/v1/wallet/manage-webhook Paystack Provider signature
POST /api/v1/wallet/manage-webhook-monnify Monnify Provider token
POST /api/v1/wallet/manage-webhook-palmpay PalmPay RSA-SHA1 signature
POST /api/v1/wallet/manage-webhook-flutterwave Flutterwave HMAC over the raw body
POST /api/v1/wallet/manage-webhook-budpay Budpay Provider check
POST /api/v1/wallet/manage-webhook-sprintcheck Sprintcheck HMAC-SHA512 of the raw body
POST /api/v1/transactions/manage-autosyncng-webhook AutoSyncNG Signature in the controller
POST /api/v1/transactions/manage-ogdams-webhook Ogdams Signature in the controller
POST /api/v1/transactions/manage-ses-notifications AWS SES SNS topic ARN allowlist
POST /api/v1/webhooks/onesignal OneSignal x-webhook-secret or Authorization: Bearer

All of these are public routes (no user credential) and are protected by their own signature verification, plus a 240 req/min rate limit on the biller callback routes.


The RSA signature scheme

Two providers (PalmPay and easeID) use a different scheme from the HMAC one. It is worth knowing if you ever need to debug a callback.

Signing (what InstaPayBill does when calling out):

signature = Base64(RSA-SHA1( UPPERCASE(MD5( sortedParams )) ))
  • sortedParams — the keys sorted alphabetically, joined as key=value&key=value, with falsy values dropped.
  • The MD5 hex digest is uppercased before signing.

Verifying (what a receiver does):

messageDigest = UPPERCASE(MD5( sortedParams ))
isVerified = RSA-SHA1-verify( messageDigest, base64Signature, publicKey )
  • The verifier excludes the sign field but keeps undefined-valued keys.

The two differ in how they treat empty values — the signer drops them, the verifier keeps them. That asymmetry is real and is in the source, not a documentation error.


Testing your webhook

# 1. Point the sandbox at a local tunnel
ngrok http 8080

# 2. Register the tunnel URL as the sandbox webhook
curl -X POST https://sandbox.instapaybill.com/api/v1/sandbox/keys/webhook \
  -H "Authorization: Bearer $IPB_SANDBOX_KEY" \
  -H "Content-Type: application/json" \
  -d '{"webhookUrl":"https://your-tunnel.ngrok.io/webhooks/instapaybill"}'

# 3. Trigger a sandbox transaction
curl -X POST https://sandbox.instapaybill.com/api/v1/sandbox/airtime \
  -H "Authorization: Bearer $IPB_SANDBOX_KEY" \
  -H "Content-Type: application/json" \
  -d '{"phone":"08012345678","amount":500,"network":"MTN","scenario":"success"}'

You should receive a sandbox.transaction event with X-InstaPayBill-Sandbox: 1 within a second or two.


  • Transactions — the resolve polling alternative
  • Errors — the 300 vs 400 rule for webhooks