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.
{
"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
- DRwallet debited amount + both fees
- CRprincipal + provider fee parked in suspense
- CRAcute fee remitted
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, emitwithdrawal.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, emitwithdrawal.failed. - Failed at initiation (the NIP never even left) → status
failed, the hold is reversed, emitwithdrawal.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
- CRwallet re-credited in full
- DRsuspense hold released
- DRAcute fee reversed
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 statusFunds 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
processingcompletedResolver confirms the NIP landed with the provider
fires
withdrawal.completedprocessingreturnedThe rail returns the funds; a reversing ledger txn re-credits the wallet
fires
withdrawal.failedprocessingfailedInitiation fails before the transfer leaves; the hold is reversed
fires
withdrawal.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."
{
"id": "acuinf118273645590evt",
"type": "withdrawal.completed",
"createdAt": "2026-06-26T12:01:31.000Z",
"data": {
"id": "acuinf509431728860wth",
"status": "completed",
"amount": 2000000,
"currency": "NGN"
}
}{
"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.