Вебхуки

Как подписываются исходящие доставки вебхуков и как получатель проверяет подпись.

Вебхуки работают в обе стороны, и обе используют одну и ту же схему подписи:

  • Исходящие — Foxguide отправляет POST с событием на ваш URL. Вы проверяете нашу подпись.
  • Входящие — вы отправляете POST на URL, который выдал Foxguide. Мы проверяем вашу подпись, если вы её включите.

Схема подписи

X-Webhook-Signature: sha256=HMAC_SHA256(secret, "<unix-секунды>.<сырое тело>")
X-Webhook-Timestamp: <unix-секунды>

Три детали определяют, заработает ли ваша реализация:

  1. Подписывается строка ${timestamp}.${body} — метка времени, буквальная точка, затем тело. Не тело само по себе.
  2. Тело — это СЫРЫЕ байты как они пришли, до какого-либо разбора JSON. Повторная сериализация через JSON.stringify(parsedBody) даёт другую последовательность байтов (порядок ключей, пробелы, экранирование юникода) и, следовательно, другой хеш. Захватывайте сырое тело в парсере тела своего фреймворка и считайте HMAC по буферу.
  3. Хеш шестнадцатеричный, с префиксом 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-Timestampunix-секунды, по которым считалась подпись
Idempotency-Keyстабилен для всех повторов одной и той же доставки
X-Correlation-Idtrace-идентификатор, новый на каждую попытку — для сопоставления логов, не для дедупликации

Дедуплицируйте по 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"} — доставка поставлена в очередь
401WEBHOOK_UNAUTHORIZEDневерный токен либо обязательная подпись не прошла проверку
410WEBHOOK_DISABLEDэндпоинт существует, но не принимает доставки
413PAYLOAD_TOO_LARGEтело превышает 1 МБ
415UNSUPPORTED_MEDIA_TYPEне Content-Type: application/json с телом JSON
429RATE_LIMIT_EXCEEDEDболее 100 доставок в минуту на этого получателя
500INTERNAL_ERRORсбой на нашей стороне — повтор безопасен

Неудачная подпись и неверный токен отвечают одинаково — оба 401. Это сделано намеренно: отдельный код сообщил бы вызывающему, угадавшему URL, что его токен верен и не сошлась лишь подпись. Конкретная причина записывается в журнал доставок вашего эндпоинта, где вы как владелец по-прежнему можете её прочитать.

Одно retired-имя

X-Foxguide-Signature выведен из употребления. Это был вариант подписи только по телу, без метки времени; он нигде не отправляется и нигде не принимается. Если вы следуете старой инструкции по интеграции, где он упомянут, эта инструкция описывает схему, на которой данный API больше не говорит — используйте X-Webhook-Signature, как описано выше.

← Вся документация