Перейти к содержимому
Crypto Merchant

Разработчикам

Вебхуки

Вебхуки сообщают вашему серверу о событиях, например об оплате счёта, поэтому вам не нужно постоянно опрашивать 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
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);
}

Ответы и повторные попытки

Ответьте любым статусом 2xx в течение 10 секунд, чтобы подтвердить получение события. Любой другой ответ или его отсутствие считается неудачей, и мы повторяем попытку через:

  1. 1 минута
  2. 5 минут
  3. 15 минут
  4. 1 час
  5. 3 часа
  6. 6 часов
  7. 12 часов
  8. 24 часа

Это до восьми повторных попыток примерно за два дня. Каждая попытка подписывается заново со свежей меткой времени.

Рекомендации

  • Сохраняйте каждое событие до того, как ответить, а затем выполняйте работу по сохранённым данным, например в фоновой задаче. Если сохранить не удалось, ответьте ошибкой, и мы отправим событие повторно.
  • События могут приходить больше одного раза и не всегда по порядку. Используйте id события, чтобы пропускать уже обработанные события.
  • Прежде чем отправлять ценный заказ, вы можете запросить счёт через API и ещё раз проверить его статус.
  • Храните секрет whsec_ на своём сервере, как и API-ключ.