Оповещения о сбоях платежей в Stripe: диспуты, отказы и мёртвые вебхуки

Stripe сообщает о диспутах и неудачных платежах по email. Как превратить события вебхуков Stripe в пуш, срочное уведомление или звонок.

Обновлено

Содержание

Stripe уже знает, когда открыт диспут, когда не прошло списание по подписке и когда выплата вернулась. И сообщает об этом письмом. Чтобы получилось громче, зарегистрируйте в Stripe второй эндпойнт вебхука, указывающий на URL канала Echobell, подпишите его на несколько типов событий и выберите тип уведомления по сроку, который привязан к каждому из них. У диспутов и ранних предупреждений о мошенничестве часы уже тикают; у неудачного продления — обычно нет.

В этом руководстве: какие события Stripe заслуживают того, чтобы вас прервать, как связать Stripe с Echobell примерно за пять минут, какие поля шаблонов реально существуют у каждого объекта и на какой компромисс вы идёте, пропуская проверку подписи.

События, которые действительно заслуживают прерывания

Оповещения о платежах ломаются всегда одинаково: кто-то подписывается на payment_intent.succeeded, потому что это приятно, телефон вибрирует сорок раз в день, а через шесть недель уведомление о диспуте проскакивает непрочитанным. Начните со срока. Если пропустить событие на восемь часов ничего не стоит, ему не нужно доходить до вас за восемь секунд.

СобытиеПочему это важноРекомендуемый тип
charge.dispute.createdНа ответ у вас ограниченное окно — обычно от 7 до 21 дня в зависимости от платёжной системы. Пропустили — проиграли автоматически.Звонок
radar.early_fraud_warning.createdБанк-эмитент сообщил Stripe, что списание может быть мошенническим. Вернуть деньги до того, как это станет формальным диспутом, — то действие, которое ещё доступно, и окно короткое.Звонок
payout.failedДеньги, собранные Stripe, не доходят до вашего банка. Всё, что ниже по цепочке — зарплаты, расчёт запаса прочности, — теперь неверно.Звонок
invoice.payment_failedНевольный отток. Достаточно часто решается само, так что звонок избыточен, но самые крупные аккаунты стоит посмотреть в тот же день.Срочное
customer.subscription.deletedДобровольный отток. Узнать стоит сегодня, будить не стоит.Обычное
payment_intent.succeededНичего не сломалось. Именно это событие приучает вас игнорировать остальные пять.Ничего

Уровни соответствуют трём типам уведомлений Echobell: Обычное — это обычный пуш, Срочное пробивается через большинство режимов фокусирования, а Звонок выглядит как входящий вызов и звонит даже в режиме «Не беспокоить». Каждый подписчик выбирает свой уровень для каждого канала, так что сооснователь может получать диспуты звонком, а коллега из поддержки — пушем.

Что понадобится

  • Аккаунт Stripe с доступом к вкладке Webhooks в Workbench
  • Установленный Echobell (App Store / Google Play)
  • Пять минут. Ни сервера, ни деплоя, ни кода — если только вам не нужна проверка подписи, о ней в последнем разделе.

Шаг 1 — По одному каналу на тип события

Хочется сделать один канал «Stripe» и слать туда всё. Не надо. Шаблон тела и ссылка в дашборд различаются для диспута, счёта и выплаты, потому что каждый несёт свой объект, — а весь смысл этой схемы в том, чтобы уведомление само говорило, что произошло, без открытия чего бы то ни было.

Создайте канал, названный по событию: Stripe Disputes. Напишите шаблоны заголовка и тела так, чтобы они читались с экрана блокировки:

Title: 🔴 Dispute opened — {{data.object.reason}}
Body: Amount: {{data.object.amount}} {{data.object.currency}}
Charge: {{data.object.charge}}
Status: {{data.object.status}}

В расширенных настройках задайте шаблон ссылки, чтобы запись уведомления открывала нужную страницу:

https://dashboard.stripe.com/disputes/{{data.object.id}}

Затем подпишитесь сами с типом Звонок и скопируйте URL вебхука из карточки канала. Он выглядит так: https://hook.echobell.one/t/<channel-token>.

