Уведомления о событиях пока относятся к предыдущей версии интерфейса, а в актуальной помечены как готовящиеся. Практически это значит две вещи. Первое: они работают и ими можно пользоваться. Второе: модель ресурсов новой версии не стоит проектировать вокруг старого формата сообщений без промежуточного слоя. Адаптер между входящим сообщением и вашей моделью данных стоит написать сразу: он занимает несколько десятков строк, а без него смена формата превращается в правку каждого места, где поля сообщения разошлись по коду.
Набор событий сейчас узкий: изменение статуса на завершение или на ошибку. Этого достаточно для основного сценария - узнать, что автономная работа закончилась, и пойти забрать результат. Но строить на этом сложную логику состояний не стоит: событий немного, и они сообщают о факте, а не о деталях. Всё содержательное - что именно сделано, какие файлы затронуты, чем закончилась проверка - берут отдельным обращением к интерфейсу, а уведомление служит только сигналом о том, что пора идти за ответом.
Главная инженерная часть здесь - проверка подписи. Уведомление приходит с заголовком подписи, идентификатором доставки и типом события. Подпись считается по сырому телу запроса, и слово сырому здесь ключевое: считать после разбора и повторной сериализации нельзя, потому что байты изменятся - порядок ключей, пробелы, форма записи чисел и экранирование юникода зависят от библиотеки. Это самая частая ошибка в реализациях, и она проявляется не сразу, а на первом же сообщении с нестандартным символом, когда всё до этого работало неделями.
Полезно один раз увидеть корректную проверку. Ниже - функция, которая считает код аутентификации по сырому телу и сравнивает его с полученным значением за время, не зависящее от содержимого. Обычное сравнение строк здесь недостаточно: оно завершается на первом различии, и разница во времени ответа постепенно выдаёт правильное значение тому, кто готов слать запросы долго. Пример из документации написан для понятности, а не для эксплуатации, и это обычная разница между демонстрацией и рабочим кодом.
Вторая половина корректной обработки - дедупликация и скорость ответа. Уведомления могут прийти повторно, поэтому идентификатор доставки сохраняют и повторные сообщения игнорируют. И отвечать нужно быстро: успешный код возврата отдают сразу, а тяжёлую работу выполняют асинхронно. Обработчик, который делает всю работу до ответа, рано или поздно получит таймаут и повторную доставку - и удвоит эффект, если дедупликации не было. Связь тут прямая: медленный ответ сам создаёт дубли, против которых потом приходится защищаться.
Отдельно стоит сказать про отсутствующие и неправильные заголовки. Их проверяют до разбора тела: если подписи нет или она искажена, запрос отклоняют, не пытаясь понять, что внутри. Это простое правило закрывает целый класс попыток скормить обработчику произвольные данные под видом уведомления - адрес обработчика открыт для сети, и постучаться в него может кто угодно.
Секрет подписи живёт своей жизнью, и про неё вспоминают только при ротации. Хранят его там же, где остальные секреты сервиса, а не в репозитории рядом с кодом проверки. Смена секрета требует окна, в котором обработчик принимает и старое, и новое значение: иначе часть доставок, отправленных до переключения, будет отвергнута. Признак неудачной ротации особенно неприятен своей тишиной - уведомления просто перестают доходить, а отклонённые подписи обычно никто не считает и не выводит в оповещения. Поэтому счётчик отклонённых подписей заводят одновременно с самой проверкой.
Есть и более общее ограничение: уведомление - это подсказка, а не источник правды. Доставка может не дойти, обработчик может быть недоступен, событие может опоздать. Машина состояний, которая двигается вперёд только по входящим сообщениям, однажды застрянет, и запуск навсегда останется в промежуточном состоянии. Рабочая схема сочетает оба механизма: уведомление ускоряет реакцию, а периодический опрос по тем запускам, которые давно не отчитывались, гарантирует, что состояние сойдётся даже при потерянной доставке.
Инженерный вывод простой: уведомление - это недоверенный вход из сети, и относиться к нему нужно как к любому другому. Проверка подписи, сравнение за постоянное время, дедупликация, быстрый ответ, асинхронная обработка, адаптер между версиями и запасной опрос. Семь пунктов, каждый из которых пишется один раз и потом просто работает.
Типичные провалы предсказуемы. Считать подпись после разбора и повторной сериализации тела. Сравнивать строки обычным способом. Делать тяжёлую работу до ответа и получать повторные доставки. Сменить секрет без окна совместимости и не заметить тишины. И привязать модель данных к формату сообщений предыдущей версии без адаптера.
import crypto from "node:crypto";
// Подпись считается по СЫРОМУ телу: после разбора байты изменятся
export function verifyCursorWebhook(secret, rawBody, signature) {
const expected = "sha256=" + crypto
.createHmac("sha256", secret)
.update(rawBody)
.digest("hex");
const expectedBytes = Buffer.from(expected);
const receivedBytes = Buffer.from(signature || "");
return expectedBytes.length === receivedBytes.length &&
crypto.timingSafeEqual(expectedBytes, receivedBytes);
}