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.
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":{ ... }}idis 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.createdAtis 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
| Type | When it fires | What to do |
|---|---|---|
| allowance.issued | An allowance was signed for a device | Record the vault against externalRef |
| allowance.closed | Every declared payment arrived after a close, or the vault expired and its grace period ended | Release any unspent funded value in your ledger |
| allowance.revoked | You revoked it, or fraud detection did | Stop treating the vault as spendable |
| payment.settled | A payment was honoured and allocated | Credit the merchant; book the source from the three from* fields |
| payment.flagged | As above, but the payment is implicated in a fork, chain break, overspend or after-close payment | Pay the merchant, and open a review |
| fraud.detected | A serious fraud case was opened and the allowance revoked | Freeze the account, start recovery |
| loan.drawn | Overdraft was drawn by a settled payment | Create or increase the customer's loan and fee |
| loan.repaid | A hosted-ledger loan was fully repaid | Close 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.
// 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}`, wherebodyis the raw request body exactly as received.
- Read the raw body before any JSON parsing or re-serialising.
- Reject if the timestamp is more than a few minutes from your clock, which limits replay.
- Recompute the HMAC, compare in constant time, and reject on mismatch.
- Only then parse the JSON, process by
ididempotently, and return 2xx.
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.
# 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.