Перейти к содержанию

Контракт доставки вебхука

Опрос позволяет клиенту самому узнавать состояние, но другой сервис может хотеть уведомление сразу после изменения курса. Для этого каталог отправляет HTTP-запрос получателю. Такой вебхук похож на обычный POST, однако потеря ответа, повторная доставка и порядок событий становятся частью договора между двумя независимыми системами.

В этом уроке спроектируем уведомление об обновлении c001. Результат — вы сможете объяснить проверку источника, долговечное принятие и повтор без обещания универсального exactly-once. Отправитель и получатель не запускались. Подпись ниже является обозначением места значения, а не вычисленным пригодным секретом.

Событие появляется вместе с изменением

Alice успешно меняет frontend-курс c001 с revision 1 на revision 2 по прежним правилам прав и If-Match. В той же атомарной границе сервер фиксирует событие e001 в outbox: долговечной очереди уведомлений, связанной с бизнес-изменением. Если изменение не прошло, уведомления об успехе не возникает.

{"id":"e001","type":"course.updated","courseId":"c001","revision":2,"data":{"title":"Современный JavaScript","topic":"frontend","lessons":41}}

Это полный учебный объект события. revision обозначает последовательность изменений самого курса в договоре событий, а не буквальный ETag JSON-представления. ETag выбранной HTML-выдачи по-прежнему отличается от JSON. Число 2 помогает получателю сравнить порядок, но не позволяет автоматически подставить произвольный валидатор в If-Match.

Outbox нужен из-за границы сбоя. Если сначала сохранить курс, а отправку выполнить позже только из памяти, остановка процесса потеряет уведомление. Если отправить до фиксации курса, получатель увидит изменение, которое затем откатилось. Будущей реализации нужен согласованный механизм записи и последующего чтения outbox; одно название не обеспечивает его.

У получателя есть фиксированный адрес

Для примера администратор заранее разрешил https://receiver.example/catalog-events. Это отдельный сервис, а не браузерный editor.catalog.example. К его серверному обмену не применяют CORS как механизм разрешения исходящего запроса: CORS ограничивает доступ браузерного скрипта к ответу, а здесь действует серверная политика назначения.

POST /catalog-events HTTP/1.1
Host: receiver.example
Content-Type: application/json
X-Catalog-Timestamp: 1791700000
X-Catalog-Signature: v1=<hex-hmac>

{"id":"e001","type":"course.updated","courseId":"c001","revision":2,"data":{"title":"Современный JavaScript","topic":"frontend","lessons":41}}

Timestamp условен и должен быть свежим относительно часов получателя в будущем опыте. <hex-hmac> — явный заполнитель, поэтому этот листинг не пройдёт настоящую проверку. Сервис не пересылает session cookie Alice или её Bearer получателю. Для вебхука предусмотрено отдельное доверие и отдельный ключ подписи.

Если продукт разрешит пользователю задавать адрес, понадобится защита от SSRF: проверки схемы, цели, DNS и недопустимых внутренних адресов, а также политика перенаправлений и сетевого выхода. Строка HTTPS сама по себе этого не решает. В данном курсе произвольная регистрация endpoint не реализована. Рекомендации OWASP по SSRF.

Подписываем исходные байты

Наш прикладной договор использует HMAC-SHA256 с общим отдельным секретом. Подписываемая последовательность состоит из UTF-8 записи timestamp, одного байта перевода строки и исходных байтов тела. Получатель вычисляет ожидаемое значение проверенной библиотекой и сравнивает подписи способом с постоянным временем сравнения.

signed_bytes = UTF8(timestamp + "\n") + raw_body_bytes
signature = HMAC-SHA256(webhook_secret, signed_bytes)
header_value = "v1=" + lowercase_hex(signature)

Это описание алгоритма нашего каталога, не код для исполнения и не формат подписей всех провайдеров. Длины ключа, его генерация, хранение и ротация потребуют отдельной реализации. Мы не создавали действующий webhook secret и не рассчитывали результат для листинга.

Важно сохранить raw body до JSON-разбора. Разобрать объект и затем снова сериализовать его недостаточно: порядок полей, пробелы и экранирование способны изменить байты при том же значении данных. Пригодность JSON проверяется после подтверждения источника по исходным байтам и с ограничением размера; порядок реализация согласует так, чтобы не допустить неограниченного накопления тела.

Получатель также проверяет timestamp: выбранное окно составляет пять минут в обе стороны. Это ограничивает воспроизведение старого подписанного запроса, но требует пригодных часов. Timestamp включён в подпись, поэтому атакующий не может просто обновить его без секрета. Каждая повторная доставка получает свежий timestamp и новую подпись, сохраняя event id и прежние байты тела.

Успех означает долговечное принятие

После проверок получатель должен сохранить событие в собственной надёжной очереди либо применить оговорённый эффект атомарно с записью дедупликации. Только затем он подтверждает 2xx. Если ответить 204 сразу после чтения в память, остановка процесса потеряет событие, а отправитель уже будет считать доставку завершённой.

HTTP/1.1 204 No Content
Cache-Control: no-store

204 не содержит тела. В нашем договоре он означает долговечное принятие получателем, но не обязательное завершение всех его фоновых действий. Например, индекс поискового сервиса может обновиться после принятия. Разделение acceptance и фонового результата должно быть понятно владельцам обоих сервисов.

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

Потерянный ответ приводит к повтору

Получатель мог сохранить e001, а его 204 — потеряться. Отправитель не знает исхода и повторяет доставку. Повтор несёт прежний id, поэтому получатель узнаёт уже принятое событие и возвращает успех без второго бизнес-эффекта.

Область дедупликации включает доверенный источник или tenant и event id. Сам id из непроверенного тела не является доказательством источника. Запись «принят e001» и необходимый эффект имеют согласованную атомарную границу: иначе сбой между ними либо потеряет эффект, либо позволит повторить его дважды.

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

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

Доверенный источник не означает доверенное отображение

После подписи получатель знает, что байты согласованы с отправителем, но title по-прежнему может происходить из пользовательского ввода. Его нельзя вставить в HTML административного журнала без безопасного отображения. Подпись подтверждает происхождение события, а не превращает любую строку data в разрешённую разметку.

Это полезно и для диагностики: журнал может сохранить event id, время, тип и безопасную причину отклонения, не печатая webhook secret. Пригодные секреты не нужны оператору для понимания повторной доставки e001.

События могут прийти в другом порядке

Предположим, получатель уже применил revision 3, а задержанный e001 с revision 2 пришёл позже. Если слепо заменить актуальное состояние его data, старое обновление откатит новое. Получатель сопоставляет revision и игнорирует устаревшее состояние, подтверждая уже пригодно обработанное уведомление.

Если замечен пробел, например известна revision 1, а пришла 3, получатель может выполнить отдельное чтение текущего публичного курса. Это чтение не обязано вернуть историческую revision 2: endpoint представляет текущее состояние. Для истории потребовался бы иной договор событий или журнала.

Дедупликация id решает повтор одного события, а revision — порядок разных событий. Подпись решает подтверждение источника и целостность сообщения. Ни один механизм не заменяет остальные. Если обработка запускает внешнюю отправку письма или платёж, ей понадобится собственная повторяемость: локальная запись e001 не обеспечивает атомарность с чужой системой.

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

Примеры продолжения серии. Оглавление курса.