Включите POST Only в расширенных настройках канала. Stripe всегда отправляет POST, а этот переключатель означает, что вставка URL в окно чата или адресную строку браузера больше не сможет поднять ложную тревогу о диспуте.

Шаг 2 — Направьте Stripe на канал

В дашборде Stripe откройте вкладку Webhooks и создайте назначение событий:

Нажмите Create an event destination, выберите Your account и оставьте версию API по умолчанию для вашего аккаунта.

Выберите ровно один тип события — для этого канала charge.dispute.created. Сам Stripe советует подписываться только на те события, которые нужны вашей интеграции; здесь это ещё и держит шаблон честным, потому что каждый приходящий payload одной и той же формы.

Выберите тип назначения Webhook endpoint и вставьте URL канала Echobell.

Сохраните, затем нажмите Send test event — или выполните stripe trigger charge.dispute.created из CLI — и убедитесь, что телефон звонит.

Повторите для каждого созданного канала. Stripe разрешает до 16 эндпойнтов вебхуков на аккаунт — с запасом хватит по одному на уровень оповещения.

Шаг 3 — Что приходит на самом деле

Stripe отправляет объект Event в JSON. Echobell читает тело как есть, поэтому любое поле доступно в шаблонах и условиях через точечную нотацию:

{
  "id": "evt_1P...",
  "type": "charge.dispute.created",
  "livemode": true,
  "created": 1757548800,
  "data": {
    "object": {
      "id": "dp_1P...",
      "amount": 4900,
      "currency": "usd",
      "reason": "fraudulent",
      "status": "needs_response",
      "charge": "ch_3P...",
      "evidence_details": { "due_by": 1759449600 }
    }
  }
}

Три вещи в этом payload удивляют чаще всего:

Суммы — целые числа в минимальной единице валюты. amount равный 4900 — это $49.00. Шаблоны Echobell подставляют и сравнивают значения, но не выполняют арифметику, поэтому {{data.object.amount}} выведет 4900. Либо честно подпишите единицу (Amount: 4900 (cents)), либо используйте прокси из последнего раздела, чтобы поделить на 100 перед отправкой.

Метки времени — это Unix-секунды. {{data.object.evidence_details.due_by}} выведет 1759449600, а не дату. Если важнее сам факт срока, чем точный час, уберите его из шаблона — страница диспута его показывает — и пусть работает шаблон ссылки.

Имена полей различаются по объектам. У диспута есть amount; у счёта — amount_due, customer_email, attempt_count и hosted_invoice_url; у выплаты — failure_message и arrival_date; у раннего предупреждения о мошенничестве — fraud_type, actionable и charge в виде обычной строки-идентификатора. Отсутствующая переменная выводится пустой строкой, а не ошибкой, поэтому шаблон, скопированный не из того канала, ломается молча. Это и есть практическая причина держать по каналу на тип события.

Шаг 4 — Фильтруйте условиями, а не силой воли

Условия канала используют тот же синтаксис выражений, что и шаблоны, но без фигурных скобок, и выполняются до любой доставки.

То, что стоит поставить на каждый канал Stripe:

livemode == true

Трафик тестового режима — ваши же запуски stripe trigger, коллега, ковыряющийся в песочнице, — больше не доходит до телефона. Добавляйте это после того, как убедитесь, что связка работает, а не до.

Для канала неудачных счетов порог убирает мелкие аккаунты из вашего вечера:

livemode == true && data.object.amount_due > 20000

Читается как «больше $200», в центах. А если вы хотите видеть действительно застрявшие повторы, а не каждую первую попытку:

livemode == true && data.object.attempt_count > 1

Если вы всё же направили один эндпойнт с несколькими типами событий в один канал, условия разведут их обратно:

type == "charge.dispute.created" || type == "payout.failed"

Шаг 5 — Держите путь оповещения вне того, что ломается

Вот часть, которая стоит дороже шаблонов.

Ваш продакшн-эндпойнт вебхука — это место, где происходит выдача: он открывает доступ, пишет в базу, отправляет чек. А значит, это и тот эндпойнт, который падает вместе с вашим приложением. Когда это случается, Stripe повторяет попытки до трёх дней с экспоненциальной задержкой и присылает письмо — а письмо о недоставленных вебхуках выглядит точно так же, как любое другое письмо от Stripe, поэтому его находят в понедельник.

