Collect via bank transfer
Spin up a virtual account, let your customer pay by NIP, and watch the ledger settle. The full bank-transfer payment lifecycle: create, settle, partial, expire.
A bank-transfer payment is the most Nigerian thing Acute does: you ask for money, we mint a dedicated virtual account (a dynamic NUBAN), your customer transfers into it over NIP, and the moment the funds land we settle the payment and credit a wallet: double-entry, to the kobo. No card, no redirect, no "please don't refresh." Just an account number and a webhook.
Here's the whole thing in one call.
# create a bank-transfer payment
curl -X POST https://sandbox.api.acute.network/v1/payments \
-H "Authorization: Bearer acuinf_test_..." \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"method": "bank_transfer",
"baseAmount": 500000,
"targetWalletId": "acuinf830192847561wlt",
"description": "Order #4821",
"expiresIn": 1800
}'baseAmount is what you want to land in the wallet, in kobo. The payer pays a little
more (the fee rides on top), so the response hands you the total fee and the account
to pay into:
{
"success": true,
"statusCode": 201,
"data": {
"id": "acuinf471028395610pay",
"method": "bank_transfer",
"status": "pending",
"baseAmount": 500000,
"fee": 7500,
"payableAmount": 507500,
"amountReceived": 0,
"currency": "NGN",
"targetWalletId": "acuinf830192847561wlt",
"description": "Order #4821",
"expiresAt": "2026-06-26T12:30:00.000Z",
"settledAt": null,
"createdAt": "2026-06-26T12:00:00.000Z",
"virtualAccount": {
"accountNumber": "9914022731",
"bankName": "Providus Bank",
"accountName": "ACUTE / Order #4821",
"expiresAt": "2026-06-26T12:30:00.000Z"
}
}
}Idempotency-Key is required here
POST /payments is a money endpoint, so the Idempotency-Key header is mandatory.
Retried the call after a flaky connection? Send the same key and you get the same
payment back: one virtual account, not two. Skip it and you get a 400 with
IDEMPOTENCY_KEY_MISSING.
A bank-transfer payment is a small state machine. It is born pending the instant the
NUBAN exists, and it ends one of three ways: fully settled, stuck partial (the payer
underpaid), or expired (nobody paid in time). Click through it:
pendingentry statusThe virtual account is live and waiting. No money has landed yet; the clock (expiresAt, default +30 min) is ticking.
Transitions out
pendingsettledAn inbound NIP credit reaches payableAmount (overpayment surplus is credited to the wallet too)
fires
payment.settledpendingpartialAn inbound credit arrives but is below payableAmount
fires
payment.settledpendingexpiredThe expiry cron fires past expiresAt with nothing (fully) paid
fires
payment.expired
POST /payments writes the row as pending and mints a virtual NUBAN against your
org's deposit account (short-lived, expiring in 30 minutes by default). No ledger
entry is posted yet; there's no money to account for. You hand the
virtualAccount.accountNumber to your customer and wait for the webhook.
Your customer transfers payableAmount (₦5,075.00 here) into the NUBAN over NIP. The
provider fires an inbound webhook; our worker matches it by the virtual NUBAN, records a
settlement row, captures the payer's account snapshot (you'll need it for bank refunds),
and (when amountReceived >= payableAmount) posts the ledger transaction and bills the
Acute fee.
This is the part worth internalising. Here's the exact posting that settles a bank-transfer payment:
Payment settles by bank transfer
₦5,000.00 base · ₦50.00 Acute fee · ₦25.00 provider fee
- DRbase + Acute fee enters from the rail
- CRbase credited to the wallet
- CRAcute fee remitted
Notice what's not there: the provider fee is invisible to the ledger. The provider
deducts its own cut on the rail before the money reaches bank_inbound_suspense, so the
ledger only ever moves base + the Acute fee. Debits equal credits, drift is zero, and the
payer's ₦25.00 went straight to the provider. That's the whole double-entry promise: every
leg accounts for itself, and the transaction balances or it doesn't post.
On settle we emit:
{
"id": "acuinf604815923077evt",
"type": "payment.settled",
"createdAt": "2026-06-26T12:07:14.000Z",
"data": {
"id": "acuinf471028395610pay",
"status": "settled",
"baseAmount": 500000,
"amountReceived": 507500,
"currency": "NGN"
}
}Real payers fat-finger amounts. Acute does not lose the money either way:
- Underpaid (
0 < amountReceived < payableAmount): the payment goespartialand stays open. The same account number keeps accepting funds; further credits can carry it tosettled. Each credit re-firespayment.settledwith the updatedamountReceived. - Overpaid (
amountReceived > payableAmount): the payment goessettledand the surplus is credited to the target wallet: the wallet leg becomesbase + surplus, the suspense debit grows to match, and the books still balance.
The account number is per-payment, not per-wallet
Each bank-transfer payment mints its own short-lived NUBAN. Don't cache it, don't reuse
it across orders. Create a fresh payment per collection. A permanent: false NUBAN is
a one-shot inbox, not a static account.
If the window closes with nothing (fully) paid, the payment-expiry cron flips the
payment to expired and emits payment.expired. The NUBAN stops accepting. If a straggler
credit lands on an expired NUBAN, it's reconciled to a manual-review path (no money is
ever silently dropped), but the happy path is: expired means closed.
{
"id": "acuinf771320948566evt",
"type": "payment.expired",
"createdAt": "2026-06-26T12:30:05.000Z",
"data": {
"id": "acuinf471028395610pay",
"status": "expired",
"baseAmount": 500000,
"amountReceived": 0,
"currency": "NGN"
}
}| Status | Meaning | Terminal? |
|---|---|---|
pending | NUBAN live, awaiting funds | no |
partial | underpaid, still open | no |
settled | fully paid, ledger posted | yes |
expired | window closed unpaid | yes |
failed | provisioning or processing fault | yes |
refunded / partially_refunded | a refund moved money back out | yes |
refunded and partially_refunded only appear after you call
/payments/:id/refund.
You can GET /payments/:id to read state, but the right pattern is the webhook:
register a payment.settled handler, verify the signature, and fulfil the order there.
Polling a virtual account is how you discover that networks have latency at the worst
possible moment.