# Send a payout (/docs/guides/send-a-payout)



## Before you start [#before-you-start]

* The destination must already be an approved, verified destination — see [Destinations](/docs/guides/destinations).
* Take `wallet_id`, `asset` and `network` from the wallet's balances in `GET /api/v1/wallets`.
* Obtain the payment instruction and **persist one idempotency key for this payment** before the first
  attempt — for example your invoice or payout reference.

## Request a withdrawal [#request-a-withdrawal]

`POST /api/v1/withdrawals` submits a withdrawal into your organisation's approval flow. Every payout
passes compliance checks and a human approval before it reaches the network. Neither the API nor the
MCP connector exposes an approval endpoint.

<Callout type="warn" title="202 means “accepted for approval”, not “sent”.">
  Never show a user "sent" on a 202 — poll the status endpoint or wait for the webhook.
</Callout>

| Field             | Type   | Notes                                                                                                                                                |
| ----------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `wallet_id`       | string | Required. From `GET /wallets`. Up to 128 characters.                                                                                                 |
| `asset`           | string | Required. Symbol, e.g. `ETH`. Must be held by that wallet.                                                                                           |
| `amount`          | string | Required. Positive decimal STRING, e.g. `"0.25"`, up to 100 characters. More decimals than the asset allows returns `400`.                           |
| `destination`     | string | Required. Must already be an approved, verified destination. Up to 128 characters.                                                                   |
| `network`         | string | Use the exact balance network. Required when the asset is held on several networks; if omitted, it resolves to the one network the asset is held on. |
| `fee_priority`    | string | Optional. `low` · `medium` · `high`.                                                                                                                 |
| `idempotency-key` | header | Required. Retry with the SAME value to avoid a second withdrawal.                                                                                    |

Bodies reject unknown fields with `400`. Idempotency keys are 1–128 printable ASCII characters without
spaces, and are unique within your organisation. A withdrawal is visible only to keys of the workspace
that created it.

```json title="body"
{
  "wallet_id": "acct_7f3a…",
  "asset": "ETH",
  "amount": "0.25",
  "destination": "0x03a6…81b9",
  "network": "ethereum"
}
```

```json title="response"
202 Accepted

{
  "data": {
    "id": "5f0c2a9e-7d1b-4c3a-9e2f-6b8d4a1c3e57",
    "status": "pending_approval",
    "wallet_id": "acct_7f3a…",
    "asset": "ETH",
    "amount": "0.25",
    "destination": "0x03a6…81b9",
    "network": "ethereum",
    "tx_hash": null,
    "created_at": "2026-07-30T09:02:11.004Z",
    "updated_at": "2026-07-30T09:02:11.004Z"
  },
  "meta": {}
}
```

With the supplied [Node.js client](/docs/get-started/first-call#or-use-the-supplied-client):

```js
// These values come from your persisted payment record.
const paymentKey = "invoice-2026-0042";
const payment = {
  wallet_id: "WALLET_ID_FROM_API", asset: "USDC", amount: "25.50",
  network: "ethereum", destination: "VERIFIED_DESTINATION_ADDRESS",
  fee_priority: "medium"
};
const result = await api.request("POST", "/api/v1/withdrawals", payment, paymentKey);
if (result.status === 202 || result.status === 200) {
  const status = await api.request("GET", `/api/v1/withdrawals/${result.body.data.id}`);
  console.log(status.body);
}
```

## Follow the lifecycle [#follow-the-lifecycle]

`GET /api/v1/withdrawals/{id}` refreshes progress through settlement. Read `data.status` even on a
successful HTTP response.

```text
received → pending_approval → approved → processing → completed
```

| Status                            | Meaning                                                                                                    |
| --------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `received`                        | Accepted by us, not yet submitted for approval.                                                            |
| `pending_approval`                | Pending approval or processing checks. The only state you can [cancel](/docs/guides/cancel-a-payout) from. |
| `approved`                        | Approved, preparing to send.                                                                               |
| `processing`                      | Submitted to the network.                                                                                  |
| `completed`                       | Settled. `tx_hash` is populated.                                                                           |
| `rejected` · `failed` · `expired` | Terminal unsuccessful outcome. Investigate before requesting another payment.                              |
| `cancelled`                       | Terminal. You cancelled it before approval; nothing was sent.                                              |

If a live refresh fails, the response carries the last recorded state with `meta.stale: true`. Final
states come from the record. Polling never approves or retries a payment.

Every status change also arrives as a `withdrawal.status_changed` [webhook](/docs/guides/webhooks) if
you have one configured. Treat the webhook as a prompt to re-read `GET /withdrawals/{id}`, which is the
source of truth.

## Retry safely [#retry-safely]

If a `POST` times out or fails with `500`/`503` or a network error, retry with the **same key and the
same body**. The client signs a fresh attempt. You get one of:

* `200` with `meta.idempotent_replay: true` and the original withdrawal — including a recorded
  `failed` or still-`received` status. A replay returns recorded state; it never resumes or reissues
  the payment.
* `202` — the earlier attempt never landed, so this one is evaluated afresh.
* `409 idempotency_conflict` — that key was already used for a different body. Recover the original
  payment before proceeding. A collision with a payment outside your workspace also returns a conflict,
  without exposing that payment.

A request refused **before it is accepted** — a `400`, a `404`, a `422` for the wallet or asset, or a
`503` — records nothing. Fix the cause if there is one, then retry with the same key: the retry is
evaluated afresh, as if it were the first attempt. Only the same key with a **different** body is
refused, with `409 idempotency_conflict`.

A NEW key on a retry is a NEW withdrawal. Never generate a replacement key for an uncertain payment.

## Recover a lost submission [#recover-a-lost-submission]

`GET /api/v1/withdrawals` lists recorded payouts, including a submission whose response was lost.

| Parameter         | Type    | Notes                                                                                                               |
| ----------------- | ------- | ------------------------------------------------------------------------------------------------------------------- |
| `limit`           | integer | 1–100; default 25.                                                                                                  |
| `offset`          | integer | 0–10000; default 0. Follow `meta.next_offset` until null. Deduplicate by id when new payments arrive during paging. |
| `wallet_id`       | string  | Optional wallet filter within this key's workspace.                                                                 |
| `idempotency_key` | string  | Look up the original payment after a timeout. Preserve its key and body when retrying.                              |

```js
const found = await api.request("GET", `/api/v1/withdrawals?idempotency_key=${encodeURIComponent(paymentKey)}`);
```

Listing returns recorded state. Use `GET /withdrawals/{id}` to refresh an individual payout. If a payout
stays `received` or otherwise unresolved, contact your administrator with the withdrawal ID and the
`X-Request-Id` of your request.

## Errors you should handle [#errors-you-should-handle]

| Code                          | HTTP | Meaning                                                       |
| ----------------------------- | ---- | ------------------------------------------------------------- |
| `idempotency_key_required`    | 400  | `POST /withdrawals` needs an `Idempotency-Key` header.        |
| `idempotency_conflict`        | 409  | That payment key was used for another payload.                |
| `asset_not_held`              | 422  | That wallet holds no such asset on that network.              |
| `destination_not_whitelisted` | 422  | The destination is not an approved, verified destination yet. |
| `no_approver_available`       | 422  | The workspace has no second approver configured.              |
| `withdrawal_failed`           | 422  | The request could not be processed.                           |

The full list is on [Errors](/docs/guides/errors).
