Webhooks

When something happens to a payment, a refund, a dispute or a payout, Pay sends a signed POST to the merchant's server. Check the signature, answer quickly, and then do the work.

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": {}
  }
}
EventWhen
payment.succeededA payment went through.
payment.failedA payment attempt failed.
refund.createdA refund was started.
refund.pendingA refund is waiting on the processor.
refund.completedA refund reached the buyer.
refund.failedA refund failed at the processor.
refund.review_requiredRefund outcomes disagree and need a person (see Refunds).
dispute.openedA buyer disputed a payment.
dispute.resolvedA dispute was decided.
payout.createdA payout was created.
payout.completedA payout arrived at the bank.
payout.failedA payout failed.
payout_batch.createdA batch payout was created.
store.order.paidA Store order was paid.
store.order.shippedA 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:

  1. Read the body as raw bytes, before any JSON parser or framework middleware changes it. Re-serialised JSON will not match.
  2. Compute the HMAC of t + "." + body and compare it with v1 in constant time.
  3. Refuse a t more than five minutes away from the server's clock, so nobody can replay an old delivery.
  4. Answer 400 when 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 "", 200

Answering, 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.

To change a secret without missing anything, add a second endpoint with the same address, move your server to the new secret, then remove the old endpoint. The API route 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.

Every endpoint with its parameters: the Bolrach Pay API reference. Machine-readable: https://api.bolrachpay.com/v1/openapi.json.