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:

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:

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.

  1. You (or the customer directly, on the dashboard) add the address. It starts as PendingVerification.
  2. Control of the address is verified — a confirmed inbound deposit whose source address matches — and Kwiikpay staff approve it; only then is it Active.
  3. That entry's id is whitelisted_address_id on 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.

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:

Withdrawal status vocabulary: RequestedSubmittedToProviderProviderPendingSettled 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

See errors for the general rules.