Bank collections
The API a bank provides, the signed events NexG receives, and the NexG Sandbox Bank for testing.
On this page
Guests can pay for an order straight from a bank account at checkout. NexG asks the bank to collect the exact amount owed against a NexG payment reference, the guest approves it on the bank's own page, and the bank tells NexG what happened in a signed event. Only that signed event marks an order paid.
The first bank is the NexG Sandbox Bank: a stand-in that behaves like a bank so the whole flow can be tested end to end. No real money moves, and it is refused on the production site. A real bank connects through the same contract, described below.
Status
| Capability | Status | Notes |
|---|---|---|
| Bank collection at checkout | Available in sandbox | Orders, from checkout and the pay page. |
| NexG Sandbox Bank | Available | Self-hosted service in apps/bank-sandbox. |
| A real bank | Not connected | Needs a bank chosen, its sandbox keys, and an adapter (see the end). |
| Settlement payouts to merchants | Not implemented | NexG does not accept payout instructions over any API. |
How a bank payment works
- The guest chooses Bank at checkout. NexG creates the order and a payment row with a reference such as
NXP-261110-7FD3EEA0C8. The amount comes from the order in the database, never from the browser. - NexG opens a collection at the bank for that reference and amount, then sends the guest to the bank's
checkout_url. - The guest approves (or cancels) at the bank and is sent back to
/pay/<reference>. - The bank posts a signed event to NexG's webhook. The database checks the signature, its age, the collection, the amount and the currency, then marks the payment and the order paid, or the payment failed with the bank's reason.
- If the webhook is late or lost, the pay page asks the bank for the collection's latest signed event and records it the same way. Either path settles a payment once.
The bank API NexG calls
Any bank (or bank adapter) NexG connects to provides these endpoints. The sandbox implements them exactly. Every call sends Authorization: Bearer <bank API key>. Amounts are whole numbers in cents (KES 1,616.25 is 161625); only KES is accepted. Errors are {"error": {"code": "...", "message": "..."}} with a 4xx or 5xx status.
Open a collection
Idempotency-Key is required. NexG sends the payment reference, so a retried request returns the same collection (200) instead of opening a second one; the same key with a different body is refused (409).
POST /v1/collections
Authorization: Bearer <bank API key>
Idempotency-Key: NXP-261110-7FD3EEA0C8
Content-Type: application/json
{
"merchant_reference": "NXP-261110-7FD3EEA0C8",
"amount": 161625,
"currency": "KES",
"description": "NexG order NX-261110-0003",
"customer": { "name": "Wanjiru Kamau", "email": "guest@example.com" },
"callback_url": "https://nexgapp.com/api/payments/bank/webhook",
"return_url": "https://nexgapp.com/pay/NXP-261110-7FD3EEA0C8"
}{
"id": "col_IwUUEAPrDf55OFD0",
"object": "collection",
"status": "pending",
"merchant_reference": "NXP-261110-7FD3EEA0C8",
"amount": 161625,
"currency": "KES",
"description": "NexG order NX-261110-0003",
"customer": { "name": "Wanjiru Kamau", "email": "guest@example.com" },
"payer": null,
"bank_reference": null,
"failure_code": null,
"failure_reason": null,
"checkout_url": "https://bank-sandbox.example.com/checkout/col_IwUUEAPrDf55OFD0",
"created_at": "2026-11-10T11:20:04.112Z",
"expires_at": "2026-11-10T11:50:04.112Z",
"completed_at": null,
"livemode": false
}| Endpoint | What it does |
|---|---|
GET /v1/collections/{id} | Read a collection. |
GET /v1/collections/{id}/event | The latest state as an event, signed like a webhook (X-Bank-Signatureheader). NexG uses it when a webhook is late. Its id is snap_<collection>_<status>. |
POST /v1/collections/{id}/cancel | Cancel a collection that is still pending. |
GET /health | No key needed. |
Webhooks NexG receives
The bank posts every change to the callback_url NexG gave it, which is https://<NexG web domain>/api/payments/bank/webhook. It is in the reference, and Webhooks covers signing, retries and duplicates in full.
POST /api/payments/bank/webhook
Content-Type: application/json
X-Bank-Signature: t=1794655243,v1=5f0c…64 hex characters…
X-Bank-Event-Id: evt_Q1rM7kHf0b2vYp3n
{
"id": "evt_Q1rM7kHf0b2vYp3n",
"type": "collection.succeeded",
"created_at": "2026-11-10T11:20:43.018Z",
"livemode": false,
"data": {
"id": "col_IwUUEAPrDf55OFD0",
"status": "succeeded",
"merchant_reference": "NXP-261110-7FD3EEA0C8",
"amount": 161625,
"currency": "KES",
"payer": { "name": "Wanjiru Kamau", "bank": "NexG Sandbox Bank", "account": "****4821" },
"bank_reference": "SBXB2FACAB615",
"failure_code": null,
"failure_reason": null,
"completed_at": "2026-11-10T11:20:43.018Z",
"…": "the other collection fields"
}
}| Event type | Collection status | What NexG does |
|---|---|---|
collection.succeeded | succeeded | Marks the payment and order paid and keeps the bank reference. |
collection.failed | failed | Fails the payment with the bank’s failure_reason; the guest can pay again. |
collection.expired | expired | Fails the payment: not approved in time. |
collection.cancelled | cancelled | Fails the payment: the guest cancelled. |
collection.processing | processing | Nothing yet; the guest sees “Waiting for your bank”. |
| NexG response | Meaning for the bank |
|---|---|
| 200 | Handled, a duplicate, or permanently refused (the body says which). Do not retry. |
| 400 bad_signature | The signature did not verify or the event is over five minutes old. Do not retry the same bytes. |
| 500 not_recorded | NexG could not record it right now. Retry. |
| 503 no_bank_configured | Bank payments are switched off on this deployment. Retry later. |
Signatures
X-Bank-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256>, where the HMAC is over the exact bytes <t>.<raw body> with the shared webhook secret. Receivers refuse a timestamp more than five minutes from their clock, so an old event cannot be replayed. Verify the raw body before parsing it, and compare in constant time.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyBankSignature(rawBody, header, secret, now = Date.now()) {
const m = /^t=(\d{9,12}),v1=([0-9a-f]{64})$/.exec(header ?? '');
if (!m) return false;
if (Math.abs(Math.floor(now / 1000) - Number(m[1])) > 300) return false; // 5 minutes
const expected = createHmac('sha256', secret).update(`${m[1]}.${rawBody}`).digest();
const given = Buffer.from(m[2], 'hex');
return given.length === expected.length && timingSafeEqual(given, expected);
}What NexG guarantees
- The signature is checked twice: in the webhook route, and again inside the database (against the Vault secret
bank_webhook_secret), so nobody can mark a payment paid by calling the database directly. - The amount and currency must match what NexG asked for. A different amount fails the payment and raises a high-severity audit event.
- The collection must be the one recorded for that payment. If a bank reports money paid on a collection NexG has replaced, the event is kept, flagged unhandled, and raised for finance to refund or apply by hand. It never settles the order silently.
- Each event is recorded once; repeats are answered as duplicates.
- Payments made through the sandbox are stored with provider
bank_sandbox, and the sandbox is refused whenVERCEL_ENV=production, so a test approval is never taken for money.
NexG Sandbox Bank
A small Node service in apps/bank-sandbox. It keeps state in memory, or in a JSON file when DATA_FILE is set, and retries webhooks after 2 s, 10 s, 1 min, 5 min and 30 min.
# On the machine that runs the sandbox. Generate the values there;
# never paste them into chat or commit them.
export SANDBOX_API_KEY=$(openssl rand -hex 24)
export SANDBOX_WEBHOOK_SECRET=$(openssl rand -hex 32)
export PUBLIC_URL=http://localhost:4010
pnpm --filter @nexg/bank-sandbox dev
curl http://localhost:4010/health
# {"ok":true,"bank":"NexG Sandbox Bank","livemode":false}Test accounts on the approval page
| Account | Behaviour |
|---|---|
| Wanjiru Kamau · 0110 0000 4821 | KES 500,000 available. Pays. |
| Otieno Achieng · 0110 0000 1177 | KES 150 available. Fails with insufficient_funds above that. |
| Blocked Account · 0110 0000 9020 | Fails with account_blocked. |
| Delayed Approval · 0110 0000 3315 | Processing for 20 seconds (SLOW_APPROVAL_SECONDS), then pays. |
A collection not approved within 30 minutes (COLLECTION_TTL_MINUTES) expires. Balances reset when the sandbox restarts.
# Drive an outcome without the approval page (automated tests)
curl -X POST http://localhost:4010/v1/collections/col_…/simulate \
-H "Authorization: Bearer $SANDBOX_API_KEY" -H "Content-Type: application/json" \
-d '{"outcome":"succeed"}' # or insufficient_funds, decline, cancel, expire
# See and resend webhooks
curl "http://localhost:4010/v1/webhook-deliveries?collection_id=col_…" \
-H "Authorization: Bearer $SANDBOX_API_KEY"
curl -X POST http://localhost:4010/v1/webhook-deliveries/dlv_…/retry \
-H "Authorization: Bearer $SANDBOX_API_KEY"Connecting NexG to the sandbox
Generate the API key and webhook secret where the sandbox runs and give the same two values to the NexG web app. The Bank option appears at checkout only when all four settings are present.
# Web app (Vercel or apps/web/.env.local). Server-only: never NEXT_PUBLIC_.
BANK_PROVIDER=sandbox
BANK_API_URL=https://bank-sandbox.<your domain> # or http://localhost:4010
BANK_API_KEY=<same value as SANDBOX_API_KEY>
BANK_WEBHOOK_SECRET=<same value as SANDBOX_WEBHOOK_SECRET>
# Supabase SQL editor, once per environment (type the value there):
# select vault.create_secret('<same value as BANK_WEBHOOK_SECRET>', 'bank_webhook_secret');The database migration 20261130000100_bank_collections.sql must be run first.
Connecting a real bank
- Choose the bank and register on its developer portal to get sandbox credentials.
- Add an adapter in
apps/web/lib/bankthat implementsBankProvider(openCollection,latestEvent,cancelCollection) with provider idbank_<name>, translating the bank's API and callbacks into the signed events above. - Test it against the bank's sandbox exactly as against the NexG Sandbox Bank.
- Only after finance approves limits and reconciliation, allow it on the production site.
Merchants connecting their own systems should start with the merchant integration guide.