CryptoRoute

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

  1. You have a CryptoRoute space (the same account Pay uses) with API keys.
  2. Your user picks a pair and an amount in your product. You call GET /estimate to show them the quote — including the fee split.
  3. You call POST /swaps with your user's recipient address (and, for cross-chain pairs, their refund address). We return a deposit address and the expected output.
  4. 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.
  5. You track the swap by polling GET /swaps/{public_id} or by receiving swap.* webhooks — signed exactly like Pay webhooks.
  6. 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 obvious TEST-DEPOSIT-… sentinels, and you drive the lifecycle yourself with POST /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 then POST /swaps with a live key gets 403 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 0 and the maximum shown by GET /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.