Вебхуки
Вы поднимете получателя наших событий, проверите подпись и разберётесь, что придёт всегда, а что может не прийти.
Оплата случается в браузере, а права в приложении раздаёт ваш сервер. Чтобы он не спрашивал нас по расписанию, мы сами стучимся на ваш адрес: доступ выдан, подписка продлена, деньги вернули. С вашей стороны нужен один обработчик: принять запрос, проверить подпись, ответить.
Перед началом
- Тариф Pro или Business. Связка с приложением платная целиком: на бесплатном тарифе раздел с получателями закрыт и события никуда не уходят.
- Адрес на
https, доступный из интернета. Локальные адреса и адреса внутренней сети мы не вызываем, за переадресацией не идём. - Владелец или администратор проекта. Настраивает получателей он. Нет доступа в кабинет — попросите его пройти шаги ниже и передать вам секрет. Кто кого зовёт и какие бывают роли — «Проект и команда» и «Передача разработчику» на дорожке владельца.
Как поднять получателя
Кабинет: Настройки проекта → Подключение приложения → Для разработчика → «События на ваш сервер» → «Добавить получателя событий».
В окне два решения: адрес вашего обработчика и набор типов событий. Сразу после создания кабинет один раз покажет секрет получателя вида whsec_… — сохраните его тут же, повторно мы его не показываем. Адрес и набор типов меняются в любой момент, секрет заменяется отдельной командой «Ротировать секрет» — новый тоже показывается один раз.
У строки получателя есть ещё две команды:
- «Отправить тестовое событие» шлёт на ваш адрес
webhook.test. Он ничего не меняет в данных и годится, чтобы проверить приём и подпись до первой продажи. Остальные типы рождаются только настоящими событиями — чтобы увидеть их заранее, проведите покупку в тестовой среде оплаты: боевые и тестовые события уходят одинаково. - «Журнал» показывает доставки за последние 30 суток: код ответа вашего сервера, номер попытки и причину, если мы решили не отправлять.
Что приходит
Каждое событие — один POST с телом такого вида:
{
"id": "cs_123",
"type": "entitlement.granted",
"created": 1789992000,
"data": {
"guid": "abc123def456",
"level": "premium",
"price_id": "price_…",
"expires_at": "2026-10-21T00:00:00.000Z",
"subscription_id": "sub_…",
"occurredAt": "2026-09-21T12:00:00.000Z"
}
}
Времени в теле два. Поле created — момент, когда мы поставили доставку в очередь, occurredAt — момент, когда факт случился у нас или у платёжной системы. После повторов и догона верьте второму: по первому событие выглядит так, будто произошло сейчас. В таблице ниже occurredAt не повторяется; у webhook.test его нет, и если поля нет в другом событии, берите created.
occurredAt — единственное слитное имя в теле, остальные поля пишутся через подчёркивание.
Личных данных в теле нет: покупателя событие называет идентификатором (guid), а почту вы забираете запросом со своим ключом — «Свойства покупателя», подробности доступа — «Проверить право доступа».
У событий подписки и споров — subscription.* и chargeback.* — идентификатор покупателя бывает null: покупателя по подписке мы не нашли. Поле при этом приходит всегда. Сопоставляйте такое событие со своими записями по subscription_id — он же приходит в entitlement.granted.
Идентификатор события приходит и в теле, и в заголовке X-Web2App-Event-Id. Он непрозрачный: не разбирайте и не декодируйте его, сравнивайте строки целиком.
Проверка подписи
В заголовке X-Web2App-Signature приходит метка времени и подпись: t=<unix-секунды>,v1=<hex>. Пересчитайте HMAC-SHA256 секретом получателя по строке «метка времени, точка, тело запроса» и сравните с подписью — сравнением, не зависящим от времени.
Секрет берите целиком, вместе с whsec_. Секрет подписи из «Аутентификации» начинается так же, но с ним подпись не сойдётся.
Метка здесь в секундах. В обратную сторону, когда ваш сервер подписывает запросы к нам, заголовок X-Signature-Timestamp идёт в миллисекундах — код проверки с одной стороны на другую не переносится (Аутентификация).
Считайте по сырому телу, как оно пришло. Тело, пересобранное из разобранного объекта, даст другую подпись, даже если все значения совпали.
Отдельно сверьте свежесть метки времени: так перехваченный и отправленный заново запрос не пройдёт. Сколько допускать между меткой и вашими часами, решаете вы — пяти минут хватает с запасом.
Подписей в заголовке бывает две. Так выглядят двое суток после смены секрета: мы подписываем и старым, и новым, чтобы вы успели заменить значение без простоя. Совпала любая — подпись верна.
const crypto = require("crypto");
function verify(rawBody, header, secret, toleranceSec = 300) {
const parts = Object.fromEntries(header.split(",").map((p) => p.trim().split("=")));
const t = Number(parts.t);
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
return header
.split(",")
.filter((p) => p.trim().startsWith("v1="))
.some((p) => {
const got = Buffer.from(p.trim().slice(3), "hex");
const exp = Buffer.from(expected, "hex");
return got.length === exp.length && crypto.timingSafeEqual(got, exp);
});
}
То же на Python:
import hashlib, hmac, time
def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = [p.strip().split("=", 1) for p in header.split(",")]
t = next((v for k, v in parts if k == "t"), None)
if t is None or not t.isdigit() or abs(time.time() - int(t)) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return any(k == "v1" and hmac.compare_digest(v, expected) for k, v in parts)
Доставка и повторы
Ответьте кодом 2xx за десять секунд — дальше мы рвём соединение и считаем попытку неудачной. Подпись проверяйте до ответа, тяжёлую обработку уводите в фон.
- Повторяем на 5xx, 429 и таймаут: до восьми попыток, пауза удваивается начиная с тридцати секунд, всё окно — около часа.
- Не повторяем на остальных 4xx: для нас это ответ «адрес разобрался и отказал».
- Одно событие может приехать дважды. Храните идентификаторы обработанных событий и отбрасывайте повтор у себя.
- Тестовое событие живёт по своим правилам: пять попыток с паузой от пяти секунд, чтобы кабинет ответил быстро.
Почему событие о выдаче доступа приходит дважды
Покупатель добирается до страницы «оплата прошла» быстрее, чем до нас доезжает уведомление платёжной системы. Ждать мы не хотим: доступ выдаётся сразу, и событие о выдаче уходит с пустым сроком и с идентификатором, оканчивающимся на -early.
Когда приходит уведомление с настоящим сроком, уходит второе такое же событие — тот же покупатель, цена и уровень, но с датой и с чистым идентификатором. Идентификаторы разные намеренно: ваш отсев повторов не должен проглотить уточнение. Обработайте второе как обновление уже выданного доступа, а не как новую покупку. Разовые покупки так не раздваиваются.
Когда мы выключаем ваш адрес
Раз в сутки мы смотрим последние трое суток: были неудачные доставки и ни одной удачной — адрес выключается, владельцу проекта уходит письмо. Одна удачная доставка настоящего события за эти трое суток адрес сохраняет; тестовое событие не считается. Пока адрес выключен, события не отправляются и потом не досылаются: то, что пришлось на эти дни, вы не получите.
Состояние видно в строке получателя — метка «включён» или «выключен»; там же кнопка «Включить».
Какие события бывают
Подписывайтесь на то, что обрабатываете: лишний тип — лишние запросы к вам и лишние строки в журнале.
| Событие | Когда приходит | Что в data |
|---|---|---|
entitlement.granted | доступ выдан по оплате | guid, level, price_id, expires_at, subscription_id |
entitlement.renewed | право по подписке продлено | guid, subscription_id, price_id, level, expires_at — новый конец периода |
entitlement.revoked | доступ отозван | guid; отзыв по подписке несёт ещё subscription_id, price_id, level и, если причина известна, reason. Отзыв после возврата, проигранного спора, сорванного отложенного платежа и возврата разовой докупки приходит с одним guid: повода в нём нет, и какое право отозвано, по событию не понять — запросите право доступа по guid и снимите у себя то, чего в ответе больше нет. О возврате и споре рядом приходят refund.processed и chargeback.resolved |
purchase.completed | оплата прошла | guid, price_id, amount_cents, currency, subscription_id. subscription_id: null — разовая покупка; price_id, amount_cents и currency бывают null, когда мы не смогли их определить |
refund.processed | деньги вернули | guid, amount_cents, currency, subscription_id |
subscription.payment_failed | очередное списание не прошло | guid, subscription_id, amount_due — сумма к оплате в минимальных единицах валюты, как её отдаёт платёжная система, currency, will_retry — будет ли ещё попытка, grace_period_ends_at — дата ISO, до которой жив доступ, next_payment_attempt — время следующей попытки в unix-секундах, null — попыток больше не будет |
subscription.payment_recovered | списание прошло после провала | guid, subscription_id, amount_paid — списанная сумма в минимальных единицах валюты, currency |
subscription.paused | сбор оплаты приостановлен, доступ сохраняется | guid, subscription_id |
subscription.resumed | сбор оплаты возобновлён | guid, subscription_id; со стороны платёжной системы ещё status |
subscription.plan_changed | покупатель сменил тариф | guid, subscription_id, previous_price_id, new_price_id, current_period_end, access_level_change_deferred, access_level_applies_at. При access_level_change_deferred: true уровень доступа меняйте не сразу, а в access_level_applies_at; там null — на ближайшем продлении |
chargeback.opened | платёж оспорен в банке, доступ пока не трогаем | guid, subscription_id, dispute_id, amount_cents, currency, reason — причина со слов банка, бывает null |
chargeback.resolved | спор решён: lost — доступ отозван, won — сохранён | guid, subscription_id, dispute_id, outcome — статус спора из Stripe как есть: won, lost, warning_closed и другие; unknown — статус не пришёл. Доступ отзывается только при lost; amount_cents, currency |
charge.completed | списание с сохранённой карты прошло | guid, price_id, amount_cents, currency, payment_intent_id, subscription_id |
charge.failed | то же списание отклонено, денег нет | guid, price_id, subscription_id |
charge.requires_action | банк требует подтверждения покупателя, списания не было | guid, price_id, subscription_id |
quiz.completed | посетитель дошёл до конца квиза | guid, quiz_id |
lead.captured | посетитель оставил почту; поток шагов воронки для этого включать не нужно | guid, quiz_id, session_id |
user.property_updated | записано одно свойство покупателя | guid, property, value, redacted, block_id — какой вопрос квиза дал ответ, см. «Свойства покупателя» |
user.properties_completed | все свойства покупателя одним массивом | guid, properties |
funnel.analytics_event | шаг воронки: показ экрана, ответ, отправка почты | guid, event_type — один из QUIZ_VIEW, QUIZ_START, SCREEN_VIEW, SCREEN_ANSWER, QUIZ_COMPLETE, EMAIL_SUBMIT, PAYWALL_VIEW, PRICE_CLICK, CHECKOUT_START, TRAFFBACK, PAYMENT_UNAVAILABLE, CYCLE_GUARD_TRIPPED; session_id, quiz_id, paywall_id, screen_id |
webhook.test | по кнопке в кабинете | одно поле с текстом |
Поток шагов воронки выключен по умолчанию: подписки на тип мало, в окне получателя нужно отметить, какие шаги слать. Сверх трёхсот таких событий в минуту на один адрес остальные пропускаются без повторов. У свойств покупателя список необязателен: пусто означает «шлём все».
На вопрос с готовыми вариантами в value приходит идентификатор выбранного варианта, а не его текст. Значения, помеченные личными, — почта и всё, что человек ввёл руками, — приходят пустыми рядом с признаком redacted. Как сопоставить варианты и забрать личные значения запросом — «Свойства покупателя».
Почему часть событий может не прийти
На публичной странице воронки посетитель отвечает на баннер согласия на отслеживание, и его решение работает шлагбаумом для наблюдательных событий. Отказ уважается в любой стране; для посетителей из Европы — ЕС, Великобритания, Швейцария, Норвегия, Исландия, Лихтенштейн — закрыто и молчание, когда человек ушёл с баннера, ничего не выбрав.
Могут не прийти: завершение квиза, захват почты, поток шагов воронки, оба события про свойства покупателя.
Приходят всегда: всё про доступ и деньги — выдача, продление и отзыв права, оплата, возврат, провал и восстановление списания, пауза, смена тарифа, споры, списания с сохранённой карты. Основание у них — исполнение договора с покупателем, а не согласие на рекламу, поэтому выдача доступа после оплаты по отказу не глохнет.
Отличить пропуск от сбоя доставки можно в журнале получателя. Строка попытки несёт код ответа вашего сервера — значит, мы отправляли, и разбираться надо у вас. Строка решения кода ответа не несёт, зато называет причину и число повторов за час — значит, мы не отправляли и почему.
Отчёты о продажах в трекер
В соседнем блоке кабинета живёт второй механизм, который путают с первым. Он нужен, когда события ждёт не ваш сервер, а партнёрский трекер: вместо подписанного POST мы дёргаем GET по шаблону адреса, который вы задали, подставляя значения прямо в адрес.
Шаблон задаётся там же, на вкладке «Для разработчика»: блок «Отчёты о продажах в трекер», поле «Адрес постбэка». Отчёт уходит, когда посетитель оставил почту, прошла оплата или установлено приложение, а также когда ваш сервер сообщил нам о регистрации, установке или покупке.
| Подстановка | Что мы поставим на её место |
|---|---|
{click_id} | партнёрская метка клика: click_id, sub_id или clickid из адреса, по которому посетитель пришёл в воронку, либо поле clickId события от вашего сервера. Метки Google, Meta и TikTok сюда не попадают; метки нет — значение пустое |
{status} | lead — почта или регистрация от вашего сервера, purchase — покупка, app_installed — установка |
{payout} | сумма покупки в главной единице валюты: 19.99, а у валют без копеек — целым |
{txid} | номер сделки: сессия посетителя у почты, оплата у покупки; у установки и регистрации пусто |
https://ваш-сервер.io/hook?event={status}&click_id={click_id}&txid={txid}
Шаблон с незнакомой подстановкой кабинет не сохранит и назовёт её, поэтому пишите только эти четыре. Повторы у отчёта те же, что у вебхуков.
Согласие на отслеживание работает и здесь: об узнанном посетителе без согласия отчёт не уходит, а метку клика там, где показывается баннер, мы запоминаем только после «Принять».
Коротко
- Получатель настраивается в кабинете владельцем проекта, секрет показывается один раз.
- Подпись считается по сырому телу вместе с меткой времени; в дни смены секрета подписей две.
- 2xx за десять секунд, иначе до восьми повторов в течение часа; трое суток сплошных неудач — адрес выключаем.
- Дедуп на вашей стороне обязателен: одно событие может приехать дважды.
- События про деньги и доступ приходят всегда, наблюдательные — только с согласия посетителя.
Дальше
- Диагностика — что смотреть, когда событие не пришло или пришло, а доступ не сошёлся.
- Проверить право доступа — спросить нас напрямую, когда события мало или обработчик отстал.
- Аутентификация — ключ, которым ваш сервер дотягивает почту и детали покупки.
- Как мы узнаём покупателя — откуда берётся идентификатор, которым событие называет человека.
- Рецепты — обработчик вебхука целиком, готовым кодом на Node.
- Отправить событие нам — обратное направление: о событии сообщает нам ваш сервер.