Вебхуки
Узнавать о событиях, не опрашивая нас: подпись, повторы, типы событий
Часть событий происходит не в тот момент, когда вы делаете запрос. Банк подтверждает списание отдельным шагом, клиент вводит код из СМС когда захочет, а покупатель уходит на страницу оплаты и возвращается не сразу. Чтобы узнать об этом, есть два пути: спрашивать нас периодически или получить уведомление. Второй и есть вебхук.
{
"id": "cmsr9f2c",
"object": "event",
"type": "payment_intent.succeeded",
"livemode": false,
"created": "2026-08-19T13:40:12.004Z",
"data": {
"id": "cmsr1a2b",
"object": "payment_intent",
"amount": 5000000,
"status": "succeeded"
}
}Assalam-Signature: t=1771582439,v1=8f3c…
Assalam-Event-Id: cmsr9f2c
Assalam-Event-Type: payment_intent.succeeded
Assalam-Delivery-Id: cmsr4e7a
Content-Type: application/jsonКак подключить
Адрес добавляется в кабинете, в разделе «Вебхуки». Там же выдаётся секрет
подписи — он начинается с whsec_. Адрес должен начинаться с https://
и быть доступен из интернета.
Тестовый и боевой режимы разделены: события тестового режима приходят только на тестовые адреса. Так репетиция не заденет боевую обработку.
Подпись
Каждая доставка подписана заголовком Assalam-Signature. В нём метка времени
и код: t=1771582439,v1=8f3c…. Подписывается строка «метка.тело» алгоритмом
HMAC-SHA256 на вашем секрете.
Метка времени внутри подписи нужна не для красоты: без неё перехваченную доставку можно отправить вам повторно спустя сутки, и подпись останется верной. Отвергайте доставки старше пяти минут.
const crypto = require('crypto');
function verify(rawBody, header, secret) {
const parts = Object.fromEntries(
header.split(',').map((p) => p.split('=')),
);
const age = Math.abs(Date.now() / 1000 - Number(parts.t));
if (age > 300) return false; // старую доставку не принимаем
const expected = crypto
.createHmac('sha256', secret)
.update(parts.t + '.' + rawBody)
.digest();
const received = Buffer.from(parts.v1, 'hex');
return (
expected.length === received.length &&
crypto.timingSafeEqual(expected, received)
);
}app.post('/assalam/webhook',
express.raw({ type: 'application/json' }),
(req, res) => {
const ok = verify(
req.body.toString('utf8'), // именно СЫРОЕ тело
req.get('Assalam-Signature'),
process.env.ASSALAM_WEBHOOK_SECRET,
);
if (!ok) return res.sendStatus(400);
res.sendStatus(200); // отвечаем сразу
handleAsync(JSON.parse(req.body)); // работаем после
});Подписывается сырое тело
Проверяйте подпись до разбора JSON. Если сначала разобрать, а потом собрать обратно, порядок полей и пробелы изменятся — подпись перестанет сходиться, хотя данные те же.
Доставка и повторы
Успехом считается любой ответ с кодом 2xx. Всё остальное — включая таймаут и отсутствие ответа — считается неудачей, и мы пробуем снова с растущими промежутками, примерно трое суток.
Отвечайте быстро, до обработки. Если делать работу до ответа, наш таймаут в 10 секунд превратит успешно обработанное событие в неудачную доставку, и оно придёт снова.
попытка 1 сразу
попытка 2 через 1 минуту
попытка 3 через 5 минут
попытка 4 через 30 минут
попытка 5 через 2 часа
попытка 6 через 6 часов
попытка 7 через 12 часов
попытка 8 через сутки
попытка 9 через сутки
дальше доставка помечается
неудавшейсяОдно событие может прийти дважды
Так устроены все системы доставки: мы не можем отличить «ваш ответ
потерялся» от «вы не ответили». Запоминайте id обработанных событий
и пропускайте повторы.
Если ваш сервис лежал дольше, чем длятся повторы, историю можно перечитать
через GET /v1/events — там те же события с постраничным обходом.
curl "https://api.assalam.uz/v1/events?limit=20" \
-H "Authorization: Bearer sk_test_..."
# следующая страница
curl "https://api.assalam.uz/v1/events?limit=20&starting_after=cmsr9f2c" \
-H "Authorization: Bearer sk_test_..."Какие события бывают
Имя строится как объект.что_произошло и всегда в прошедшем времени: событие
описывает случившееся, а не намерение.
Ниже — то, что платформа отправляет на самом деле. Какие вам нужны, зависит от поверхности: продаёте товары — хватит трёх событий заказа, списываете с сохранённой карты — двух событий платежа.
| Событие | Когда приходит |
|---|---|
order.paid | покупатель заплатил на нашей странице |
order.failed | банк отказал в оплате заказа |
order.expired | покупатель не заплатил до истечения срока |
payment_intent.succeeded | списание с привязанной карты прошло |
payment_intent.failed | списание не прошло, причина — в failureCode |
При подключении адреса можно выбрать нужные типы. Если не выбирать — приходят все. Новые типы мы добавляем со временем, поэтому не считайте неизвестный тип ошибкой: просто пропускайте его.
В списке подписки типов больше, чем в таблице
Платформа принимает подписку и на типы, которых сегодня никто не отправляет
(payment_intent.canceled, refund.succeeded, события карт и сессий) — они
заведены заранее. Подписаться на такой тип можно, дождаться его нельзя:
стройте логику только на событиях из таблицы.
Что внутри data
Тот же объект, что вернул бы соответствующий метод API, на момент события. Это снимок: если платёж потом изменится, снимок останется прежним. За текущим состоянием обращайтесь к API по идентификатору.