Исходящие уведомления о событиях
Подписка и события
Подписки настраиваются в панели «Настройки → Каналы связи и интеграции → События 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>
- Выберите секрет подписки по идентификатору из заголовка
X-ServiceApp-Key-Id. Токен API для проверки подписи не используется. - Прочитайте исходные байты тела HTTP-запроса. До проверки не пересобирайте JSON и не меняйте пробелы, Unicode или порядок ключей.
- Проверьте время из
X-ServiceApp-Timestamp: допускается отклонение не более 300 секунд в любую сторону. Синхронизируйте часы получателя. - Соедините ASCII-байты
v1., исходную строку timestamp, ASCII-точку.и исходные байты тела. - Вычислите HMAC-SHA256, используя байты UTF-8 строки секрета без декодирования base64. Представьте результат шестнадцатеричной строкой в нижнем регистре с префиксом
v1=. Сравните подписи функцией, время работы которой не зависит от совпавших символов. - После проверки подписи разберите 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. При потере событий восстановите актуальное состояние по процедуре синхронизации.