Интеграция вебхуков¶
Платформа доставляет уведомления о событиях в режиме реального времени на
ваш backend через исходящие вебхуки. Когда что-то происходит с
заявителем — завершилась верификация, сменился статус, AML-скрининг вернул
совпадения, заявитель отправил анкету — мы отправляем подписанный POST на
URL, который вы настроили в портале продукта.
Это руководство охватывает настройку, проверку подписи, формат запроса, каталог событий, семантику доставки, ротацию секретов и лучшие практики для построения надёжного приёмника.
Как это работает¶
Ваш backend платформа Outbox-воркер
| | |
| (в портале продукта) | |
| Settings → Webhooks | |
| url + события + секрет | |
| | |
| (событие случается) |
| |-- в outbox ------------->|
| | |
|<-- POST {payload}, X-Webhook-Signature ------------|
|-- 2xx ----------------->| |
- Пользователь с ролью
tenant_adminнастраивает вебхук в портале продукта в разделе Settings → Webhooks: HTTPS-URL приёмника, типы событий для подписки, опциональные пользовательские HTTP-заголовки. - Портал генерирует HMAC-SHA256 подписывающий секрет. Он показывается ровно один раз через flow одноразового открытия — сразу сохраните его в secrets manager.
- По мере наступления событий они сохраняются в долговременный outbox.
- Фоновый воркер доставляет каждое событие на ваш URL с гарантией
at-least-once. Неудачи переотправляются с экспоненциальным backoff
(полное расписание ниже). Успешная доставка — любой ответ
2xxв течение 5 секунд. - Ваш приёмник проверяет подпись, обрабатывает событие и быстро возвращает
2xx.
Настройка вебхуков пока только через UI. Платформа не предоставляет API
/v1/webhooks/...для программного управления вебхуками; интеграции настраиваются один раз через портал продукта.
Настройка¶
1. Настройка в портале продукта¶
В портале продукта перейдите в Settings → Webhooks и:
-
Введите HTTPS-URL приёмника.
-
HTTP отклоняется. URL должен начинаться с
https://. -
Loopback-имена (
localhost,127.0.0.1,::1), приватные IP (10.0.0.0/8,172.16.0.0/12,192.168.0.0/16), CGNAT (100.64.0.0/10), link-local (169.254.0.0/16— включая metadata endpoints AWS / Azure / GCP), а также IPv6 ULA / multicast — отклоняются. Имя резолвится в момент сохранения, поэтому A-запись, указывающая в приватный диапазон, тоже отлавливается. -
Выберите типы событий. По умолчанию вы получите все события; сузьте список, если вам нужны только конкретные переходы (например, только
applicantReviewedдля биллинговой логики). -
Опционально добавьте кастомные HTTP-заголовки — полезно для авторизации на стороне партнёра (
X-Tenant-Id,X-Source: complianceи т.п.). Следующие имена зарезервированы и не могут быть установлены: -
Любые с префиксом
X-Webhook-(наши системные). - Hop-by-hop / framing:
Host,Content-Length,Content-Type,Connection,Transfer-Encoding,Upgrade,Expect. -
Носители учётных данных:
Authorization,Proxy-Authorization,Cookie,Set-Cookie, заголовки видаX-Api-Key*,X-Auth-*,X-Access-Token*,X-Session-*, или содержащие-bearer,-secret,-password. -
Сохраните. При первом сохранении генерируется подписывающий секрет.
2. Получите и сохраните подписывающий секрет¶
Нажмите Rotate / View Secret. Портал покажет секрет ровно один раз. Сразу скопируйте его в secrets manager (env var, Vault, Secrets Manager и т.п.) — закрытие диалога без копирования означает, что для получения нового значения нужна повторная ротация.
Секрет хранится зашифрованным на стороне платформы с использованием AES-256-GCM с отдельным KMS-ключом.
3. Тестирование интеграции¶
Нажмите Test webhook в портале. Платформа отправит образец payload с
eventType, который вы выбрали (по умолчанию applicantReviewed). Диалог
покажет HTTP-код и тело ответа от вашего приёмника. Используйте это, чтобы
проверить вашу проверку подписи перед запуском в продакшен.
Формат запроса¶
Каждая доставка — HTTPS POST со следующими заголовками:
| Заголовок | Значение |
|---|---|
Content-Type |
application/json; charset=utf-8 |
User-Agent |
NeoxCompliance-Webhook/1.0 (может измениться — не зависьте от него) |
X-Webhook-Event |
Тип события, например applicantReviewed. |
X-Webhook-Delivery-Id |
Per-попытка id вида <outboxEntryId>-<attemptNumber> (например f3a…-1, f3a…-2). Каждый retry того же логического события получает уникальное значение. |
X-Webhook-Event-Id |
Логический id события (GUID outbox-записи), стабильный для всех повторов того же события. Используйте как ключ идемпотентности — одно и то же событие может быть доставлено повторно при retry (network blip, janitor recovery, ручной portal re-fire). |
X-Webhook-Timestamp |
Unix epoch в миллисекундах на момент доставки (информационный — то же значение зашито в поле t= подписи, верификация должна использовать именно его). |
X-Webhook-Signature |
t=<timestampMs>,v1=<нижний регистр hex>, где hex = HMAC-SHA256(secret, "{timestampMs}.{rawBody}"). Timestamp вшит в HMAC, поэтому атакующий не может реплейнуть захваченную пару (body, signature) с подменой timestamp. См. Проверка подписи. |
X-Webhook-Signature-Previous |
(только во время grace-окна ротации) — тот же формат, но подписан предыдущим секретом. См. Ротацию секретов. |
Совместимо с де-факто стандартом KYC-вебхуков. Форма payload-а (верхний уровень
applicantId/inspectionId/type/reviewStatus+ вложенныйreviewResult.reviewAnswerGREEN/RED) повторяет формат, используемый ведущими KYC-провайдерами на рынке. Обработчик, написанный под любого из них, обычно работает с нашей платформой при замене URL и подписывающего секрета без изменений в коде.
Тело — JSON. Top-level форма:
{
"applicantId": "9d8b5c84-...-b2",
"inspectionId": "9d8b5c84-...-b2",
"externalUserId": "user_42",
"applicantType": "individual",
"levelName": "kyc-basic",
"type": "applicantReviewed",
"reviewStatus": "completed",
"reviewResult": {
"reviewAnswer": "GREEN",
"rejectLabels": [],
"reviewRejectType": null
},
"createdAt": "2026-05-05T10:15:32.418Z",
"correlationId": "8a4f...e0c1"
}
Заметки по полям:
reviewResultвсегда присутствует на событиях, несущих решение (applicantReviewed,applicantWorkflowFailed). Все три подполя всегда присутствуют —rejectLabelsэто[], если меток нет, никогдаnullили отсутствует.- События без решения (например,
applicantCreated,applicantPersonalInfoChanged) несут плоский payload безreviewResult. Доп. поля по событиям — в каталоге ниже. correlationIdуникален на событие (независимо от количества попыток); для идемпотентности на попытку используйтеX-Webhook-Delivery-Id.
Проверка подписи¶
Подпись использует версионированную схему где timestamp доставки
криптографически вшит в HMAC — атакующий, захвативший валидную пару
(body, signature), не сможет реплейнуть её с подменой timestamp. Формат
заголовка:
Подписываемая строка — {timestampMs}.{rawBody}, HMAC ключом подписывающего
секрета через SHA-256, в нижнем регистре hex:
Заголовок X-Webhook-Timestamp несёт тот же timestampMs для удобства
клиентов, которые не хотят парсить заголовок подписи — но проверяйте
именно тот timestamp, что зашит внутри заголовка подписи (часть t=), а
не отдельный заголовок, иначе атакующий сможет подменить timestamp в
заголовке без эффекта на верификацию.
Хешируйте байты, как они пришли, а не пересериализованную версию. Перекодирование JSON изменит пробелы и сломает сравнение.
Node.js¶
import crypto from 'node:crypto'
function parseSig(header) {
// "t=1717...,v1=4f8c..." → { t: "1717...", v1: "4f8c..." }
return Object.fromEntries(header.split(',').map(p => p.split('=')))
}
function verify(rawBody, signatureHeader, secret, toleranceMs = 5 * 60 * 1000) {
const parts = parseSig(signatureHeader || '')
if (!parts.t || !parts.v1) return false
const ageMs = Math.abs(Date.now() - parseInt(parts.t, 10))
if (ageMs > toleranceMs) return false
const signed = `${parts.t}.${rawBody}`
const expected = crypto
.createHmac('sha256', Buffer.from(secret, 'base64'))
.update(signed)
.digest('hex')
const a = Buffer.from(parts.v1, 'hex')
const b = Buffer.from(expected, 'hex')
return a.length === b.length && crypto.timingSafeEqual(a, b)
}
Python¶
import base64, hmac, hashlib, time
def verify(raw_body: bytes, signature_header: str, secret_b64: str, tolerance_ms: int = 5 * 60 * 1000) -> bool:
parts = dict(p.split("=", 1) for p in (signature_header or "").split(","))
t, v1 = parts.get("t"), parts.get("v1")
if not t or not v1:
return False
if abs(int(time.time() * 1000) - int(t)) > tolerance_ms:
return False
key = base64.b64decode(secret_b64)
signed = t.encode() + b"." + raw_body
expected = hmac.new(key, signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(v1, expected)
Подписывающий секрет — base64-encoded случайные байты — декодируйте перед использованием как HMAC-ключ.
Защита от replay¶
Timestamp в поле t= заголовка подписи — время доставки в Unix
миллисекундах. Оба сниппета выше уже отбраковывают доставки старше окна
допуска в 5 минут — это ограничивает, как долго захваченный запрос остаётся
реплейаемым. Подгоняйте окно под свой допустимый clock-skew + сколько
готовы оставить replay-окно открытым; 5 минут — дефолт Stripe.
Каталог событий¶
Имена событий в camelCase (без точек).
Жизненный цикл заявителя¶
| Событие | Когда |
|---|---|
applicantCreated |
Создан новый заявитель. |
applicantPersonalInfoChanged |
Обновлены персональные данные заявителя. Payload включает changedFields. |
applicantLevelChanged |
Изменён verification level заявителя. |
applicantActivated |
Заявитель снова активирован после деактивации. |
applicantDeactivated |
Заявитель деактивирован (не может начать верификации). |
applicantDeleted |
Заявитель soft-удалён (GDPR). |
applicantPersonalDataDeleted |
PII заявителя анонимизирован (GDPR right-to-erasure). |
applicantReset |
Состояние верификации заявителя сброшено. |
Жизненный цикл верификации (IDV)¶
| Событие | Когда |
|---|---|
applicantAwaitingOperatorReview |
Верификация попала в очередь ручной проверки оператора. Действий от заявителя не требуется; reviewer-у тенанта нужно одобрить или отклонить из product portal. |
applicantOnHold |
Верификация вручную поставлена на hold для review. |
applicantReviewed |
Верификация достигла финального решения. Включает reviewResult.reviewAnswer (GREEN / RED). Если уровень включал анкету, в envelope также приходят questionnaireScore и questionnaireScoreBand. Результаты AML и adverse-media теперь приходят в этом событии — ветвитесь по reviewResult.reviewAnswer / reviewResult.riskLabel. Подпишитесь как на единственный сигнал "верификация завершена". |
applicantWorkflowFailed |
Workflow завершился технической неудачей (не путать с отказом по причине user-side rejection). |
applicantExpired |
Сессия верификации истекла по сроку (sweeper). reviewResult.reviewAnswer = RED с reviewRejectType EXPIRED — выпустите новую ссылку, если хотите дать пользователю ретрай. |
applicantManagedComplianceDecided |
Reviewer-ы Neox Managed Compliance вынесли решение по заявителю, которого ваша команда эскалировала. Payload содержит managedComplianceDecision (approve / reject), managedComplianceDecisionReason, managedComplianceDecidedAtUtc. Подпишитесь, если у вас есть собственный шлюз одобрения, который должен повторять вердикт Managed Compliance. |
Отправка анкеты не отдельное событие. Результат анкеты входит в финальный envelope
applicantReviewedкакquestionnaireScore+questionnaireScoreBand. СчитайтеapplicantReviewedединственным сигналом «верификация завершена» вне зависимости от того, входила ли в уровень анкета.Если нужного события пока нет в каталоге — свяжитесь с нами. Каталог намеренно консервативный, события добавляются по реальной потребности.
Обработка отказа — retry-рецепт¶
Когда applicantReviewed приходит с reviewResult.reviewAnswer === "RED"
или приходит applicantWorkflowFailed, проверка пользователя завершилась
отказом. В SDK пользователь видит терминальный экран "Проверка не одобрена"
с причиной — но retry-кнопки в SDK нет. Восстановление — задача вашего
tenant-кода. Три паттерна в зависимости от rejectLabels:
Мягкий отказ — retry рекомендован¶
Для лейблов типа BLURRY_IMAGE, LOW_QUALITY_PHOTO, WRONG_DOCUMENT_TYPE
пользователю достаточно чистой попытки. Рецепт:
- На webhook с мягким лейблом — выпустите свежую ссылку:
POST /v1/applicants/{externalUserId}/verifications
X-Api-Key: pk_…
X-Api-Secret: ps_…
Content-Type: application/json
{ "verificationLevelName": "KYC_01" }
-
Ответ содержит
url(новая короткая ссылка) — отправьте её пользователю своим каналом (email, SMS, in-app баннер) с дружелюбным сообщением: "Нужно более чёткое фото документа — попробуйте снова здесь." -
Новая попытка — это свежая
enrollmentsзапись; оригинальный applicant сохраняет историю. Webhooks для новой попытки приходят как обычно.
Жёсткий отказ — эскалация, не auto-retry¶
Для лейблов типа FORGED_DOCUMENT, CRIMINAL_RECORD, SANCTIONS_HIT
auto-retry — неправильный шаг. Рецепт:
- Уведомите fraud / compliance команду.
- Заблокируйте аккаунт / откажите в выдаче новой ссылки до проверки человеком.
- Не вызывайте
POST /verificationsавтоматически — это переиздаст ссылку и даст пользователю продолжать попытки. Используйте flow ручного одобрения через operator portal, если человек решит, что отказ был ложным.
Override оператором (false positive)¶
Когда compliance-команда решает, что жёсткий отказ был ошибкой:
POST /v1/applicants/{externalUserId}/decision
{ "action": "approve", "moderationComment": "Ручной override — расписка #..." }
Это снова выпустит applicantReviewed с reviewAnswer === "GREEN" и
переведёт applicant в approved без необходимости пользователю отправлять
заново.
Данные анкеты сохраняются. Был ли отказ IDV-driven или после анкеты, envelope
applicantReviewedвсё равно несётquestionnaireScore+questionnaireScoreBand(если у level была анкета). При retry не нужно переделывать анкету — новая попытка подхватит с того места, где пользователь остановился, если так настроен level.
Семантика доставки¶
- At-least-once. Доставка считается "успешной" только если приёмник
вернул
2xxв течение 5 секунд. Всё остальное (4xx/5xx, network error, timeout, redirect) трактуется как неудача и переотправляется. - Retry. Backoff между попытками: 10 с, 30 с, 1 мин, 5 мин, 15 мин.
После 5 попыток доставка попадает в dead-letter — видна в истории
доставок портала со статусом
DeadLettered. Оператор может нажать Retry, чтобы поставить новую доставку в очередь (создаётся новая запись outbox, связанная с оригиналом черезretryOf). - Janitor. Фоновая зачистка каждые 5 минут подбирает доставки,
застрявшие на полпути (worker crash → строка зависла в
Processing), или просроченные по запланированному retry, и переотправляет их. Снаружи это незаметно — просто означает, что рестарт воркера во время деплоя не теряет события. - Порядок не гарантируется. Два события для одного заявителя могут
прийти не по порядку. Используйте
createdAtдля сверки. - Без redirects. HTTP-клиент отказывается следовать
3xx— настройте финальный URL напрямую. Redirect засчитывается как failed delivery. - Тело ответа > 64 КБ. Мы читаем максимум 64 КБ ответа (и сохраняем только первые 500 символов для аудита). Возвращайте короткий ack — всё, что больше, тратится впустую.
- Идемпотентность.
X-Webhook-Delivery-Idуникален на попытку доставки. Используйте его как ключ дедупликации на вашей стороне. При retry повторно используется один и тот жеcorrelationId(идентичность события), но новыйX-Webhook-Delivery-Id(идентичность попытки). Ручной retry из портала создаёт НОВУЮ outbox-запись, поэтому у неё свежие id обоих типов.
Ротация секретов¶
Операторы могут ротировать подписывающий секрет из портала в любой момент. Механизм — двухфазное открытие, чтобы держать секрет вне browser history / proxy-логов / monitoring captures:
- Нажмите Rotate. Портал вызывает
POST /api/product-portal/webhooks/secret/rotateи получает короткоживущий (5 мин) reveal token. Секрета в этом ответе нет. - Нажмите Reveal. Портал вызывает
POST /api/product-portal/webhooks/secret/revealс токеном и получает новый секрет — ровно один раз. После этого токен сжигается.
Следующие 24 часа после ротации (настраивается, по умолчанию 24ч) платформа отправляет два signature-заголовка на каждой доставке:
X-Webhook-Signature— подписан новым секретом.X-Webhook-Signature-Previous— подписан старым секретом.
Обновите хранимый секрет на новое значение в течение grace-окна. Ваша логика проверки должна принимать любую из подписей во время короткого overlap, чтобы избежать пропусков. После окончания grace отправляется только новая подпись.
Исходные IP (опциональный firewall allowlist)¶
Если хотите ограничить входящие webhook-запросы на firewall, разрешите NAT-egress диапазон платформы. Диапазоны, действующие для этого развёртывания:
31.210.65.157
Авторитетный, всегда актуальный список по окружениям доступен живым JSON-ом на публичном эндпоинте без авторизации — подтягивайте его напрямую в свою автоматизацию security group / WAF / reverse-proxy ACL:
Пример ответа:
В массивах смешаны CIDR-диапазоны и одиночные IPv4 — каждую запись рассматривайте как непрозрачное правило для firewall и переносите как есть. Пустой массив означает, что оператор не публикует явный egress для этого окружения — используйте hostname-based ACL у себя или полагайтесь на проверку подписи (см. ниже).
Опрашивайте с любой удобной периодичностью. Список рассчитан на стабильность, но мы изредка меняем outbound-IP при масштабировании NAT — синк раз в сутки точно поймает изменения до того, как устаревшие правила приведут к сбоям.
IP-allowlist опционален и дополняет защиту — основной механизм аутентификации это подпись. Не отключайте проверку подписи в пользу IP-фильтра: подпись защищает от подделки тела даже от разрешённого IP, а наши IP-диапазоны могут ротироваться.
Лучшие практики¶
- Отвечайте быстро. Возвращайте
200 OKза пару секунд и обрабатывайте событие асинхронно (положите в очередь и сразу ответьте). Если держать запрос открытым слишком долго, воркер отвалится по timeout и переотправит — то же логическое событие выполнится дважды на вашей стороне. - Проверяйте подпись на каждом запросе. Отклоняйте отсутствующую или
невалидную подпись через
401. Не падайте назад на "trust the IP" — IP могут меняться. - Ведите ledger Delivery-Id. Вставляйте каждый
X-Webhook-Delivery-Idв таблицу с unique constraint и отбрасывайте дубликаты. Это защищает от retry-штормов во время инцидентов на вашей стороне. - Валидируйте timestamp. Отклоняйте доставки со skew > 5 мин — защита от replay payload, если ключ когда-нибудь утечёт.
- Только TLS и реальный сертификат. Мы не отправляем на
http://URL'ы. Self-signed работают, но дебажить delivery failures будет сложнее. - Останавливайте кровотечение во время инцидентов. Если ваш приёмник сломан, оператор может выключить Webhook is active в портале — события перестанут попадать в очередь. Когда будете готовы — включите обратно; события, которые "стрельнули" во время паузы, не будут back-fill'ить (они в очередь не попали). Для событий, которые вы получили, но не смогли обработать — повторите вручную из истории доставок портала.