Skip to main content
Withdrawal access is disabled on new API keys by default. Enable the wallets:withdraw scope when creating a dedicated key, store its signing secret once, and keep it in a server-side secret store. Production withdrawal submission requires approved KYB for every supported NGN and USD settlement rail. Withdrawal quotes remain available before approval, but submit returns 403 while KYB is non-approved. Development and staging skip this KYB gate. The signed Partner API request authorizes the withdrawal directly. There is no emailed verification step and no client-supplied withdrawal token after submit.
See Withdrawal examples for complete Node.js and Python implementations that quote, sign, submit, and hand completion off to webhooks.

Routes

This page covers a withdrawal to one beneficiary and the signing, retry, and error rules that apply to every withdrawal. An NGN request can also carry up to 50 beneficiaries; see Bulk withdrawals for the multi-beneficiary specifics. Quote and submit require X-Api-Key, X-Stabyl-Timestamp, X-Stabyl-Nonce, and X-Stabyl-Signature. Transaction reads require X-Api-Key and wallets:view.

Request Signing

Use the exact bytes sent in the HTTP body. Do not parse and reserialize JSON between hashing and sending the request.
Join those six values with newline characters and no trailing newline. Sign the UTF-8 bytes with Ed25519, then encode the 64-byte signature as unpadded base64url. Sign the complete path exactly as it appears in the request URL. For the production submit URL, sign /v1/partner/wallets/withdrawals, not /partner/wallets/withdrawals. Include the query string exactly when one is present. The server accepts a timestamp up to 30 seconds behind or ahead. A verified nonce is single-use for five minutes. Synchronize server clocks with NTP.

Test Vector

These values are synthetic and must never be used outside tests.
The body hash covers the exact bytes you send. The vector above hashes this single-line body with no whitespace (the same JSON shown pretty-printed below would hash differently):
Whatever serializer you use, hash and sign the exact bytes you put on the wire.

Quote NGN

Quote bodies always use items and omit idempotency_key:
A single bank transfer is capped at 100000000.00 NGN, so a quote for 200000000.00 NGN reports two transfers. The parent amount, fee, and debit remain aggregate values.

Quote Response

Quotes return one entry per settlement route under quotes:
An NGN quote always contains exactly one bank_transfer entry with status: "available". A USD quote contains one entry for the requested settlement_rail; it adds expires_at and settlement_estimate, omits max_transfer_amount, and can carry a status other than available with a reason. Submit a USD withdrawal before its quote expires. Quoting never reserves funds, but it does run the same account checks as submit: it returns 422 ACCOUNT_WITHDRAWAL_LIMIT_EXCEEDED or 402 INSUFFICIENT_BALANCE when the request could not be submitted, and it has its own rate limit, so quote as part of a user action rather than in a loop.

Submit NGN

Submit uses the same list envelope and adds one parent idempotency key:
You can send multiple beneficiaries in one request. client_item_id is optional but recommended for correlating your records with response items.
Paying many bank accounts at once? Bulk withdrawals covers the 50-beneficiary limit, per-transfer fees, partial outcomes, and how to reconcile each transfer.

Submit USD

USD stablecoin settlement uses the same envelope, with a parent settlement_rail and exactly one item in the current version:
Supported settlement rails are USDT and USDC on an available chain. A USD quote validates the supplied chain, slug, and destination against the current environment and enabled route before it can return available. Cash USD settlement is not available through this API. An unsupported rail, a destination that does not match the rail, or an unsupported chain returns UNSUPPORTED_WITHDRAWAL_ROUTE; more than one USD item or a missing settlement_rail returns VALIDATION_FAILED. The USD submit response differs from the NGN one in a few ways: rail is crypto, settlement_rail is present, amount equals debit_amount with the fee taken out of it (so estimated_receive is what the destination receives), transfer_count is 1, max_transfer_amount is absent, and reference equals id.

Response Shape

The submit response represents one parent transaction. items is always a list, including one-item NGN and USD withdrawals. NGN items can contain multiple transfers. The NGN fee is charged for every generated bank transfer, so fee is the ₦100 flat transfer fee multiplied by transfer_count.
The parent starts as pending and its transfers as processing. reference is WD- followed by a full UUID; it is generated by Stabyl and is not derived from id, so store it rather than reconstructing it. Do not infer completion from the initial response. Persist data.id and wait for the corresponding withdrawal.success, withdrawal.partial, or withdrawal.failed webhook. A parent moves from pending to processing and can remain there while item transfers settle independently; the terminal statuses are success, partial, failed, and cancelled. withdrawal.partial applies only to NGN parents with more than one bank transfer, including a single beneficiary whose amount was split above the per-transfer limit. Use GET /partner/wallets/transactions/{id} for on-demand confirmation, webhook-gap recovery, and periodic reconciliation rather than continuous polling.

Safe Retries

For an uncertain HTTP response:
  1. Keep the same body and idempotency_key.
  2. Generate a new timestamp and nonce.
  3. Sign the new canonical payload.
  4. Retry submit, then read the returned parent transaction.
The idempotency-key namespace is shared across every withdrawal currency and settlement rail for the account. The same key with the same normalized intent returns the existing parent. Reusing it for USD after NGN, for NGN after USD, or with a changed amount, destination, settlement rail, description, item order, client item value, or currency returns IDEMPOTENCY_CONFLICT. Amounts are compared as decimals, so 200000000.00 and 200000000 are the same intent.

Errors

Never automatically retry a 400, 401, 402, 403, 409, or 422; correct the request, funding, or key configuration first. Retry 429 and 503 with bounded backoff and a new nonce. Requests need Content-Type: application/json. A body with an unknown field, or one that does not match the request schema, is rejected before the request is processed with a plain-text 422 and no error envelope, so branch on the status code for that case.