跳到主要内容
Crypto Merchant

开发者

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:

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。
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_…

验证签名

在信任事件之前,请务必检查签名。任何人都可以向您的端点发送请求,但只有 Crypto Merchant 知道您的密钥。

  1. 1

    在解析 JSON 之前,按到达时的原样读取原始请求正文。

  2. 2

    按逗号拆分 CM-Signature 请求头,读取 t 和 v1 的值。

  3. 3

    以您的端点密钥作为密钥,计算时间戳、一个句点和原始正文拼接后的 HMAC-SHA256,结果为小写十六进制。

  4. 4

    使用恒定时间比较将其与 v1 进行比较。

  5. 5

    拒绝时间戳超过五分钟的事件,以防旧请求被重放。

verify-webhook.js · 在 Node.js 中验证 Webhook
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";
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]);
  }
}
verify_webhook.py · Python
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)
verify_webhook.php · PHP
<?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. 1 分钟
  2. 5 分钟
  3. 15 分钟
  4. 1 小时
  5. 3 小时
  6. 6 小时
  7. 12 小时
  8. 24 小时

即在约两天内最多重试八次。每次尝试都会使用新的时间戳重新签名。

最佳实践

  • 在响应之前先保存每个事件,然后根据已保存的内容进行处理,例如放在后台任务中。如果保存失败,请返回错误响应,我们会重新发送该事件。
  • 事件可能会多次送达,且不一定按顺序到达。使用事件 id 跳过您已处理过的事件。
  • 在发出贵重订单之前,您可以通过 API 获取账单以再次确认其状态。
  • 像对待 API 密钥一样,将您的 whsec_ 密钥保存在服务器上。