Перейти к содержимому
СервисАпп API
На этой странице

Исходящие уведомления о событиях

Подписка и события

Подписки настраиваются в панели «Настройки → Каналы связи и интеграции → События API» для персонального подключения. Укажите адрес HTTPS, выберите события и разрешения на чтение. Сохраните секрет и идентификатор ключа key_id, затем включите доставку. При создании доставка выключена по умолчанию. Публичных API-операций управления подписками в нет.

Адрес использует HTTPS и порт 443, без имени пользователя, пароля и части после #. Сервер проверяет публичность адреса назначения; внутренние и смешанные публичные/закрытые адреса не допускаются. Перенаправления не выполняются. Сохранение адреса не посылает тестовый запрос.

Полный доступный набор событий и нужные разрешения на чтение — в таблице событий. Не выводите имя события автоматически из имени ресурса: например, user_day_off.deleted требует перечитать агрегат user-availability, а его resource.deleted=false.

Для доставки нужны действующий тариф, доступный модуль, права чтения сотрудника, разрешения подписки и доступ к ресурсу. Разрешение прежней команды записи их не заменяет. После утраты доступа событие может быть пропущено (suppressed) или доставка приостановлена (paused_access). Замена токена API сохраняет подписку; её секрет меняется отдельно.

Формат события

{
  "event_id": "9944e31b-184c-40af-8b06-2dd018ce190a",
  "event_type": "act.updated",
  "schema_version": 1,
  "occurred_at": "2026-11-04T09:00:00Z",
  "resource": {
    "type": "acts",
    "id": 301,
    "version": "1793782800",
    "deleted": false
  },
  "origin": {"kind": "api", "client_id": "<API_CLIENT_ID>"},
  "operation_id": "<OPERATION_ID>"
}
Поле Тип и смысл
event_id UUID, стабилен при всех попытках доставки этого события
event_type Строка из таблицы доступных событий
schema_version Целое 1
occurred_at Момент бизнес-изменения в UTC ISO 8601
resource.type Публичное имя ресурса
resource.id целое число или строка, исходный тип ID
resource.version Строка версии на момент изменения либо null для удаления
resource.deleted логическое значение
origin.kind api, panel, background
origin.client_id ID инициатора только для собственных API-изменений подписчика, иначе null
operation_id ID доступной этому подключению API-операции, иначе null

Все поля присутствуют в событии, даже если их значение null. Контакты, заметки и полная карточка не передаются. resource.version не является ETag: несколько изменений за секунду могут иметь одну версию и разные event_id. Полученное событие служит поводом перечитать актуальный ресурс через GET с обычной авторизацией.

Проверка подписи

Доставка — HTTP POST с Content-Type: application/json и заголовками:

X-ServiceApp-Event-Id: <EVENT_UUID>
X-ServiceApp-Timestamp: <UNIX_SECONDS>
X-ServiceApp-Key-Id: <WEBHOOK_KEY_ID>
X-ServiceApp-Signature: v1=<64 lowercase hex>
  1. Выберите секрет подписки по идентификатору из заголовка X-ServiceApp-Key-Id. Токен API для проверки подписи не используется.
  2. Прочитайте исходные байты тела HTTP-запроса. До проверки не пересобирайте JSON и не меняйте пробелы, Unicode или порядок ключей.
  3. Проверьте время из X-ServiceApp-Timestamp: допускается отклонение не более 300 секунд в любую сторону. Синхронизируйте часы получателя.
  4. Соедините ASCII-байты v1., исходную строку timestamp, ASCII-точку . и исходные байты тела.
  5. Вычислите HMAC-SHA256, используя байты UTF-8 строки секрета без декодирования base64. Представьте результат шестнадцатеричной строкой в нижнем регистре с префиксом v1=. Сравните подписи функцией, время работы которой не зависит от совпавших символов.
  6. После проверки подписи разберите JSON и проверьте равенство event_id в заголовке и теле.

Обработчики вебхуков

Выберите язык: PHP, Rust, Kotlin, Java, Go. Полные исходники доступны по ссылкам ниже и в скачиваемом комплекте документации.

Во всех примерах проверяются подпись исходного тела, время, UUID и совпадение event_id, schema_version=1 и наличие объекта resource. Секрет выбирается по key_id; карта секретов поддерживает ротацию. До ответа 204 событие сохраняется в SQLite с уникальным ключом (subscription_id, event_id). Повтор уже сохранённого события также получает 204. При ошибке базы возвращается 503 с Retry-After: 2.

Общие настройки для запуска любого примера из корня комплекта:

# Подставьте пару key_id/секрет из панели. Предыдущий ключ можно добавить вторым.
export SERVICEAPP_WEBHOOK_SECRETS='{"<WEBHOOK_KEY_ID>":"<WEBHOOK_SECRET>"}'
export SERVICEAPP_WEBHOOK_SUBSCRIPTION_ID='<SUBSCRIPTION_ID>'
export SERVICEAPP_WEBHOOK_DB='webhook-inbox.sqlite3'

SERVICEAPP_WEBHOOK_SUBSCRIPTION_ID задаётся в конфигурации получателя, а не берётся из запроса. Храните файл базы вне публичного каталога веб-сервера.

PHP

Обработчик вебхуков на PHP. Нужен PHP 8.3+ с расширением pdo_sqlite; Composer-зависимостей нет. Проверка HMAC в функции verifyEvent:

