Разработчикам
Вебхуки
Вебхуки сообщают вашему серверу о событиях, например об оплате счёта, поэтому вам не нужно постоянно опрашивать 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);
}Ответы и повторные попытки
Ответьте любым статусом 2xx в течение 10 секунд, чтобы подтвердить получение события. Любой другой ответ или его отсутствие считается неудачей, и мы повторяем попытку через:
- 1 минута
- 5 минут
- 15 минут
- 1 час
- 3 часа
- 6 часов
- 12 часов
- 24 часа
Это до восьми повторных попыток примерно за два дня. Каждая попытка подписывается заново со свежей меткой времени.
Рекомендации
- Сохраняйте каждое событие до того, как ответить, а затем выполняйте работу по сохранённым данным, например в фоновой задаче. Если сохранить не удалось, ответьте ошибкой, и мы отправим событие повторно.
- События могут приходить больше одного раза и не всегда по порядку. Используйте
idсобытия, чтобы пропускать уже обработанные события. - Прежде чем отправлять ценный заказ, вы можете запросить счёт через API и ещё раз проверить его статус.
- Храните секрет
whsec_на своём сервере, как и API-ключ.