> ## Documentation Index
> Fetch the complete documentation index at: https://docs.stabyl.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Withdrawals

> Quote and submit signed, list-shaped withdrawals.

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.

<Info>
  See [Withdrawal examples](/docs/withdrawal-examples) for complete Node.js and
  Python implementations that quote, sign, submit, and hand completion off to
  webhooks.
</Info>

## Routes

| Route                                     | Purpose                                                                           |
| ----------------------------------------- | --------------------------------------------------------------------------------- |
| `POST /partner/wallets/withdrawal/quotes` | Validate an intent and preview fees, items, and transfers without reserving funds |
| `POST /partner/wallets/withdrawals`       | Directly create one parent withdrawal                                             |
| `GET /partner/wallets/transactions`       | List resulting parent transactions                                                |
| `GET /partner/wallets/transactions/{id}`  | Read parent status and item-level transfer outcomes                               |

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.

```text theme={null}
STABYL-SIGNATURE-V1
<unix timestamp in milliseconds>
<UUIDv4 or UUIDv7 nonce>
<uppercase HTTP method>
<exact path and query>
<lowercase hex SHA-256 of the raw body>
```

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.

```text theme={null}
private seed (base64url): BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc
public key (base64url):  6kpsY-KcUgq-9VB7Ey7F-ZVHdq6-vnuSQh7qaRRG0iw
timestamp:               1783756800000
nonce:                   018f32f0-3333-7333-8333-333333333333
method:                  POST
path:                    /v1/partner/wallets/withdrawals
body SHA-256:            1a73bf403deda52f4271d542a62e1961dee1959607e06b439d24a8f9d61aaf81
signature (base64url):   -_flQmASKXMSSh5cMTAON3zdPWWDWus4ONfim0kYqaG3nGPyWw-TC4bbr4YfmoebWgYgj6V2DNmM6WIl1oaHAA
```

The exact compact body for that vector is:

```json theme={null}
{
  "currency": "NGN",
  "items": [
    {
      "client_item_id": "beneficiary-a",
      "amount": "200000000.00",
      "destination": {
        "type": "bank_account",
        "bank_code": "044",
        "bank_name": "Access Bank",
        "account_number": "0123456789",
        "account_name": "Example Recipient"
      },
      "description": "Supplier settlement"
    }
  ],
  "idempotency_key": "partner-withdrawal-20260711-001"
}
```

## Quote NGN

Quote bodies always use `items` and omit `idempotency_key`:

```json theme={null}
{
  "currency": "NGN",
  "items": [
    {
      "client_item_id": "beneficiary-a",
      "amount": "200000000.00",
      "destination": {
        "type": "bank_account",
        "bank_code": "044",
        "bank_name": "Access Bank",
        "account_number": "0123456789",
        "account_name": "Example Recipient"
      },
      "description": "Supplier settlement"
    }
  ]
}
```

A quote for `200000000.00` NGN reports two transfers because no individual bank transfer exceeds `100000000.00` NGN. The parent amount, fee, and debit remain aggregate values.

## Submit NGN

Submit uses the same list envelope and adds one parent idempotency key:

```json theme={null}
{
  "currency": "NGN",
  "items": [
    {
      "client_item_id": "beneficiary-a",
      "amount": "200000000.00",
      "destination": {
        "type": "bank_account",
        "bank_code": "044",
        "bank_name": "Access Bank",
        "account_number": "0123456789",
        "account_name": "Example Recipient"
      },
      "description": "Supplier settlement"
    }
  ],
  "idempotency_key": "partner-withdrawal-20260711-001"
}
```

You can send multiple beneficiaries in one request. `client_item_id` is optional but recommended for correlating your records with response items.

## Submit USD

USD stablecoin settlement uses the same envelope, with a parent `settlement_rail` and exactly one item in the current version:

```json theme={null}
{
  "currency": "USD",
  "settlement_rail": "USDC",
  "items": [
    {
      "client_item_id": "customer-payout-42",
      "amount": "1000.00",
      "destination": {
        "type": "crypto_address",
        "chain": "evm",
        "slug": "base",
        "address": "0x1111111111111111111111111111111111111111"
      },
      "description": "Customer payout"
    }
  ],
  "idempotency_key": "partner-withdrawal-20260711-002"
}
```

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; unsupported combinations return `UNSUPPORTED_WITHDRAWAL_ROUTE`.

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

```json theme={null}
{
  "status": "success",
  "data": {
    "id": "0197f1f0-0000-7000-8000-000000000014",
    "status": "processing",
    "rail": "fiat",
    "currency": "NGN",
    "tx_type": "withdrawal",
    "amount": "200000000.00",
    "reference": "WD-0197f1f000007000",
    "fee": "500.00",
    "debit_amount": "200000500.00",
    "estimated_receive": "200000000.00",
    "receive_currency": "NGN",
    "transfer_count": 2,
    "max_transfer_amount": "100000000.00",
    "items": [
      {
        "client_item_id": "beneficiary-a",
        "amount": "200000000.00",
        "destination": {
          "type": "bank_account",
          "bank_code": "044",
          "bank_name": "Access Bank",
          "account_number": "0123456789",
          "account_name": "Example Recipient"
        },
        "transfers": [
          { "sequence": 1, "amount": "100000000.00", "status": "pending" },
          { "sequence": 2, "amount": "100000000.00", "status": "pending" }
        ]
      }
    ]
  }
}
```

Do not infer completion from the initial response. Persist `data.id` and wait for
the corresponding `withdrawal.success` or `withdrawal.failed` webhook. A parent
can remain `processing` while item transfers settle independently. 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, description, item order, client item
value, currency, or settlement rail returns `IDEMPOTENCY_CONFLICT`.

## Errors

| Code                                | Meaning                                                                   |
| ----------------------------------- | ------------------------------------------------------------------------- |
| `MISSING_SCOPE`                     | The key is not enabled for withdrawals                                    |
| `SIGNING_REQUIRED`                  | The key or required signing headers do not support signed withdrawals     |
| `INVALID_SIGNATURE`                 | The canonical payload or signature does not match                         |
| `INVALID_TIMESTAMP`                 | The timestamp is malformed or outside the accepted window                 |
| `REPLAYED_REQUEST`                  | The nonce was already accepted for this key                               |
| `SIGNING_VERIFICATION_UNAVAILABLE`  | Replay protection is temporarily unavailable; fail closed and retry later |
| `IDEMPOTENCY_CONFLICT`              | The idempotency key is already bound to another intent                    |
| `ACCOUNT_WITHDRAWAL_LIMIT_EXCEEDED` | The aggregate parent amount exceeds the account policy                    |
| `INSUFFICIENT_BALANCE`              | Available balance cannot cover principal plus fee                         |
| `UNSUPPORTED_WITHDRAWAL_ROUTE`      | Currency, rail, and destination do not form a supported route             |

Never automatically retry `MISSING_SCOPE`, `INVALID_SIGNATURE`, `IDEMPOTENCY_CONFLICT`, or `UNSUPPORTED_WITHDRAWAL_ROUTE`. Correct the request or key configuration first.
