Aller au contenu

Démarrage rapide

De la clé d'API au paiement rapproché

Ce pas-à-pas s'adresse à un partenaire qui tient les soldes dans son propre registre (mode registre externe). Vous bloquez les fonds, PayVault signe l'allocation, les appareils paient hors ligne, et vous réglez à partir des événements.

Cette page est disponible en anglais pour le moment.

1. Create an API key

In the partner console, open the API keys section and create a key. The full key is shown once. It looks like pv_a1b2c3d4e5f6_<secret>: a fixed pv_ prefix, 12 hex characters that identify the key, and the secret. PayVault stores only a hash of the secret. Keep the key on your server; never put it in an app.

shell
export PAYVAULT_URL="https://<your-payvault-host>"export PAYVAULT_KEY="pv_a1b2c3d4e5f6_<secret shown once in the console>"

2. Give each device a key

Each customer device generates its own ECDSA P-256 key pair. The public key is 33 bytes when compressed, which is 66 hex characters, and it is the only thing your backend needs.

device (TypeScript)
import { generateKeyPair, exportPublicKey, toHex } from "@payvault/protocol";// ECDSA P-256. The private key is non-extractable; store the CryptoKeyPair// in IndexedDB (it can be stored, but never read out).const deviceKeys = await generateKeyPair();const devicePublicKey = toHex(await exportPublicKey(deviceKeys.publicKey)); // 66 hex chars// Send devicePublicKey to your backend, authenticated as your customer.

3. Lock funds, then issue the allowance

Lock funded (and approve any credit) in your own ledger first, then ask PayVault to sign the certificate. PayVault does not move money in external-ledger mode: it signs, tracks and reconciles. The response carries the certificate as base64url.

POST /api/v1/allowances
curl -sS -X POST "$PAYVAULT_URL/api/v1/allowances" \  -H "Authorization: Bearer $PAYVAULT_KEY" \  -H "content-type: application/json" \  -d '{    "devicePublicKey": "02c4f1...e9",    "country": "NG",    "funded": 500000,    "credit": 0,    "externalRef": "cust_8123_vault_1",    "ttlHours": 72  }'# 201 Created{  "id": "9f2c1e5b7a3d4c08b6e1a2d3f4c5b6a7",  "cert": "AQEAn8sc63...",  "issuerKid": 3,  "currency": "NGN",  "funded": 500000,  "credit": 0,  "perTxLimit": 1500000,  "expiresAt": "2026-10-02T09:30:00.000Z"}
  • funded + credit must be above zero and at most the country pack's offline cap, or you get over_limit.
  • The per-payment limit and payment count come from the country pack. ttlHours can shorten the default expiry but not extend it.
  • externalRef is your own reference (for example your vault id). It comes back in reads, payments and events. The request is not deduplicated, so store the response rather than retrying blindly.

4. The device holds the certificate

Send the certificate to the device. It decodes it and creates its wallet state, which it must persist after every payment.

device (TypeScript)
import { decodeAllowance, fromBase64Url, initWalletState } from "@payvault/protocol";const cert = decodeAllowance(fromBase64Url(response.cert));let state = await initWalletState(cert); // { cert, seq: 0, cumulative: 0, lastHash: genesis }await saveState(state);                  // your storage (IndexedDB, SQLite...)

5. The merchant verifies, offline

Merchant devices cache the issuer keys and revocation list from the public network snapshot whenever they have signal. After that, the sequence below needs no network at all.

