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.5%, applied as a spread inside the swap. So a $100 invoice typically settles around $98.50 of USDT-OP, give or take 1–2 % of slippage.
Authentication
Issue an API key from your space's dashboard at /dashboard/pay/{your-space}. Keys are formatted pk_live_<32-hex> and shown exactly once at creation — copy it then, you cannot recover the plaintext afterwards.
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": … } }.
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 URL the customer is redirected to after settlement.
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"
}
Send the customer to url. That's the only field they need to see.
Fetch one invoice
GET /api/v1/pay/invoices/{public_id}
Returns the same shape as the create response, with the current status and (after settlement) settled_at, settled_amount_usdt, expected_amount_usdt.
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.
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",
"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.
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}/cancel |
100 / min |
GET /invoices and GET /invoices/{id} |
1,000 / min |
Hosted-checkout polling is rate-limited separately at the route level.
Test mode
There isn't a separate test mode in v0.1. To smoke-test your integration, create a small-amount invoice on your live space, pay it from a wallet you own, and verify the webhook arrives signed correctly. (We're working on a dedicated pk_test_* key path — let us know if this is blocking your integration.)
Need help?
Email [email protected]. Include your space slug and the affected public_id if it's about a specific invoice.