CryptoRoute Pay — API & Webhooks
CryptoRoute Pay — API & Webhooks
This is the reference for merchants integrating CryptoRoute Pay: how invoices work, how to call the REST API, what webhooks fire and when, and how to verify their signatures. If you're just looking to accept crypto from a dashboard, you don't need this — sign in and use /dashboard/pay.
How it works
- You (merchant) create an invoice denominated in USD.
- We hand you a hosted checkout link (
https://cryptoroute.io/pay/inv_…). - The customer picks any supported crypto on the checkout page and pays.
- Behind the scenes we swap their deposit to USDT on Optimism and deliver it directly to your settlement address. We never hold the funds on the happy path.
- You receive webhooks at each lifecycle step. The final
invoice.settledevent includes the on-chain settlement transaction hash and the actual amount delivered (which may differ from the quoted amount by a small slippage — see "Settlement amount" below).
The default fee is 1%, applied as a spread inside the swap. So a $100 invoice typically settles around $99.00 of USDT-OP, give or take 1–2 % of slippage.
Two integration modes
You can integrate either way — pick per invoice:
- Hosted checkout (simplest): create an invoice and redirect the customer to its
url. We render the coin picker, deposit address, QR, and live status. Nothing else to build. - Fully API (your own checkout): drive the whole cycle over the API and render the payment yourself — (1) create an invoice, (2)
GET /currenciesfor the coins you accept, (3)POST /invoices/{id}/payto bind a coin and get the deposit address + exact amount + QR data, (4) pollGET /invoices/{id}for status. The samepaymentobject is returned by both the bind call and the invoice fetch.
Authentication
Issue an API key from your space's dashboard at /dashboard/pay/{your-space}. Every key is shown exactly once at creation — copy it then, you cannot recover the plaintext afterwards.
Keys come in two flavours, distinguished by their prefix:
pk_test_<32-hex>— a sandbox key. It works the moment your space exists, before any verification, and everything it touches (invoices, payments, webhooks, products) is taggedtest. Test payments are simulated — no real crypto moves. See Test mode.pk_live_<32-hex>— a live key. It only works once your space is approved (active), and everything it touches is real money.
The key you send determines the environment. A pk_test_ invoice is a test invoice; a pk_live_ invoice is a live one. The two data sets never mix — a test key can't see live invoices and vice-versa.
Send the key as a Bearer token on every request:
Authorization: Bearer pk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
API keys are scoped to one payment space. Revoke a leaked key from the dashboard; revocation takes effect immediately.
REST API
Base URL: https://cryptoroute.io/api/v1/pay. All bodies are JSON. Errors follow { "error": { "code": …, "message": … } }.
Error codes:
| HTTP | error.code |
When |
|---|---|---|
| 401 | unauthorized |
Missing/invalid/revoked Bearer key, or a suspended space. |
| 404 | not_found |
No invoice with that public_id under your space. |
| 409 | cannot_cancel |
Cancel attempted on an invoice past payment_detected. |
| 422 | cannot_start_payment |
Bind refused — coin not available/accepted, missing refund address for a cross-chain coin, or the invoice already has a detected/terminal payment. The message says which. |
| 422 | — | Request validation failed (Laravel's { "message", "errors" } shape). |
| 500 | internal_error |
Unexpected server error opening a payment — safe to retry. |
Create an invoice
POST /api/v1/pay/invoices
Request body:
{
"amount_usd": "100.00",
"description": "Order #42",
"expires_in": 1800,
"metadata": { "order_id": "42", "channel": "tg-bot" },
"webhook_url": "https://merchant.example/cryptoroute-webhook",
"success_url": "https://merchant.example/thanks?order=42"
}
amount_usd— required string, 0.01 – 1,000,000.description— optional string, ≤ 255 chars. Shown on the customer checkout page.expires_in— optional seconds, 60 – 86400. Defaults to 1800 (30 min).metadata— optional JSON object passthrough, returned on every webhook.webhook_url— optional per-invoice override of your space's default webhook URL.success_url— optional. When set, the hosted checkout sends the customer back to this URL once the payment settles (auto-redirect on settlement, plus a visible "Return to merchant" link). Echoed on the invoice object for fully-API flows.
Response (201 Created):
{
"public_id": "inv_2p3k8nqx",
"url": "https://cryptoroute.io/pay/inv_2p3k8nqx",
"amount_usd": "100.00",
"description": "Order #42",
"metadata": { "order_id": "42", "channel": "tg-bot" },
"status": "created",
"expires_at": "2026-06-24T15:30:00Z",
"settled_at": null,
"expected_amount_usdt": null,
"settled_amount_usdt": null,
"webhook_url": "https://merchant.example/cryptoroute-webhook",
"success_url": "https://merchant.example/thanks?order=42",
"created_at": "2026-06-24T15:00:00Z",
"payment": null
}
For the hosted flow, send the customer to url — that's the only field they need. For the fully-API flow, payment stays null until you bind a coin (next two endpoints).
List payable currencies
GET /api/v1/pay/currencies
The coins this space accepts for checkout — the valid pay_asset_slug values for the bind endpoint. Honors the accepted-coins setting in your dashboard.
Response (200 OK):
{
"data": [
{ "slug": "usdt-tron", "symbol": "USDT", "name": "Tether USD", "chain": "Tron", "needs_refund_address": true },
{ "slug": "eth-base", "symbol": "ETH", "name": "Ether", "chain": "Base", "needs_refund_address": true }
]
}
needs_refund_address— whentrue, collect the customer's origin-chain refund address and pass it to the bind call. It's where funds return if the swap can't complete (cross-chain routes without a managed refund address). Whenfalse, omit it.
Bind a currency and open a payment
POST /api/v1/pay/invoices/{public_id}/pay
Opens a live payment for the invoice in the chosen coin and returns the deposit address, the exact amount to send, and the QR payload. This is the fully-API alternative to redirecting to the hosted url.
Request body:
{
"pay_asset_slug": "usdt-tron",
"refund_address": "TJRabPrwbZy45sbavfcjinPJC18kjpRTv8"
}
pay_asset_slug— required; one of theslugs fromGET /currencies.refund_address— required only when that coin'sneeds_refund_addressistrue; the customer's address on the origin chain.
Response (200 OK): the invoice resource (same shape as create) with a populated payment object:
{
"public_id": "inv_2p3k8nqx",
"status": "awaiting_payment",
"payment": {
"pay_asset_slug": "usdt-tron",
"pay_asset_symbol": "USDT",
"pay_asset_chain": "Tron",
"pay_amount": "100.40",
"deposit_address": "TXYZ…",
"deposit_memo": null,
"qr_code_data": "TXYZ…",
"expected_amount_usdt": "98.50",
"min_amount_usdt": "97.20",
"status": "open",
"expires_at": "2026-06-24T15:14:30Z"
}
}
pay_amount— the exact amount ofpay_asset_slugthe customer must send todeposit_address.deposit_memo— a destination tag/memo the customer must include, when the chain requires one (e.g. XRP, TON);nullotherwise.qr_code_data— the string to render as a QR: the raw deposit address. (A chain-specific payment URI like BIP21/EIP-681 has no portable form across the token transfers here, so wallets scan the bare address; showpay_amount/deposit_memoas text alongside.)expires_at— the quote deadline. After it passes, thepaymentobject reverts tonullon the invoice fetch (the quote is dead) — bind again for a fresh one.
Calling this again (e.g. the customer switches coin) supersedes the previous payment and returns a new deposit address — only the latest one is payable. A payment already in flight to a superseded/expired address is not lost: we still detect late deposits and settle them. So never reuse a superseded deposit_address for new instructions, but don't panic if a customer paid one just before re-binding.
Binding advances the invoice's own status to awaiting_payment (and fires the invoice.payment_pending webhook); it then moves to payment_detected → settled as the deposit is seen and swept. Drive your checkout UI off payment.status (open → payment_detected) to know when funds arrive, and off the invoice status for the terminal outcome. Binding is only allowed while the invoice is created/awaiting_payment; once a payment is detected, a re-bind is refused with 422 (cannot_start_payment).
Fetch one invoice
GET /api/v1/pay/invoices/{public_id}
Returns the same shape as the create response — current status, (after settlement) settled_at/settled_amount_usdt/expected_amount_usdt, and the live payment object (the bound coin's deposit address/amount/QR, or null if none is bound yet — including once its quote has expired). Poll this to drive your own checkout UI: watch payment.status (open → payment_detected) to know when the deposit lands, and the invoice status for the terminal outcome (settled / expired / refunding). Prefer webhooks (below) over tight polling where you can.
List invoices
GET /api/v1/pay/invoices?status=settled&created_after=2026-06-01T00:00:00Z&limit=50
status— filter by lifecycle state (see below).created_after— ISO-8601 timestamp.limit— 1–200, default 50.
Returns { "data": [...], "has_more": bool }. Pagination is cursor-less in v0.1 — page by passing a more recent created_after. List items omit the payment object (it requires a per-invoice lookup) — fetch a single invoice when you need it.
Cancel an invoice
POST /api/v1/pay/invoices/{public_id}/cancel
Marks an unpaid invoice as expired. Refused with 409 Conflict if the invoice is past payment_detected (you can't cancel after the customer has actually sent funds).
Lifecycle states
created → awaiting_payment → payment_detected → settled
↓ ↓ ↓
expired refunding (terminal)
↓
refunded
(or manual_review)
| state | meaning |
|---|---|
created |
Invoice exists, no customer interaction yet. |
awaiting_payment |
Customer is on the checkout page; deposit address issued, no on-chain deposit detected. |
payment_detected |
We see the customer's deposit on the origin chain; the swap is in flight. |
settled |
USDT-OP delivered to your settlement address. Terminal. |
expired |
expires_at passed without a deposit. Terminal. |
refunding |
The swap failed after deposit; refund obligation queued via managed refunds. |
refunded |
Refund tx confirmed back to the customer. Terminal. |
manual_review |
An anomaly; ops will reach out. |
Webhooks
Set a webhook_url on your space or per-invoice. Each lifecycle transition POSTs JSON to that URL with an X-CryptoRoute-Signature header.
Event types
invoice.created— first time the invoice exists (only fires for API-created invoices; dashboard creates don't emit this).invoice.payment_pending— customer opened the checkout, deposit address issued.invoice.payment_detected— origin deposit observed; swap in flight.invoice.settled— USDT-OP delivered. Includes thesettlementsub-object with tx hash.invoice.expired— invoice expired before a deposit landed.invoice.refunding— deposit landed but swap failed; refund queued.invoice.refunded— refund tx confirmed back to the customer.invoice.manual_review— refund failed or anomaly — ops intervention required.
Payload shape
{
"id": "evt_8fz1y2xa9pq6kh3w1v0c",
"type": "invoice.settled",
"created_at": "2026-06-24T15:12:34Z",
"livemode": true,
"data": {
"invoice": {
"public_id": "inv_2p3k8nqx",
"amount_usd": "100.00",
"description": "Order #42",
"metadata": { "order_id": "42" },
"status": "settled",
"expires_at": "2026-06-24T15:30:00Z",
"settled_at": "2026-06-24T15:12:34Z",
"expected_amount_usdt": "98.5000",
"settled_amount_usdt": "97.8412"
},
"settlement": {
"expected_amount_usdt": "98.5000",
"settled_amount_usdt": "97.8412"
}
}
}
The settlement block is only present on invoice.settled events.
Every payload carries a top-level livemode boolean: true for live invoices, false for sandbox (test) invoices. Test invoices deliver to a separate test webhook URL signed with a separate test secret (both configured under the dashboard's Test toggle) — so you can point sandbox events at a staging endpoint and never cross the streams. Branch on livemode if a single endpoint handles both.
Signature verification (load-bearing)
Every webhook is signed with HMAC-SHA256 using your space's webhook secret. Header:
X-CryptoRoute-Signature: t=1719247954,v1=4d2c…hex
The signed payload is "{t}.{raw_request_body}" — NOT the body alone. Signing only the body would let an attacker who once captured a webhook replay it forever; binding the timestamp into the HMAC means each replay needs a fresh signature.
To verify on your side:
- Pull
tandv1out of the header. - Reject the request if
abs(now() - t) > 300(5-minute tolerance). - Compute
HMAC-SHA256(secret, t + "." + raw_body)and compare in constant time.
Node.js
const crypto = require('crypto');
function verifyWebhook(req, secret) {
const sig = req.headers['x-cryptoroute-signature'];
if (!sig) return false;
const [tPart, vPart] = sig.split(',');
const t = parseInt(tPart.slice(2), 10);
const v1 = vPart.slice(3);
if (Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${t}.${req.rawBody}`)
.digest('hex');
return crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(v1, 'hex'));
}
PHP
function verifyWebhook(string $header, string $rawBody, string $secret): bool
{
[$tPart, $vPart] = explode(',', $header, 2);
$t = (int) substr($tPart, 2);
$v1 = substr($vPart, 3);
if (abs(time() - $t) > 300) return false;
$expected = hash_hmac('sha256', "{$t}.{$rawBody}", $secret);
return hash_equals($expected, $v1);
}
Python
import hmac, hashlib, time
def verify_webhook(header: str, raw_body: bytes, secret: str) -> bool:
t_part, v_part = header.split(',', 1)
t = int(t_part[2:])
v1 = v_part[3:]
if abs(time.time() - t) > 300:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
Delivery semantics
- At-least-once. Dedupe on
id— it's stable across all retries of the same event. Treat events as state assertions, not diffs. - Retry ladder. 1 min → 5 min → 15 min → 1 h → 6 h → 24 h. After that the row is marked failed and surfaced in your dashboard with a manual "Resend" action (resend mints a fresh
id). - Order not guaranteed across events. A
settledevent can theoretically land before itspayment_detectedpredecessor if your endpoint was briefly down — assume the new state, don't try to reconstruct the timeline. - HTTP semantics. Return any 2xx to acknowledge. 4xx and 5xx are both retried (we can't distinguish "permanent merchant bug" from "transient outage" reliably).
Secret rotation
Rotate your webhook secret from the dashboard. The previous secret stays valid for a configurable overlap window (default 24 h) so you can update your verifier on your side without dropping events. During the overlap, signatures from both the new and old secret pass verification.
Settlement amount: expected vs settled
A $100 invoice tells the customer "pay X BTC ≈ $100". The swap then delivers somewhere between quoted_min_output and quoted_expected_output USDT-OP — typically within 1–2 % of expected, sometimes a bit more during volatile windows.
We surface both numbers on every settled invoice:
expected_amount_usdt— what the quote promised at the moment of payment (1Click'sestimatedOutput).settled_amount_usdt— what actually landed at your settlement address.
Reconcile against settled_amount_usdt. Treat the spread as the cost of being non-custodial — the alternative is us holding inventory and acting as a market maker, which is a different product.
Limits and quotas
| Endpoint | Rate limit (per API key) |
|---|---|
POST /invoices |
100 / min |
POST /invoices/{id}/pay |
100 / min |
POST /invoices/{id}/cancel |
100 / min |
GET /invoices, GET /invoices/{id}, GET /currencies |
1,000 / min |
Hosted-checkout polling is rate-limited separately at the route level.
Test mode
Pay has a full sandbox. It mirrors the live API, checkout, and webhook surface exactly — the only difference is that nothing touches a real chain.
Getting sandbox keys
Create a payment space (just a display name — no settlement address, no verification). It lands in draft status with a pk_test_ key issued on the spot. Grab it from the dashboard's Test toggle and start integrating immediately. You do not need to go live first.
What's simulated vs. live
Everything you build against is identical between test and live — same endpoints, same request/response shapes, same lifecycle states, same webhook signing. What differs under the hood:
- Quotes are fake. A test invoice is quoted by a simulated provider, not the live swap engine.
- Deposit addresses are sentinels. A test payment's
deposit_addressis aTEST-DEPOSIT-…string, not a real chain address. Don't send crypto to it — nothing is watching a chain. - No network, no settlement. No real USDT-OP ever moves. Test invoices are excluded from the live status poller entirely; their lifecycle is driven by you, via simulation (below).
Because the surface is identical, code that works in the sandbox works in production unchanged — you just swap the pk_test_ key for a pk_live_ one.
The livemode flag
Every webhook payload carries a top-level "livemode" boolean — false in the sandbox, true in production. Test invoices deliver to a separate test webhook URL, signed with a separate test secret (configured under the dashboard's Test toggle). The HMAC scheme is the same t=<unix>,v1=<hmac> over "{t}.{body}" described above — just verified against your test secret.
Simulating a payment lifecycle
Open a test invoice's hosted checkout (/pay/{public_id}) and you'll see a TEST MODE banner with Simulate buttons. Each drives the invoice through one real production transition and fires the corresponding webhook (with livemode:false, to your test URL). Programmatically, POST /pay/{public_id}/simulate with a { "scenario": … } body does the same (this route is test-only — it 404s for a live invoice).
The scenarios are exactly the states the live engine actually produces:
scenario |
Drives the invoice to | Webhook |
|---|---|---|
settle |
settled |
invoice.settled |
detect |
payment_detected |
invoice.payment_detected |
refund |
refunding (+ a refund obligation) |
invoice.refunding |
expire |
expired |
invoice.expired |
That list is deliberately complete. There is no refunded or manual_review simulation, and no underpaid/overpaid: the v0.1 engine doesn't emit those events in production, so the sandbox won't teach you to handle events you'll never receive. Build your integration around the four grounded outcomes above and it will match production.
Going live
When you're ready, hit Go live on the space: submit your merchant/entity details and prove control of your settlement address (an EIP-191 signature from that address, or self-attestation). That flips the space draft → pending_review; once an admin approves it (active), you can mint pk_live_ keys and take real payments. Your test data and keys keep working alongside live — the sandbox never goes away.
Need help?
Email [email protected]. Include your space slug and the affected public_id if it's about a specific invoice.