merchant (TypeScript)
import { fromHex, importPublicKey } from "@payvault/protocol";// Public endpoint. Call it whenever you have signal and cache the result.const net = await (await fetch(`${BASE}/api/network`)).json();const issuerKeys = new Map<number, CryptoKey>();for (const k of net.issuerKeys) issuerKeys.set(k.kid, await importPublicKey(fromHex(k.publicKey)));const revoked = new Set<string>(net.revocations.map((r) => r.allowanceId));
request and payment
import { bundleToQr, createRequest, parseQr, requestToQr, signPayment } from "@payvault/protocol";// MERCHANT: ask for 2,500.00 NGN (minor units).const request = createRequest(merchantId, 250000, "NGN", "Mama Ade Stores");showQr(requestToQr(request)); // "PV" + base45// PAYER: scan the request, sign, persist, then show the bundle.const scanned = parseQr(scannedText);if (scanned.kind !== "request") throw new Error("Not a payment request");const { payment, state: next } = await signPayment(deviceKeys.privateKey, state, scanned.request);await saveState(next);                       // persist BEFORE showing the QRstate = next;showQr(bundleToQr({ cert: state.cert, payment }));
merchant (TypeScript)
import { encodeBundle, parseQr, toBase64Url, verifyBundle } from "@payvault/protocol";// MERCHANT: scan the bundle and verify it with no network.const scanned = parseQr(scannedText);if (scanned.kind !== "bundle") throw new Error("Not a payment bundle");const result = await verifyBundle(scanned.bundle, {  issuerKey: (kid) => issuerKeys.get(kid),  isRevoked: (allowanceIdHex) => revoked.has(allowanceIdHex),  merchantId,                                  // your 8-byte merchant id  request,                                     // the request you displayed  isNonceUsed: (nonceHex) => usedNonces.has(nonceHex),});if (!result.ok) {  showRejected(result.code);                   // e.g. "expired", "over_cap", "fork"} else {  // Persist before handing over goods. This is the outbox you sync later.  await outbox.add(result.id, toBase64Url(encodeBundle(scanned.bundle)));  showAccepted(result.payment.amount);}

6. Sync and reconcile

When a merchant, payer or courier has signal, upload the outbox. Up to 500 bundles go in one call and each is settled in its own transaction. Uploading a bundle twice is safe: the second result has status: "duplicate".

POST /api/v1/payments/sync
curl -sS -X POST "$PAYVAULT_URL/api/v1/payments/sync" \  -H "Authorization: Bearer $PAYVAULT_KEY" \  -H "content-type: application/json" \  -d '{ "bundles": ["AQQBAZ...", "AQQBAY..."] }'# 200 OK{  "results": [    {      "id": "5d0e...c1",      "allowanceId": "9f2c1e5b7a3d4c08b6e1a2d3f4c5b6a7",      "status": "settled",      "amount": 250000,      "currency": "NGN",      "merchantUserId": null,      "payerUserId": null    },    { "status": "rejected", "code": "cert_mismatch", "allowanceId": "9f2c..." }  ]}

Each result is settled, flagged (the payment is honoured but implicated in a fork, broken chain or overspend), duplicate or rejected with a code. The full list of codes is in the API reference.

7. Consume events

Every settlement, revocation, closure and overdraft movement produces an event. Configure a webhook to receive them with a signature you can verify, or poll the events feed. Settle the merchant in your ledger from payment.settled, using fromFunded, fromCredit and fromRiskPool to book the source.

GET /api/v1/events
curl -sS "$PAYVAULT_URL/api/v1/events?after=0&limit=100" \  -H "Authorization: Bearer $PAYVAULT_KEY"{  "data": [    { "id": 41, "type": "payment.settled", "createdAt": "2026-09-29T10:02:11.000Z",      "data": { "paymentId": "5d0e...c1", "allowanceId": "9f2c...", "amount": 250000,                "currency": "NGN", "merchantId": "a1b2c3d4e5f60718", "seq": 1,                "fromFunded": 250000, "fromCredit": 0, "fromRiskPool": 0 } }  ],  "nextAfter": 41}

Optional: cash out early

To let a customer release unspent value before expiry, have the device call signClose and send the encoded statement to your backend, which posts it to POST /api/v1/allowances/close. Unspent funded value is yours to release in your ledger; the declared cumulative tells you how much was spent, and an allowance.closed event fires when every declared payment has arrived.