$expected = 'v1='.hash_hmac('sha256', 'v1.'.$timestamp.'.'.$rawBody, $secret);
if (! hash_equals($expected, $signature)) {
    throw new InvalidArgumentException('Неверная подпись');
}
php -S 127.0.0.1:8080 examples/webhook_receiver.php

Rust

Обработчик вебхуков на Rust и его Cargo.toml. Нужны Rust и Cargo; зависимости указаны в манифесте, SQLite собирается вместе с примером. Проверка HMAC в функции verify_signature:

let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
    .map_err(|_| 400_u16)?;
mac.update(format!("v1.{timestamp}.").as_bytes());
mac.update(raw_body);
mac.verify_slice(&hex::decode(digest).map_err(|_| 400_u16)?)
    .map_err(|_| 400_u16)?;

Здесь digest — проверенные 64 шестнадцатеричных символа после v1=; verify_slice сравнивает подписи за постоянное время.

cargo run --release --manifest-path examples/rust/Cargo.toml

Kotlin

Обработчик вебхуков на Kotlin. Нужны JDK 21+ и Kotlin с поддержкой main.kts. Зависимости Jackson и SQLite JDBC объявлены через @file:DependsOn. Проверка HMAC в функции verifySignature:

val mac = Mac.getInstance("HmacSHA256")
mac.init(SecretKeySpec(secret.toByteArray(UTF_8), "HmacSHA256"))
mac.update("v1.$timestamp.".toByteArray(UTF_8))
val expected = mac.doFinal(rawBody)
require(MessageDigest.isEqual(expected, HexFormat.of().parseHex(signature.substring(3))))
kotlin examples/webhook_receiver.main.kts

Java

Обработчик вебхуков на Java. Нужны JDK 21+ и JBang. Зависимости Jackson и SQLite JDBC объявлены в исходнике через //DEPS. Проверка HMAC в методе verifySignature:

var mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
mac.update(("v1." + timestamp + ".").getBytes(StandardCharsets.UTF_8));
var expected = mac.doFinal(rawBody);
if (!MessageDigest.isEqual(expected, HexFormat.of().parseHex(signature.substring(3)))) {
    throw new IllegalArgumentException("Неверная подпись");
}
jbang examples/WebhookReceiver.java

Go

Обработчик вебхуков на Go, go.mod и go.sum. Нужны Go 1.23+ и C-компилятор для драйвера SQLite; сборка выполняется с CGO_ENABLED=1. Проверка HMAC в функции verifySignature:

mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte("v1." + timestamp + "."))
mac.Write(rawBody)
return hmac.Equal(mac.Sum(nil), provided)

Здесь provided — байты проверенной подписи после удаления v1= и декодирования hex. hmac.Equal сравнивает их за постоянное время.

cd examples/go
CGO_ENABLED=1 go run .

Запускайте один пример за раз: каждый принимает POST на 127.0.0.1:8080. Внешний адрес HTTPS подключается через ваш веб-сервер. Настройте на нём ограничения размера тела и времени чтения, отклонение дублирующихся заголовков подписи и передачу тела без преобразований. В примерах дополнительно ограничен размер тела до 64 КиБ и проверяется Content-Type: application/json.

Отдельный обработчик должен читать сохранённые строки state=pending и получать актуальные карточки. Обрабатывайте события одного ресурса последовательно. Ответ 204 подтверждает сохранение события во входящей очереди; дальнейшая обработка может продолжаться.

Защита от повторной обработки и подтверждение

События доставляются как минимум один раз. Подтверждайте 2xx после сохранения события. Для уже сохранённого event_id возвращайте успешный ответ, не повторяя действие. Уникальное ограничение базы защищает также от одновременных доставок. Используйте ID подписки или другой доверенный контекст подключения как часть ключа защиты от повторной обработки.

Глобальный порядок событий не гарантируется. Чтения одного ресурса выполняйте последовательно. Нельзя сравнивать resource.version по величине или отбрасывать новое event_id только из-за равной version. После deleted перечитайте доступное состояние с учётом возможного более позднего restore. Свои изменения узнавайте по origin; сравнивайте фактические поля перед обратной записью, чтобы не создать цикл синхронизации.

Повторы и ротация

При каждой попытке обновляются время в X-ServiceApp-Timestamp и подпись. event_id, occurred_at и байты тела сохраняются. После замены секрета новые попытки используют новый key_id; предыдущий секрет сохраняйте 24 часа для проверки уже отправленных запросов.

Любой ответ 2xx подтверждает доставку. Время ожидания соединения — 5 секунд, общее время запроса — 10 секунд. При сетевом сбое и ответах 408, 429 или 5xx повторные попытки выполняются через 1 минуту, 5 минут, 30 минут, 2 часа, 8 часов и 24 часа. Всего допускается до семи попыток. Корректный Retry-After для 429 или 503 увеличивает ожидание, но не более чем до 24 часов.

3xx и остальные 4xx — окончательный отказ. 401/403 приостанавливают подписку до исправления и явного включения; одна ротация секрета её не возобновляет. 410 отключает URL. После исчерпания попыток доставка становится failed. Журнал и тело события доступны для ручного повтора 30 дней; ручной повтор сохраняет event_id и начинает новый цикл доставки, не исполняя бизнес-команду заново. Возобновление не продлевает срок хранения.

Превышение времени ожидания не означает, что получатель не сохранил событие. Повторная доставка использует прежний event_id. При потере событий восстановите актуальное состояние по процедуре синхронизации.