Вебхуки
Как подписываются исходящие доставки вебхуков и как получатель проверяет подпись.
Вебхуки работают в обе стороны, и обе используют одну и ту же схему подписи:
- Исходящие — Foxguide отправляет POST с событием на ваш URL. Вы проверяете нашу подпись.
- Входящие — вы отправляете POST на URL, который выдал Foxguide. Мы проверяем вашу подпись, если вы её включите.
Схема подписи
X-Webhook-Signature: sha256=HMAC_SHA256(secret, "<unix-секунды>.<сырое тело>")
X-Webhook-Timestamp: <unix-секунды>
Три детали определяют, заработает ли ваша реализация:
- Подписывается строка
${timestamp}.${body}— метка времени, буквальная точка, затем тело. Не тело само по себе. - Тело — это СЫРЫЕ байты как они пришли, до какого-либо разбора JSON.
Повторная сериализация через
JSON.stringify(parsedBody)даёт другую последовательность байтов (порядок ключей, пробелы, экранирование юникода) и, следовательно, другой хеш. Захватывайте сырое тело в парсере тела своего фреймворка и считайте HMAC по буферу. - Хеш шестнадцатеричный, с префиксом
sha256=. Сравнивайте всё значение заголовка сравнением за постоянное время.
Метка времени имеет допуск ±5 минут в обе стороны. Устаревшая метка отклоняется, потому что допуск ограничивает время, в течение которого перехваченный запрос остаётся воспроизводимым; метка из далёкого будущего отклоняется по той же причине — иначе отправитель мог бы выпустить подпись, действующую сколь угодно долго.
Проверка исходящей доставки
import crypto from 'node:crypto';
// Express: захватите сырые байты, не полагайтесь на разобранный объект.
app.post('/hooks/foxguide',
express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }),
(req, res) => {
const ts = req.get('X-Webhook-Timestamp');
const presented = req.get('X-Webhook-Signature');
if (!ts || !presented?.startsWith('sha256=')) return res.sendStatus(401);
if (Math.abs(Math.floor(Date.now() / 1000) - Number(ts)) > 300) return res.sendStatus(401);
const expected = 'sha256=' + crypto
.createHmac('sha256', process.env.FOXGUIDE_WEBHOOK_SECRET)
.update(ts + '.')
.update(req.rawBody) // ИСХОДНЫЕ байты
.digest('hex');
const a = Buffer.from(presented), b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.sendStatus(401);
res.sendStatus(200); // подтвердите быстро, обрабатывайте асинхронно
});
Исходящие доставки
Каждая доставка несёт:
| заголовок | смысл |
|---|---|
X-Webhook-Signature | хеш, описанный выше — присутствует, если у эндпоинта задан секрет подписи |
X-Webhook-Timestamp | unix-секунды, по которым считалась подпись |
Idempotency-Key | стабилен для всех повторов одной и той же доставки |
X-Correlation-Id | trace-идентификатор, новый на каждую попытку — для сопоставления логов, не для дедупликации |
Дедуплицируйте по Idempotency-Key, никогда по X-Correlation-Id. Повторная
доставка переиспользует ключ идемпотентности и выпускает новый correlation id,
поэтому получатель, опирающийся на correlation id, обработает одно событие дважды.
Исходите из доставки «хотя бы один раз» и делайте обработчик идемпотентным.
Быстро отвечайте 2xx, а работу выполняйте после. Медленный обработчик выглядит как неудавшаяся доставка и будет повторён.
Входящие эндпоинты
Foxguide выдаёт вам URL получателя:
POST https://api.foxguide.io/webhooks/{endpointId}/{token}
Content-Type: application/json
Сегмент token и есть учётные данные — он сравнивается за постоянное время, а
значит сам URL является секретом. Обращайтесь с ним как с паролем: он ротируемый,
поэтому при утечке выполняйте ротацию, а не удаляйте эндпоинт.
Дополнительно можно включить для эндпоинта проверку подписи. Будучи обязательной, она работает fail-closed: неподписанный или неверно подписанный запрос отклоняется, а не откатывается к проверке одного лишь токена — так утечки URL уже недостаточно, чтобы подделать событие. Подписывайте запрос по той же схеме, что описана выше, секретом эндпоинта.
Ответы
| статус | error в теле | смысл |
|---|---|---|
| 200 | — | {"status":"accepted"} — доставка поставлена в очередь |
| 401 | WEBHOOK_UNAUTHORIZED | неверный токен либо обязательная подпись не прошла проверку |
| 410 | WEBHOOK_DISABLED | эндпоинт существует, но не принимает доставки |
| 413 | PAYLOAD_TOO_LARGE | тело превышает 1 МБ |
| 415 | UNSUPPORTED_MEDIA_TYPE | не Content-Type: application/json с телом JSON |
| 429 | RATE_LIMIT_EXCEEDED | более 100 доставок в минуту на этого получателя |
| 500 | INTERNAL_ERROR | сбой на нашей стороне — повтор безопасен |
Неудачная подпись и неверный токен отвечают одинаково — оба 401. Это сделано намеренно: отдельный код сообщил бы вызывающему, угадавшему URL, что его токен верен и не сошлась лишь подпись. Конкретная причина записывается в журнал доставок вашего эндпоинта, где вы как владелец по-прежнему можете её прочитать.
Одно retired-имя
X-Foxguide-Signature выведен из употребления. Это был вариант подписи только
по телу, без метки времени; он нигде не отправляется и нигде не принимается. Если
вы следуете старой инструкции по интеграции, где он упомянут, эта инструкция
описывает схему, на которой данный API больше не говорит — используйте
X-Webhook-Signature, как описано выше.