Quickstart
Receive one credit end to end: a payer sends money to an account number, your endpoint gets a signed event, and a holder’s balance moves by the net. Fifteen minutes, no bank integration of your own.
1 · Register an endpoint
In the console, Webhooks → register. You get a signing secret once; it is never shown again. Store it where you keep your other secrets, not in your repository.
POST /v1/webhooks
Authorization: Bearer nvk_live_...
{ "url": "https://api.yourproduct.com/hooks/nineveh", "label": "production" }
201 { "id": "ep_...", "secret": "whsec_...", "status": "PENDING_VERIFICATION" }2 · Verify the signature
Every delivery carries nineveh-signature: t=<unix ms>,v1=<hex>. The MAC is HMAC-SHA256 over `${t}.${rawBody}` with your endpoint secret. Verify against the raw body, before any JSON parsing — a re-serialised body will not match.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(rawBody: string, header: string, secret: string): boolean {
const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
const t = Number(parts.t);
if (!Number.isFinite(t)) return false;
// reject anything older than five minutes: a valid signature on a replayed
// request is still a replay
if (Math.abs(Date.now() - t) > 5 * 60_000) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(parts.v1 ?? "", "hex");
return a.length === b.length && timingSafeEqual(a, b);
}=== on hex strings leaks how much of the signature you got right, one character at a time.3 · Answer 2xx, quickly
Acknowledge within ten seconds. Do the work afterwards — write the event down, return 200, then process. Anything that is not a 2xx is a failed attempt and will be retried on the published schedule.
export async function POST(req: Request) {
const raw = await req.text();
if (!verify(raw, req.headers.get("nineveh-signature") ?? "", SECRET)) {
return new Response("bad signature", { status: 401 });
}
const event = JSON.parse(raw);
// de-duplicate on event_id: a replay reuses it deliberately
await enqueueOnce(event.id, event);
return Response.json({ ok: true });
}4 · Receive a credit
In the sandbox console, Simulate a top-up raises a real credit on a real account number and runs the whole spine: match, price, post, deliver. Your endpoint receives:
{
"type": "credit.received",
"id": "evt_01J9...",
"data": {
"id": "crd_01J9...",
"account": "600 123 4567",
"holderRef": "occ_01J9F2",
"rootGroup": "North Site",
"group": "A-08",
"gross": { "currency": "NGN", "value": "10150.00" },
"net": { "currency": "NGN", "value": "10000.00" },
"fees": {
"institution": { "currency": "NGN", "value": "100.00" },
"product": { "currency": "NGN", "value": "50.00" }
},
"receivedAt": "2026-09-12T14:32:00.000Z"
}
}5 · Read the balance
The tenant’s balance has moved by the net, not the gross. You do not have to compute that — ask for it, and you will get the number the ledger derived from its postings.
GET /v1/wallets/hld_01J9.../balance
200 { "available": { "currency": "NGN", "value": "10000.00" }, "asOf": "2026-09-12T14:32:01.114Z" }