Skip to content
Crypto Merchant

Developers

Crypto Payment API: Invoices, Webhooks and HMAC Signatures

Integrate a crypto payment API in Node.js: Bearer keys, invoices, checkout_url redirects, and HMAC-SHA256 webhook signature checks with retries and testing.

By the Crypto Merchant team9 min read

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

MethodPathWhat it does
POST/invoicesCreate an invoice.
GET/invoicesList invoices, newest first, by status and page.
GET/invoices/{id}Retrieve one invoice.
POST/invoices/{id}/cancelCancel an invoice while no payment has been seen.
POST/payment-linksCreate a payment link, fixed price or open amount.
GET/payment-linksList your 100 most recent payment links.
GET/balancesBalances per coin, the US dollar total and your gas balance.
GET/coinsThe coins you accept, with their IDs.
Payouts are not part of the API. They are made in the portal, by Admins only.

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.

HTTP
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:

JSON
{
  "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.

Node.js · create an invoice
// 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_url

Set 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_email and customer_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 as USDT.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.

Response · invoice (shortened)
{
  "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:

StatusWhat it meansWebhook event
openCreated; no coin picked yet.invoice.created
awaiting_paymentCoin chosen, address assigned, rate locked.None
confirmingPayment seen on-chain, waiting for confirmations.invoice.confirming
paidPaid and confirmed; the amount minus the fee is in your balance.invoice.paid
underpaidConfirmed payment short by more than your tolerance.invoice.underpaid
expiredTime ran out before any payment was seen.invoice.expired
cancelledCancelled before any payment was seen.invoice.cancelled
refundedThe 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.cancelled and invoice.refunded.
  • Money-out events: payout.sent, payout.failed and swap.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:

HTTP · webhook delivery
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. 1

    Read the raw request body exactly as it arrived, before parsing the JSON.

  2. 2

    Split the CM-Signature header on commas and read the t and v1 values.

  3. 3

    Compute the HMAC-SHA256 of the timestamp, a full stop and the raw body (${t}.${body}), keyed with your whsec_ secret, as lowercase hex.

  4. 4

    Compare it with v1 using a constant-time comparison.

  5. 5

    Reject events whose timestamp is more than five minutes old, so captured requests can't be replayed.

verify-webhook.js · Node.js
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().

Next.js · webhook route
// 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.

Node.js · idempotent handler
// 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. 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. 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. 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. 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. 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.

Frequently asked questions

What is a crypto payment API?
It is the part of a crypto payment gateway that your own software calls. Your server creates an invoice, sends the customer to a hosted payment page, and receives a webhook when the payment is confirmed.
How do I verify a webhook HMAC signature?
Read t and v1 from the CM-Signature header, compute HMAC-SHA256 of t, a full stop and the raw request body with your whsec_ endpoint secret, and compare the lowercase hex result with v1 in constant time. Reject timestamps more than five minutes old.
Which webhook event means a crypto payment is complete?
invoice.paid. It is sent once the payment is confirmed and the amount, minus the fee, is in your balance. invoice.confirming only means a payment was seen and is waiting for confirmations.
What happens if my webhook endpoint is down?
Any reply other than a 2xx within 10 seconds counts as a failure, and the event is retried up to eight times over about two days. Skip event IDs you have already processed, because the same event can arrive more than once.
Do I need the API to accept crypto payments?
No. Every store has a hosted checkout page, and you can share payment links with a fixed or open amount. The API is for creating invoices automatically from your own website or app.

Start accepting crypto today

Free setup, no ID checks to sign up, 1% per paid invoice, and payouts whenever you want.