Chuyển đến nội dung
Crypto Merchant

Nhà phát triển

Webhook

Webhook báo cho máy chủ của bạn khi có sự kiện xảy ra, chẳng hạn một hóa đơn được thanh toán, để bạn không phải liên tục hỏi API.

Thêm endpoint

Trong cổng quản lý, mở Nhà phát triển và thêm địa chỉ HTTPS trên máy chủ của bạn sẽ nhận sự kiện. Mỗi endpoint có khóa bí mật ký riêng, bắt đầu bằng whsec_. Một endpoint có thể nhận mọi sự kiện, kể cả những sự kiện được thêm sau này, hoặc chỉ các sự kiện bạn chọn. Dùng Gửi sự kiện thử để kiểm tra endpoint của bạn có hoạt động không.

Sự kiện

invoice.created
Một hóa đơn đã được tạo.
invoice.confirming
Đã thấy một khoản thanh toán on-chain và đang chờ xác nhận.
invoice.paid
Hóa đơn đã được thanh toán và xác nhận. Hãy xử lý đơn hàng khi nhận sự kiện này.
invoice.underpaid
Khoản thanh toán đã xác nhận thiếu nhiều hơn mức dung sai của bạn.
invoice.expired
Đã hết thời gian trước khi thấy khoản thanh toán nào.
invoice.cancelled
Hóa đơn đã bị hủy.
invoice.refunded
Coin đã được trả lại cho khách hàng: hoàn tiền cho một hóa đơn thanh toán thiếu, hoặc một liên kết hoàn tiền bạn tạo đã được nhận. Được gửi cho mỗi lần nhận, nên hoàn tiền một phần cũng gửi sự kiện này.
deposit.pending
Đã thấy on-chain một khoản nạp vào ví cố định của một khách hàng của bạn và đang chờ xác nhận.
deposit.confirmed
Một khoản nạp vào ví cố định đã được xác nhận và cộng vào số dư của bạn sau khi trừ phí: hãy cộng tiền cho khách hàng đó ngay.
deposit.dropped
Một khoản nạp vào ví cố định đã thấy nhưng không bao giờ được xác nhận (thất bại hoặc bị loại khỏi chuỗi khối).
payout.sent
Một khoản chi trả đã được gửi đến ví của bạn, hoặc phần của đối tác đã được gửi đến ví của đối tác.
payout.failed
Không thể gửi một khoản chi trả.
swap.completed
Một lượt hoán đổi sang coin nắm giữ của bạn đã hoàn tất.

Payload

Mỗi sự kiện được gửi dưới dạng HTTP POST với body JSON:

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

id là duy nhất cho mỗi sự kiện, type là một trong các sự kiện ở trên, và created là thời điểm sự kiện xảy ra. Với sự kiện hóa đơn, data là đối tượng hóa đơn. Với sự kiện chi trả, đó là đối tượng chi trả. Với sự kiện hoán đổi, data mô tả lượt hoán đổi.

Header

Content-Type
Luôn là application/json.
CM-Signature
Dấu thời gian và chữ ký: t= là thời gian Unix tính bằng giây, sau đó v1= là chữ ký HMAC-SHA256 dạng hex.
CM-Event-Id
ID sự kiện, giống với id trong body.
User-Agent
Luôn là 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_…

Xác minh chữ ký

Luôn kiểm tra chữ ký trước khi tin tưởng một sự kiện. Bất kỳ ai cũng có thể gửi yêu cầu đến endpoint của bạn, nhưng chỉ Crypto Merchant biết khóa bí mật của bạn.

  1. 1

    Đọc body thô của yêu cầu đúng như khi nhận được, trước khi phân tích JSON.

  2. 2

    Tách header CM-Signature theo dấu phẩy và đọc các giá trị t và v1.

  3. 3

    Tính HMAC-SHA256 của dấu thời gian, một dấu chấm và body thô, dùng khóa bí mật của endpoint làm khóa, ở dạng hex chữ thường.

  4. 4

    So sánh kết quả với v1 bằng phép so sánh thời gian hằng định (constant-time).

  5. 5

    Từ chối các sự kiện có dấu thời gian cũ hơn năm phút, để các yêu cầu cũ không thể bị phát lại.

verify-webhook.js · Xác minh webhook trong 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";
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);
}

Phản hồi và thử lại

Trả lời bằng bất kỳ trạng thái 2xx nào trong vòng 10 giây để xác nhận bạn đã nhận sự kiện. Mọi phản hồi khác, hoặc không phản hồi, đều được tính là thất bại, và chúng tôi sẽ thử lại sau:

  1. 1 phút
  2. 5 phút
  3. 15 phút
  4. 1 giờ
  5. 3 giờ
  6. 6 giờ
  7. 12 giờ
  8. 24 giờ

Tức là tối đa tám lần thử lại trong khoảng hai ngày. Mỗi lần thử đều được ký lại với dấu thời gian mới.

Thực hành tốt

  • Lưu từng sự kiện trước khi phản hồi, sau đó xử lý dựa trên dữ liệu đã lưu, ví dụ trong một tác vụ nền. Nếu lưu không thành công, hãy phản hồi bằng một lỗi và chúng tôi sẽ gửi lại sự kiện đó.
  • Sự kiện có thể đến nhiều lần, và không phải lúc nào cũng theo thứ tự. Hãy dùng id của sự kiện để bỏ qua các sự kiện bạn đã xử lý.
  • Trước khi giao một đơn hàng giá trị cao, bạn có thể lấy hóa đơn từ API để kiểm tra lại trạng thái.
  • Giữ khóa bí mật whsec_ trên máy chủ của bạn, giống như khóa API.