Skip to content
NexG ConciergeDevelopers

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

CapabilityStatusNotes
Bank collection at checkoutAvailable in sandboxOrders, from checkout and the pay page.
NexG Sandbox BankAvailableSelf-hosted service in apps/bank-sandbox.
A real bankNot connectedNeeds a bank chosen, its sandbox keys, and an adapter (see the end).
Settlement payouts to merchantsNot implementedNexG does not accept payout instructions over any API.

How a bank payment works

  1. 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.
  2. NexG opens a collection at the bank for that reference and amount, then sends the guest to the bank's checkout_url.
  3. The guest approves (or cancels) at the bank and is sent back to /pay/<reference>.
  4. 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.
  5. 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).

Request
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"
}
Response · 201 Created
{
  "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
}
EndpointWhat it does
GET /v1/collections/{id}Read a collection.
GET /v1/collections/{id}/eventThe 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}/cancelCancel a collection that is still pending.
GET /healthNo 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.

Request
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 typeCollection statusWhat NexG does
collection.succeededsucceededMarks the payment and order paid and keeps the bank reference.
collection.failedfailedFails the payment with the bank’s failure_reason; the guest can pay again.
collection.expiredexpiredFails the payment: not approved in time.
collection.cancelledcancelledFails the payment: the guest cancelled.
collection.processingprocessingNothing yet; the guest sees “Waiting for your bank”.
NexG responseMeaning for the bank
200Handled, a duplicate, or permanently refused (the body says which). Do not retry.
400 bad_signatureThe signature did not verify or the event is over five minutes old. Do not retry the same bytes.
500 not_recordedNexG could not record it right now. Retry.
503 no_bank_configuredBank 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 when VERCEL_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

AccountBehaviour
Wanjiru Kamau · 0110 0000 4821KES 500,000 available. Pays.
Otieno Achieng · 0110 0000 1177KES 150 available. Fails with insufficient_funds above that.
Blocked Account · 0110 0000 9020Fails with account_blocked.
Delayed Approval · 0110 0000 3315Processing 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.

Testing outcomes and deliveries
# 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.

Settings
# 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

  1. Choose the bank and register on its developer portal to get sandbox credentials.
  2. Add an adapter in apps/web/lib/bank that implements BankProvider (openCollection, latestEvent, cancelCollection) with provider id bank_<name>, translating the bank's API and callbacks into the signed events above.
  3. Test it against the bank's sandbox exactly as against the NexG Sandbox Bank.
  4. 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.