Webhook для статусов SMS: прием DLR без дублей и потерь

Webhook для статусов SMS позволяет провайдеру асинхронно сообщать, что произошло с отправкой: сообщение доставлено, отклонено, просрочено или ещё обрабатывается. Такой отчёт часто называют DLR. Надёжный обработчик должен принять событие быстро, проверить его, пережить повторную доставку и не откатить финальный статус более старым уведомлением.
Значения самих статусов разобраны в материале про отчёты о доставке SMS. Здесь речь о техническом канале, который переносит эти статусы из SMS-шлюза в вашу CRM, приложение или систему поддержки.
Почему webhook асинхронный
Ответ на запрос отправки подтверждает прежде всего приём команды шлюзом. До оператора и телефона сообщение доходит позже, поэтому держать исходное HTTP-соединение открытым невозможно. Провайдер сохраняет ваш callback URL и вызывает его, когда состояние меняется.
Одна отправка может породить несколько событий: принято, передано оператору, доставлено. Набор промежуточных статусов зависит от API. Финальное событие тоже может прийти повторно, если провайдер не получил подтверждение от вашего endpoint.
Поэтому обработчик нельзя проектировать как «один POST — одно новое действие». Он получает поток событий с моделью как минимум at-least-once.
Быстрый ответ и фоновая обработка
Webhook должен проверить минимальные обязательные поля, устойчиво сохранить событие и быстро вернуть успешный HTTP-код. Тяжёлую работу — изменение CRM, уведомление сотрудников, аналитику — лучше выполнять в фоне.
Если endpoint долго отвечает, провайдер считает доставку неуспешной и повторяет её. Так медленная внутренняя операция создаёт ещё больше нагрузки. Практичная последовательность выглядит так:
- Принять запрос и ограничить размер тела.
- Проверить источник и формат.
- Зафиксировать уникальный идентификатор события и исходные данные.
- Поставить нормализованное событие во внутреннюю очередь.
- Вернуть 2xx.
- Обновить бизнес-системы отдельным воркером.
Если сохранение не удалось, возвращать успех нельзя: иначе провайдер не повторит событие, и статус потеряется.
Идентификаторы и дедупликация
У события должен быть delivery_id, а у исходного сообщения — message_id или ваш client_message_id. Уникальный индекс по идентификатору доставки защищает от повторной обработки одного callback.
Если провайдер не даёт отдельный event ID, ключ можно составить из стабильных полей: провайдер, message ID, нормализованный статус и время события. Делать ключ только по message ID нельзя, потому что у одного сообщения законно меняется несколько состояний.
Повторный webhook должен возвращать тот же успешный ответ, не создавая повторных писем, начислений или изменений. Это тот же принцип, что и идемпотентность SMS API, только применённый к входящим событиям.
Порядок событий
Сеть не гарантирует, что callbacks придут в хронологическом порядке. Сначала может появиться DELIVERED, а затем повтор старого SENT. Если безусловно записывать последнее полученное значение, система откатит финальный статус назад.
Задайте явную модель переходов. Финальные состояния не заменяются промежуточными. Для каждого события сохраняйте время у провайдера и время приёма у себя: они нужны для разбора задержек, но ни одно поле в одиночку не гарантирует порядок.
Не стоит придумывать единый линейный ранг для всех кодов без учёта документации. FAILED, EXPIRED и DELIVERED — разные финальные ветви, а не просто числа на одной шкале.
Нормализация статусов
Разные маршруты и провайдеры называют состояния по-разному. Внутри компании полезно иметь небольшой стабильный словарь, например:
accepted— запрос принят в обработку;sent— передан дальше по маршруту;delivered— получен финальный отчёт о доставке;failed_permanent— повтор без изменения данных не поможет;failed_temporary— временная проблема;expired— закончился срок доставки;unknown— код пока не классифицирован.
Исходный код провайдера сохраняют рядом. Нормализованный статус удобен бизнес-логике, а оригинальный нужен поддержке и для обновления маппинга.
Проверка подлинности
Endpoint доступен из интернета, поэтому нельзя доверять любому POST. Используйте механизм, который поддерживает провайдер: подпись тела общим секретом, API-токен, mTLS или список исходящих адресов. IP allowlist полезен как дополнительный слой, но адреса могут меняться и не защищают от всех ошибок конфигурации.
Сравнивайте подпись с исходными байтами тела до преобразования JSON. Учитывайте timestamp и допустимое окно, если схема подписи это предусматривает, чтобы снизить риск повторного воспроизведения старого запроса. Секреты не выводите в логи.
Ответы endpoint
Возвращайте 2xx только после устойчивой фиксации события. 4xx означает, что запрос некорректен и повтор в том же виде обычно не поможет. 5xx сообщает о временной проблеме вашей стороны и просит провайдера повторить доставку.
Документируйте, какой именно код ожидает конкретный шлюз: некоторые системы считают успехом только 200, другие принимают весь диапазон 2xx. Максимальный размер тела, таймаут и политика повторов тоже должны быть известны заранее.
Публичные webhook-системы поддерживают просмотр попыток и redelivery по идентификатору доставки; например, такой подход документирован в GitHub Webhooks API. Для SMS-провайдера возможности могут отличаться, поэтому проверьте наличие журнала и ручного повторения при выборе интеграции.
Логи и наблюдаемость
Для каждого callback фиксируйте event ID, message ID, провайдера, нормализованный и исходный статус, время события, время приёма, HTTP-результат и идентификатор внутренней задачи. Номер телефона лучше маскировать, а текст SMS обычно не нужен.
Полезные метрики:
- количество событий и дублей;
- доля 4xx и 5xx endpoint;
- время ответа webhook;
- задержка между событием и приёмом;
- неизвестные коды статуса;
- события без найденного исходного message ID;
- попытки отката финального статуса.
Состав безопасного журнала задают заранее: технические идентификаторы сохраняются, а содержание и телефоны минимизируются по принципам защиты данных в SMS.
Как тестировать
Отправьте одно и то же событие дважды, поменяйте порядок промежуточного и финального статуса, передайте неизвестный код, неверную подпись и событие для отсутствующего message ID. Затем временно верните 500 и убедитесь, что повтор приходит и обрабатывается один раз.
Тест завершён успешно, если бизнес-система сохраняет всю историю, но выполняет побочные действия один раз; финальный статус не откатывается; неизвестные события не теряются; повтор после временной ошибки восстанавливает поток.
Отдельный контрактный тест полезно запускать после каждого изменения интеграции. Он сверяет обязательные поля, типы значений, допустимые статусы и алгоритм подписи с зафиксированным примером события. Так несовместимое изменение обнаруживается до того, как реальные callbacks начнут уходить в ошибки или молча терять данные.
Итог
Надёжный webhook — это короткий публичный вход в устойчивую внутреннюю обработку. Он проверяет источник, сохраняет событие до ответа, дедуплицирует повторы, нормализует коды и защищает финальные состояния от устаревших callbacks. Тогда DLR становится не случайным полем в CRM, а проверяемой историей жизни каждого сообщения.