Skip to content

Reference

API reference, v1

The partner API is JSON over HTTPS. Binary values are hex (ids, public keys) or base64url without padding (certificates, bundles, statements).

Conventions

  • Base URL: the origin that serves PayVault, written $PAYVAULT_URL here. 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.

Authorization header
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.

Error response
HTTP/1.1 400{  "error": {    "code": "invalid_request",    "message": "devicePublicKey: invalid hex"  }}
Error codes
CodeHTTPMeaning
unauthorized401Missing, malformed or revoked API key, or the partner is suspended
bad_json400The body is not valid JSON
invalid_request400A field failed validation. The message names the first offending field
hosted_partner409POST /api/v1/allowances on a hosted-ledger partner
over_limit400funded + credit is zero, or above the country pack's offline cap
unsupported_country400country has no country pack
not_found404Unknown allowance, or one that belongs to another partner
forbidden403The allowance belongs to another partner (close)
decode400The close statement could not be decoded
bad_signature400The close statement signature does not match the allowance's device key
internal500Unexpected 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.

Request and response
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

Request
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}
Response
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

Response
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

Request and response
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

Request and response
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

Request
POST /api/v1/payments/sync{ "bundles": ["AQQBAZ...", "AQQBAY..."] }   // 1..500 base64url bundles (276 bytes each)
Response
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. code holds the stored status.
  • rejected: not honoured. code is one of the codes below.
Payment reject codes
Reject codeMeaning
decodeThe bytes are not a valid bundle
unknown_allowanceNo allowance with that id was issued by PayVault
cert_mismatchThe certificate is not byte-identical to the issued one
wrong_allowanceThe payment names a different allowance
bad_payer_signatureThe payment signature does not verify under the device key
bad_amountAmount is 0, cumulative below amount, or seq below 1
over_tx_limitAmount is above the allowance's per-payment limit
over_capCumulative is above funded + credit
too_many_paymentsSequence number is above maxPayments
expiredDevice time is more than 300 seconds after expiry
unknown_merchantHosted ledger only: the merchant id is not registered
forbiddenYour 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).

Request and response
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.

Request and response
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.

Reference app API
MethodPathPurpose
GET/api/app/meEverything the wallet app renders: balances, limits, allowances, loans, activity, network snapshot
POST/api/app/onboardCreate the user profile (name, country, locale)
POST/api/app/profileUpdate display name or locale
POST/api/app/kycSubmit an ID for verification (sandbox check), which raises the KYC tier
POST/api/app/devicesRegister a device public key
POST/api/app/vaultLock funded value and optional overdraft into an offline vault; returns the certificate
POST/api/app/vault/closeSubmit a close statement for early cash-out
POST/api/app/syncUpload bundles held on the device; returns results and a fresh network snapshot
POST/api/app/topupSandbox top-up of the wallet
POST/api/app/transferSend wallet money to another user by email
POST/api/app/repayRepay open overdraft loans from the wallet
POST/api/app/merchantEnable merchant mode and get a merchant id
POST/api/app/sweepMove merchant earnings to the wallet