Webhooks
Webhooks eliminate polling by pushing real-time notifications directly to your HTTPS endpoint whenever transactions complete, bank payouts settle, or asynchronous reviews resolve.
Configuring your webhook URL
You can configure or update your webhook URL using your authenticated partner dashboard token. Both PATCH /partner/webhook and GET /partner/profile return your unique webhookSecret, which you use to verify that incoming webhook payloads originate from Billpadi.
Update webhook URL
| Field | Type | Required | Description |
|---|---|---|---|
| webhookUrl | string (URL) | required | Publicly reachable HTTPS URL (e.g. https://api.yourdomain.com/webhooks/billpadi) |
curl -X PATCH https://api.billpadi.com/api/v1/partner/webhook \
-H "Authorization: Bearer <your-login-token>" \
-H "Content-Type: application/json" \
-d '{"webhookUrl": "https://api.yourdomain.com/webhooks/billpadi"}'{
"success": true,
"message": "Webhook URL updated successfully",
"data": {
"webhookUrl": "https://api.yourdomain.com/webhooks/billpadi",
"webhookSecret": "a8f3b4c1d2e9...7f6e5d4c3b2a1"
},
"timestamp": "2026-07-10T09:20:03.000Z",
"path": "/api/v1/partner/webhook"
}Your webhookSecret is also accessible at any time via GET /partner/profile under data.partner.webhookSecret.
Supported webhook events
Webhooks fire immediately upon synchronous transaction success, as well as asynchronously when background polling resolves pending carrier orders (such as electricity token generation) or when manual reviews complete (gift card sales, crypto trades):
| Event Name | Trigger Source | Delivery Data Included |
|---|---|---|
deposit.success | Incoming bank transfer / virtual account deposit credited | amount, reference, paymentMethod, channel |
withdrawal.success | Bank payout successfully settled to beneficiary | amount, bankName, accountNumber, fee |
withdrawal.failed | Bank payout rejected/reversed by recipient NIP bank | amount, failureReason |
airtime.purchase.success | Airtime top-up confirmed by carrier network | phone, network, amount, totalCost |
data.purchase.success | Mobile data bundle top-up confirmed | phone, network, plan, amount |
electricity.purchase.success | Prepaid / Postpaid electricity payment processed | meterNumber, disco, token, units |
cabletv.purchase.success | DSTV / GOtv / StarTimes renewal confirmed | smartCardNumber, package, amount |
betting.fund.success | Betting customer account credit confirmed | customerId, provider, amount |
education.purchase.success | WAEC / JAMB registration PINs generated | examType, pins, amount |
international.airtime.success | International airtime delivered across borders | phone, countryCode, operator, amount |
international.data.success | International data package delivered | phone, countryCode, operator, productCode |
giftcard.sale.approved | Submitted gift card trade verified & settled | amountPaid, rate, currency |
giftcard.sale.declined | Submitted gift card trade declined after review | declineReason |
crypto.purchase.success | Crypto purchase transferred to partner wallet address | cryptoAmount, txHash, network |
crypto.sale.approved | Crypto deposit verified on-chain and fiat credited | fiatAmount, rate, txHash |
crypto.sale.declined | Crypto sale trade rejected | declineReason |
Verifying webhook signatures
Every webhook HTTP POST sent by Billpadi includes cryptographic headers to prevent spoofing and tampering. Signatures are computed using HMAC-SHA256 with your webhookSecret:
| HTTP Header | Description |
|---|---|
| X-Webhook-Signature | Hexadecimal HMAC-SHA256 digest of {timestamp}.{rawBody} |
| X-Webhook-Timestamp | Epoch millisecond timestamp of notification dispatch |
const express = require("express");
const crypto = require("crypto");
const app = express();
const WEBHOOK_SECRET = process.env.BILLPADI_WEBHOOK_SECRET;
// Use express.raw to preserve exact raw bytes for signature validation
app.post("/webhooks/billpadi", express.raw({ type: "*/*" }), (req, res) => {
const signature = req.headers["x-webhook-signature"];
const timestamp = req.headers["x-webhook-timestamp"];
const rawBody = req.body.toString("utf8");
if (!signature || !timestamp) {
return res.status(400).send("Missing signature headers");
}
// Prevent replay attacks (5 minute window)
if (Math.abs(Date.now() - Number(timestamp)) > 300000) {
return res.status(401).send("Timestamp expired");
}
const message = `${timestamp}.${rawBody}`;
const expectedSignature = crypto
.createHmac("sha256", WEBHOOK_SECRET)
.update(message)
.digest("hex");
const isValid = crypto.timingSafeEqual(
Buffer.from(signature, "hex"),
Buffer.from(expectedSignature, "hex")
);
if (!isValid) {
return res.status(401).send("Invalid signature");
}
const eventPayload = JSON.parse(rawBody);
console.log("Verified webhook event:", eventPayload.event);
// Process event asynchronously on your end
handleEventAsync(eventPayload);
// Acknowledge receipt immediately with HTTP 200
res.status(200).json({ received: true });
});import hmac
import hashlib
import time
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
WEBHOOK_SECRET = "your_webhook_secret_here"
@app.post("/webhooks/billpadi")
async def handle_webhook(request: Request):
signature = request.headers.get("x-webhook-signature")
timestamp = request.headers.get("x-webhook-timestamp")
if not signature or not timestamp:
raise HTTPException(status_code=400, detail="Missing signature headers")
if abs(time.time() * 1000 - int(timestamp)) > 300000:
raise HTTPException(status_code=401, detail="Timestamp expired")
raw_body = (await request.body()).decode("utf-8")
message = f"{timestamp}.{raw_body}".encode("utf-8")
expected = hmac.new(WEBHOOK_SECRET.encode("utf-8"), message, hashlib.sha256).hexdigest()
if not hmac.compare_digest(signature, expected):
raise HTTPException(status_code=401, detail="Invalid signature")
payload = await request.json()
# Process payload...
return {"received": True}<?php
$webhookSecret = getenv("BILLPADI_WEBHOOK_SECRET");
$signature = $_SERVER["HTTP_X_WEBHOOK_SIGNATURE"] ?? "";
$timestamp = $_SERVER["HTTP_X_WEBHOOK_TIMESTAMP"] ?? "";
$rawBody = file_get_contents("php://input");
if (!$signature || !$timestamp) {
http_response_code(400);
exit("Missing signature headers");
}
if (abs(round(microtime(true) * 1000) - (int)$timestamp) > 300000) {
http_response_code(401);
exit("Timestamp expired");
}
$message = "{$timestamp}.{$rawBody}";
$expected = hash_hmac("sha256", $message, $webhookSecret);
if (!hash_equals($signature, $expected)) {
http_response_code(401);
exit("Invalid signature");
}
$payload = json_decode($rawBody, true);
// Process event...
http_response_code(200);
echo json_encode(["received" => true]);
?>Sample webhook payloads
Electricity token delivery payload:
{
"event": "electricity.purchase.success",
"transactionReference": "TXN_1720603200000_E98A",
"partnerReference": "BILL-ELEC-4421",
"status": "success",
"product": "electricity",
"amount": 10000,
"token": "4123 9081 2234 5678 9012",
"timestamp": 1752136800000
}Wallet deposit credited payload:
{
"event": "deposit.success",
"transactionReference": "DEP_1720603200000_33B1",
"partnerReference": "DEP-USR-990",
"status": "success",
"amount": 250000,
"paymentMethod": "bank_transfer",
"timestamp": 1752136800000
}Delivery retry policy
- Your endpoint must return an HTTP status code in the 2xx range within 10 seconds.
- If your endpoint times out or returns any non-2xx status code, Billpadi retries up to 3 times (at 5 minutes, 15 minutes, and 60 minutes).
- You can inspect all dispatch attempts, response codes, and payloads in real time via
GET /partner/webhooks.