本文へスキップ
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
保有コインへのスワップが完了しました。

ペイロード

各イベントは、JSONボディ付きの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=の後に16進数の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を、小文字の16進数で計算します。

  4. 4

    定数時間比較を使ってv1と比較します。

  5. 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時間

約2日間で最大8回再試行します。各試行は新しいタイムスタンプで改めて署名されます。

推奨事項

  • 応答する前に各イベントを保存し、その後、保存した内容をもとにバックグラウンドジョブなどで処理を行ってください。保存に失敗した場合はエラーを返してください。当社がイベントを再送します。
  • イベントは複数回届くことがあり、順序どおりに届くとは限りません。イベントのidを使って、処理済みのイベントをスキップしてください。
  • 高額な注文を発送する前に、APIから請求書を取得してステータスを再確認できます。
  • whsec_シークレットは、APIキーと同様にサーバー上で保管してください。