Skip to content
Crypto Merchant

Developers

Webhooks

Webhooks tell your server when something happens, such as an invoice being paid, so you don't have to keep asking the API.

Add an endpoint

In the portal, open Developers and add the HTTPS address on your server that should receive events. Each endpoint gets its own signing secret, starting with whsec_. An endpoint can receive every event, including ones added later, or only the events you pick. Use Send test event to check that your endpoint works.

Events

invoice.created
An invoice was created.
invoice.confirming
A payment was seen on-chain and is waiting for confirmations.
invoice.paid
The invoice is paid and confirmed. Fulfil orders on this event.
invoice.underpaid
The confirmed payment is short by more than your tolerance.
invoice.expired
Time ran out before any payment was seen.
invoice.cancelled
The invoice was cancelled.
invoice.refunded
The customer was refunded what they sent on an underpaid invoice.
payout.sent
A payout was sent to your wallet.
payout.failed
A payout could not be sent.
swap.completed
A swap into your holding coin finished.

Payload

Each event is sent as an HTTP POST with a JSON body:

JSON
{
  "id": "evt_…",
  "type": "invoice.paid",
  "created": "2026-10-28T09:41:07.000Z",
  "data": {
    "id": "inv_…",
    "object": "invoice",
    "status": "paid",
    …
  }
}

id is unique to the event, type is one of the events above, and created is when it happened. For invoice events, data is the invoice object. For payout and swap events, data describes the payout or swap.

Headers

Content-Type
Always application/json.
CM-Signature
The timestamp and signature: t= the Unix time in seconds, then v1= the hex HMAC-SHA256 signature.
CM-Event-Id
The event ID, the same as id in the body.
User-Agent
Always CryptoMerchant-Webhooks/1.
HTTP
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_…

Verify the signature

Always check the signature before trusting an event. Anyone can send a request to your endpoint, but only Crypto Merchant knows your secret.

  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, using your endpoint secret as the key, 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 old requests can't be replayed.

verify-webhook.js · Verify a webhook in 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).
 */
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) // your endpoint secret, whsec_…
    .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);
}
server.js · Express
import express from "express";
import { verifyWebhook } from "./verify-webhook.js";

const app = express();

// Keep the raw body: re-serialised JSON would not match the signature.
app.post(
  "/webhooks/crypto-merchant",
  express.raw({ type: "application/json" }),
  (req, res) => {
    let event;
    try {
      event = verifyWebhook(req.body, req.get("CM-Signature"), process.env.CM_WEBHOOK_SECRET);
    } catch {
      return res.sendStatus(400);
    }

    res.sendStatus(200); // reply first, then do the work

    if (event.type === "invoice.paid") {
      // Look up event.data.order_id and mark the order as paid.
      // Skip it if you have already handled event.id.
    }
  },
);

Responding and retries

Reply with any 2xx status within 10 seconds to confirm you received the event. Any other reply, or no reply, counts as a failure, and we try again after:

  1. 1 minute
  2. 5 minutes
  3. 15 minutes
  4. 1 hour
  5. 3 hours
  6. 6 hours
  7. 12 hours
  8. 24 hours

That is up to eight retries over about two days. Each attempt is signed again with a fresh timestamp.

Good practice

  • Reply quickly and do slow work afterwards, for example in a background job.
  • Events can arrive more than once, and not always in order. Use the event id to skip events you have already handled.
  • Before shipping a valuable order, you can fetch the invoice from the API to double-check its status.
  • Keep your whsec_ secret on your server, like an API key.