Référence
Référence de l'API, v1
L'API partenaire est du JSON sur HTTPS. Les valeurs binaires sont en hexadécimal (identifiants, clés publiques) ou en base64url sans remplissage (certificats, lots, relevés).
Cette page est disponible en anglais pour le moment.
Conventions
- Base URL: the origin that serves PayVault, written
$PAYVAULT_URLhere. Paths start with/api/v1. - Requests and responses are JSON. Responses are sent with
cache-control: no-store. - Amounts are integers in the currency's minor units. Timestamps in responses are ISO 8601, except
serverTime(Unix seconds). - Ids of allowances and payments are lowercase hex. Certificates, bundles and close statements are base64url without padding.
- Uploading payments is idempotent per payment id. Issuing an allowance is not deduplicated.
Authentication
Send your API key as a Bearer token. Keys look like pv_<12 hex>_<secret>. The 12 hex characters select the key, the secret is compared against a stored SHA-256 hash in constant time, and revoked keys stop working immediately. Create and revoke keys in the partner console.
curl -sS "$PAYVAULT_URL/api/v1/network" \ -H "Authorization: Bearer pv_a1b2c3d4e5f6_Zx9...secret"Errors
Errors use one shape: { "error": { "code", "message" } }. Branch on code; messages are for humans and may change.
HTTP/1.1 400{ "error": { "code": "invalid_request", "message": "devicePublicKey: invalid hex" }}| Code | HTTP | Meaning |
|---|---|---|
| unauthorized | 401 | Missing, malformed or revoked API key, or the partner is suspended |
| bad_json | 400 | The body is not valid JSON |
| invalid_request | 400 | A field failed validation. The message names the first offending field |
| hosted_partner | 409 | POST /api/v1/allowances on a hosted-ledger partner |
| over_limit | 400 | funded + credit is zero, or above the country pack's offline cap |
| unsupported_country | 400 | country has no country pack |
| not_found | 404 | Unknown allowance, or one that belongs to another partner |
| forbidden | 403 | The allowance belongs to another partner (close) |
| decode | 400 | The close statement could not be decoded |
| bad_signature | 400 | The close statement signature does not match the allowance's device key |
| internal | 500 | Unexpected error. Safe to retry reads |
Endpoints
GET/api/network
Issuer public keys and recent revocations. Public and unauthenticated so any device can cache it. Includes the keys of all partners, so a merchant can verify another partner's allowances offline.
Auth: none
Query: since (optional, Unix seconds) returns revocations created after that time. The default is the last 30 days, and a response carries at most 5,000 revocations. status is active or retired; retired keys stay listed so older certificates still verify.
GET /api/network?since=1790000000200 OK{ "issuerKeys": [ { "kid": 3, "partnerId": "0b7c...", "publicKey": "02c4f1...e9", "status": "active" }, { "kid": 2, "partnerId": "0b7c...", "publicKey": "03a91d...07", "status": "retired" } ], "revocations": [ { "allowanceId": "9f2c1e5b7a3d4c08b6e1a2d3f4c5b6a7", "at": "2026-09-28T14:02:11.000Z" } ], "serverTime": 1790012345}POST/api/v1/allowances
Sign an allowance for a device whose funds (and credit) you hold in your own ledger. External-ledger partners only.
Auth: Bearer API key
POST /api/v1/allowancesAuthorization: Bearer pv_...Content-Type: application/json{ "devicePublicKey": "02c4f1...e9", // 66 hex chars, compressed P-256 "country": "NG", // must be a supported country pack "funded": 500000, // minor units, >= 0 "credit": 0, // minor units, >= 0 (default 0) "externalRef": "cust_8123_vault_1", // 1..100 chars "ttlHours": 72 // optional, 1..720, capped by the pack TTL}201 Created{ "id": "9f2c1e5b7a3d4c08b6e1a2d3f4c5b6a7", "cert": "AQEAn8sc63...", // base64url of the 146-byte certificate "issuerKid": 3, "currency": "NGN", "funded": 500000, "credit": 0, "perTxLimit": 1500000, "expiresAt": "2026-10-02T09:30:00.000Z"}Errors: unauthorized, invalid_request, hosted_partner, over_limit, unsupported_country. Emits allowance.issued.
GET/api/v1/allowances/{id}
Current state of one of your allowances, including how much has settled and any fraud cases.
Auth: Bearer API key
200 OK{ "id": "9f2c1e5b7a3d4c08b6e1a2d3f4c5b6a7", "externalRef": "cust_8123_vault_1", "status": "active", // active | closing | closed | revoked "currency": "NGN", "funded": 500000, "credit": 0, "settled": { "funded": 250000, "credit": 0, "riskPool": 0, "payments": 1 }, "refunded": 0, "declared": null, // or { "seq": 4, "cumulative": 180000 } after a close statement "expiresAt": "2026-10-02T09:30:00.000Z", "fraudCases": [ { "kind": "fork", // fork | chain_break | overspend | after_close "seq": 3, "loss": 0, "status": "open", // open | recovered | written_off "paymentIds": ["5d0e...c1", "77ab...09"], "createdAt": "2026-09-29T10:04:00.000Z" } ]}id is 32 hex characters. Errors: invalid_request for a malformed id, not_found if it is not yours.
POST/api/v1/allowances/{id}/revoke
Revoke an allowance, for example after a device is reported lost. It joins the revocation list that merchants cache. Revoking twice is harmless.
Auth: Bearer API key
POST /api/v1/allowances/9f2c1e5b7a3d4c08b6e1a2d3f4c5b6a7/revoke{ "reason": "device_reported_lost" } // optional, 1..100 chars, default "partner_request"200 OK{ "id": "9f2c1e5b7a3d4c08b6e1a2d3f4c5b6a7", "status": "revoked" }Merchants only learn of a revocation when they next sync, so revocation shrinks the risk window but does not close it. Emits allowance.revoked.
POST/api/v1/allowances/close
Submit a device-signed close statement for early cash-out. The allowance moves to closing, then to closed when every declared payment has arrived.
Auth: Bearer API key
POST /api/v1/allowances/close{ "close": "AQUp..." } // base64url of the 92-byte close statement200 OK{ "id": "9f2c1e5b7a3d4c08b6e1a2d3f4c5b6a7", "status": "closing", // "closed" once every declared payment has arrived "declared": { "seq": 4, "cumulative": 180000 }}Errors: decode, not_found, forbidden, bad_signature. Submitting a statement for an allowance that is no longer active returns its current state. In external-ledger mode PayVault does not move money: release the unspent funded value in your own ledger, using declared.cumulative, and treat allowance.closed as the signal that nothing more is expected.
POST/api/v1/payments/sync
Upload offline payment bundles collected by your devices, merchants or couriers. Each bundle is processed in its own transaction and the call never fails as a whole because one bundle is bad.
Auth: Bearer API key
POST /api/v1/payments/sync{ "bundles": ["AQQBAZ...", "AQQBAY..."] } // 1..500 base64url bundles (276 bytes each)200 OK{ "results": [ { "id": "5d0e...c1", "allowanceId": "9f2c1e5b7a3d4c08b6e1a2d3f4c5b6a7", "status": "settled", "amount": 250000, "currency": "NGN", "merchantUserId": null, "payerUserId": null }, { "id": "5d0e...c1", "allowanceId": "9f2c...", "status": "duplicate", "code": "settled" }, { "allowanceId": "9f2c...", "status": "rejected", "code": "over_cap" }, { "status": "rejected", "code": "decode" } ]}Result status
settled: honoured and allocated across funded value, overdraft and the risk pool.flagged: honoured, but implicated in a fork, chain break, overspend or after-close payment.duplicate: already known.codeholds the stored status.rejected: not honoured.codeis one of the codes below.
| Reject code | Meaning |
|---|---|
| decode | The bytes are not a valid bundle |
| unknown_allowance | No allowance with that id was issued by PayVault |
| cert_mismatch | The certificate is not byte-identical to the issued one |
| wrong_allowance | The payment names a different allowance |
| bad_payer_signature | The payment signature does not verify under the device key |
| bad_amount | Amount is 0, cumulative below amount, or seq below 1 |
| over_tx_limit | Amount is above the allowance's per-payment limit |
| over_cap | Cumulative is above funded + credit |
| too_many_payments | Sequence number is above maxPayments |
| expired | Device time is more than 300 seconds after expiry |
| unknown_merchant | Hosted ledger only: the merchant id is not registered |
| forbidden | Your partner is neither the allowance's partner nor the merchant's |
merchantUserId and payerUserId are set only for users of a hosted ledger and are null in external-ledger mode. Emits payment.settled or payment.flagged, and fraud.detected when a case is serious.
GET/api/v1/payments
Your settled payments, newest first: those on your allowances plus those received by your merchants.
Auth: Bearer API key
Query: limit (1 to 200, default 50) and before (ISO timestamp, from nextBefore).
GET /api/v1/payments?limit=50&before=2026-09-29T10:00:00.000Z200 OK{ "data": [ { "id": "5d0e...c1", "allowanceId": "9f2c1e5b7a3d4c08b6e1a2d3f4c5b6a7", "externalRef": "cust_8123_vault_1", "seq": 1, "amount": 250000, "currency": "NGN", "merchantId": "a1b2c3d4e5f60718", "fromFunded": 250000, "fromCredit": 0, "fromRiskPool": 0, "status": "settled", // settled | flagged "receivedVia": "merchant", // merchant | payer | courier | api "deviceTime": "2026-09-29T09:58:40.000Z", "receivedAt": "2026-09-29T10:02:11.000Z" } ], "nextBefore": null // pass as ?before= for the next page, or null at the end}GET/api/v1/events
Pull the same events that webhooks deliver, oldest first, with a cursor.
Auth: Bearer API key
Query: after (an event id, default 0) and limit (1 to 500, default 100). Store nextAfter and pass it next time. Event types and payloads are on the webhooks page.
GET /api/v1/events?after=40&limit=100200 OK{ "data": [ { "id": 41, "type": "payment.settled", "createdAt": "2026-09-29T10:02:11.000Z", "data": { "...": "..." } } ], "nextAfter": 41}GET/api/v1/network
The same snapshot as /api/network, behind your API key. Use whichever is convenient; devices should prefer the public one.
Auth: Bearer API key
Query: since (Unix seconds). Same response as above.
Reference app API
The reference wallet and merchant app in this repository uses a separate first-party API under /api/app. It authenticates with the user's session cookie, not an API key, is meant for the reference app only, and can change without notice. It is listed so you can see how the hosted-ledger sandbox works. Do not build partner integrations on it.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/app/me | Everything the wallet app renders: balances, limits, allowances, loans, activity, network snapshot |
| POST | /api/app/onboard | Create the user profile (name, country, locale) |
| POST | /api/app/profile | Update display name or locale |
| POST | /api/app/kyc | Submit an ID for verification (sandbox check), which raises the KYC tier |
| POST | /api/app/devices | Register a device public key |
| POST | /api/app/vault | Lock funded value and optional overdraft into an offline vault; returns the certificate |
| POST | /api/app/vault/close | Submit a close statement for early cash-out |
| POST | /api/app/sync | Upload bundles held on the device; returns results and a fresh network snapshot |
| POST | /api/app/topup | Sandbox top-up of the wallet |
| POST | /api/app/transfer | Send wallet money to another user by email |
| POST | /api/app/repay | Repay open overdraft loans from the wallet |
| POST | /api/app/merchant | Enable merchant mode and get a merchant id |
| POST | /api/app/sweep | Move merchant earnings to the wallet |