CryptoRoute

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

  1. You (merchant) create an invoice denominated in USD.
  2. We hand you a hosted checkout link (https://cryptoroute.io/pay/inv_…).
  3. The customer picks any supported crypto on the checkout page and pays.
  4. 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.
  5. You receive webhooks at each lifecycle step. The final invoice.settled event 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 /currencies for the coins you accept, (3) POST /invoices/{id}/pay to bind a coin and get the deposit address + exact amount + QR data, (4) poll GET /invoices/{id} for status. The same payment object 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 tagged test. 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 — when true, 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). When false, 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 the slugs from GET /currencies.
  • refund_address — required only when that coin's needs_refund_address is true; 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 of pay_asset_slug the customer must send to deposit_address.
  • deposit_memo — a destination tag/memo the customer must include, when the chain requires one (e.g. XRP, TON); null otherwise.
  • 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; show pay_amount/deposit_memo as text alongside.)
  • expires_at — the quote deadline. After it passes, the payment object reverts to null on 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_detectedsettled as the deposit is seen and swept. Drive your checkout UI off payment.status (openpayment_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 (openpayment_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 the settlement sub-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:

  1. Pull t and v1 out of the header.
  2. Reject the request if abs(now() - t) > 300 (5-minute tolerance).
  3. 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 settled event can theoretically land before its payment_detected predecessor 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's estimatedOutput).
  • 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_address is a TEST-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.