S Subster

Вебхуки

Вы поднимете получателя наших событий, проверите подпись и разберётесь, что придёт всегда, а что может не прийти.

Оплата случается в браузере, а права в приложении раздаёт ваш сервер. Чтобы он не спрашивал нас по расписанию, мы сами стучимся на ваш адрес: доступ выдан, подписка продлена, деньги вернули. С вашей стороны нужен один обработчик: принять запрос, проверить подпись, ответить.

Перед началом

Как поднять получателя

Кабинет: Настройки проекта → Подключение приложения → Для разработчика → «События на ваш сервер» → «Добавить получателя событий».

В окне два решения: адрес вашего обработчика и набор типов событий. Сразу после создания кабинет один раз покажет секрет получателя вида whsec_… — сохраните его тут же, повторно мы его не показываем. Адрес и набор типов меняются в любой момент, секрет заменяется отдельной командой «Ротировать секрет» — новый тоже показывается один раз.

У строки получателя есть ещё две команды:

Что приходит

Каждое событие — один 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 за десять секунд — дальше мы рвём соединение и считаем попытку неудачной. Подпись проверяйте до ответа, тяжёлую обработку уводите в фон.

Почему событие о выдаче доступа приходит дважды

Покупатель добирается до страницы «оплата прошла» быстрее, чем до нас доезжает уведомление платёжной системы. Ждать мы не хотим: доступ выдаётся сразу, и событие о выдаче уходит с пустым сроком и с идентификатором, оканчивающимся на -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}

Шаблон с незнакомой подстановкой кабинет не сохранит и назовёт её, поэтому пишите только эти четыре. Повторы у отчёта те же, что у вебхуков.

Согласие на отслеживание работает и здесь: об узнанном посетителе без согласия отчёт не уходит, а метку клика там, где показывается баннер, мы запоминаем только после «Принять».

Коротко

Дальше