Webhooks — Обзор
Webhook’и — авторитетный сигнал. Браузерные callback’и
(onSuccess) и представления панели — удобство; webhook’и — истина.
Гарантии доставки
- Не менее одного раза. Одно событие может быть доставлено до
6 раз, если ваш сервер не возвращает 2xx в течение таймаута.
Делайте обработчик идемпотентным — дедуплицируйте по
X-Delivery(payload не имеет поляevent_id; стабильный delivery UUID — это ключ идемпотентности). - Одно событие на HTTP-запрос. Без батчинга.
- Изоляция per-endpoint. Если у вас несколько зарегистрированных endpoint’ов, у каждого свой трек доставки + повторов. Один медленный URL мерчанта не может голодать другие — у каждого хоста собственный circuit breaker.
- Подписано. Каждый payload несёт заголовок
X-Signature(а в течение 24-часового окна после ротации — такжеX-Signature-Prev). Проверяйте до того, как что-либо делать с телом. См. Проверка подписи.
Подписываемые типы событий
| Событие | Срабатывает когда… |
|---|---|
payment.settled | On-chain перевод набрал число подтверждений сети. Используйте это, чтобы отметить заказы оплаченными. |
payment.failed | Фиатный платёж был явно отклонён провайдером (сейчас: Stripe webhook, сигнализирующий о неудаче). Не срабатывает для крипто-таймаутов — те всплывают как checkout.expired, а короткие крипто-платежи как payment.underpaid. |
payment.underpaid | Средства пришли, но меньше общей суммы заказа (типично: комиссия за перевод стейблкоина вычтена из суммы). |
payment.overpaid | Средства пришли в избытке от общей суммы заказа. Излишек записан, но не возвращается автоматически. |
order.created | Открыт новый заказ — либо через ваш B2B API-вызов, либо через конверсию checkout-сессии. |
order.canceled | Заказ перешёл в отменён. data.reason в payload’е отличает ручную отмену от payment_timeout (устаревший неоплаченный заказ, выметенный worker’ом). |
order.resolved | Заказ PARTIAL_PAID был разрешён в PAID — мерчант принял недоплату. |
order.reopened | Ранее авто-отменённый заказ (canceled_reason=payment_timeout) был переоткрыт мерчантом. |
checkout.created | Покупатель открыл checkout для заказа. |
checkout.completed | Поток на стороне покупателя завершён (не подразумевает on-chain расчёта — используйте payment.settled для этого). |
checkout.expired | Покупатель забросил, и TTL сессии истёк. |
payment.refund.requested | Создана запись возврата — либо из инициированного мерчантом API-вызова, либо из отправленной клиентом формы refund-request. |
payment.refund.approved | Ожидающий возврат прошёл ваш workflow одобрения. |
payment.refund.rejected | Ожидающий возврат отклонён. |
payment.refund.executed | On-chain перевод возврата прошёл, и запись перешла в терминальное executed. |
refund_request.created | Refund-request токен был выпущен. data.source равно b2b / dashboard / renewal. Подписка опциональна — полезно для аудиторских pipeline’ов, отслеживающих, какой токен сейчас активен per заказ. |
refund_request.renewal_requested | Покупатель нажал «Запросить новую ссылку» после истечения токена. Настоятельно рекомендуется подписаться — это сигнал мерчанту, что в виджете обновлений появился новый элемент для действия. |
refund_request.renewed | Обновление было одобрено, и новый токен заменил старый. data.old_token / data.new_token формируют аудиторскую цепочку. |
refund_request.canceled | Мерчант перевёл токен в CANCELED из панели (например, отклонил запрос на обновление, убил живую ссылку). Идемпотентно — отправляется только при первом переходе. data.reason — опциональная заметка мерчанта. |
Панель получает этот список из GET /v1/webhooks/event-types, чтобы
форма создания / редактирования endpoint’а всегда соответствовала тому,
что платформа реально отправляет. Подписка на событие, которое мы не
отправляем, будет отклонена на этапе создания с чёткой ошибкой.
Тестовые события не подписываемы. Кнопка Send Test per-endpoint
в панели делает POST конверта webhook.test.ping синхронно на этот
один endpoint (минуя pipeline повторов), а legacy-путь на уровне
мерчанта «Send test event» делает fan-out конверта webhook.test на
каждый активный endpoint независимо от его фильтра. Ни одно из них
не появляется в каталоге выше — вы получаете их по факту наличия
зарегистрированного endpoint’а, а не подписавшись.
Подписывайтесь только на события, которые вы обрабатываете. У
каждого endpoint’а свой фильтр событий; wildcard "*" означает
«каждое событие, включая добавленные в будущем». Подписка на
меньшее число событий держит ваш обработчик чище и уменьшает
поверхность, которую нам нужно повторять при ошибках.
Payload + заголовки
HTTP-тело — это напрямую объект данных конкретного события. Нет
внешнего Stripe-стиля конверта — поля вроде типа события, delivery ID
и timestamp эмиссии живут в заголовках. Для payment.settled тело
выглядит так:
{
"receipt_id": "rcp_…",
"order_id": "ord_…",
"payment_intent_id": "pin_…",
"checkout_session_id": "cst_…",
"merchant_id": "mer_…",
"customer_id": "cus_…",
"total": "49.00",
"currency": "USD",
"payment_method": "crypto",
"token": "USDC",
"network": "polygon",
"tx_hash": "0x…",
"deposit_address": "0x…",
"treasury_address": "0x…",
"amount_received": "49.00",
"confirmations": 5,
"metadata": { /* per-event */ }
}Другие события несут свой набор полей — см. publisher-структуры в
payment-service/internal/domain/events.go для канонической формы, пока
per-event документация не появится. Имена полей стабильны (нижний
snake_case); on-chain tx hash всегда tx_hash (не
transaction_hash).
Заголовки входящего запроса
Content-Type: application/json
X-Event: payment.settled
X-Delivery: 7c9e6679-7425-40de-944b-e07fc1f90ae7
Idempotency-Key: 7c9e6679-7425-40de-944b-e07fc1f90ae7
X-Timestamp: 1729536000
X-Signature: sha256=9a8b7c…
X-Signature-Prev: sha256=fa31b2… (только в течение grace-окна ротации)| Заголовок | Что это |
|---|---|
X-Event | Тип события (например, payment.settled). Маршрутизируйте по нему на уровне прокси, если хотите пропустить парсинг JSON. |
X-Delivery | UUID, идентифицирующий строку доставки. Стабилен между всеми повторами одной пары (событие, endpoint) — используйте его как ключ идемпотентности. |
Idempotency-Key | Зеркалит X-Delivery (то же значение). Установлен на каждой доставке — перенимает конвенцию из Stripe / GitHub. |
X-Timestamp | Unix-секунды отправки попытки. Подписан в payload, чтобы захваченную пару (тело, X-Signature) нельзя было replay’ить бесконечно — отклоняйте доставки, у которых timestamp вне вашего окна допуска. |
X-Signature | sha256=<hex> от HMAC-SHA256(secret, X-Timestamp + "." + raw_body). См. Проверка подписи. |
X-Signature-Prev | Тот же алгоритм с предыдущим секретом. Присутствует только в 24-часовом окне после ротации — позволяет verifier’ам, запущенным с любым из ключей, продолжать принимать доставки во время cutover. После закрытия окна заголовок перестаёт отправляться. |
Расписание повторов
Если ваш endpoint не возвращает 2xx в течение таймаута, мы повторяем
по этому расписанию (timestamp’ы относительно первой попытки):
| Попытка | Задержка | Накопительно |
|---|---|---|
| 1 | 0с | 0с |
| 2 | +1 мин | 1м |
| 3 | +5 мин | 6м |
| 4 | +15 мин | 21м |
| 5 | +1 час | 1ч 21м |
| 6 | +6 часов | 7ч 21м |
После того как попытка 6 не удалась, доставка переводится в
dead letter, и email мерчантского аккаунта уведомляется.
Dead-lettered события можно проиграть заново из панели Developers →
Webhooks → Delivery history или напрямую через
POST /v1/webhooks/deliveries/:id/replay. Каждый replay создаёт новую
строку доставки со своим X-Delivery — аудиторская цепочка
возвращается к оригиналу через parent_delivery_id, чтобы повторы
replay’ев не затеняли исходное событие.
Регистрация endpoint’а
Из панели мерчанта :
- Developers → Webhooks → + Add endpoint
- Вставьте ваш URL — только
https://…(обычный HTTP отклоняется; форма создания также блокируетlocalhost, частные IP-диапазоны и URL с userinfo) - Выберите события для подписки (или
*для всех) - Выберите окружение — test или live (у каждого свой секрет; они никогда не пересекаются)
- Save → панель отображает секрет подписи (
whsec_…) один раз. Сохраните его на сервере; он понадобится для следующих двух фич.
Вы можете зарегистрировать до 10 endpoint’ов на окружение на мерчанта (например, один для обработки в продакшене, один для зеркалирования в staging, один для уведомлений в Slack). У каждого своё состояние повторов, секрет и per-host circuit breaker.
Действия жизненного цикла на каждом endpoint’е
Меню ⋮ на карточке каждого endpoint’а показывает:
- Edit — изменить URL, описание или список подписок. Новый URL
пере-валидируется теми же правилами
https:///SSRF, что и при создании. - Send Test — синхронный POST конверта
webhook.test.ping, подписанный вашим текущим секретом. Панель показывает HTTP-статус, latency и 512-байтный сниппет вашего ответа. Минует RMQ-pipeline, чтобы ответ был мгновенным. - Rotate Secret — генерирует новый секрет. Предыдущий остаётся
валидным 24 часа (доставки несут и
X-Signature, иX-Signature-Prevв окне, чтобы verifier’ы, работающие с любым из ключей, продолжали принимать события, пока вы переразворачиваете). - Reveal Secret — повторно отображает существующий секрет. Защищён свежей 2FA-проверкой и записывается в журнал аудита; используйте, только когда вы потеряли копию и Rotate неприемлем.
- Enable / Disable — переключает
is_activeбез потери истории доставки. Отключённые endpoint’ы остаются в панели, но не получают новых доставок. - Delete — необратимо. Используйте Disable, если можете переключиться обратно позже.
Советы для обработчиков
- Возвращайте 2xx быстро. Подтверждайте
200 OKдо тяжёлой работы — кидайте обработку в фоновую очередь. Таймаут per-попытка — 10 секунд; удержание ответа дольше этого триггерит повтор. Таймаут на стороне платформы и не настраивается мерчантом — пишите в поддержку, если ваш обработчик действительно нуждается в большем времени. - Дедуплицируйте по
X-Delivery(илиIdempotency-Key— то же значение). Даже если вы возвращаете 2xx, upstream-прокси может уронить соединение и спровоцировать повтор; delivery ID стабилен между каждым повтором одной строки доставки, поэтому это правильный ключ. - Толерируйте неизвестные типы событий. Новые события могут появиться; возвращайте 200 и no-op, а не 4xx, иначе заполните очередь повторов.
- Логируйте
X-Deliveryрядом с бизнес-логикой. Когда что-то пошло не так, это ключ join между нашей стороной и вашей.
Что дальше
- Проверка подписи — точный алгоритм + паттерны защиты от replay.
- Концепции → Сессии — в каком состоянии сессия, когда срабатывает каждое событие.