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.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 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",
  "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:

  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}/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.