Skip to content

Reference

Webhooks and events

PayVault writes an event in the same database transaction as the change it describes. You receive it by webhook, or you can pull it.

Delivery

Set your webhook URL and signing secret in the partner console. PayVault sends each event as an HTTP POST with a JSON body and treats any 2xx response as success. A request that takes longer than 8 seconds or returns anything else counts as a failed attempt.

Delivery
POST https://your-server.example/payvault/webhookcontent-type: application/jsonx-payvault-timestamp: 1790012345x-payvault-signature: 3f6a9c...   (hex HMAC-SHA256){"id":41,"type":"payment.settled","createdAt":"2026-09-29T10:02:11.000Z","data":{ ... }}
  • id is a number that increases with each event. Use it as your idempotency key: delivery is at least once, and ordering across retries is not guaranteed.
  • createdAt is when the change happened. The event is written in the same database transaction as the change it describes, so an event never exists for a change that rolled back.
  • Amounts are minor units. Ids are hex.

Event types

Event types
TypeWhen it firesWhat to do
allowance.issuedAn allowance was signed for a deviceRecord the vault against externalRef
allowance.closedEvery declared payment arrived after a close, or the vault expired and its grace period endedRelease any unspent funded value in your ledger
allowance.revokedYou revoked it, or fraud detection didStop treating the vault as spendable
payment.settledA payment was honoured and allocatedCredit the merchant; book the source from the three from* fields
payment.flaggedAs above, but the payment is implicated in a fork, chain break, overspend or after-close paymentPay the merchant, and open a review
fraud.detectedA serious fraud case was opened and the allowance revokedFreeze the account, start recovery
loan.drawnOverdraft was drawn by a settled paymentCreate or increase the customer's loan and fee
loan.repaidA hosted-ledger loan was fully repaidClose the loan record

A payment between two different partners produces payment.settled for both: one for the partner that issued the allowance and one for the partner whose merchant was paid.

Payloads

The data field of each event. The fromFunded, fromCredit and fromRiskPool fields always add up to amount.

data by type
// allowance.issued{ "allowanceId": "9f2c...", "userId": null, "currency": "NGN", "funded": 500000, "credit": 0,  "expiresAt": "2026-10-02T09:30:00.000Z", "externalRef": "cust_8123_vault_1" }// externalRef is present when issued through POST /api/v1/allowances. userId is set for hosted ledgers.// allowance.closed{ "allowanceId": "9f2c...", "reason": "cashout" }     // reason: "cashout" | "expired"// allowance.revoked{ "allowanceId": "9f2c...", "reason": "fork" }        // your reason, or the fraud case kind// payment.settled  and  payment.flagged   (same shape){ "paymentId": "5d0e...c1", "allowanceId": "9f2c...", "amount": 250000, "currency": "NGN",  "merchantId": "a1b2c3d4e5f60718", "seq": 1,  "fromFunded": 250000, "fromCredit": 0, "fromRiskPool": 0 }// fraud.detected{ "allowanceId": "9f2c...", "userId": null,  "cases": [ { "kind": "fork", "seq": 3, "paymentIds": ["5d0e...c1", "77ab...09"], "loss": 0 } ] }// loan.drawn{ "allowanceId": "9f2c...", "userId": "c2f1...", "currency": "NGN", "amount": 120000, "fee": 2400 }// loan.repaid{ "loanId": "e6a0...", "userId": "c2f1..." }

Verifying the signature

Every request carries two headers:

  • x-payvault-timestamp: Unix seconds when this attempt was sent.
  • x-payvault-signature: lowercase hex HMAC-SHA256, keyed with your webhook secret, of the string `${timestamp}.${body}`, where body is the raw request body exactly as received.
  1. Read the raw body before any JSON parsing or re-serialising.
  2. Reject if the timestamp is more than a few minutes from your clock, which limits replay.
  3. Recompute the HMAC, compare in constant time, and reject on mismatch.
  4. Only then parse the JSON, process by id idempotently, and return 2xx.
Node.js (Express)
import { createHmac, timingSafeEqual } from "node:crypto";import express from "express";const app = express();const SECRET = process.env.PAYVAULT_WEBHOOK_SECRET!;const TOLERANCE_SECONDS = 300;// The signature covers the exact bytes we sent, so read the raw body.app.post("/payvault/webhook", express.raw({ type: "application/json" }), (req, res) => {  const timestamp = req.header("x-payvault-timestamp") ?? "";  const signature = req.header("x-payvault-signature") ?? "";  const body = req.body.toString("utf8");  const age = Math.abs(Date.now() / 1000 - Number(timestamp));  if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return res.sendStatus(400);  const expected = createHmac("sha256", SECRET).update(`${timestamp}.${body}`).digest("hex");  const a = Buffer.from(expected);  const b = Buffer.from(signature);  if (a.length !== b.length || !timingSafeEqual(a, b)) return res.sendStatus(401);  const event = JSON.parse(body); // { id, type, createdAt, data }  // Process idempotently, keyed by event.id, then acknowledge.  handleEvent(event);  res.sendStatus(200);});

Retries

Failed events stay queued. They are attempted again on each delivery run, oldest first, up to 12 attempts in total. After the twelfth failure PayVault stops pushing that event. The failure is recorded, and the event remains available from the events feed, so nothing is lost. Return 2xx quickly and do the heavy work afterwards; a slow handler looks like a failure.

Pulling instead

You can use the feed instead of, or as well as, webhooks. It returns the same events in id order, and a cursor makes it safe to resume after downtime. A common pattern is to receive webhooks for speed and poll once every few minutes from your last processed id to close any gap.

GET /api/v1/events
# Poll from the last id you storedcurl -sS "$PAYVAULT_URL/api/v1/events?after=$LAST_ID&limit=100" \  -H "Authorization: Bearer $PAYVAULT_KEY"# { "data": [ { "id": 42, "type": "...", "createdAt": "...", "data": { ... } } ], "nextAfter": 42 }

Resuming after downtime

Ids increase but are not guaranteed to be consecutive for your account, so do not treat a jump as a lost event. Store the highest id you have fully processed, and after an outage call the feed with after set to it.