RADOK

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.

  1. Go to Developers → Webhooks. The environment switch decides which endpoint you are editing — test and live are separate, and so are their signing secrets.
  2. Enter your URL and tick the events you want. Saving requires your password and 2FA code.
  3. Generate a signing secret. It is shown once. Put it in your environment configuration before your first event arrives.
  4. 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

Private, loopback, and link-local addresses are rejected, and the check is repeated at delivery time to defeat DNS rebinding. Redirects are treated as a delivery failure and are not followed, so point at the final URL. For local development, tunnel with something like ngrok rather than pointing at localhost.

What a delivery looks like

A POST with a JSON body and three headers that matter.

Request
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

FieldTypeDescription
X-Radok-Signaturestringt=<unix seconds>,v1=<hex>, plus v1_prev=<hex> during a secret rotation window.
X-Radok-Event-IdstringMatches id in the body. Stable across retries and replays — this is your idempotency key.
Content-TypestringAlways application/json.

Body

FieldTypeDescription
idstringEvent id, prefixed evt_.
typestringThe event name. See the event reference.
createdAtstringISO 8601, when the event was generated.
dataobjectEvent-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.

  1. Read the raw request body as text, before any JSON parsing.
  2. Parse the header into t, v1, and possibly v1_prev.
  3. Compute HMAC-SHA256(key = your secret, message = "{t}.{raw body}") and hex-encode it.
  4. Compare against v1 — and against v1_prev if present — using a constant-time comparison.
  5. Reject anything where t is more than five minutes old, so a captured delivery cannot be replayed at you later.
Node.js (Express)
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);
  },
);
Python (Flask)
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", 200

The 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 2xx to acknowledge. Anything else — including a 3xx redirect — 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.

Idempotent handling
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

Deliveries are queued independently, so a retry of an earlier event can arrive after a later one. Key your state off the resource’s own status rather than assuming the sequence you received.

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.

  1. Rotate with a grace period long enough to cover your deploy.
  2. Copy the new secret and roll it out.
  3. The old secret stops signing when the window ends.

Rotate with no overlap only if the secret has leaked

A zero-length grace window means the previous secret stops signing immediately. That is correct for a compromise and wrong for a routine rotation.

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.

GET/v1/webhooks/deliveries?environment=test
Auth
Dashboard session

Dashboard only — not available with an API key. Filterable by status and eventType. Cursor-paginated.

GET/v1/webhooks/deliveries/{deliveryId}
Auth
Dashboard session

Dashboard only. Includes the full payload and the response body your endpoint returned.

POST/v1/webhooks/deliveries/{deliveryId}/replay
Auth
Dashboard session

Dashboard only. Delivers synchronously and returns the outcome.

Before you go live

  1. Configure the LIVE endpoint separately. A test URL does not carry over.
  2. Generate a live signing secret and deploy it.
  3. Verify the signature on every request, using the raw body.
  4. Reject deliveries older than five minutes.
  5. Deduplicate on the event id.
  6. Acknowledge within 15 seconds; do the work asynchronously.
  7. Ignore unknown event types rather than erroring on them.
  8. Send a test event and confirm you see a 200 in the deliveries list.