CryptoRoute Exchange API — Partner Swaps
CryptoRoute Exchange API — Partner Swaps
This is the reference for partners who want to offer CryptoRoute swaps to their own users — a wallet, a bot, a dApp, an exchange's "swap" tab — through their own product, with their own margin on top. If you want to get paid in crypto instead, you're looking for CryptoRoute Pay.
How it works
- You have a CryptoRoute space (the same account Pay uses) with API keys.
- Your user picks a pair and an amount in your product. You call
GET /estimateto show them the quote — including the fee split. - You call
POST /swapswith your user's recipient address (and, for cross-chain pairs, their refund address). We return a deposit address and the expected output. - Your user sends the input asset to the deposit address. We swap it wallet-to-wallet and deliver the output to their recipient address. We never hold funds on the happy path.
- You track the swap by polling
GET /swaps/{public_id}or by receivingswap.*webhooks — signed exactly like Pay webhooks. - Your fee accrues on every completed swap and is reported per swap and on your dashboard.
Every swap is attributed to your space and to the key that created it, so GET /swaps (and the dashboard's Exchange tab) is a complete ledger of what your integration created.
Authentication
Same keys as Pay. Issue one from your space's dashboard at /dashboard/pay/{your-space} — it's shown exactly once.
pk_test_<32-hex>— sandbox. Works the moment your space exists. Swaps are simulated: no real crypto moves, deposit addresses are obviousTEST-DEPOSIT-…sentinels, and you drive the lifecycle yourself withPOST /swaps/{id}/simulate.pk_live_<32-hex>— live. Requires your space to be approved (active) and, to create swaps, the Exchange API to be enabled for it. Pay approval alone is not enough: a Pay settlement goes to an address you proved control of, while an exchange swap pays out wherever your request says, so live exchange is switched on per partner. Ask us once your sandbox integration works — until thenPOST /swapswith a live key gets403 exchange_disabled(everything else, including reading your swaps and setting your fee, works).
Authorization: Bearer pk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
The key you send determines the environment. Test keys only ever see test swaps and vice-versa.
Fees
Two parts, collected together inside the quote (charged in the input asset, the way the swap engine nets fees):
- CryptoRoute's fee — the platform rate for your space (the standard rate, or a negotiated one).
- Your fee — a margin you set yourself, between
0and the maximum shown byGET /fee(in basis points; 100 bps = 1%).
Your user sees one all-in quote. GET /estimate and every swap resource report the split, so you can display whichever breakdown you like. Your fee applies to swaps created after you change it; already-quoted swaps keep the fee locked on their quote.
Fees you've earned are listed per swap (fees.partner_fee_amount) and totalled per asset on your dashboard's Exchange tab. They are settled to you per your partner agreement.
GET /api/v1/exchange/fee
POST /api/v1/exchange/fee { "partner_fee_bps": 50 }
{ "partner_fee_bps": 50, "max_partner_fee_bps": 300, "platform_fee_bps_default": 50 }
The fee is one setting for both environments, so changing it needs a live key (or the dashboard's Exchange tab — use that while your space is still in sandbox). Test keys can read it, and it applies to your test swaps too; a test key gets 403 live_key_required on POST.
REST API
Base URL: https://cryptoroute.io/api/v1/exchange. All bodies are JSON. Errors follow { "error": { "code", "message", "field"? } } — field names the offending input when there is one.
| HTTP | error.code |
When |
|---|---|---|
| 401 | unauthorized |
Missing/invalid/revoked Bearer key, or a suspended space. |
| 403 | exchange_disabled |
POST /swaps with a live key on a space that isn't enabled for live exchange yet. Reads, the fee and existing swaps still work; use a test key to create swaps meanwhile. |
| 403 | live_key_required |
POST /fee with a test key — set the fee with a live key or from the dashboard. |
| 404 | not_found |
No swap with that public_id under your space in this environment. |
| 422 | cannot_create_swap |
Unknown pair, malformed address, missing refund address for a cross-chain pair, amount out of bounds, or the provider declined the quote. message says which; field names the input. |
| 422 / 409 | cannot_submit_deposit |
The swap has no deposit address (422), or is already finished (409). |
| 422 | invalid_tx_hash |
The provider didn't recognise the tx_hash for this deposit address — don't retry with the same hash. |
| 451 | country_required |
Live swap without country on a deployment that fails closed on unknown jurisdictions. |
| 422 | — | Request validation failed (Laravel's { "message", "errors" } shape). |
| 409 | simulation_not_allowed |
Simulate called on a live or already-finished swap. |
| 451 | blocked_jurisdiction |
The country you passed for your user is one we can't serve. |
| 502 | provider_unavailable |
The swap provider is down — safe to retry in a minute. |
| 500 | internal_error |
Unexpected server error — safe to retry. |
List currencies
GET /api/v1/exchange/currencies
Every asset you may name as from or to, identified by slug (asset + chain):
{ "data": [ { "slug": "usdt-trc20", "symbol": "USDT", "name": "Tether USD", "chain": "Tron", "chain_slug": "tron", "decimals": 6 }, … ] }
Not every pair is routable; GET /estimate tells you whether a specific one is.
Estimate
GET /api/v1/exchange/estimate?from=usdt-trc20&to=usdc-solana&amount=100[&country=DE]
Read-only and cheap — call it on every keystroke of your amount field. Nothing is created.
{
"from": { "slug": "usdt-trc20", "symbol": "USDT", "chain": "Tron" },
"to": { "slug": "usdc-solana", "symbol": "USDC", "chain": "Solana" },
"amount_in": "100",
"available": true,
"estimated_output": "99.2",
"min_output": "98.2",
"rate": "0.99200000",
"fees": { "platform_bps": 50, "partner_bps": 30, "total_bps": 80, "fee_asset": "USDT" },
"refund_address_required": true
}
available: false (with estimated_output: null) means the pair can't be quoted for that amount right now — show it as unavailable rather than as an error. refund_address_required is true for cross-chain pairs: you must collect your user's origin-chain address before calling POST /swaps.
Create a swap
POST /api/v1/exchange/swaps
| Field | Type | Notes |
|---|---|---|
from, to |
string | Currency slugs from GET /currencies. |
amount |
decimal string | Amount of from your user will send. At most as many decimal places as the asset has (decimals in GET /currencies). |
recipient_address |
string | Your user's address on the destination chain. Format-validated server-side. |
refund_address |
string | Your user's address on the origin chain. Required for cross-chain pairs — a failed swap refunds there, straight from the provider. |
external_ref |
string ≤120 | Your own reference (user id, order id). Echoed on every read and webhook; filterable on the list. |
metadata |
object ≤50 keys | Free-form, echoed back. |
country |
ISO-2 | Where your end user is. We can't read it from your server's IP, so pass it; restricted jurisdictions are refused with 451, and live swaps without it are refused (country_required) where the deployment fails closed on unknown jurisdictions. Sandbox never requires it. |
Response 201 — the swap resource:
{
"public_id": "01J9…",
"status": "quoted",
"livemode": true,
"from": { "slug": "usdt-trc20", "symbol": "USDT", "chain": "Tron" },
"to": { "slug": "usdc-solana", "symbol": "USDC", "chain": "Solana" },
"amount_in": "100",
"expected_output": "99.2",
"min_output": "98.2",
"rate": "0.99200000",
"recipient_address": "7Np4…",
"refund_address": "TJRa…",
"deposit": { "address": "TAbc…", "memo": null, "expires_at": "2026-09-08T12:34:56Z" },
"fees": { "platform_bps": 50, "partner_bps": 30, "total_bps": 80, "partner_fee_amount": "0.3", "fee_asset": "USDT" },
"external_ref": "user-42-order-7",
"metadata": { "plan": "pro" },
"tx_hash": null,
"track_url": "https://cryptoroute.io/track/01J9…",
"events": [],
"created_at": "…", "updated_at": "…"
}
Show your user deposit.address (and deposit.memo when present — some chains need it), the exact amount_in, and deposit.expires_at. track_url is a public status page you can hand them.
Fetch one swap
GET /api/v1/exchange/swaps/{public_id}
Returns the same resource, refreshed from the provider for live swaps. status walks:
quoted → deposit_pending → deposit_detected → processing → success, with expired (no deposit before the quote deadline), incomplete_deposit (less than quoted arrived), refunded (swap failed after deposit; funds returned to the refund address) and failed. success, refunded, failed and expired are terminal. events is the append-only history with provider statuses and transaction hashes.
List swaps
GET /api/v1/exchange/swaps?status=success&external_ref=user-42&limit=50
GET /api/v1/exchange/swaps?limit=50&before=<public_id of the last swap on the previous page>
Newest first, scoped to your space and the key's environment. limit 1–200 (default 50). While has_more is true, pass the public_id of the last swap you received as before to get the next (older) page — the cursor is exact, even for swaps created in the same second. created_after / created_before (ISO-8601, second precision) are coarse date filters for incremental sync.
Notify a deposit
POST /api/v1/exchange/swaps/{public_id}/deposit { "tx_hash": "…" }
Optional. If your product knows the transaction hash of your user's deposit, tell us — it speeds up detection. Returns the refreshed swap.
Webhooks
Set a webhook URL on your space (dashboard → Overview, per environment). Each status change POSTs JSON with the same X-CryptoRoute-Signature header Pay uses, signed with the same secret for that environment.
Event types
swap.deposit_pending, swap.deposit_detected, swap.processing, swap.success, swap.incomplete_deposit, swap.refunded, swap.failed, swap.expired — one per internal status change. (quoted is what POST /swaps already returned, so it doesn't fire.)
Payload
{
"id": "evt_…",
"type": "swap.success",
"livemode": true,
"created_at": "2026-09-08T12:40:00Z",
"data": { "swap": { …the swap resource above… } }
}
Signature verification
Identical to Pay — the Pay docs carry Node/PHP/Python verification snippets under "Signature verification". In short: header t=<unix>,v1=<hex>, HMAC-SHA256 over "{t}.{raw_body}", reject if |now − t| > 300s, compare in constant time.
Delivery semantics
At-least-once. Dedupe on id (stable across retries), treat events as state assertions rather than diffs (order isn't guaranteed), answer any 2xx quickly. A failed delivery is retried after 5m, 15m, 1h, 6h and 24h, then dead-lettered.
Test mode
With a pk_test_ key everything above works against a simulated provider: quotes are deterministic, deposit addresses are TEST-DEPOSIT-…, nothing moves on-chain and test swaps are never polled. Drive the lifecycle yourself:
POST /api/v1/exchange/swaps/{public_id}/simulate { "scenario": "settle" }
scenario |
What happens |
|---|---|
detect |
The user's deposit is seen → deposit_detected. |
settle |
Deposit seen, then the swap completes → success (fires swap.deposit_detected then swap.success). |
refund |
Deposit seen, then the swap fails and refunds → refunded. |
expire |
No deposit arrives → expired. |
Only states the live engine really produces can be simulated. Test webhooks go to your test webhook URL, signed with your test secret, with livemode: false.
Limits and quotas
| Endpoint | Rate limit |
|---|---|
POST /swaps, POST /swaps/{id}/deposit, POST /swaps/{id}/simulate, POST /fee |
100 / min |
GET /currencies, GET /estimate, GET /swaps, GET /swaps/{id}, GET /fee |
1,000 / min |
Compliance
Swaps are non-custodial and wallet-to-wallet, but they are not exempt from your own obligations. Pass your user's country when you know it, keep external_ref pointing at your own user record, and review the AML policy. We may decline pairs, amounts or jurisdictions; every refusal comes back as a structured error you can show your user.
Need help?
Write to the support address on your dashboard with the swap's public_id — it's the key to everything we know about a swap.