POST /v1/withdrawals
Submit a withdrawal from your internal balance to an arbitrary on-chain destination. The gateway holds amount + fee, broadcasts the transaction from your hot wallet, and reports back the on-chain tx_hash.
| Method & path | POST /v1/withdrawals |
| Required permission | withdraw |
| Auth | HMAC — see Authentication |
To check the status of an existing withdrawal, see GET /v1/withdrawals/:withdrawal_id.
Request body
{
"currency": "USDT",
"network": "ETH",
"to_address": "0xrecipient...",
"amount": "50.00",
"client_reference": "order_2026_05_24_xyz789"
}
| Field | Type | Required | Description |
|---|---|---|---|
currency | string | required | Currency symbol (1–32 chars). Must be enabled for withdrawal on this account. |
network | string | required | Network code (1–32 chars). |
to_address | string | required | Destination address (10–256 chars). Validated against the network's chain family before any balance is held. |
amount | string | number | required | Amount to send, as a decimal string (recommended) or number greater than 0. Must be at least the currency's min_withdrawal_amount. |
client_reference | string | optional | Idempotency key (up to 128 chars). See "Idempotency" below. |
Response
{
"success": true,
"message": "OK",
"data": {
"withdrawal_id": 17,
"status": "broadcasted",
"tx_hash": "0xwd...",
"network": "ETH",
"currency": "USDT",
"to_address": "0xrecipient...",
"amount_decimal": "50.00",
"fee_decimal": "0.50"
}
}
| Field | Type | Description |
|---|---|---|
withdrawal_id | integer | Use with GET /v1/withdrawals/:withdrawal_id to poll. |
status | string | Always broadcasted on a successful synchronous response. The webhook withdrawal.confirmed or withdrawal.failed fires later. |
tx_hash | string | On-chain transaction hash. |
network, currency, to_address, amount_decimal, fee_decimal | — | Echo back the resolved request — useful for accounting / display. |
Fee math
The fee is calculated server-side from the currency's admin-configured fee schedule:
fee = amount * (withdrawal_fee_percent / 100) + withdrawal_fee_flat
total_debited = amount + fee
withdrawal_fee_percent and withdrawal_fee_flat are set per currency from your dashboard's Currencies page. Both are returned to you only at submission time (in fee_decimal) — there's no separate "preview" endpoint right now. Your dashboard's Currencies page shows the current values you'd be charged.
The fee is debited from your balance in addition to amount when the withdrawal confirms.
Holds vs debits — what changes when
Submitting a withdrawal is a two-step balance change so you don't see double-spending windows in the meantime:
| Lifecycle event | balance | held | Visible available (balance - held) |
|---|---|---|---|
| Before submission | unchanged | unchanged | full balance |
POST /v1/withdrawals accepted | unchanged | += amount + fee | drops by amount + fee |
withdrawal.confirmed fires | -= amount + fee | -= amount + fee | unchanged from previous row |
withdrawal.failed or admin cancel | unchanged | -= amount + fee | restored to full balance |
The practical upshot: you can call POST /v1/withdrawals again immediately for any remaining available without risk of double-spending — the hold makes subsequent reads of /v1/balances consistent.
Idempotency
A retried POST /v1/withdrawals after a network timeout could broadcast twice if your previous request actually succeeded but the response was lost. Always set client_reference.
- On the first call with a given
(api_key, client_reference)pair, the withdrawal is created and you get the submission response shown above (data.withdrawal_id,data.amount_decimal,data.fee_decimal). - On any retry with the same
(api_key, client_reference), the gateway does not create a duplicate debit. It returns the current status of the original withdrawal — using the same body as GET /v1/withdrawals/:withdrawal_id, not the submission shape.
Critical — the retry response uses the GET shape, so field names differ:
| Submission response (first call) | Idempotent replay (retry) |
|---|---|
data.withdrawal_id | data.id |
data.amount_decimal | data.amount |
data.fee_decimal | data.fee |
| (absent) | data.requested_at, data.broadcast_at, data.confirmed_at, data.error_message |
If you read data.withdrawal_id unconditionally it will be missing on a retry. Read the id as data.withdrawal_id ?? data.id, or simply follow every submit with a GET /v1/withdrawals/:withdrawal_id call and parse only that shape.
// Initial POST — succeeds, broadcasts tx_hash=0xabc..., returns the submission shape with withdrawal_id=17
{ "currency": "USDT", "network": "ETH", "to_address": "0x...", "amount": "50.00", "client_reference": "ord_99_t1" }
// Retried after timeout — returns the GET-status shape for the same row (id=17), with whatever status it's in now
{ "currency": "USDT", "network": "ETH", "to_address": "0x...", "amount": "50.00", "client_reference": "ord_99_t1" }
Like deposit addresses, the key is scoped per API key. Use globally unique values to keep things predictable.
Status lifecycle
| Status | Meaning | Next event |
|---|---|---|
pending | Transient initial state — the row exists and amount + fee is held, but the chain broadcast hasn't completed yet. You rarely observe it on the POST path (the synchronous response is emitted after broadcast), but an idempotent retry can surface it. | broadcasted or failed. |
broadcasted | Tx accepted by the chain; awaiting confirmations. The synchronous response always returns this on success. | withdrawal.confirmed or .failed. |
confirmed | Confirmations met; balance debited. Final state. | — (final) |
failed | Broadcast rejected, transaction dropped, or no hot wallet. Hold released; balance unchanged. error_message is set. | — (final). See withdrawal.failed reasons in the Webhooks section. |
cancelled | Admin cancelled before broadcast. Hold released. | — (final) |
Subscribe to the matching webhook events instead of polling.
Errors
| Status | message | Cause |
|---|---|---|
400 | The address format is invalid for this network | to_address doesn't validate for the network's chain family (e.g. an EVM hex address on Tron). |
400 | Withdrawal amount is below the minimum allowed | amount is less than currency.min_withdrawal_amount, OR the raw on-chain unit rounds to zero. |
400 | Insufficient available balance for this withdrawal | balance - held is less than amount + fee at submission time. |
401 / 403 / 429 | Standard middleware. | See Errors page. |
403 | Withdrawals for this currency are not enabled for this account | Currency exists but withdrawals are toggled off via the dashboard. |
404 | Network not found / Currency not found | Bad network or currency. |
502 | No hot wallet configured for this network | You never enabled any currency on this network, so no hot wallet was allocated. Enable one first. |
502 | Failed to broadcast withdrawal transaction | The gateway could not broadcast the transaction. The hold is released; the withdrawal is marked failed; withdrawal.failed fires with reason='broadcast_rejected'. |
Code samples
Examples assume sign_request(...) from the Authentication page.
- curl + bash
- Python
- Node.js
- PHP
METHOD="POST"
ENDPOINT="/v1/withdrawals"
QUERY=""
BODY='{"currency":"USDT","network":"ETH","to_address":"0xrecipient...","amount":"50.00","client_reference":"order_2026_05_24_xyz789"}'
# ... sign as on the Authentication page; body hash is sha256($BODY) ...
curl -sS "${BASE_URL}${ENDPOINT}" \
-X POST \
-H "Content-Type: application/json" \
-H "X-CPG-Key: ${API_KEY}" \
-H "X-CPG-Timestamp: ${TS}" \
-H "X-CPG-Signature: ${SIG}" \
--data-raw "$BODY"
import json
body_dict = {
"currency": "USDT",
"network": "ETH",
"to_address": "0xrecipient...",
"amount": "50.00",
"client_reference": "order_2026_05_24_xyz789",
}
body_bytes = json.dumps(body_dict).encode()
headers = sign_request("POST", "/v1/withdrawals", body=body_bytes)
headers["Content-Type"] = "application/json"
resp = requests.post(f"{BASE_URL}/v1/withdrawals", data=body_bytes, headers=headers, timeout=10)
resp.raise_for_status()
print(resp.json())
const bodyDict = {
currency: 'USDT',
network: 'ETH',
to_address: '0xrecipient...',
amount: '50.00',
client_reference: 'order_2026_05_24_xyz789',
};
const bodyStr = JSON.stringify(bodyDict);
const headers = {
...signRequest('POST', '/v1/withdrawals', bodyStr),
'Content-Type': 'application/json',
};
const resp = await fetch(`${BASE_URL}/v1/withdrawals`, {
method: 'POST',
headers,
body: bodyStr,
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${await resp.text()}`);
console.log(await resp.json());
$body = json_encode([
'currency' => 'USDT',
'network' => 'ETH',
'to_address' => '0xrecipient...',
'amount' => '50.00',
'client_reference' => 'order_2026_05_24_xyz789',
]);
$headers = sign_request('POST', '/v1/withdrawals', $body);
$headers[] = 'Content-Type: application/json';
$ch = curl_init("$baseUrl/v1/withdrawals");
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => $headers,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
]);
echo curl_exec($ch);
curl_close($ch);
Notes
- Always send
amountas a string. JSON parses numbers as IEEE-754 floats, which silently drops precision on values like0.1 + 0.2. The gateway accepts both, but parsing back through float can change your effective withdrawal amount by satoshi-grade rounding errors. feeisn't deductible fromamount. Thetotal_debited = amount + feeis taken from your balance; the recipient receives exactlyamount. There's no "send X but charge fee from the recipient" mode.- Hot wallet must hold both the asset and gas. USDT withdrawals on ETH need both USDT and ETH for gas. The gateway will attempt automatic gas top-ups when configured, but if the hot wallet is fully drained you'll see
withdrawal.failedwithreason='broadcast_rejected'.