Перейти к содержанию
Sursa для разработчиков

Приём вебхуков

Вебхук сообщает вашей системе об изменении в Sursa в момент, когда оно происходит: карточку опубликовали, подтвердили или отправили в архив, вопрос остался без ответа. Sursa отправляет HTTPS-запрос POST с событием на зарегистрированный вами адрес. В этом руководстве мы соберём получатель, который работает правильно при повторах, нарушенном порядке и дублях.

Что означает и содержит каждое событие, описано в обзоре вебхуков и в каталоге событий справочника API.

1. Зарегистрируйте вебхук

Администратор регистрирует вебхуки в Sursa в разделе Администрирование → Интеграции → Вебхуки и API или через POST /v1/webhooks с ключом, у которого есть роль admin и область webhooks:manage:

curl -X POST https://api.sursa.com/v1/webhooks \
  -H "Authorization: Bearer $SURSA_ADMIN_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://hooks.example.com/sursa",
    "description": "Sync verified cards to the help center",
    "events": ["card.published", "card.updated", "card.verified", "card.archived"]
  }'

Ответ содержит секрет подписи в secret, он начинается с whsec_. Секрет показывается один раз. Храните его рядом с получателем, например в переменной окружения SURSA_WEBHOOK_SECRET.

Адрес должен использовать https и указывать на публичные IP-адреса: адреса во внутренних, локальных (loopback) и других зарезервированных сетях отвергаются и при регистрации, и при каждой доставке. Чтобы вести разработку на своём компьютере, откройте доступ к локальному получателю через сервис туннелирования, который даст ему публичный HTTPS-адрес.

Отправить проверочное событие можно в любой момент через POST /v1/webhooks/{webhook_id}/test. Ответ показывает, что ответил ваш получатель.

2. Как выглядит доставка

POST /sursa HTTP/1.1
Host: hooks.example.com
Content-Type: application/json
webhook-id: evt_0123456789abcdef0123456789abcdef
webhook-timestamp: 1791194400
webhook-signature: v1,l1LLl/aEl4dQq7Geb383KbyNeZgs7VZpiw6iuoJgunI=
X-KB-Signature: t=1791194400,v1=82aa6b27378d89d7d880d17c3fe96268598443ae0e64d059c0c41e94e5d3ffbe
X-KB-Event-Id: evt_0123456789abcdef0123456789abcdef
X-KB-Event: card.verified
X-KB-Delivery: 99812
X-KB-Attempt: 1
X-KB-Sequence: 1042

{"id":"evt_0123456789abcdef0123456789abcdef","type":"card.verified","created_at":"2026-10-05T10:00:00Z","org_id":1,"api_version":"1.60.0","sequence":1042,"object":{"type":"card","id":"c1"},"payload":"full","data":{"card":{"id":"c1"}},"test":false}
ЗаголовокЗначение
webhook-idИдентификатор события. Один и тот же при каждом повторе. По нему отбрасывайте дубли.
webhook-timestampКогда подписана эта попытка, в секундах Unix.
webhook-signatureПодпись в формате Standard Webhooks.
X-KB-SignatureТа же доставка, подписанная в прежнем формате Sursa.
X-KB-EventТип события.
X-KB-AttemptНомер попытки, начиная с 1.
X-KB-SequenceМесто этой доставки в порядке доставок вебхука.

Имена заголовков не зависят от регистра; ваш фреймворк может показывать их как X-Kb-Signature или x-kb-signature.

Тело — это событие. Его data зависит от типа. Вебхук с телом доставки thin получает событие без data; вместо этого читайте объект через API своим ключом.

3. Проверьте подпись

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

ФорматПодписываемая строкаКлючПодпись
Standard Webhooks (webhook-signature)<webhook-id>.<webhook-timestamp>.<body>Часть секрета после whsec_, декодированная из base64v1, + base64 от HMAC-SHA256
Прежний формат (X-KB-Signature)<t>.<body>Вся строка секрета как текстv1= + hex от HMAC-SHA256

