Partner API
Guide

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

PATCH /partner/webhook
Dashboard Bearer Auth
FieldTypeRequiredDescription
webhookUrlstring (URL)requiredPublicly reachable HTTPS URL (e.g. https://api.yourdomain.com/webhooks/billpadi)
cURL
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"}'
Example response — 200 OK
{
  "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 NameTrigger SourceDelivery Data Included
deposit.successIncoming bank transfer / virtual account deposit creditedamount, reference, paymentMethod, channel
withdrawal.successBank payout successfully settled to beneficiaryamount, bankName, accountNumber, fee
withdrawal.failedBank payout rejected/reversed by recipient NIP bankamount, failureReason
airtime.purchase.successAirtime top-up confirmed by carrier networkphone, network, amount, totalCost
data.purchase.successMobile data bundle top-up confirmedphone, network, plan, amount
electricity.purchase.successPrepaid / Postpaid electricity payment processedmeterNumber, disco, token, units
cabletv.purchase.successDSTV / GOtv / StarTimes renewal confirmedsmartCardNumber, package, amount
betting.fund.successBetting customer account credit confirmedcustomerId, provider, amount
education.purchase.successWAEC / JAMB registration PINs generatedexamType, pins, amount
international.airtime.successInternational airtime delivered across bordersphone, countryCode, operator, amount
international.data.successInternational data package deliveredphone, countryCode, operator, productCode
giftcard.sale.approvedSubmitted gift card trade verified & settledamountPaid, rate, currency
giftcard.sale.declinedSubmitted gift card trade declined after reviewdeclineReason
crypto.purchase.successCrypto purchase transferred to partner wallet addresscryptoAmount, txHash, network
crypto.sale.approvedCrypto deposit verified on-chain and fiat creditedfiatAmount, rate, txHash
crypto.sale.declinedCrypto sale trade rejecteddeclineReason

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 HeaderDescription
X-Webhook-SignatureHexadecimal HMAC-SHA256 digest of {timestamp}.{rawBody}
X-Webhook-TimestampEpoch millisecond timestamp of notification dispatch
Node.js / Express
Python / FastAPI
PHP
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:

electricity.purchase.success
{
  "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:

deposit.success
{
  "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.