Вебхуки доставляют события идентификации на ваш сервер в реальном времени. Каждый
раз, когда посетитель идентифицирован, TRACIO отправляет HTTP-запрос POST на
настроенный вами URL вебхука. Тело запроса и есть payload события.
Они же — единственный канал, доставляющий поздние вердикты: те, где поведение посетителя доказало, что он автоматизирован, уже после загрузки страницы.
Настраиваются в дашборде, в разделе Settings → Webhooks. Вебхуки требуют тариф Pro или выше.
| Событие | Когда | Тариф |
|---|---|---|
identification | На каждый визит — фазы primary, late и correction | Все |
account_takeover | Поведение под аккаунтом больше не совпадает с профилем его владельца | Business+ |
attack_detected | Всплеск ботов на вашем сайте | Business+ |
reputation_changed | Репутация человека за устройством изменилась | Business+ |
Имена событий пишутся через подчёркивания, никогда через точки — никаких
visitor.created или session.created. reputation_changed требует слоя личности,
поэтому срабатывает только для тех workspace, где включено сопоставление личности
между устройствами.
Вебхук подписывается на конкретные типы; отдельное значение * означает «все типы,
включая те, что появятся позже». Неизвестный тип отклоняется с кодом 400 при
создании или редактировании подписки, поэтому опечатка не оставит вас с вебхуком,
который молча никогда не срабатывает.
identificationОдин визит порождает до трёх доставок, разделяющих один и тот же requestId:
primary — первичный вердикт, на загрузке страницы.late — обогащение примерно через девять секунд, когда доедут медленные проверки.correction — коррекция на основе поведения (указатель, клавиатура, скроллинг).Сопоставляйте их по requestId, а различайте по phase. Более поздняя фаза
главнее: если primary сказал human, а correction говорит bot, правильный
ответ — второй.
Не полагайтесь на порядок прихода. Каждая фаза доставляется независимо и по
своему графику повторов: если primary ушёл в повторы, а late прошёл с первой
попытки, вы получите их в обратном порядке. Определяйте приоритет по полю phase,
а не по времени получения.
Этими тремя фазы события identification и исчерпываются. До вас доходит ещё одно
значение: account_takeover несёт phase: "beacon", потому что тревога о захвате
аккаунта поднимается только из поведенческого beacon-события.
Обратите внимание на возникающее расхождение — оно влияет на идемпотентность. У
продовой доставки identification eventId равен ровно <requestId>:<phase>, но две
доставки эту формулу нарушают. У account_takeover это <requestId>:ato — суффиксом
идёт литерал ato, а не значение поля phase. Пробная доставка, отправленная из
дашборда, — это <requestId>:test, при этом phase в её теле по схеме 2 по-прежнему
показывает primary, а в теле по схеме 1 поля phase нет вовсе, так что суффикс виден
только в заголовке. Используйте eventId как ключ идемпотентности напрямую и никогда
не пересобирайте его из requestId и phase. Сопоставляйте те значения, которые
обрабатываете, а остальные игнорируйте, а не отклоняйте доставку.
attack_detected — событие уровня workspace: у него нет ни requestId, ни
visitorId, ни блоков browser, geo, bot, decision — этих ключей просто нет.
account_takeover порождается конкретным визитом и несёт полное тело идентификации
для вашего тарифа плюс блок accountAlert. Если вы разбираете все события в одном
обработчике, проверяйте event до того, как обращаться к полям визита.
| Версия | Для кого | Как переключить |
|---|---|---|
1 | Вебхуки, созданные до появления v2 | Остаётся для них значением по умолчанию |
2 | Новые вебхуки | Переключатель на карточке вебхука в дашборде |
Схема v1 заморожена — ни одно её поле не меняется, поэтому существующие интеграции продолжают работать без правок. Всё новое живёт в v2, и именно её отдают новые вебхуки.
{ "version": 2, "event": "identification", "eventId": "9c1f6a2e-3b7d-4c58-a1e2-6f0d8b4a7c31:primary", // "<requestId>:<phase>" — the idempotency key "requestId": "9c1f6a2e-3b7d-4c58-a1e2-6f0d8b4a7c31", // visit identifier, shared by all phases "phase": "primary", "visitorId": "X7fh2Hg9LkMn3pQr5tBvQw3xZa9mK2pL4nR8dT6y", "linkedId": "user-42", // your ?lid=, if you passed one "tag": "checkout", "timestamp": "2026-07-30T12:00:00Z", "url": "https://shop.example.com/checkout", "ip": "203.0.113.44", "userAgent": "Mozilla/5.0 …", "browser": { "name": "Chrome", "version": "138" }, "os": { "name": "macOS", "version": "15.5" }, "device": "desktop", "gpu": "Intel Iris Plus Graphics 655", // модель видеоадаптера, нормализованная; отсутствует, когда неизвестна "geo": { "country": "DE", "city": "Berlin", "lat": 52.52, "lon": 13.405, "timezone": "Europe/Berlin" }, "network": { "vpn": false, "proxy": true, "tor": false, "datacenter": true, "connectionType": "DCH", "proxyDetected": true // HTTP-трафик и сырой сетевой путь выходят через разные сети }, "bot": { "result": "human", "score": 3.2 }, // human | bot | uncertain "identification": { "confidence": 0.97, "incognito": false }, "decision": { "action": "real", "riskScore": 12.2 } // real | fake | suspicious}Нулевые и пустые значения опускаются. Строковые и числовые поля с нулевым
значением (например, bot.type для человека) в JSON отсутствуют — не делайте их
обязательными в своих схемах и читайте вложенные блоки защитно.
bot.score и decision.riskScore — десятичные числа в шкале 0..100 с одним
знаком после запятой, ровно те, что дашборд показывает для того же визита. (В
замороженной схеме v1 у них другие единицы: доля 0..1 и 0..255 соответственно.)
bot.type — это либо имя распознанного бота, либо метка семейства. Словарь значений
см. в разделе Типы ботов — внутренние имена проверок
не раскрываются ни на одном тарифе.
| Поле | Тип | Описание |
|---|---|---|
version | number | Версия схемы payload (2) |
event | string | Тип события |
eventId | string | Идентификатор доставки — ключ идемпотентности |
requestId | string | Идентификатор визита (UUID), общий для всех его фаз |
phase | string | primary, late, correction; account_takeover несёт beacon |
visitorId | string | Стабильный идентификатор посетителя |
linkedId | string | Связанный идентификатор, переданный клиентом |
tag | string | Произвольный тег, переданный клиентом |
timestamp | string | Время события (RFC 3339) |
url | string | URL страницы, где событие было зафиксировано |
ip | string | IP-адрес клиента |
userAgent | string | Сырая строка user-agent клиента |
browser.name / .version | string | Определённый браузер |
os.name / .version | string | Определённая операционная система |
device | string | Класс устройства (например, desktop, mobile) |
gpu | string | Модель видеоадаптера, как её сообщает браузер (WebGL), нормализованная до читаемого имени (Intel Iris Xe Graphics, Apple M1 Pro); Software renderer для программных растеризаторов; отсутствует, когда неизвестна |
geo | object | Геолокация по IP: country, city, lat, lon, timezone |
network | object | vpn, proxy, tor, datacenter (булевы) и connectionType |
network.proxyDetected | boolean | HTTP-трафик визита и его сырые сетевые пути выходят через разные сети — перед браузером стоит прокси или VPN. Два адреса одного и того же провайдера (NAT оператора, второй выход того же VPN) не считаются |
bot.result | string | human, bot или uncertain |
bot.type | string | Имя бота или метка семейства, когда бот обнаружен |
bot.score | number | Оценка бота (0–100) |
identification.confidence | number | Уверенность идентификации (0.0–1.0) |
identification.incognito | boolean | Приватный режим / инкогнито |
decision.action | string | real, fake или suspicious |
decision.riskScore | number | Совокупная оценка риска (0–100) |
Pro и выше — как посетитель ведёт себя во времени:
{ "identification": { "matchType": "exact", // exact | fuzzy | new — how the visitor was recognized "matchConfidence": 0.93, "visits": 42, "incognitoVisits": 3 }, // Present when visitor counters are available at event time (usually primary). // A missing block means "no data", not "zeros". "velocity": { "events5m": 7, "uniqueIps": 2, "uniqueLocations": 1 }, "bot": { "antidetectScore": 0 }, // antidetect indicators, 0..100 "session": { "durationSeconds": 95 } // where the visit duration is already known}Business и выше — почему вердикт получился именно таким:
{ "reasons": [ // at most 8, sorted by importance { "code": "headless_browser", "severity": "high" }, { "code": "privacy_hardening", "severity": "low" } ], // Behavioral biometrics — present only when behavioral scoring ran for the // visit. A missing block means "no data", never "nothing suspicious". "behavior": { "score": 87, "verdict": "human", "confidence": 0.92 }, "identification": { "driftScore": 0.31 }, // divergence from the account profile "deviceInfo": { "deviceId": "…", // the physical device across browsers on it "crossBrowser": true, "confidence": 0.88, "linkedBrowsers": 3 }, "network": { "isp": "Deutsche Telekom", "asn": 3320, // публичный адрес, наблюдаемый на сыром сетевом пути, то есть адрес за // прокси или VPN; отсутствует, когда такого адреса не наблюдали "realIp": { "address": "203.0.113.7", "country": "NL", "isp": "KPN" } }, // Окружение рабочего стола, ИЗМЕРЕННОЕ на машине с Linux ("Mint 22+", // "Ubuntu", "GNOME", "KDE"); User-Agent выразить дистрибутив не может. // Отсутствует, когда не определено — у большинства Linux-визитов и у // каждого не-Linux. "osEnvironment": "Mint 22+", // Что визит заявил о себе против того, что измерили независимые проверки. // Присутствует только тогда, когда подмена действительно обнаружена; // пустое поле `real` означает "проверка промолчала", а не "подтверждено". // По оси `gpu` поле `claimed.gpu` несёт заявленный адаптер тем же читаемым // именем модели, что и поле `gpu` верхнего уровня. "spoofing": { "detected": true, "claimed": { "os": "Windows 10", "browser": "Chrome 139.0" }, "real": { "os": "macOS" }, "spoofedAxes": ["os", "screen"], // os | gpu | screen | network | browser "anonymousBrowser": { "detected": true, "names": ["Linken Sphere"] } }, // Факты об устройстве — что браузер посетителя сообщает о машине, // очищенное на нашей стороне. screen: разрешение, глубина цвета и device // pixel ratio. locale: собственные предпочитаемые языки браузера и его // таймзона — в отличие от geo.timezone, которая выводится из IP-адреса; // расхождение между ними — частый признак подменённого местоположения. // clientHints: User-Agent Client Hints — архитектура и разрядность // процессора, модель устройства (на Android: код модели в `model`, // например "SM-A556B", и её маркетинговое имя из списка устройств // Google Play в `deviceName`, например "Samsung Galaxy A55 5G") и точная // версия платформы; // сообщают их только браузеры на Chromium. Каждый блок отсутствует, когда // визит таких данных не принёс, поэтому считайте любой из них // необязательным. "screen": { "width": 2560, "height": 1600, "colorDepth": 30, "pixelRatio": 2 }, "locale": { "languages": ["en-US", "de"], "timezone": "Europe/Berlin" }, "clientHints": { "architecture": "arm", "bitness": "64", "platformVersion": "15.5.0" }, // Присутствует только тогда, когда видеоадаптер сам представился // виртуальным; hypervisor — закрытый словарь (vmware, virtualbox, // parallels, qemu, hyperv, bochs, intel-gvt, vgpu). Отсутствие блока // означает, что таких свидетельств нет. "environment": { "virtualMachine": true, "hypervisor": "vmware" }, "decision": { "suspectScore": 55 }, "guidance": { "version": 1, "overall": "review" } // see below}Словарь кодов причин и смысл severity — в разделе
Обнаружение ботов.
guidance несёт готовые рекомендации «что делать» для каждой точки интеграции,
чтобы вам не приходилось выводить политику из сырых оценок:
{ "guidance": { "version": 1, "overall": "review", // the strictest advice across the scenarios "payment": "review", // whether to accept the payment "registration": "challenge", // whether to create the account "login": "challenge", // whether to let them into the account "affiliate": "review", // whether to credit the conversion to the partner "basis": ["risk", "network"] // the axes that determined the advice }}Каждый сценарий стартует с allow и движется только вверх по лестнице:
allow → challenge → review → deny. Внутри сценария побеждает самая строгая
сработавшая ось, а overall — самая строгая по всем четырём сценариям.
| Рекомендация | Платёж | Регистрация | Вход | Партнёрская программа |
|---|---|---|---|---|
allow | Проводите | Создавайте | Пропускайте | Засчитайте конверсию |
challenge | 3-D Secure / подтверждение | Капча, подтверждение по email или телефону | Step-up 2FA, повторная аутентификация | Пометьте как сомнительную, пока не появится активность |
review | Проведите, но поставьте в очередь на ревью | Создайте с ограничениями | Пропустите, но поднимите алерт | Придержите выплату до проверки |
deny | Не проводите транзакцию | Откажите в создании аккаунта | Не пропускайте | Не засчитывайте конверсию |
version — версия набора правил, она повышается по мере улучшения логики.
Guidance аддитивен: новые сценарии приходят новыми ключами, не ломая контракт.
Более поздняя фаза побеждает, кроме частичных рекомендаций: доставка,
посчитанная на неполном наборе входных данных, помечается "partial": true, и
частичная рекомендация не перекрывает полную, полученную ранее для того же
requestId. В обычной доставке поле partial отсутствует целиком.
Точные пороги намеренно не документируются. Рекомендация, которую можно обратной разработкой свести к оценке, перестаёт быть защитой.
account_takeoverТолько Business и Enterprise. Тело — полный конверт идентификации для вашего тарифа
плюс блок accountAlert, доставляется не более одного раза на визит:
{ "version": 2, "event": "account_takeover", "eventId": "9c1f6a2e-3b7d-4c58-a1e2-6f0d8b4a7c31:ato", "requestId": "9c1f6a2e-3b7d-4c58-a1e2-6f0d8b4a7c31", "phase": "beacon", // тревога поднимается из beacon; только eventId говорит "ato" "accountAlert": { "type": "behavior-drift", "accountId": "user-42", // your linkedId for the account "driftScore": 0.83 } // …the remaining identification fields}В v1 этот блок несёт type, linkedId и drift; в v2 два поля переименованы —
linkedId → accountId и drift → driftScore. Обновите свой обработчик, когда
переключаете payloadVersion, иначе ваша логика по захвату аккаунтов молча
перестанет видеть данные.
attack_detected{ "version": 2, "event": "attack_detected", "eventId": "c0a8e1f2-…", "timestamp": "2026-07-30T12:00:00Z", "attack": { "kind": "bot_spike", "severity": "critical", // info | warning | critical "windowMinutes": 15, "recentBots": 4210, "recentTotal": 5100, "expected": 180.5 // the baseline expected over a window this size }}Каждая доставка включает заголовок X-Tracio-Signature:
X-Tracio-Signature: t=1710432000,v1=5257a869e7ecebed…t — Unix-таймстамп (в секундах), когда запрос был подписан.v1 — HMAC-SHA256 от "<t>.<rawRequestBody>" в hex-кодировке, с ключом — вашим
секретом вебхука.Таймстамп входит в подписываемое содержимое, что даёт защиту от повторов (replay).
Две вещи, которые важно сделать правильно, иначе проверка сломается на проде:
v1=. В период ротации секрета
заголовок несёт две подписи, и парсер, который оставляет только одну из них,
будет отклонять валидные доставки всё окно ротации.// Express.js exampleimport express from "express"import crypto from "crypto"
const app = express()
// Capture the raw body so the signature can be verified byte-for-byte.app.use( express.json({ verify: (req, _res, buf) => { ;(req as any).rawBody = buf }, }),)
function verifySignature(rawBody: Buffer, header: string, secret: string): boolean { if (!header) return false
const parts = header.split(",").map((p) => p.trim()) const ts = parts.find((p) => p.startsWith("t="))?.slice(2) if (!ts) return false
// Replay protection: reject timestamps more than five minutes old. if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false
// Sign the raw bytes: the "<t>." prefix plus the raw request body. const signed = Buffer.concat([Buffer.from(`${ts}.`, "utf8"), rawBody]) const expected = crypto.createHmac("sha256", secret).update(signed).digest("hex") const exp = Buffer.from(expected, "hex")
// During a rotation window the header carries several v1= — any may match. return parts.some((p) => { if (!p.startsWith("v1=")) return false const got = Buffer.from(p.slice(3), "hex") // Compare lengths BEFORE timingSafeEqual: it throws on differing lengths, // and one junk header would turn the handler into a 500. return got.length === exp.length && crypto.timingSafeEqual(got, exp) })}
app.post("/webhook/tracio", (req, res) => { const header = req.headers["x-tracio-signature"] as string if (!verifySignature((req as any).rawBody, header, WEBHOOK_SECRET)) { return res.status(401).json({ error: "Invalid signature" }) }
const event = req.body console.log(`Visitor: ${event.visitorId}`) console.log(`Bot: ${event.bot?.result}`) // "human" | "bot" | "uncertain"
res.status(200).send("OK")})Доставки по схеме 2 дополнительно несут X-Tracio-Signature-Ed25519
(t=<unix>,kid=<id>,v1=<base64>). HMAC-секрет знают обе стороны, поэтому HMAC
доказывает, что отправитель знает секрет, но не то, что запрос исходит от TRACIO;
асимметричная подпись — доказывает. Публичные ключи публикуются на
https://api.tracio.ai/.well-known/webhook-keys и индексируются по kid.
Тестовые доставки, отправленные из дашборда, подписываются только HMAC: приватный
ключ платформы живёт на узлах доставки и намеренно недоступен дашборду. Верификатор,
жёстко требующий Ed25519, обязан пропускать тестовые доставки (у них суффикс :test
в eventId), иначе тестирование из дашборда будет падать при полностью здоровом
проде. Та же осторожность нужна с проверками формата: у пробной доставки requestId
имеет вид test_<hex>, а visitorId — литерал test_visitor, поэтому обработчик,
который сверяет их с продовыми формами, отвергнет вполне исправную доставку.
После ротации оба секрета остаются валидными 24 часа, а заголовок несёт обе подписи, так что вы можете обновить конфигурацию, не теряя доставок. Действие Revoke now обрывает это окно досрочно. Обновите секрет у себя в течение 24 часов: как только окно закроется, старый секрет перестанет совпадать, а если ваш эндпоинт отвечает на неверную подпись кодом 4xx, пять таких ответов подряд отключат вебхук.
| Заголовок | Описание |
|---|---|
Content-Type | application/json |
X-Tracio-Signature | t=<unix>,v1=<hmac_sha256_hex> — две v1= в окне ротации |
X-Tracio-Signature-Ed25519 | Подпись платформы, t=<unix>,kid=<id>,v1=<base64> (только v2) |
X-Tracio-Event-Id | Идентификатор доставки — ключ идемпотентности |
X-Tracio-Request-Id | Идентификатор визита (v2, только события визита) |
X-Tracio-Event-Type | Тип события (только v2) |
X-Tracio-Delivery-Attempt | Номер попытки, начиная с 1 (только v2) |
X-Tracio-Payload-Version | 2 (только v2) |
X-Tracio-Webhook-Id | Идентификатор вебхука, который сформировал эту доставку |
Доставки могут повторяться, и повтор несёт тот же X-Tracio-Event-Id.
Дедуплицируйте по нему:
app.post("/webhook/tracio", async (req, res) => { const eventId = req.headers["x-tracio-event-id"] as string
const existing = await db.webhooks.findOne({ eventId }) if (existing) return res.status(200).send("Already processed")
await db.webhooks.insert({ eventId, processedAt: new Date() }) await processWebhookEvent(req.body)
res.status(200).send("OK")})Учтите, что eventId уникален на событие, а не на вебхук: если несколько
вебхуков в workspace подписаны на одно и то же событие, каждый получит доставку с
одним и тем же идентификатором. Он строится как <requestId>:<phase>, поэтому три
фазы одного визита дедуплицируются независимо, а не схлопываются в одну.
Отвечайте кодом 2xx — это единственный признак того, что доставка принята.
| Ответ | Что происходит |
|---|---|
2xx | Доставка завершена |
429 Too Many Requests | Не считается сбоем и не расходует попытку; более длинный Retry-After соблюдается |
408, 425, 5xx, разрыв соединения | Повторяется с растущей паузой |
410 Gone | Эндпоинт считается удалённым — вебхук отключается немедленно |
Прочие 4xx | Повторяется, но пять подряд отключают вебхук — 400/401/404 повторами не лечатся |
График повторов: 5 с → 30 с → 2 мин → 10 мин → 30 мин → 2 ч → 6 ч (8 попыток). Первые повторы укладываются в минуту, поэтому короткий рестарт вашего сервиса не стоит вам уведомления. Каждая пауза случайно размывается между половиной и полным значением, чтобы после сбоя повторы не били одним залпом.
Автоотключение требует одновременно и порога (20 сбоев подряд либо 5 ошибок конфигурации), и минимум 15 минут непрерывных сбоев — короткий рестарт не убьёт интеграцию, даже если в очереди накопилось много доставок. Разрыв длиннее 15 минут обнуляет счёт. Дашборд показывает причину, вместе с кодом ответа и текстом ошибки, и кнопку Re-enable, которая сбрасывает счётчики.
| Тариф | Вебхуков на workspace |
|---|---|
| Free | Недоступно |
| Pro | 5 |
| Business | 20 |
| Enterprise | 100 |
Эндпоинты должны быть https и с публичным IP — приватные и loopback-адреса
отклоняются, в том числе на редиректе, — а глубина редиректов не более двух.
Следуем только редиректам 307 и 308. 301, 302 и 303 предписывают
клиенту переключиться на GET и отбросить тело, поэтому доставка за ними не идёт, а
попытка считается неуспешной. Если ваш балансировщик нормализует URL (добавляет www
или завершающий слеш), направьте вебхук сразу на финальный URL.
Вебхуками управляют в дашборде. Дашборд обращается к management API с областью
действия workspace, который обслуживается на хосте приложения (например,
https://app.tracio.ai/api/v1), и перечисленные ниже эндпоинты — ровно то, что он
вызывает. Все эндпоинты вебхуков находятся под /workspaces/{wsId}.
Это не server-to-server-поверхность. Management API принимает только JWT вашей сессии дашборда, который проверяется по вашей роли в workspace (RBAC); секретный ключ
tracio_sk_…здесь отклоняется. Поскольку такая сессия живёт в браузере и истекает вместе с ним, воспринимайте вызовы ниже как описание того, что делает дашборд, а не как интеграцию для автоматизации. Для программного доступа со своего бэкенда используйте Data API, доступный только на чтение.
curl -X POST https://app.tracio.ai/api/v1/workspaces/{wsId}/webhooks \ -H "Authorization: Bearer <session-jwt>" \ -H "Content-Type: application/json" \ -d '{ "url": "https://your-server.com/webhook/tracio", "events": [] }'Секрет подписи генерируется TRACIO и возвращается один раз при создании (и при
ротации) в поле signingSecret. Храните его безопасно — это ключ, которым вы
проверяете подписи.
{ "ok": true, "data": { "id": "b3d4f8a1-2c67-4e9b-8f05-7a1d3c9e2b48", "workspaceEnvironmentId": "b201f2ba-…", "url": "https://your-server.com/webhook/tracio", "events": [], "signingSecret": "f3a9…<hex>", "status": "active", "successRate": 100, "createdAt": "2026-07-30T12:00:00Z" }}При последующих чтениях signingSecret замаскирован (null) — он раскрывается
только при создании и при ротации секрета.
| Метод | Путь | Описание |
|---|---|---|
GET | /workspaces/{wsId}/webhooks | Список вебхуков |
PATCH | /workspaces/{wsId}/webhooks/{webhookId} | Обновить url / events / status |
DELETE | /workspaces/{wsId}/webhooks/{webhookId} | Удалить вебхук |
POST | /workspaces/{wsId}/webhooks/{webhookId}/test | Отправить подписанную тест-доставку |
POST | /workspaces/{wsId}/webhooks/{webhookId}/secret/rotate | Ротировать секрет подписи |
GET | /workspaces/{wsId}/webhooks/{webhookId}/deliveries | Список недавних попыток доставки |
Возвращайте 2xx как можно быстрее и обрабатывайте payload асинхронно, чтобы
избежать таймаутов:
app.post("/webhook/tracio", async (req, res) => { res.status(200).send("OK") processWebhookEvent(req.body).catch(console.error)})
async function processWebhookEvent(event: WebhookPayload) { await db.events.insert(event)
if (event.decision?.riskScore > 50) { await alertFraudTeam(event) }
if (event.bot?.result === "bot") { await blockVisitor(event.visitorId) }}Используйте действие Test на вебхуке (или POST .../webhooks/{webhookId}/test),
чтобы отправить подписанный образец payload на ваш эндпоинт и убедиться, что он
доступен и корректно проверяет подписи.
Для локальной разработки откройте доступ к вашему серверу через туннель, например
ngrok:
ngrok http 3000# Use the generated URL as your webhook endpoint