Webhooks
Receive events, verify the signature, and handle retries safely.
Contents
Why webhooks
The events that matter most happen when nobody is looking: a customer transfers money an hour after leaving your site, a payout settles overnight. Webhooks are how you find out. Polling is not a substitute — it is slower, it burns your read rate limit, and it still misses the case where the customer never returns.
You configure one endpoint per environment. Radok POSTs a signed JSON body to it for every event you subscribe to.
Setting up your endpoint
Webhook configuration lives in the merchant dashboard — not on an API key. You set the URL and signing secret while signed in; your integration only needs to receive and verify the deliveries.
- Go to Developers → Webhooks. The environment switch decides which endpoint you are editing — test and live are separate, and so are their signing secrets.
- Enter your URL and tick the events you want. Saving requires your password and 2FA code.
- Generate a signing secret. It is shown once. Put it in your environment configuration before your first event arrives.
- Press Send test event. It performs a real, signed HTTP request and reports the status your endpoint returned — so a misconfiguration surfaces now rather than during your first real payment.
Your URL must be publicly reachable
localhost.What a delivery looks like
A POST with a JSON body and three headers that matter.
POST /webhooks/radok HTTP/1.1
Host: api.example.com
Content-Type: application/json
X-Radok-Event-Id: evt_7c1f3a9d2b8e4506
X-Radok-Signature: t=1755948123,v1=5f2c...e91a
{
"id": "evt_7c1f3a9d2b8e4506",
"type": "collection.success",
"createdAt": "2026-08-23T11:42:10.000Z",
"data": {
"reference": "col_3a7d5e1f9b2c4d68",
"amount": { "value": 500000, "currency": "NGN" },
"netAmount": { "value": 495000, "currency": "NGN" },
"paidAt": "2026-08-23T11:42:10.000Z"
}
}Headers
| Field | Type | Description |
|---|---|---|
| X-Radok-Signature | string | t=<unix seconds>,v1=<hex>, plus v1_prev=<hex> during a secret rotation window. |
| X-Radok-Event-Id | string | Matches id in the body. Stable across retries and replays — this is your idempotency key. |
| Content-Type | string | Always application/json. |
Body
| Field | Type | Description |
|---|---|---|
| id | string | Event id, prefixed evt_. |
| type | string | The event name. See the event reference. |
| createdAt | string | ISO 8601, when the event was generated. |
| data | object | Event-specific. Shape depends on type. |
Verifying the signature
Do this before anything else. An endpoint that acts on an unverified webhook is an endpoint where anyone who guesses your URL can mark orders as paid.
- Read the raw request body as text, before any JSON parsing.
- Parse the header into
t,v1, and possiblyv1_prev. - Compute
HMAC-SHA256(key = your secret, message = "{t}.{raw body}")and hex-encode it. - Compare against
v1— and againstv1_previf present — using a constant-time comparison. - Reject anything where
tis more than five minutes old, so a captured delivery cannot be replayed at you later.
import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.RADOK_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 5 * 60;
function verify(rawBody, header) {
if (!header) return false;
// "t=1755948123,v1=5f2c...,v1_prev=8a3d..."
const parts = Object.fromEntries(
header.split(",").map((pair) => pair.split("=", 2)),
);
const timestamp = Number(parts.t);
if (!Number.isFinite(timestamp)) return false;
// Reject stale deliveries so a captured request can't be replayed later.
if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac("sha256", SECRET)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
// Accept EITHER signature: during a rotation window Radok signs with both the
// new secret (v1) and the previous one (v1_prev).
return ["v1", "v1_prev"].some((field) => {
const candidate = parts[field];
if (!candidate || candidate.length !== expected.length) return false;
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(candidate));
});
}
// express.raw, NOT express.json — the signature covers the exact bytes we sent,
// and JSON.parse followed by JSON.stringify does not reproduce them.
app.post(
"/webhooks/radok",
express.raw({ type: "application/json" }),
async (req, res) => {
const rawBody = req.body.toString("utf8");
if (!verify(rawBody, req.get("X-Radok-Signature"))) {
return res.status(400).send("invalid signature");
}
const event = JSON.parse(rawBody);
// Acknowledge first, work afterwards. Radok gives you 15 seconds.
res.status(200).send("ok");
await handleEvent(event);
},
);import hashlib, hmac, os, time
from flask import Flask, request
app = Flask(__name__)
SECRET = os.environ["RADOK_WEBHOOK_SECRET"].encode()
TOLERANCE_SECONDS = 5 * 60
def verify(raw_body: bytes, header: str | None) -> bool:
if not header:
return False
parts = dict(pair.split("=", 1) for pair in header.split(","))
try:
timestamp = int(parts["t"])
except (KeyError, ValueError):
return False
if abs(time.time() - timestamp) > TOLERANCE_SECONDS:
return False
expected = hmac.new(
SECRET, f"{timestamp}.".encode() + raw_body, hashlib.sha256
).hexdigest()
# Either signature is acceptable during a rotation window.
return any(
hmac.compare_digest(expected, parts[field])
for field in ("v1", "v1_prev")
if field in parts
)
@app.post("/webhooks/radok")
def radok_webhook():
# get_data() gives the exact bytes; request.json would not round-trip.
raw_body = request.get_data()
if not verify(raw_body, request.headers.get("X-Radok-Signature")):
return "invalid signature", 400
event = request.get_json()
enqueue_event(event) # do the work off the request thread
return "ok", 200The raw body, byte for byte
JSON.parse then JSON.stringify does not reproduce the bytes we signed — key order, whitespace, and number formatting can all differ. Every “signature never matches” report comes down to a body-parser middleware consuming the stream before the verifier sees it.Responding
- Return any
2xxto acknowledge. Anything else — including a3xxredirect — counts as a failure. - You have 15 seconds. Acknowledge first and do the work on a queue; a slow database call will otherwise turn a successful delivery into a retry.
- Your response body is recorded, capped at 1000 characters, and shown in the dashboard. A short error string there makes debugging much faster.
Retries and idempotency
A failed delivery is attempted up to five times in total, with exponential backoff starting around five seconds. Every attempt reuses the same id, so your endpoint must be prepared to see the same event more than once.
async function handleEvent(event) {
// Insert-if-absent on the event id. A duplicate delivery loses the race and
// returns here without doing the work twice.
const inserted = await db.processedEvents.insertIfAbsent(event.id);
if (!inserted) return;
switch (event.type) {
case "collection.success":
await markOrderPaid(event.data.reference, event.data.netAmount);
break;
case "collection.failed":
await releaseOrder(event.data.reference, event.data.reason);
break;
case "payout.success":
await markPayoutSettled(event.data.reference);
break;
case "payout.failed":
await markPayoutFailed(event.data.reference, event.data.reason);
break;
default:
// Unknown types are normal — we add events over time. Ignore, don't throw.
break;
}
}Order is not guaranteed
Signing secrets and rotation
Each environment has its own secret, prefixed whsec_. Radok stores it so it can sign with it, but never returns it after the moment it is generated.
Rotating generates a new secret and, for the grace window you choose (up to seven days), signs every delivery with both: the new secret as v1 and the old one as v1_prev. A verifier that accepts either — as the samples above do — keeps working throughout, so you can deploy on your own schedule with no dropped events.
- Rotate with a grace period long enough to cover your deploy.
- Copy the new secret and roll it out.
- The old secret stops signing when the window ends.
Rotate with no overlap only if the secret has leaked
Inspecting and replaying deliveries
The webhooks page lists every attempt for the current environment. Open one to see the exact payload sent, the HTTP status your endpoint returned, its response body, and the attempt count.
Replay re-sends an event to your currently configured URL, signed with your current secret. The event id is unchanged, so a properly idempotent handler recognises the repeat. Use it after fixing a bug that made your endpoint reject a batch of real events.
/v1/webhooks/deliveries?environment=test- Auth
- Dashboard session
Dashboard only — not available with an API key. Filterable by status and eventType. Cursor-paginated.
/v1/webhooks/deliveries/{deliveryId}- Auth
- Dashboard session
Dashboard only. Includes the full payload and the response body your endpoint returned.
/v1/webhooks/deliveries/{deliveryId}/replay- Auth
- Dashboard session
Dashboard only. Delivers synchronously and returns the outcome.
Before you go live
- Configure the LIVE endpoint separately. A test URL does not carry over.
- Generate a live signing secret and deploy it.
- Verify the signature on every request, using the raw body.
- Reject deliveries older than five minutes.
- Deduplicate on the event id.
- Acknowledge within 15 seconds; do the work asynchronously.
- Ignore unknown event types rather than erroring on them.
- Send a test event and confirm you see a 200 in the deliveries list.