开发者
Webhook
Webhook 会在发生某些事情时通知您的服务器,例如账单已付款,这样您就无需反复查询 API。
添加端点
在后台打开开发者,添加您服务器上用于接收事件的 HTTPS 地址。每个端点都有自己的签名密钥,以 whsec_ 开头。端点可以接收所有事件(包括以后新增的事件),也可以只接收您选择的事件。使用发送测试事件检查您的端点是否正常工作。
事件
invoice.created- 已创建账单。
invoice.confirming- 已在链上检测到付款,正在等待确认。
invoice.paid- 账单已付款并确认。请在收到此事件时履行订单。
invoice.underpaid- 已确认的付款短缺超出您的容差。
invoice.expired- 在检测到任何付款之前时间已用完。
invoice.cancelled- 账单已取消。
invoice.refunded- 币已退还给客户:少付账单的退款,或您创建的退款链接已被领取。每次领取都会发送,因此部分退款也会发送此事件。
deposit.pending- 已在链上检测到一笔向您某位客户的固定钱包的充值,正在等待确认。
deposit.confirmed- 一笔固定钱包充值已确认,并在扣除手续费后计入您的余额:现在请为该客户入账。
deposit.dropped- 已检测到的一笔固定钱包充值始终未确认(交易失败或已从链上移除)。
payout.sent- 已向您的钱包发送一笔提现,或已向合作伙伴的钱包发送合作伙伴分成。
payout.failed- 一笔提现未能发送。
swap.completed- 兑换为您的持有币种已完成。
负载
每个事件都以 HTTP POST 请求发送,正文为 JSON:
{
"id": "evt_…",
"type": "invoice.paid",
"created": "2026-10-28T09:41:07.000Z",
"data": {
"id": "inv_…",
"object": "invoice",
"status": "paid",
…
}
}id 是事件的唯一标识,type 是上述事件之一,created 是事件发生的时间。对于账单事件,data 是账单对象。对于提现事件,它是提现对象。对于兑换事件,data 描述该兑换。
请求头
Content-Type- 始终为
application/json。 CM-Signature- 时间戳和签名:
t=后为以秒为单位的 Unix 时间,然后v1=后为十六进制的 HMAC-SHA256 签名。 CM-Event-Id- 事件 ID,与正文中的
id相同。 User-Agent- 始终为
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_…验证签名
在信任事件之前,请务必检查签名。任何人都可以向您的端点发送请求,但只有 Crypto Merchant 知道您的密钥。
- 1
在解析 JSON 之前,按到达时的原样读取原始请求正文。
- 2
按逗号拆分
CM-Signature请求头,读取t和v1的值。 - 3
以您的端点密钥作为密钥,计算时间戳、一个句点和原始正文拼接后的 HMAC-SHA256,结果为小写十六进制。
- 4
使用恒定时间比较将其与
v1进行比较。 - 5
拒绝时间戳超过五分钟的事件,以防旧请求被重放。
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";
import { db } from "./db.js"; // your database, e.g. a pg Pool
// CREATE TABLE webhook_events (
// id text PRIMARY KEY, type text NOT NULL, payload jsonb NOT NULL,
// received_at timestamptz NOT NULL DEFAULT now(), processed_at timestamptz
// );
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" }),
async (req, res) => {
let event;
try {
event = verifyWebhook(req.body, req.get("CM-Signature"), process.env.CM_WEBHOOK_SECRET);
} catch {
return res.sendStatus(400);
}
try {
// 1. Save the event before replying. A repeat delivery has the same id and is ignored.
await db.query(
"INSERT INTO webhook_events (id, type, payload) VALUES ($1, $2, $3) ON CONFLICT (id) DO NOTHING",
[event.id, event.type, event],
);
} catch {
return res.sendStatus(500); // not saved: we send it again later
}
// 2. It's stored, so confirm it.
res.sendStatus(200);
// 3. Do the work from what you saved, here or in a background job.
// If your server stops now, the event is still waiting in the table.
processEvents().catch(console.error);
},
);
async function processEvents() {
const { rows } = await db.query(
"SELECT payload FROM webhook_events WHERE processed_at IS NULL ORDER BY received_at",
);
for (const { payload: event } of rows) {
if (event.type === "invoice.paid") {
// Mark the order in event.data.order_id as paid. Doing it twice must change nothing.
}
await db.query("UPDATE webhook_events SET processed_at = now() WHERE id = $1", [event.id]);
}
}import hashlib, hmac, json, time
def verify_webhook(raw_body: bytes, signature_header: str, secret: str, tolerance_sec: int = 300) -> dict:
"""Checks a Crypto Merchant webhook and returns the event. raw_body: the request body exactly as it arrived."""
parts = dict(part.strip().split("=", 1) for part in (signature_header or "").split(",") if "=" in part)
t, v1 = parts.get("t"), parts.get("v1")
if not t or not v1:
raise ValueError("Missing signature")
signed = t.encode() + b"." + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() # your endpoint secret, whsec_…
if not hmac.compare_digest(expected, v1):
raise ValueError("Bad signature")
if abs(time.time() - int(t)) > tolerance_sec:
raise ValueError("Timestamp too old")
return json.loads(raw_body)<?php
// Checks a Crypto Merchant webhook and returns the event.
// Read the raw body with file_get_contents('php://input') and the header with $_SERVER['HTTP_CM_SIGNATURE'].
function verify_webhook(string $rawBody, string $signatureHeader, string $secret, int $toleranceSec = 300): array {
$parts = [];
foreach (explode(',', $signatureHeader) as $part) {
[$key, $value] = array_pad(explode('=', trim($part), 2), 2, '');
$parts[$key] = $value;
}
$t = $parts['t'] ?? '';
$v1 = $parts['v1'] ?? '';
if ($t === '' || $v1 === '') {
throw new RuntimeException('Missing signature');
}
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret); // your endpoint secret, whsec_…
if (!hash_equals($expected, $v1)) {
throw new RuntimeException('Bad signature');
}
if (abs(time() - (int) $t) > $toleranceSec) {
throw new RuntimeException('Timestamp too old');
}
return json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);
}响应和重试
在 10 秒内返回任意 2xx 状态码,以确认您已收到事件。任何其他响应或无响应都视为失败,我们会在以下时间后重试:
- 1 分钟
- 5 分钟
- 15 分钟
- 1 小时
- 3 小时
- 6 小时
- 12 小时
- 24 小时
即在约两天内最多重试八次。每次尝试都会使用新的时间戳重新签名。
最佳实践
- 在响应之前先保存每个事件,然后根据已保存的内容进行处理,例如放在后台任务中。如果保存失败,请返回错误响应,我们会重新发送该事件。
- 事件可能会多次送达,且不一定按顺序到达。使用事件
id跳过您已处理过的事件。 - 在发出贵重订单之前,您可以通过 API 获取账单以再次确认其状态。
- 像对待 API 密钥一样,将您的
whsec_密钥保存在服务器上。