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:

201 Created
json
{
  "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 status

The virtual account is live and waiting. No money has landed yet; the clock (expiresAt, default +30 min) is ticking.

Transitions out

  • pendingsettled

    An inbound NIP credit reaches payableAmount (overpayment surplus is credited to the wallet too)

    firespayment.settled

  • pendingpartial

    An inbound credit arrives but is below payableAmount

    firespayment.settled

  • pendingexpired

    The expiry cron fires past expiresAt with nothing (fully) paid

    firespayment.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

ledger_transaction kind: payment_settlement. The ₦25.00 provider fee never enters the Acute pool; the provider takes it off the inbound directly, so it isn't a ledger leg.

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:

webhook: payment.settled
json
{
  "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 goes partial and stays open. The same account number keeps accepting funds; further credits can carry it to settled. Each credit re-fires payment.settled with the updated amountReceived.
  • Overpaid (amountReceived > payableAmount): the payment goes settled and the surplus is credited to the target wallet: the wallet leg becomes base + 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.

webhook: payment.expired
json
{
  "id": "acuinf771320948566evt",
  "type": "payment.expired",
  "createdAt": "2026-06-26T12:30:05.000Z",
  "data": {
    "id": "acuinf471028395610pay",
    "status": "expired",
    "baseAmount": 500000,
    "amountReceived": 0,
    "currency": "NGN"
  }
}

StatusMeaningTerminal?
pendingNUBAN live, awaiting fundsno
partialunderpaid, still openno
settledfully paid, ledger postedyes
expiredwindow closed unpaidyes
failedprovisioning or processing faultyes
refunded / partially_refundeda refund moved money back outyes

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.