API reference
Webhooks
Signed notifications to your server when money moves.
Add your endpoint in Settings → API keys & webhooks (separate URLs for test and live). We POST JSON to it and sign every request. Your signing secret (whsec_…) is shown once and can be rolled.
Request body
{
"id": "evt_5b1c0e7a9d2f4e6a8c3b1d0e",
"event": "payment.success",
"created_at": "2026-11-12T14:30:41.220Z",
"data": {
"reference": "BST-4F9A21C0D7E3",
"status": "successful",
"amount_minor": 12000,
"currency": "GHS",
"method": "mtn_momo",
"merchant_reference": "ORDER-10482",
"failure_reason": null
}
}Verify the signature
Header: BST-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "t.raw_body" with your signing secret>. Recompute it over the raw body, compare in constant time, and reject anything older than 5 minutes.
import crypto from "node:crypto";
// Use the RAW request body: parsing and re-serialising JSON changes the bytes.
export function verifyBstSignature(rawBody: string, header: string, secret: string): boolean {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false; // older than 5 minutes: reject
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const given = Buffer.from(parts.v1 ?? "", "hex");
const want = Buffer.from(expected, "hex");
return given.length === want.length && crypto.timingSafeEqual(given, want);
}Delivery and retries
- Answer with any 2xx within 10 seconds. Do slow work after responding.
- Failed deliveries are retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours, then marked failed. You can resend from the dashboard.
- The same event can arrive more than once: use the event id to ignore duplicates.
- Endpoints must be public https:// URLs on port 443. Redirects are not followed.