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.

http
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.

typescript
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);
}
Use a constant-time comparison. === 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.

typescript
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:

json
{
  "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.

http
GET /v1/wallets/hld_01J9.../balance
200 { "available": { "currency": "NGN", "value": "10000.00" }, "asOf": "2026-09-12T14:32:01.114Z" }
Do not mirror the ledger. Keep your own record of what you were told, by event id, for your own audit. But when you need a balance, ask for it. A second ledger is a second answer, and one of them will be wrong on the day it matters.