Crypto wallets & withdrawals
This guide covers the crypto routes on /api/v1/partner: issuing a deposit address, reading
wallets and balances, submitting and listing whitelist entries, listing withdrawals, and sending a
withdrawal. Every claim here describes what the API does today.
Assets and networks
| Asset | Scale (decimals) | Deposit address issued on | Withdrawal networks accepted | Convertible? |
|---|---|---|---|---|
| BTC | 8 | BITCOIN | BITCOIN | yes |
| ETH | 18 | ETHEREUM | ETHEREUM | yes |
| USDC | 6 | ETHEREUM | ETHEREUM | yes |
| USDT | 6 | ETHEREUM only | ETHEREUM or TRON | yes |
| TRX | 6 | TRON | TRON | no — deposit and withdraw only |
Asset codes are case-insensitive and trimmed. TRX and USDT-on-TRON are enabled per tenant: if your
tenant has no policy row for them, the deposit-address route answers 422 crypto_asset_network_policy_not_configured and the withdrawal route answers 409 crypto_withdrawal_failed with detail "Crypto asset/network policy was not found."
The receive/send asymmetry for USDT. You can send USDT on TRON ("network": "TRON" on the
withdrawal), but you cannot receive it through this API: POST /api/v1/partner/customers/{customerId}/crypto/wallets/{asset}/address takes no network and always
issues the Ethereum (ERC-20) address for USDT. Tell depositors the network explicitly. USDT sent on
TRON to that address is lost.
1. Issue a deposit address
Requires approved onboarding (check GET /api/v1/partner/customers/{customerId}/onboarding/status
first) and the wallets:write scope.
# {asset} is one of BTC, ETH, USDC, USDT, TRX — e.g. .../crypto/wallets/USDC/address
curl -X POST "https://api.kwiikpay.io/api/v1/partner/customers/{customerId}/crypto/wallets/{asset}/address" \
-H "X-Api-Key: kp_live_..." -H "Idempotency-Key: 9f3c1a2b-usdc-address-0001"
{
"success": true,
"status_code": 200,
"message": "Deposit address generated successfully",
"data": {
"wallet": { "id": "…", "asset_code": "USDC", "network": "ETHEREUM", "address": "0x…", "balance": "0.000000", "status": "Active", "…": "…" },
"address": "0x…",
"tag": null
}
}
Three things to know about this response:
- It is get-or-create. An existing Active address for the asset is returned; a new
Idempotency-Keydoes not mint a second one. - A 200 can carry
"address": null. The 200 branch is taken for every outcome that is not a refusal, including two where the custody provider has not issued the address yet: the provider call failed (wallet.statusisManualReviewRequired) or a concurrent same-key request is still in flight (wallet.statusis whatever it was, typicallyRequested). The reason code is not in the body. Branch onaddress != null, not on the HTTP status. When it is null, poll the balance route (next section) untilwallet.statusisActive. tagis always null. There is no shared-address-plus-memo model; never ask a depositor for a tag. The balance figures on this response are hardcoded to zero.
Refusals: 409 kyc_or_kyb_not_approved (onboarding), 422 unsupported_asset, 422 crypto_deposits_disabled / crypto_asset_network_policy_not_configured /
crypto_wallet_opening_not_configured (tenant configuration), and 409 with
crypto_wallet_provisioning_in_progress or crypto_wallet_provider_exception when the customer's
custody wallet container is itself still being created (message "Crypto deposit address is
pending." — retry later with a new key). The full per-route table is on each operation in the
reference.
2. Read wallets and balances
Two reads, with different semantics for the same wallet object:
| Route | Scope | Rows / status |
address |
id |
|---|---|---|---|---|
GET /api/v1/partner/customers/{customerId}/crypto/wallets |
wallets:read |
Only issued addresses; status is always Active. Unbounded, no pagination. |
never null | deposit-address id |
GET /api/v1/partner/customers/{customerId}/crypto/wallets/{asset}/balance |
balances:read |
The newest request for the asset on its default network, no status filter: Requested, ManualReviewRequired, Blocked, Failed, Active, or the literal Unknown when there is none. |
null when none | falls back to the pooled ledger account id, then 00000000-0000-0000-0000-000000000000 |
The vocabulary for wallet.status is the workflow's states — Requested, SubmittedToProvider,
Active, ProviderPending, ManualReviewRequired, Blocked, Failed, Settled — plus
Unknown. Only Active means the address can receive.
How balances are computed. balance, reserved_balance and available_balance are keyed on
the asset across all of the customer's networks, not on the address:
- A customer holding USDT on both ETHEREUM and TRON gets two rows on the list route that each report the full pooled USDT total. Do not sum wallet rows.
- The balance route labels the row with the default network (USDT → ETHEREUM) while the figure includes TRON holdings.
balanceincludes amounts reserved by open withdrawals, and on the list route every ledger bucket including pending postings.available_balanceismax(0, balance − reserved_balance). It is a books figure: a withdrawal on one network can still be refused withinsufficient_network_fundswhen that network's share is short.- Both routes reconcile with the custody provider before answering. If that fails you get 503
provider_balance_refresh_failed— retry with backoff; nothing is wrong with your request.
Units. The string fields (balance, reserved_balance, available_balance) are major units
rendered with exactly the asset's scale of decimals. The *_atomic fields are the same figures in
atomic units (satoshi, wei, 10⁻⁶) as JSON numbers. At ETH's scale of 18, 0.01 ETH is
10000000000000000 — beyond JavaScript's Number.MAX_SAFE_INTEGER — so JavaScript and TypeScript
clients must read the string form, never JSON.parse the atomic one.
3. Whitelist the destination
In the default configuration a crypto withdrawal can only pay a whitelisted address, and whitelisting one still always requires two things: proof the destination is actually controlled by its owner, and Kwiikpay staff approval. Neither of those is skippable through this API — this section only removes the need to visit the dashboard to submit the address.
- You (or the customer directly, on the dashboard) add the address. It starts as
PendingVerification. - Control of the address is verified — a confirmed inbound deposit whose source address matches —
and Kwiikpay staff approve it; only then is it
Active. - That entry's id is
whitelisted_address_idon the withdrawal.
curl -X POST "https://api.kwiikpay.io/api/v1/partner/customers/{customerId}/crypto/withdrawal-addresses" \
-H "X-Api-Key: kp_live_..." -H "Idempotency-Key: 7d1e-whitelist-20260915-0001" \
-H "Content-Type: application/json" \
-d '{ "asset": "USDT", "network": "TRON", "address": "T9y...", "label": "Client payout wallet" }'
Requires withdrawal-addresses:write (:read for the GET) — a dedicated scope, deliberately
not the same one your withdrawal calls use, so a key that can pay an existing whitelisted address
does not automatically gain the power to nominate a new one. An existing key needs this scope added
explicitly, or a new key minted with it, before either route will accept it.
customerId may be your own id or the id of a direct end-customer you onboarded — the same
delegation every other route on this API already honours. network is required only for a
multi-network asset (see the table above); omit it and the asset's default network is used.
label is required. The response's status is always PendingVerification: this route never
skips verification or approval, whatever the caller. GET the same path to list the customer's
entries and poll for Active. A repeat submission of the identical asset/network/address is
refused as 409 crypto_withdrawal_address_already_exists rather than creating a second row.
rejected_reason is populated only while status is Rejected — staff's own explanation for
refusing the submission. It stays null for every other status, including Disabled, where the
reason may name a sanctions-screening outcome about the address that is not yours to see.
There is still no API-key route to approve an entry — that step is deliberately staff-only and stays on the dashboard, whichever way the entry was added.
A withdrawal without whitelisted_address_id is refused with 422 whitelisted_address_required;
destination_address is only honoured when raw addresses have been enabled for your tenant (they
are off by default), and is ignored whenever a whitelist id is supplied.
The entry must match both the asset and the network of the withdrawal: paying a whitelisted
USDT/TRON address means also sending "network": "TRON", else 422 whitelisted_address_asset_mismatch.
4. Send a withdrawal
Requires withdrawals:write, approved onboarding, and an existing custody wallet for the customer
(issue a deposit address first, else 409 crypto_withdrawal_prerequisites_missing).
curl -X POST "https://api.kwiikpay.io/api/v1/partner/customers/{customerId}/crypto/withdrawals" \
-H "X-Api-Key: kp_live_..." -H "Idempotency-Key: 7d1e-payout-20260902-0001" \
-H "Content-Type: application/json" \
-d '{ "asset": "USDT", "network": "TRON", "amount": 25.5, "whitelisted_address_id": "018f…" }'
amount is in major units (whole coins, fractional expected) — the only request amount on
this API that is not minor units — and is rounded away from zero at the asset's scale. The
response reports amount in atomic units. description is stored for your records and never
reaches the chain.
Three outcomes — read the HTTP status and data.withdrawal.status together.
| HTTP | withdrawal.status |
Meaning | What to do |
|---|---|---|---|
| 200 | SubmittedToProvider |
Submitted to the custody provider. | Poll the list for Settled; withdrawal.completed fires then. |
| 202 | ManualReviewRequired |
Parked, not accepted for delivery. Reasons collapse into this one response and the reason code is not in the body: a transaction limit was hit, address-risk / travel-rule / provider screening put it on hold, wallet delegation is still provisioning, the custody submission's outcome is indeterminate, or a same-key request is still in flight. | Poll the list until Settled, Blocked or Failed. No webhook fires for a park. |
| 202 | Blocked |
One edge case: a tenant fee policy of "manual review" blocks the row while the route still answers 202. | Treat body status Blocked as final. |
| 422 | Blocked / Failed |
Refused after the row was created; it stays visible in the list. data.code says why: insufficient_funds, insufficient_network_funds, awaiting_crypto_delivery (coins from a recent conversion have not landed — detail says how much can go now), crypto_withdrawals_disabled, crypto_withdrawal_below_minimum, provider_submission_failed (hold released), or a screening block code. |
Fix and resend with a new key. |
Validation refusals before a row exists: 422 unsupported_asset (also for a network the asset
does not support), 422 invalid_amount (≤ 0 or rounds to zero), the four whitelist codes above
(whitelisted_address_required, whitelisted_address_not_found, 409 whitelisted_address_not_active, whitelisted_address_asset_mismatch), 409 kyc_or_kyb_not_approved,
409 crypto_withdrawal_failed (the workflow threw; detail is the raw message), and a flat
403/409 with a compliance code when a compliance control blocks or pauses the customer.
Minimums, maximums and fees.
- Minimums are per-tenant configuration and are not published by any route; a breach is
422 crypto_withdrawal_below_minimumand the row is leftBlocked. - There is no maximum. An amount over the customer's transaction limit is parked as 202, not refused.
- The Kwiikpay withdrawal fee is charged on top: the customer's ledger is debited
amount + fee, the recipient receives exactlyamount, and no response field reports the fee. The applicable fee rows are the wallet-custody rows returned byGET /api/v1/partner/customers/{customerId}/feesfor that asset; that response does not distinguish networks.
A same-key retry within 24 hours replays the original response verbatim (see idempotency); use a new key for a new withdrawal.
5. Reconcile
What exists:
GET /api/v1/partner/customers/{customerId}/crypto/withdrawals(withdrawals:read) — every withdrawal in every state, newest first.limitis clamped to 1–500,pageis 1-based, andpagination.total/last_pageare real counts. There is no status, date or asset filter and no single-withdrawal GET: to find one withdrawal, page the list and match onid. Persist theidfrom the create response.GET /api/v1/partner/customers/{customerId}/crypto/deposits(deposits:read) — what arrived on-chain, with transaction hashes; failed, reorged and held deposits are included, so readstatusandreason_codebefore crediting anyone.- Webhooks:
deposit.received(a confirmed crypto deposit,deposit_typecrypto) andwithdrawal.completed(a withdrawal reachingSettled). Nothing is pushed for a withdrawal that is parked,BlockedorFailed— those are only visible by polling the list.
Withdrawal status vocabulary: Requested → SubmittedToProvider → ProviderPending →
Settled on the happy path; ManualReviewRequired while parked; Blocked and Failed are
final refusals with nothing sent. Settled is the only success.
Crypto rows on the withdrawal list have type "crypto", asset_code set, currency null,
amount in atomic units (same JavaScript caveat as above), and destination as the unmasked
on-chain address (the webhook masks it).
Error shapes on these routes
- Validation and conflict refusals: the envelope
{ "success": false, "status_code": 422|409|503, "message": "...", "data": { "code": "...", "detail": "..." } }— branch ondata.code. - Customer not found (or not visible to this key): flat
{ "title", "detail", "status": 404 }. - Missing or malformed
Idempotency-Key: RFC 7807problem+jsonwith"code": "idempotency_key_invalid". - Compliance block or pause: flat
{ "title", "detail", "status": 403|409, "code" }.
See errors for the general rules.