Add an endpoint
In Developers > Webhooks, choose Add endpoint and pick Bolrach Pay as the sender. The address must be public HTTPS with a host name. We look the name up when you save it, and again before every delivery, and refuse anything that points into a private network. Pick the mode: a test endpoint hears only about test activity, a live one only about real money. Choose all events or just the ones you handle.
The signing secret appears the moment the endpoint is added, once. We store it encrypted and cannot show it again, so put it in your server's configuration straight away. Pay asks for a fresh sign-in before it saves the endpoint.
Your server can do the same through the API, with a key carrying payments:write. The endpoint takes the key's mode.
curl -s -X POST https://api.bolrachpay.com/v1/webhook_endpoints \
-H "Authorization: Bearer $BOLRACH_KEY" \
-H "Content-Type: application/json" \
-d '{ "url": "https://shop.example/hooks/bolrach", "events": ["payment.succeeded", "refund.completed"] }'
# The answer includes the signing secret once. Leave out "events" to hear every event.
# Rotate later with POST /v1/webhook_endpoints/:id/rotate_secret.What arrives
Each delivery is a JSON body with the event's id, its type, created as Unix seconds and a data object describing what changed. Field names inside data are camelCase, and money is a minor-unit string, as everywhere else in Pay.
POST /hooks/bolrach HTTP/1.1
Content-Type: application/json
Bolrach-Signature: t=1791215760,v1=5f0c8e...e41a
{
"id": "6c1d0f0e-...",
"type": "payment.succeeded",
"created": 1791215760,
"data": {
"id": "0b6f2c9a-...",
"publicId": "pay_36bac68976f34574be70",
"amountMinor": "250000",
"currency": "NGN",
"status": "succeeded",
"provider": "korapay",
"metadata": {}
}
}| Event | When |
|---|---|
payment.succeeded | A payment went through. |
payment.failed | A payment attempt failed. |
refund.created | A refund was started. |
refund.pending | A refund is waiting on the processor. |
refund.completed | A refund reached the buyer. |
refund.failed | A refund failed at the processor. |
refund.review_required | Refund outcomes disagree and need a person (see Refunds). |
dispute.opened | A buyer disputed a payment. |
dispute.resolved | A dispute was decided. |
payout.created | A payout was created. |
payout.completed | A payout arrived at the bank. |
payout.failed | A payout failed. |
payout_batch.created | A batch payout was created. |
store.order.paid | A Store order was paid. |
store.order.shipped | A Store order was shipped. |
Check the signature
The Bolrach-Signature header looks like t=1791215760,v1=5f0c8e.... t is when we signed, in Unix seconds. v1 is the hex HMAC-SHA256 of the string t, a full stop and the raw body, keyed with your endpoint's signing secret. To check it:
- Read the body as raw bytes, before any JSON parser or framework middleware changes it. Re-serialised JSON will not match.
- Compute the HMAC of
t + "." + bodyand compare it withv1in constant time. - Refuse a
tmore than five minutes away from the server's clock, so nobody can replay an old delivery. - Answer
400when any of that fails, and do nothing else with the request.
Node, with Express:
import crypto from "node:crypto";
import express from "express";
// raw is the body exactly as it arrived, before any JSON parsing touched it.
export function verifyBolrach(raw, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries((header ?? "").split(",").map((kv) => kv.trim().split("=")));
const t = Number(parts.t);
if (!parts.v1 || !Number.isFinite(t)) return false;
if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.${raw}`).digest("hex");
const a = Buffer.from(expected), b = Buffer.from(parts.v1);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
const app = express();
app.post("/hooks/bolrach", express.raw({ type: "application/json" }), async (req, res) => {
if (!verifyBolrach(req.body.toString("utf8"), req.get("Bolrach-Signature"), process.env.BOLRACH_WEBHOOK_SECRET)) {
return res.status(400).send("bad signature");
}
const event = JSON.parse(req.body.toString("utf8"));
if (await events.seen(event.id)) return res.sendStatus(200); // already handled
await queue.add(event); // do the work after answering
res.sendStatus(200);
});Python, with Flask:
import hashlib, hmac, json, time
def verify_bolrach(raw: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = dict(p.strip().split("=", 1) for p in (header or "").split(",") if "=" in p)
try:
t = int(parts["t"])
except (KeyError, ValueError):
return False
if "v1" not in parts or abs(time.time() - t) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts["v1"])
# Flask
@app.post("/hooks/bolrach")
def bolrach_hook():
if not verify_bolrach(request.get_data(), request.headers.get("Bolrach-Signature"), BOLRACH_WEBHOOK_SECRET):
return "bad signature", 400
event = json.loads(request.get_data())
enqueue(event)
return "", 200Answering, retries and duplicates
Answer with any 2xx within eight seconds. Anything else counts as a failed delivery, including a redirect, because we do not follow redirects. A failed delivery is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours. After the sixth failed attempt it stops, and the delivery log in Developers > Webhooks shows it with the last status code or error.
The same event can reach you more than once, and events can arrive out of order. Keep the id of every event you have handled and skip repeats. When an event matters, read the object again from the API and act on what it says now, not on the order events arrived in.
POST /v1/webhook_endpoints/:id/rotate_secret swaps it in one step. A secret that may have leaked should be replaced the same day.Each endpoint listed in Developers > Webhooks has a Send test button that delivers a signed test event, so a handler can be checked before a real payment. In test mode, every payment made with a test card produces real signed events too.