RU

Вебхуки криптоплатежей: проверка подписи, повторы и дубли

Вебхуки SnapEX Pay

Вебхук — то, как ваш магазин, биллинг или бот узнаёт, что клиент заплатил. SnapEX Pay отправляет HTTP POST на ваш адрес при каждом событии счёта, платежа и выплаты. Сделанный правильно, это самый быстрый и надёжный сигнал. Сделанный небрежно — способ отметить оплаченным заказ, за который никто не платил. Здесь — то, что важно.

Настройка

В кабинете мерчанта: «Инвойсы» → карточка «Webhook», введите адрес и нажмите «Сохранить». Один адрес на аккаунт, на него приходят все типы событий. При первом сохранении кабинет один раз показывает секрет подписи whsec_… — храните его только на своём сервере.

Адрес — абсолютный https:// с публичным хостом: без login:password, не localhost, хост не должен указывать на частный или зарезервированный IP. Редиректы не выполняются, поэтому указывайте конечный адрес.

Что приходит

В каждом запросе четыре заголовка:

Заголовок Что это
Webhook-Id Уникальный id события. Не меняется при повторах и ручной переотправке.
Webhook-Timestamp Unix-время (секунды) отправки этой попытки. У каждого повтора — новое.
Webhook-Signature v1= и hex (нижний регистр) от HMAC-SHA256 строки {Webhook-Timestamp}.{сырое тело}.
Content-Type Всегда application/json.

В теле — id (то же, что Webhook-Id), type, created_at и data. Для событий invoice.* в data лежит счёт в том же виде, что GET /invoices/{id}, в том числе environment, amount_usd, status и ваш external_id.

События счёта:

Событие Когда
invoice.created Счёт создан, способ оплаты ещё не выбран.
invoice.awaiting Выбран способ оплаты, выдан адрес.
invoice.underpaid Пришла часть суммы, тот же адрес ждёт доплату.
invoice.paid Полная сумма оплачена и зачислена. Единственный сигнал для отгрузки заказа.
invoice.partially_paid Оплачена только часть, окно доплаты закончилось, полученная часть зачислена вам.
invoice.expired Срок счёта истёк без оплаты.
invoice.extra_payment После оплаты пришёл ещё один перевод; он зачислен отдельно.

Проверка подписи

  1. Прочитайте сырое тело запроса как байты, до разбора JSON. Пересобранный JSON с подписью не совпадёт.
  2. Посчитайте HMAC-SHA256 от {Webhook-Timestamp}.{сырое тело}; ключ — секрет целиком, вместе с префиксом whsec_; результат — в hex.
  3. Сравните v1= + hex с заголовком Webhook-Signature функцией сравнения за постоянное время.
  4. Отклоняйте Webhook-Timestamp, который отличается от ваших часов больше чем на 5 минут. У повторов метка времени свежая, они не пострадают.

PHP:

$secret = getenv('SNAPEX_WEBHOOK_SECRET');          // whsec_… из кабинета
$body   = file_get_contents('php://input');           // сырое тело, до json_decode
$ts     = $_SERVER['HTTP_WEBHOOK_TIMESTAMP'] ?? '';
$sig    = $_SERVER['HTTP_WEBHOOK_SIGNATURE'] ?? '';

$expected = 'v1=' . hash_hmac('sha256', $ts . '.' . $body, $secret);
if (!hash_equals($expected, $sig) || abs(time() - (int) $ts) > 300) {
    http_response_code(400);
    exit;
}

$event = json_decode($body, true);
// пропустить, если $event['id'] уже обработан; тяжёлую работу — в очередь
http_response_code(200);

Node.js (Express):

const crypto = require('crypto');

app.post('/hooks/snapex', express.raw({ type: 'application/json' }), (req, res) => {
  const ts = req.get('Webhook-Timestamp') || '';
  const sig = Buffer.from(req.get('Webhook-Signature') || '');
  const expected = Buffer.from('v1=' + crypto
    .createHmac('sha256', process.env.SNAPEX_WEBHOOK_SECRET)
    .update(ts + '.' + req.body)
    .digest('hex'));

  const fresh = Math.abs(Date.now() / 1000 - Number(ts)) <= 300;
  if (!fresh || sig.length !== expected.length || !crypto.timingSafeEqual(sig, expected)) {
    return res.sendStatus(400);
  }

  const event = JSON.parse(req.body);
  // пропустить, если event.id уже обработан; тяжёлую работу — в очередь
  res.sendStatus(200);
});

Повторы, дубли и порядок

  • Успех — любой ответ 2xx. Другой код, таймаут или сетевая ошибка — неудача. Таймауты: 5 секунд на соединение и 15 секунд на весь запрос, поэтому отвечайте быстро, а тяжёлую работу делайте асинхронно.
  • Повторы. До 5 попыток на событие: первая, затем примерно через 10 секунд, 1 минуту, 5 минут и 15 минут после каждой неудачи. После пятой неудачи автоматическая доставка прекращается; в кабинете есть список последних доставок и кнопка «Повторить» — она отправляет событие с тем же Webhook-Id.
  • Дубли. Доставка «как минимум один раз»: одно событие может прийти несколько раз. Храните обработанные Webhook-Id и пропускайте повторы.
  • Порядок. События могут прийти не по порядку: повтор старого события — после нового. Не полагайтесь на порядок, сравнивайте статус в событии с тем, что у вас уже есть.
  • Перепроверка. После вебхука можно перечитать объект через API: GET /invoices/{id}.

Проверить без реальных денег

Создайте тестовый счёт ("environment": "test"), оплатите его на странице оплаты и посмотрите, как приходят вебхуки с data.environment = "test". Подробно: Как протестировать криптоплатежи без реальных денег.

Готовые модули для WHMCS, BILLmanager, WooCommerce и других платформ уже проверяют подпись и никогда не зачисляют платёж дважды — см. pay.snapex.pro. Полный справочник — документация SnapEX Pay API.

Готовы принимать криптовалюту?

Создайте аккаунт мерчанта и получите API-ключ. 1% с платежа, без абонентской платы.