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:
{
"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, thenv1=the hex HMAC-SHA256 signature. CM-Event-Id- The event ID, the same as
idin the body. User-Agent- Always
CryptoMerchant-Webhooks/1.
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
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, using your endpoint secret as the key, as lowercase hex.
- 4
Compare it with
v1using a constant-time comparison. - 5
Reject events whose timestamp is more than five minutes old, so old 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).
*/
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);
}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 minute
- 5 minutes
- 15 minutes
- 1 hour
- 3 hours
- 6 hours
- 12 hours
- 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
idto 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.