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:
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:
The HMAC is SHA-512, keyed with your live key, over the exact raw request body:
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¶
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):
sortedParams— the keys sorted alphabetically, joined askey=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
signfield but keepsundefined-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.
Related¶
- Transactions — the
resolvepolling alternative - Errors — the
300vs400rule for webhooks