Withdrawals

Move money wallet → external bank over NIP. The non-lossy status model, the async resolver, and the reversing ledger transaction that makes "failed but money moved" impossible.

A withdrawal sends money out of Acute: from a wallet to a real Nigerian bank account over NIP. The instant money crosses the bank rail, you inherit the hardest problem in payments: the call timed out, but did the money move? Acute answers that for you with a deliberately non-lossy design. A withdrawal is never "failed" on a guess; it's processing until the rail tells us the truth, and if it failed, the wallet is re-credited by a reversing ledger transaction. No money is ever silently lost or double-spent.

Here's the call.

# wallet → bank withdrawal
curl -X POST https://sandbox.api.acute.network/v1/wallets/acuinf661043820917wlt/withdraw \
  -H "Authorization: Bearer acuinf_test_..." \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 2000000,
    "bankNipCode": "000013",
    "accountNumber": "0123456789",
    "accountName": "Ada Lovelace",
    "verifyName": true
  }'

The response comes back 201 with status processing, not completed. That is the whole point: the money is debited and held the moment you call, but "did NIP land" is a question only the rail can answer, asynchronously.

201 Created
json
{
  "success": true,
  "statusCode": 201,
  "data": {
    "id": "acuinf509431728860wth",
    "sourceWalletId": "acuinf661043820917wlt",
    "amount": 2000000,
    "fee": 20000,
    "totalAmount": 2020000,
    "status": "processing",
    "counterparty": {
      "accountNumber": "0123456789",
      "accountName": "Ada Lovelace",
      "bankCode": "000013",
      "bankName": "GTBank"
    },
    "failureReason": null,
    "currency": "NGN",
    "createdAt": "2026-06-26T12:00:00.000Z",
    "completedAt": null
  }
}

The wallet is debited totalAmount (amount + fee = ₦20,200.00 here). The fee is the Acute clamp clamp(₦5, 1%, ₦180) plus a flat ₦20.00 NIP charge from the provider.

The non-lossy guarantee starts in the ledger. We post the outbound hold before the rail call: debit the wallet, park the principal (plus the provider's flat fee) in bank_outbound_suspense, remit the Acute fee. If the rail later confirms, the suspense clears against the real debit. If it fails, we reverse this exact transaction.

Withdrawal hold (posted before the NIP call)

₦20,000.00 out · ₦180.00 Acute fee · ₦20.00 flat provider fee

ledger_transaction kind: withdrawal. The principal (₦20,000.00) plus the flat provider NIP fee (₦20.00) sit in bank_outbound_suspense until the rail confirms or returns.

Why the debit comes first

If we called NIP first and posted later, a timeout would leave money in limbo: moved on the rail, unaccounted in the ledger. By debiting up front and treating an indeterminate rail result as processing, the ledger is always at least as conservative as reality. Worst case we've over-held; we never under-account. Money-moving rail calls are also never blind-retried; a retry on a maybe-succeeded transfer is how you double-pay.

An async resolver watches every processing withdrawal. It asks the provider what actually happened and drives the final state:

  • Confirmed → status completed, emit withdrawal.completed. The hold in suspense is now backed by the real bank debit.
  • Returned / failed by the rail → status returned, post a reversing ledger transaction that re-credits the wallet, emit withdrawal.failed.
  • Failed at initiation (the NIP never even left) → status failed, the hold is reversed, emit withdrawal.failed.

The reversal is the original posting run backwards: it credits the wallet back its totalAmount and unwinds the suspense and fee legs, so the wallet ends exactly where it started:

Reversal on a returned withdrawal

The hold unwound, wallet made whole

ledger_transaction kind: reversal, linked to the original withdrawal. Corrections are always reversing transactions: entries are immutable and never edited in place.

This is the state machine to internalise. A withdrawal starts processing and ends one of three ways. Two of those three involve money coming back to the wallet, and the resolver, not your code, is what gets it there.

processingentry status

Funds debited and held in bank_outbound_suspense; the NIP is in flight. The API returns this status: never assume completion from a 201.

Transitions out

  • processingcompleted

    Resolver confirms the NIP landed with the provider

    fireswithdrawal.completed

  • processingreturned

    The rail returns the funds; a reversing ledger txn re-credits the wallet

    fireswithdrawal.failed

  • processingfailed

    Initiation fails before the transfer leaves; the hold is reversed

    fireswithdrawal.failed

processing is not pending, and 201 is not done

A 201 on a withdrawal means "accepted and held," not "money delivered." If you fulfil an order off the API response, you'll ship before the bank confirms. Wait for the withdrawal.completed webhook. Treat withdrawal.failed (whether the underlying status is returned or failed) as "money is back in the wallet. Re-decide."

webhook: withdrawal.completed
json
{
  "id": "acuinf118273645590evt",
  "type": "withdrawal.completed",
  "createdAt": "2026-06-26T12:01:31.000Z",
  "data": {
    "id": "acuinf509431728860wth",
    "status": "completed",
    "amount": 2000000,
    "currency": "NGN"
  }
}
webhook: withdrawal.failed (status: returned)
json
{
  "id": "acuinf118273645591evt",
  "type": "withdrawal.failed",
  "createdAt": "2026-06-26T12:02:48.000Z",
  "data": {
    "id": "acuinf509431728860wth",
    "status": "returned",
    "amount": 2000000,
    "currency": "NGN",
    "failureReason": "Beneficiary account inactive"
  }
}

verifyName defaults on (an org-level setting, overridable per request). When on, we resolve the destination through the provider's account-name lookup first; if the returned name doesn't match accountName, the withdrawal is rejected up front with 422 WITHDRAWAL_NAME_MISMATCH, before any money moves. Turn it off only if you've already verified the account yourself; then nameVerified is recorded as false.