Причины тихой смерти вебхука скучны. Stripe считает редирект 3xx неудачей, поэтому эндпойнт, который вдруг начал перенаправлять http на https или дописывать слеш, перестаёт получать события. Требуется TLS 1.2 или выше — достаточно просроченного или неверно настроенного сертификата. 403 от правила WAF, которое кто-то добавил на прошлой неделе, тоже подойдёт.

Второй эндпойнт, направленный прямо на Echobell, не разделяет ничего из этого. Это другой URL на другом хосте с другим сертификатом, и он продолжает звонить, пока ваше приложение лежит. Правило обобщается: путь, который сообщает, что что-то сломалось, не должен проходить через то, что сломалось.

Отказ собственного эндпойнта всё равно должен быть заметен. Загляните во вкладку Event deliveries в Workbench, когда что-то кажется неправильным, — она показывает Delivered, Pending и Failed по каждому событию и HTTP-статус каждой попытки. Stripe позволяет переотправить событие в течение 15 дней из дашборда или 30 дней через stripe events resend в CLI, так что дыру, замеченную за две недели, ещё можно закрыть.

URL канала Echobell — это bearer-учётные данные: кто им владеет, тот может запустить канал. Направив Stripe прямо туда, вы соглашаетесь, что заголовок Stripe-Signature никто не проверяет, поэтому утёкший URL — это машина ложных тревог, а не утечка данных. Держите его подальше от репозиториев и скриншотов, используйте Reset Token, если он всё-таки утёк, и прочитайте следующий раздел, если этот компромисс вас смущает.

Опционально — Сначала проверить подпись

Если вы хотите, чтобы подпись Stripe действительно проверялась, а суммы выглядели как деньги, поставьте перед каналом небольшой прокси. Этот Cloudflare Worker проверяет событие, сразу отвечает 200, как просит Stripe, и отправляет в Echobell плоский payload:

import Stripe from "stripe";

export default {
  async fetch(request, env, ctx) {
    const stripe = new Stripe(env.STRIPE_SECRET_KEY);
    const body = await request.text();

    let event;
    try {
      event = await stripe.webhooks.constructEventAsync(
        body,
        request.headers.get("stripe-signature"),
        env.STRIPE_WEBHOOK_SECRET,
      );
    } catch {
      return new Response("invalid signature", { status: 400 });
    }

    const invoice = event.data.object;
    ctx.waitUntil(
      fetch(env.ECHOBELL_HOOK_URL, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          customer: invoice.customer_email || invoice.customer,
          amount: (invoice.amount_due / 100).toFixed(2),
          currency: invoice.currency.toUpperCase(),
          attempt: invoice.attempt_count,
          externalLink: invoice.hosted_invoice_url,
        }),
      }),
    );

    return new Response("ok", { status: 200 });
  },
};

Шаблон на другой стороне становится куда приятнее, потому что вся подготовка произошла в коде:

Title: 💳 Payment failed — {{currency}} {{amount}}
Body: Customer: {{customer}}
Attempt #{{attempt}}

externalLink — специальная переменная: если шаблон ссылки не задан, Echobell использует её как ссылку записи уведомления, и размещённая страница счёта оказывается в одно касание.

Обратите внимание на форму компромисса: теперь это ещё один элемент инфраструктуры, который сам может упасть, — ровно то, о чём предупреждает шаг 5. Разумная середина: проверять подписи на канале с большим потоком, где ложные тревоги раздражали бы, и оставить канал диспутов подключённым напрямую — там цена ложного звонка — недоумённый взгляд, а цена пропущенного — сумма диспута.

