What a crypto payment API does
A crypto payment API is the part of a cryptocurrency payment gateway that your own software talks to. Your website or app asks the gateway for an invoice, the gateway deals with the blockchain, and your server hears back when the money has arrived. You get crypto checkout inside your normal order flow, without running nodes or managing addresses yourself.
With Crypto Merchant, the integration comes down to three moves: create an invoice with POST /invoices, send the customer to the checkout_url in the response, and handle the signed invoice.paid webhook. This guide walks through each step with working Node.js code that matches the API reference and the webhooks docs.
How the integration fits together
- 1
Create a secret key
In the portal, open Developers and create an API key. It is shown once, so store it in your server's environment straight away.
- 2
Create an invoice at checkout
Your server calls POST /invoices with the price and your order ID, and saves the returned invoice ID with the order.
- 3
Redirect to checkout_url
The customer pays on the hosted invoice page and can come back to your site through your redirect_url.
- 4
Verify the webhook, then fulfil
When invoice.paid arrives, check the signature, skip events you have already handled, and deliver the order.
The API is small. These are all of its endpoints, relative to https://cryptomerchant.online/api/v1:
| Method | Path | What it does |
|---|---|---|
| POST | /invoices | Create an invoice. |
| GET | /invoices | List invoices, newest first, by status and page. |
| GET | /invoices/{id} | Retrieve one invoice. |
| POST | /invoices/{id}/cancel | Cancel an invoice while no payment has been seen. |
| POST | /payment-links | Create a payment link, fixed price or open amount. |
| GET | /payment-links | List your 100 most recent payment links. |
| GET | /balances | Balances per coin, the US dollar total and your gas balance. |
| GET | /coins | The coins you accept, with their IDs. |
Authentication with Bearer API keys
Every request sends your secret key in the Authorization header as a Bearer token. Create keys in the portal under Developers.
Authorization: Bearer cm_live_…- The full key is shown only once, and only a hash of it is stored, so copy it into your secret manager straight away.
- Use the key only on your server, never in a web page, a mobile app or a public code repository.
- If a key may have leaked, revoke it in the portal and create a new one.
Errors
A failed request returns an HTTP error status and a JSON body with a machine-readable code and a human-readable message:
{
"error": {
"code": "amount_too_small",
"message": "Amount must be at least 0.50 USD."
}
}invalid_request(400): a field is missing or not valid, and the message names it.amount_too_small(400): the amount is worth less than 0.50 US dollars.unauthorized(401): the key is missing, wrong or revoked.merchant_not_live(403): your account isn't live yet. The one-time go-live payment of $25 is credited in full to your gas balance.
Creating an invoice with the API
Create the invoice when the customer chooses to pay with crypto. Only amount is required, and it must be worth at least 0.50 US dollars. currency defaults to USD.
// Runs on your server. Never put the secret key in browser code.
const res = await fetch("https://cryptomerchant.online/api/v1/invoices", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.CM_SECRET_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
amount: "49.90",
currency: "USD",
order_id: order.id,
description: "Annual plan",
customer_email: order.email,
redirect_url: `https://yourstore.example/orders/${order.id}`,
metadata: { plan: "annual" },
}),
});
if (!res.ok) {
const { error } = await res.json();
throw new Error(`${error.code}: ${error.message}`);
}
const invoice = await res.json();
await saveInvoiceId(order.id, invoice.id); // your own database
// Now send the customer to invoice.checkout_urlSet the optional fields from the start:
order_id: your own reference, up to 120 characters, so every invoice and webhook maps back to an order.description: what the customer is paying for, up to 300 characters, shown on the invoice page.customer_emailandcustomer_name: payments are grouped by customer in the portal.redirect_url: a full URL where the customer can go after paying.metadata: any JSON object up to 4 KB, returned unchanged on the invoice and in every webhook.coin: pre-select one of your accepted coins by ID, such asUSDT.TRON(see Coins & networks).
The response has status 201 and the invoice object. Amounts are decimal strings, so do any arithmetic with a decimal library rather than floating-point numbers.
{
"id": "inv_…",
"object": "invoice",
"status": "open",
"amount": "49.9",
"currency": "USD",
"amount_usd": "49.9",
"order_id": "1042",
"metadata": { "plan": "annual" },
"coin": null,
"pay_address": null,
"pay_amount": null,
"received_amount": "0",
"checkout_url": "https://cryptomerchant.online/pay/i/inv_…",
"redirect_url": "https://yourstore.example/orders/1042",
"expires_at": "2026-10-28T10:00:00.000Z",
"paid_at": null,
"created_at": "2026-10-28T09:30:00.000Z"
}Showing payment details in your own interface
If you pass coin, the deposit address is assigned and the rate locked at once, so pay_address and pay_amount are filled in and you can show them in your own checkout. You then have to show the network, the time left and every status change yourself, so for most stores the hosted page is simpler.
Sending the customer to checkout_url
Redirect the browser to invoice.checkout_url. There the customer picks a coin and network you accept, sees a QR code, a deposit address made for this invoice and the exact amount at a locked rate, and watches the status change live. Each invoice is open for a limited time, shown as a countdown.
The invoice moves through these statuses, and most have a matching webhook event:
| Status | What it means | Webhook event |
|---|---|---|
open | Created; no coin picked yet. | invoice.created |
awaiting_payment | Coin chosen, address assigned, rate locked. | None |
confirming | Payment seen on-chain, waiting for confirmations. | invoice.confirming |
paid | Paid and confirmed; the amount minus the fee is in your balance. | invoice.paid |
underpaid | Confirmed payment short by more than your tolerance. | invoice.underpaid |
expired | Time ran out before any payment was seen. | invoice.expired |
cancelled | Cancelled before any payment was seen. | invoice.cancelled |
refunded | The customer got back what they sent on an underpaid invoice. | invoice.refunded |
Treat underpaid as “not yet”: the customer can still send the rest or ask for a refund. Shortfalls within your tolerance (we recommend at least $1) count as paid, and a customer who overpays by more than $5 can withdraw the extra. The details are in underpaid and overpaid crypto payments.
Crypto payment webhooks: events and payload
Webhooks tell your server about payments without polling the API. In the portal under Developers, add the HTTPS endpoint that should receive events. Each endpoint gets its own signing secret, starting with whsec_, and receives every event or only the ones you pick.
- Invoice events:
invoice.created,invoice.confirming,invoice.paid,invoice.underpaid,invoice.expired,invoice.cancelledandinvoice.refunded. - Money-out events:
payout.sent,payout.failedandswap.completed.
Each event is a POST with a JSON body. For invoice events, data is the invoice object, including your order_id and metadata. The event id is repeated in the CM-Event-Id header:
POST /webhooks/crypto-merchant HTTP/1.1
Content-Type: application/json
User-Agent: CryptoMerchant-Webhooks/1
CM-Signature: t=1792834867,v1=5f2c…
CM-Event-Id: evt_…
{
"id": "evt_…",
"type": "invoice.paid",
"created": "2026-10-28T09:41:07.000Z",
"data": { "id": "inv_…", "object": "invoice", "status": "paid", … }
}How to verify the HMAC webhook signature
Anyone can send a POST request to a public URL, so check the signature before you trust an event. Every delivery is signed with HMAC-SHA256 (RFC 2104), keyed with the endpoint's secret. The header looks like t=1792834867,v1=5f2c…: the Unix time in seconds, then the hex signature. To check it:
- 1
Read the raw request body exactly as it arrived, before parsing the JSON.
- 2
Split the
CM-Signatureheader on commas and read thetandv1values. - 3
Compute the HMAC-SHA256 of the timestamp, a full stop and the raw body (
${t}.${body}), keyed with yourwhsec_secret, as lowercase hex. - 4
Compare it with
v1using a constant-time comparison. - 5
Reject events whose timestamp is more than five minutes old, so captured requests can't be replayed.
import crypto from "node:crypto";
/**
* Checks a Crypto Merchant webhook and returns the event.
* rawBody: the request body exactly as it arrived (string or Buffer).
* signatureHeader: the value of the CM-Signature header.
* secret: this endpoint's signing secret, whsec_…
*/
export function verifyWebhook(rawBody, signatureHeader, secret, toleranceSec = 300) {
const parts = Object.fromEntries(
String(signatureHeader ?? "")
.split(",")
.map((part) => part.trim().split("=")),
);
const t = parts.t;
const v1 = parts.v1 ?? "";
if (!t || !v1) throw new Error("Missing signature");
const body = Buffer.isBuffer(rawBody) ? rawBody.toString("utf8") : rawBody;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${body}`)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(v1);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
throw new Error("Bad signature");
}
if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSec) {
throw new Error("Timestamp too old");
}
return JSON.parse(body);
}In a Next.js route handler, req.text() gives you the raw body. In Express, use express.raw({ type: "application/json" }) on the webhook route instead of express.json().
// app/api/webhooks/crypto-merchant/route.js
import { verifyWebhook } from "@/lib/verify-webhook";
export async function POST(req) {
const raw = await req.text(); // the raw body, before any JSON parsing
let event;
try {
event = verifyWebhook(raw, req.headers.get("cm-signature"), process.env.CM_WEBHOOK_SECRET);
} catch {
return new Response("Invalid signature", { status: 400 });
}
await saveEventForLater(event); // e.g. insert into a jobs table
return new Response(null, { status: 200 }); // reply fast, work later
}Common mistakes
- Verifying a re-serialized body.
JSON.stringify(JSON.parse(body))can change spacing or key order, and the signature won't match. - Comparing with ===. Use crypto.timingSafeEqual after checking that the lengths match, as in the example.
- Skipping the timestamp check. Without it, an old request with a valid signature could be replayed.
Idempotency, retries and duplicate events
Reply with any 2xx status within 10 seconds. Otherwise the event is retried after 1 minute, 5 minutes, 15 minutes, 1 hour, 3 hours, 6 hours, 12 hours and 24 hours: up to eight retries over about two days, each signed again with a fresh timestamp.
Reply first, work later
Verify the signature, store the event, return 200, and do slow work such as emails and fulfilment in a background job, so you never hit the 10-second limit.
Deduplicate on the event ID
Events can arrive more than once, and not always in order. Store each event id with a unique constraint, skip repeats, and make state changes idempotent: “mark the order paid if it isn't already” is safe to repeat, while “add a payment to the order” is not.
// Runs in a background job, not in the request.
async function handleEvent(event) {
// 1. Skip duplicates: event IDs are stored with a unique constraint.
const isNew = await db.processedEvents.insertIfMissing(event.id);
if (!isNew) return;
// 2. Make the state change idempotent too.
if (event.type === "invoice.paid") {
const invoice = event.data;
const order = await db.orders.findByInvoiceId(invoice.id);
if (!order || order.status === "paid") return;
await db.orders.markPaid(order.id, { paidAt: invoice.paid_at });
}
}Don't move orders backwards
If invoice.confirming arrives after invoice.paid, ignore it. Before you ship something valuable, fetch the invoice with GET /invoices/{id} to double-check its status.
Avoid duplicate invoices
There's no idempotency key on POST /invoices, so save the invoice ID with the order and reuse it while it is open. Cancel invoices you no longer need with POST /invoices/{id}/cancel, which works while no payment has been seen.
Testing your integration before launch
Most of the integration can be tested without moving money, and the rest with one small payment of your own.
- 1
Unit-test the signature check
Sign a sample body yourself with a made-up secret. verifyWebhook should accept it and reject a changed body, a wrong secret and an old timestamp.
- 2
Send a test event
In the portal under Developers, use Send test event to confirm your endpoint is reachable over HTTPS and replies with a 2xx.
- 3
Exercise the API
Create an invoice, fetch it, cancel it, and list invoices with status=cancelled. Send an amount below 0.50 US dollars to test your error handling.
- 4
Check the failure paths
Let one invoice expire and handle invoice.expired. Make your endpoint fail once on purpose, and confirm the retry is processed exactly once.
- 5
Make one small real payment
Pay a small invoice from your own wallet. Check that invoice.confirming and invoice.paid arrive, the order is marked paid, and GET /balances shows the amount.
That last step is a real payment, so the usual 1% fee and the network fee apply. A low-fee network keeps it cheap, and crypto network fees explained shows how networks compare. Your account must be live before the API creates invoices, and pricing lists every cost. When you're ready, create an account and make your first API call.