Create a withdrawal
POST /api/v1/partner/customers/{customerId}/withdrawals
Required scope:
withdrawals:write, plus thewithdrawalscapability — which is KYB-gated, so it turns on when the customer’s verification is approved.
Sends crypto from the customer’s wallet to a registered destination.
Needs both an Idempotency-Key and an X-Withdrawal-Signature. See
Signing withdrawals for how to build
the assertion — it is the same construction for both money-out endpoints.
Register the destination with
Register a withdrawal wallet first; to
must match one that already exists for this customer.
Request
curl -X POST https://www.niftipay.com/api/v1/partner/customers/{customerId}/withdrawals \
-H "Authorization: Bearer <partner_api_key>" \
-H "Idempotency-Key: <uuid>" \
-H "X-Withdrawal-Signature: <ed25519-assertion>" \
-H "Content-Type: application/json" \
-d '{ "chain": "BTC", "asset": "BTC", "amount": "0.5", "to": "bc1qdest" }'
| Field | Type | Required | Notes |
|---|---|---|---|
chain |
string | yes | Uppercased. A registered destination must exist for this chain+address. |
asset |
string | yes | Uppercased. |
amount |
string | yes | Decimal string. |
to |
string | yes | Destination address. Must match a registered withdrawal wallet for this customer. |
network |
string | no | If present, must be mainnet (anything else is refused). |
Only chain, asset, amount, and to are forwarded to the platform service; everything else in the body is ignored. userId is set server-side from the relationship and cannot be overridden. destinationTag, if any, is taken from the registered destination row, never from the request body.
The merchant may have a minimum
Each merchant carries a minimum withdrawal value in EUR. A request whose gross converts to less than that is refused, whatever the asset. The default is no minimum, so most merchants have none — but the ones that do can set it high (€300 is a real value in production).
There is no endpoint that reports it, so the refusal carries it instead:
{
"error": {
"code": "below_minimum",
"message": "The amount is below the minimum withdrawal this merchant is configured for. See `details[0].minimum_eur`.",
"request_id": "req-1",
"details": [
{
"minimum_eur": 300
}
]
}
}minimum_eur is the threshold in euros. Compare your gross against it and
retry with a larger amount — this is a refusal you can act on without contacting
anyone. It is absent, rather than null, on the rare occasion we cannot read a
number, so you can tell “we did not say” from a value you can use.
Response 201
{
"request_id": "req-1",
"api_version": "2026-08-01",
"data": {
"withdrawal": {
"tx_hash": "0xabc",
"chain": "BTC",
"asset": "BTC",
"gross": "0.5",
"fee": "0.001",
"net": "0.499",
"gas": "0.0001",
"to_address": "bc1qdest"
}
}
}Errors
400 idempotency_key_required when no Idempotency-Key header is provided.
401 signature_required when X-Withdrawal-Signature is missing or invalid.
400 invalid_request for a non-JSON/unreadable body or missing chain, asset, amount, or to.
400 unsupported_network when network is present but not mainnet.
403 destination_not_registered when to is not a registered destination for this customer (identical response for a destination registered to another merchant).
409 insufficient_balance when the wallet or token balance is too low.
400 amount_too_small when the amount is consumed by gas and fees.
400 unsupported_asset when the asset is unsupported on the chain.
400 invalid_amount when amount is not a valid positive number.
409 below_minimum when the gross is under the merchant’s configured EUR minimum; details[0].minimum_eur carries the threshold.
400 withdrawal_refused for any other service refusal not in the mapped set.
503 temporarily_unavailable on a 5xx from the service or a transport failure (reservation is kept so a retry is safe).
500 internal_error for permanent unclassified failures.
409 idempotency_key_reuse when the same key is reused with a different body.
409 idempotency_key_in_progress when retried inside the window after a transport failure.