В журнале endpoint два одинаковых webhook, а в CRM появились две задачи менеджеру. Здесь легко обвинить модель или REST, хотя причина обычно проще: обработчик считает каждую HTTP-доставку новой бизнес-операцией.
Первое, что я бы проверил, — заголовок webhook-id у обеих доставок. Если он одинаковый, перед вами один event, доставленный повторно. В CRM должна попасть одна операция. Две строки в access log при этом не ошибка.
Сначала подпись, потом бизнес-логика
Проверяйте подпись по исходному телу запроса. Если middleware уже распарсил JSON и собрал строку заново, байты могут отличаться. Для официального SDK используйте helper client.webhooks.unwrap(): он одновременно проверяет подпись и возвращает событие.
import OpenAI from 'openai';
const client = new OpenAI();
export async function webhook(req, res) {
const rawBody = req.rawBody;
let event;
try {
event = client.webhooks.unwrap(
rawBody,
req.headers,
process.env.OPENAI_WEBHOOK_SECRET
);
} catch {
return res.status(400).send('invalid signature');
}
const deliveryId = req.headers['webhook-id'];
const accepted = await reserveOnce(deliveryId, event.type);
if (!accepted) return res.status(200).send('duplicate ignored');
await queue.add('openai-webhook', { deliveryId, event });
return res.status(200).send('accepted');
}
Секрет не кладите в код и не печатайте в лог. Он нужен только серверу. Если подпись не прошла, endpoint возвращает 400 и не трогает очередь.
Почему «сначала SELECT, потом INSERT» не защищает
Два процесса могут одновременно выполнить SELECT, оба увидеть пустой результат и оба продолжить. Защиту ставят на уровне хранилища: уникальный индекс по webhook_id и атомарный INSERT. Конфликт уникальности означает «уже приняли».
| Слой | Уникальный ключ | Что он защищает |
|---|---|---|
| Приём webhook | webhook-id | Повторную доставку одного события |
| Очередь | job ID из webhook-id | Две одинаковые задачи worker |
| CRM | Ваш бизнес-ID результата | Повторную запись после сбоя ответа |
Рабочий алгоритм
- Получите сырое тело и заголовки. Ограничьте размер запроса до разумного значения.
- Проверьте подпись официальным helper. До этого шага не вызывайте CRM.
- Возьмите
webhook-idи зарезервируйте его атомарно. - Поставьте событие в надёжную очередь и быстро верните 2xx.
- Worker выполнит действие и запишет бизнес-результат по стабильному ключу.
- После успеха отметьте событие завершённым; после временной ошибки оставьте конечный retry.
PASS и FAIL для контрольного теста
- PASS: две доставки с одним webhook-id создают одну задачу worker.
- PASS: изменённое тело со старой подписью получает 400 и не попадает в очередь.
- PASS: endpoint отдаёт 2xx до завершения долгой записи в CRM.
- FAIL: повторная доставка создаёт вторую сделку, письмо или задачу.
- FAIL: при сбое CRM событие навсегда отмечается завершённым.
Где чаще ломается
Ответ отправляется слишком поздно. Endpoint ждёт расшифровку, письмо и запись в CRM. Поставщик не видит 2xx и повторяет доставку. Приём и обработка должны быть разными этапами.
Уникальный ключ выбран неверно. Тип response.completed одинаков у тысяч событий. Ключом служит идентификатор конкретной доставки, а бизнес-операцию дополнительно защищает ваш ID.
Статус «готово» записан до CRM. Worker упал, но повтор уже блокируется. Используйте состояния accepted, processing, completed и failed; разрешайте безопасный повтор незавершённой задачи.
В лог попадает payload целиком. Для разбора обычно хватает ID, типа, времени, кода ответа и внутреннего ID. Текст звонка и секрет подписи там не нужны.
Чек-лист перед production
- Сырое тело доступно проверяющему SDK.
- Signing secret хранится в переменной окружения.
- Webhook ID имеет уникальный индекс.
- Очередь получает стабильный job ID.
- Ответ 2xx не зависит от скорости CRM.
- Повтор одного payload входит в автоматический тест.
Сценарий отказа, который надо воспроизвести
Не ограничивайтесь двумя последовательными запросами из Postman. Самая неприятная гонка появляется, когда первая доставка уже зарезервировала ID, worker начал писать в CRM, а второй запрос пришёл до завершения операции. Запустите две одинаковые доставки параллельно. Один INSERT должен пройти, второй получить конфликт уникальности и завершиться 2xx без постановки второй задачи.
Затем оборвите worker после создания внешней операции, но до записи статуса completed. При повторном запуске он должен найти бизнес-результат по стабильному ключу и завершить событие, а не создать копию. Для задачи менеджеру таким ключом может быть пара «event ID + тип действия». Для записи анализа звонка — ID исходного звонка и версия анализатора.
Минимальные поля журнала событий
webhook_idи тип события;- время первой и последней доставки;
- статус accepted, processing, completed или failed;
- число попыток worker;
- внутренний бизнес-ID без содержимого разговора;
- код последней ошибки и время следующей попытки.
Поставьте срок хранения ключей длиннее возможного окна повторной доставки. Удаление записи через несколько минут возвращает старую проблему: поздний retry снова выглядит новым событием. Срок выбирайте по документации поставщика и требованиям к журналу, а очистку выполняйте отдельной контролируемой задачей.
Что делать, если не помогло
Возьмите один webhook-id и проведите его по четырём журналам: reverse proxy, endpoint, очередь, CRM. Две строки появляются впервые в одном конкретном слое. Если endpoint видит две доставки, а очередь одну, защита работает. Если очередь одна, а CRM-записей две, ищите повтор внутри worker. Для соседней проверки пригодится валидация JSON до записи в CRM и безопасный retry AI API. Мы можем разобрать AI-контур и связать события с CRM без дублей.



