
Вебхук — то, как ваш магазин, биллинг или бот узнаёт, что клиент заплатил. 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 |
После оплаты пришёл ещё один перевод; он зачислен отдельно. |
Проверка подписи
- Прочитайте сырое тело запроса как байты, до разбора JSON. Пересобранный JSON с подписью не совпадёт.
- Посчитайте HMAC-SHA256 от
{Webhook-Timestamp}.{сырое тело}; ключ — секрет целиком, вместе с префиксомwhsec_; результат — в hex. - Сравните
v1=+ hex с заголовкомWebhook-Signatureфункцией сравнения за постоянное время. - Отклоняйте
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.