Чего эта схема не даёт

  • Ни дежурств, ни эскалации. Все подписчики канала со звонком звонят одновременно. При четверых это плюс, при сорока — проблема; при сорока нужна платформа управления инцидентами.
  • Никакой дедупликации. Stripe не гарантирует порядок событий и может доставить одно и то же событие несколько раз. Два звонка на один диспут вполне возможны.
  • Никакого подтверждения. Ничто не фиксирует, что человек это увидел, и ничто не передаёт второму, если никто не отреагировал.
  • Нет оповещения «платежи прекратились». Stripe присылает события, когда что-то происходит, и никогда — когда перестаёт. Если сломается оформление заказа, не сработает ни одно событие. Для этого нужна задача по расписанию на вашей стороне, которая дёргает канал, когда за последний час ноль списаний, — «выключатель мертвеца» на cron.

Диагностика

В Stripe тестовое событие показывает 200, но уведомление не пришло. Echobell отвечает 200 с телом JSON даже тогда, когда ничего не доставляет, — посмотрите тело ответа во вкладке Event deliveries. success: false при токене правильной длины означает неверный токен канала. Если success равен true, вероятная причина — условие: livemode == true по замыслу блокирует все тестовые события.

Stripe сообщает 405 Method Not Allowed. У канала включён POST Only, а что-то отправило GET. Сам Stripe всегда шлёт POST, так что это превью ссылки или вкладка браузера, а не Stripe.

Уведомление приходит с пустыми полями. Шаблон обращается не к тому объекту — {{data.object.amount}} в канале счетов, где поле называется amount_due. Отправьте одно настоящее событие, откройте его в дашборде и прочитайте JSON.

Доставки начали падать после недель работы. Проверьте сертификат и любой редирект перед URL. Для канала, используемого напрямую, это редкость; для развёрнутого вами прокси — обычный подозреваемый.

Частые вопросы

Может ли Stripe позвонить мне при открытии диспута?

Сам по себе — нет. Stripe уведомляет письмом, в дашборде, событием charge.dispute.created и пушем, если вы пользуетесь приложением Stripe Dashboard. Чтобы телефон реально зазвонил, направьте это событие в канал с типом подписки Звонок.

Нужно ли писать код, чтобы связать Stripe с Echobell?

Нет. Stripe отправляет JSON на любой публичный HTTPS-URL, а URL канала Echobell — как раз такой. Код нужен только если вы хотите проверять заголовок Stripe-Signature или переформатировать суммы.

Безопасно ли давать Stripe сторонний URL вебхука?

Это осознанный компромисс. Payload, который отправляет Stripe, содержит метаданные клиента и платежа, а Echobell не хранит сырые payload постоянно — отрисованное уведомление живёт на вашем устройстве. Вы отказываетесь от проверки подписи: тот, кто узнает URL, может прислать вам убедительную подделку. Относитесь к нему как к API-ключу и используйте схему с прокси для всего, что хотите проверять.

Почему в оповещении 4900 вместо $49.00?

Stripe отправляет суммы целыми числами в минимальной единице валюты, а шаблоны Echobell не считают. Укажите единицу в шаблоне или поделите на 100 в прокси перед отправкой.

Как сделать, чтобы события тестового режима меня не будили?

Добавьте в канал условие livemode == true. Stripe помечает каждое событие песочницы и stripe trigger как livemode: false.

Может ли сооснователь получать те же оповещения без оплаты места?

Да. Поделитесь ссылкой на канал; каждый подписчик выбирает свой тип уведомления. Один получает диспуты звонком, другой — обычным пушем, и никакой платы за подписчика нет.

Стоит ли оповещать об успешных платежах?

Только недолго и только пока бизнес достаточно мал, чтобы каждый платёж ещё был событием. Как только уведомление об успешной оплате становится рутиной, оно начинает подтачивать вашу реакцию на действительно важные — базовый механизм усталости от оповещений.

Итог

Вся схема — это одно назначение событий Stripe на каждый канал Echobell, условие livemode == true и дисциплина: уровень звонка остаётся за событиями с тикающими часами. У диспутов и ранних предупреждений о мошенничестве они есть. У неудачного продления тарифа за $9 — нет, и попытка сделать вид, что есть, — это ровно тот путь, на котором проспишь то самое, с часами.

Скачайте Echobell для iPhone или возьмите в Google Play, создайте сначала канал диспутов и запустите stripe trigger charge.dispute.created, прежде чем доверить этому пути что-то настоящее.


Похожее