Send withdrawals
A withdrawal sends funds from your workspace available balance to an external address. It is the
half of the product where the security controls live, so this guide covers the wall you will meet
before the first request, not only the request itself.
Withdrawals are a Live-side flow: sandbox keys are deliberately not granted withdrawal scopes (the why is in Going live), and the allowlist below applies on mainnet.
Before the first withdrawal: the allowlist
Section titled “Before the first withdrawal: the allowlist”A live workspace starts with the withdrawal allowlist ON. A withdrawal to an address that is not
an approved beneficiary is refused with a 422, whatever else is correct about the request.
Beneficiaries are managed on the dashboard’s Security page, not through this API, because adding
a payout destination is a protected action: it requires a strong second factor, and the new address
only becomes usable after a cooling-off delay (24 h by default). Your servers and your owners
are told when it happens: BENEFICIARY_ADDED and BENEFICIARY_REMOVED webhooks fire, and any
change that weakens the policy fires WITHDRAWAL_POLICY_CHANGED with data.weakened: true.
Plan it: add the beneficiaries you will pay before you need them, so the cooling-off has elapsed when the first real withdrawal is due.
Know the cost before you commit
Section titled “Know the cost before you commit”POST /v2/fees/quote (scope fees:read) prices a withdrawal with the exact engine that will settle
it: send direction, chain, asset and amount, and read back the fee and the net. Use
mode: "gross_up" to answer the inverse question, what to withdraw so the recipient nets a target
amount. Quotes are indicative; the binding fee is computed at settlement.
Request the withdrawal
Section titled “Request the withdrawal”POST /v2/withdrawals (scope withdrawals:write). Always send an Idempotency-Key header; the
consistency section below is built on it.
Request:
{ "chain": "TRON", "asset": "USDT_TRON", "amount": "50", "destination_address": "TExternalRecipientAddress123456789"}Response (201):
{ "id": "e5f6a7b8-0000-0000-0000-000000000005", "chain": "TRON_MAINNET", "asset": "USDT_TRON", "amount": "50.000000", "fee": "0.900000", "net_amount": "49.100000", "status": "PENDING", "idempotency_key": "3e1f8a20-6a5c-4d5e-9b2a-8c7d6e5f4a3b", "requested_at": "2026-08-09T11:00:00.000Z"}The requested amount is locked immediately: it leaves available for locked the moment the
request is accepted, so it cannot be double-spent while the withdrawal is in flight. fee and
net_amount are estimates at this point; the final figures come with WITHDRAWAL_CONFIRMED.
Track it: every status, and which webhook tells you
Section titled “Track it: every status, and which webhook tells you”| Status | Meaning | Webhook | Final? |
|---|---|---|---|
PENDING |
Accepted, funds locked, queued for broadcast | (the creation response itself) | no |
PENDING_APPROVAL |
Above the approval threshold, awaiting a second member | WITHDRAWAL_PENDING_APPROVAL |
no |
UNDER_REVIEW |
Destination held for compliance review | WITHDRAWAL_HELD |
no |
PROCESSING |
Signed and broadcast; txid is now set |
WITHDRAWAL_PROCESSING |
no |
CONFIRMING |
Accumulating on-chain confirmations | (none) | no |
CONFIRMED |
Confirmed on-chain; fee final, breakdown in the payload | WITHDRAWAL_CONFIRMED |
yes |
FAILED |
On-chain failure; the locked funds return to available |
WITHDRAWAL_FAILED |
yes |
CANCELLED |
Cancelled before broadcast; funds return to available |
WITHDRAWAL_CANCELLED |
yes |
Treat CONFIRMED, FAILED and CANCELLED as final: they will not change again. Everything else is
transitional, and the terminal outcome always arrives as one of those three.
Approvals and holds
Section titled “Approvals and holds”Two of the transitional states involve humans, and your integration should expect both:
PENDING_APPROVAL(double approval). If your workspace policy sets an approval threshold, a withdrawal above it waits for a second member’s approval in the dashboard; the member who created it can never be its approver. Approved, it proceeds toPENDINGand the normal flow. Rejected, it endsCANCELLEDwith the funds restored andWITHDRAWAL_CANCELLEDfires. Left past the approval window, it expires and the locked funds return toavailable; poll the withdrawal if you need to detect that case promptly.UNDER_REVIEW(compliance hold). A destination matching a screening list holds the withdrawal for review instead of signing it. Released, it proceeds; rejected, it endsCANCELLEDwith the funds restored. TheWITHDRAWAL_HELDpayload carries the status and the standard fields, nothing about the screening itself.
Cancel while you still can
Section titled “Cancel while you still can”DELETE /v2/withdrawals/{id} cancels a withdrawal that has not been broadcast (PENDING or
PENDING_APPROVAL). The locked funds return to available and WITHDRAWAL_CANCELLED fires. Once
PROCESSING, the transaction is on the network and can no longer be cancelled; wait for the
terminal state.
Handling refusals at request time
Section titled “Handling refusals at request time”Every refusal carries a code; branch on it, never on the message. The ones this endpoint returns,
with the action to take, are grouped in the error codes catalogue; the
frequent ones are insufficient balance, a malformed destination for the chain, a destination that is
a CowriePay-managed address (withdraw to external addresses only), a chain not yet enabled, a
compliance freeze on the workspace, and the mainnet allowlist and limit refusals described above.
Ensuring consistency
Section titled “Ensuring consistency”The failure that costs money is the ambiguous one: you sent the request, the connection died, and
you do not know whether the withdrawal exists. The Idempotency-Key header exists for exactly this;
send it on every request and the ambiguity disappears:
key = stored_key_for(payout) # generate once per intent, persist itresponse = POST /v2/withdrawals (Idempotency-Key: key)
if timeout or network error: retry POST with the SAME key -> 201: it did not exist; created now -> 200 + Idempotent-Replay: true: it existed; this is the original -> 409: still in flight; wait, retry the SAME key -> 422 IDEMPOTENCY_MISMATCH: your retry body differs; bug on your side, do not force itNever generate a fresh key on retry: that is how one payout becomes two. If a request went out with
no key and no response, reconcile before retrying: list recent withdrawals
(GET /v2/withdrawals) and look for your amount and destination before creating anything.
What next
Section titled “What next”- Webhooks: signature verification and the deduplication contract your status handling relies on.
- Collect deposits: the other half of the money flow.
- Going live: the full first-day checklist, allowlist included.