Достаточно проверить любую из двух подписей. Предпочитайте заголовок Standard Webhooks: такая же проверка есть во многих готовых библиотеках. Во время смены секрета каждый заголовок несёт по подписи на каждый действующий секрет (через пробел в webhook-signature, повторяющимся v1= в X-KB-Signature). Доставка действительна, если совпала любая из них.

Кроме того, отвергайте метку времени, которая отличается от ваших часов больше чем на 5 минут. Так никто не сможет позже повторно отправить записанную доставку.

# Check a saved delivery by hand: body.json holds the raw body, the values come from its headers.
SECRET="whsec_…"; WEBHOOK_ID="evt_…"; TIMESTAMP="1791194400"

# Standard Webhooks: compare with the value after "v1," in webhook-signature
KEY=$(printf '%s' "${SECRET#whsec_}" | tr '_-' '/+')
while [ $(( ${#KEY} % 4 )) -ne 0 ]; do KEY="$KEY="; done
KEY_HEX=$(printf '%s' "$KEY" | openssl base64 -d -A | xxd -p | tr -d '\n')
{ printf '%s.%s.' "$WEBHOOK_ID" "$TIMESTAMP"; cat body.json; } \
  | openssl dgst -sha256 -mac HMAC -macopt "hexkey:$KEY_HEX" -binary | openssl base64 -A

# Earlier format: compare with the value after "v1=" in X-KB-Signature
{ printf '%s.' "$TIMESTAMP"; cat body.json; } | openssl dgst -sha256 -hmac "$SECRET" -r | cut -d' ' -f1

Проверьте свою проверку

Прежде чем подключать код к настоящим доставкам, прогоните его на этих значениях. Если часы установлены на эту метку времени, обе проверки должны проходить, а при изменении одного байта тела — не проходить.

Входные данныеЗначение
Секретwhsec_test_secret_0123456789abcdef
webhook-idevt_0123456789abcdef0123456789abcdef
webhook-timestamp и t1791194400
Тело{"id":"evt_0123456789abcdef0123456789abcdef","type":"card.verified","created_at":"2026-10-05T10:00:00Z","org_id":1,"data":{"card":{"id":"c1"}}}
webhook-signaturev1,+xxWSXH6weQJ/MVg4+N78a1Dj4y/AQ4p94+PRNzpECg=
X-KB-Signaturet=1791194400,v1=ce26a19cb63cd3ae620217bf7688e1df1044fa7d59d9ae148d215f0292b768dc

4. Отвечайте быстро, работайте потом

Sursa ждёт вашего ответа до 10 секунд. Любой статус 2xx означает, что доставка удалась; всё остальное, включая перенаправление, тайм-аут или закрытое соединение, означает, что она не удалась и будет повторена.

Поэтому получатель должен только проверить, записать событие и ответить. Настоящую работу выполняйте потом, из собственной очереди.

// Node.js 20+, no framework. verifyStandard and verifyLegacy come from step 3.
import http from "node:http";

const SECRET = process.env.SURSA_WEBHOOK_SECRET;

http
  .createServer(async (req, res) => {
    const parts = [];
    for await (const part of req) parts.push(part);
    const body = Buffer.concat(parts); // the raw bytes, before any JSON parsing

    const valid = req.headers["webhook-signature"]
      ? verifyStandard(SECRET, req.headers, body)
      : verifyLegacy(SECRET, req.headers["x-kb-signature"], body);
    if (!valid) {
      res.writeHead(400).end();
      return;
    }

    const event = JSON.parse(body);
    await saveEventOnce(event); // insert into your table or queue, keyed by event.id; ignore a duplicate
    res.writeHead(204).end();
  })
  .listen(3000);

Отвечайте 2xx только после того, как событие надёжно сохранено. Если ответить сначала, а потом упасть, событие для вас потеряно, потому что Sursa уже засчитала его как доставленное.

5. Повторы

Неудачная доставка повторяется с нарастающими паузами: примерно через 1 минуту, 5 минут, 30 минут, 2 часа, 5 часов, 10 часов, 24 часа и ещё 24 часа — девять попыток почти за три дня. Каждая пауза меняется в пределах 20 %, чтобы повторы для множества вебхуков не приходили в один и тот же момент.

  • Ответ 429 или 503 с Retry-After откладывает следующую попытку как минимум на это время, но не больше чем на 24 часа. Используйте его, когда ваша система перегружена или на обслуживании.
  • Ответ 410 Gone выключает вебхук: отправляйте его, только если больше не хотите получать доставки на этот адрес.
  • После 10 неудачных попыток подряд вебхук ставится на паузу, а администраторы организации получают уведомление. События продолжают накапливаться, и после возобновления вебхука — в интерфейсе администрирования или через POST /v1/webhooks/{webhook_id}/resume — они доставляются по порядку.
  • После последней попытки доставка помечается exhausted. Её можно отправить снова вручную.

Доставки вебхука с каждой попыткой, кодом вашего ответа и временем ответа показывает GET /v1/webhooks/{webhook_id}/deliveries, а также интерфейс администрирования. POST /v1/webhooks/{webhook_id}/deliveries/{delivery_id}/redeliver отправляет доставку снова. У повторной доставки тот же webhook-id.

6. Порядок и дубли

  • Порядок. Доставки на один вебхук идут строго одна за другой в порядке sequence. Пока одна доставка повторяется, следующие ждут. Разные вебхуки друг друга не ждут.
  • Дубли. Одно и то же событие может прийти больше одного раза, например если ваш ответ потерялся на обратном пути. Сохраняйте webhook-id (он же id в теле) каждого обработанного события и пропускайте те, что уже видели.
  • Думайте состояниями. Для изменений одного и того же объекта сравнивайте created_at или sequence с тем, что у вас сохранено, и пропускайте событие старше уже известного вам состояния. Если сомневаетесь, прочитайте текущий объект через API: событие сообщает, что что-то изменилось, а API — каким оно стало сейчас.

7. Сверка с лентой событий

Каждое событие также хранится 30 дней в ленте событий — независимо от того, был ли на него подписан хоть один вебхук. Если ваш получатель не работал дольше, чем покрывают повторы, или вы хотите проверить, что ничего не пропущено, читайте ленту с ключом, у которого есть роль admin и область webhooks:manage:

curl -G https://api.sursa.com/v1/webhook-events \
  -H "Authorization: Bearer $SURSA_API_KEY" \
  --data-urlencode "type=card.*" \
  --data-urlencode "created_since=2026-10-20T00:00:00Z" \
  --data-urlencode "limit=200"

Событие в ленте имеет тот же id и ту же форму, что и в доставке, но без полей доставки sequence, payload и test. Ваш получатель пропускает уже виденные события, поэтому сверку можно запускать сколь угодно часто, например раз в час.

GET /v1/webhook-events/{event_id} читает одно событие по его идентификатору.

8. Смена секрета

POST /v1/webhooks/{webhook_id}/rotate-secret выпускает новый секрет и оставляет прежний действующим на время перекрытия — по умолчанию 24 часа, но не больше 7 дней. Пока действует перекрытие, каждая доставка подписывается обоими секретами.

  1. Смените секрет и сохраните новый рядом со старым в своём получателе.
  2. Принимайте доставку, если она проходит проверку любым из двух секретов.
  3. Когда перекрытие закончится, удалите старый секрет.

Если старый секрет утёк, завершите перекрытие сразу через POST /v1/webhooks/{webhook_id}/revoke-previous-secret или смените секрет с "overlap_hours": 0.

Чек-лист

  • Проверяйте одну из подписей по сырому телу и проверяйте метку времени.
  • Сохраняйте событие с ключом webhook-id, затем отвечайте 2xx в течение 10 секунд.
  • Пропускайте дубли и устаревшие события; когда это важно, читайте текущий объект через API.
  • Отвечайте 429 или 503 с Retry-After, когда нужно, чтобы Sursa сбавила темп; никогда не отвечайте 410 случайно.
  • Пропускайте незнакомые типы событий и поля.
  • Сверяйтесь с лентой событий по расписанию.