웹훅은 식별 이벤트를 실시간으로 서버에 전달합니다. 방문자가 식별될 때마다 TRACIO는
구성된 웹훅 URL로 HTTP POST 요청을 보냅니다. 요청 본문이 곧 이벤트
페이로드입니다.
또한 지연 판정 — 페이지가 이미 로드된 뒤 방문자의 행동을 통해 자동화임이 드러난 경우 — 을 전달하는 유일한 채널이기도 합니다.
대시보드의 설정 → Webhooks에서 설정합니다. 웹훅은 Pro 요금제 이상에서 사용할 수 있습니다.
| 이벤트 | 시점 | 요금제 |
|---|---|---|
identification | 모든 방문에서 — primary, late, correction 단계 | 전체 |
account_takeover | 계정에서의 행동이 더 이상 소유자 프로필과 일치하지 않을 때 | Business 이상 |
attack_detected | 사이트에서 봇이 급증할 때 | Business 이상 |
reputation_changed | 기기 뒤에 있는 사람의 평판이 바뀌었을 때 | Business 이상 |
이벤트 이름은 언제나 밑줄을 쓰며 점은 쓰지 않습니다 — visitor.created나
session.created 같은 것은 없습니다. reputation_changed는 person 레이어를
필요로 하므로, 기기 간 신원 결합이 활성화된 워크스페이스에서만 발생합니다.
웹훅은 특정 유형을 구독합니다. 별도의 값 *는 "나중에 추가되는 것을 포함한 모든
유형"을 뜻합니다. 알 수 없는 유형은 구독을 만들거나 수정할 때 400으로 거부되므로,
오타 때문에 아무것도 발화하지 않는 웹훅이 조용히 남는 일은 없습니다.
identification 이벤트의 단계한 번의 방문은 **동일한 requestId**를 공유하는 최대 3건의 전달을 만들어냅니다:
primary — 페이지 로드 시점의 최초 판정.late — 약 9초 뒤, 느린 검사들이 도착한 시점의 보강.correction — 행동(포인터, 키보드, 스크롤)에 기반한 정정.requestId로 상관 짓고 phase로 구분하세요. 나중 단계가 우선합니다: primary가
human이라고 했고 correction이 bot이라고 한다면, 정답은 두 번째입니다.
도착 순서에 의존하지 마세요. 각 단계는 독립적으로, 각자의 재시도 일정에 따라
전달됩니다 — primary가 재시도에 들어간 사이 late가 첫 시도에 성공하면 순서가
뒤바뀌어 도착합니다. 우선순위는 수신 시각이 아니라 phase 필드로 판단하세요.
identification 이벤트의 단계는 이 세 가지뿐입니다. 다만 다른 값 하나가 더
도착합니다: account_takeover는 phase: "beacon"을 담고 옵니다. 계정 탈취 경보는
언제나 행동 비컨에서만 올라오기 때문입니다.
여기서 생기는 불일치에 유의하세요. 멱등성에 영향을 주기 때문입니다. 프로덕션의
identification 전달은 eventId가 정확히 <requestId>:<phase>이지만, 두 가지
전달은 이 공식을 벗어납니다. account_takeover는 <requestId>:ato이며, 접미사는
리터럴 ato이지 phase 필드의 값이 아닙니다. 대시보드에서 보낸 테스트 전달은
<requestId>:test인데, 그 스키마 2 본문의 phase는 여전히 primary로 나옵니다 —
게다가 스키마 1 본문에는 phase 필드 자체가 없으므로, 그 접미사가 보이는 곳은
헤더뿐입니다. eventId를 그대로 멱등성 키로 쓰고, requestId와 phase로 다시
조립하는 일은 결코 하지 마세요. 직접 처리하는 값에 대해서만 매칭하고, 나머지는
전달을 거부하는 대신 무시하세요.
attack_detected는 워크스페이스 수준 이벤트입니다: requestId도 visitorId도 없고
browser, geo, bot, decision 블록도 없습니다 — 해당 키들이 아예 존재하지
않습니다. account_takeover는 특정 방문에서 생성되며, 요금제에 해당하는 전체 식별
본문에 accountAlert 블록이 더해진 형태로 전달됩니다. 모든 이벤트를 하나의 핸들러에서
파싱한다면, 방문 관련 필드를 건드리기 전에 event를 확인하세요.
| 버전 | 대상 | 전환 방법 |
|---|---|---|
1 | v2가 생기기 전에 만들어진 웹훅 | 해당 웹훅에서는 계속 기본값입니다 |
2 | 새로 만드는 웹훅 | 대시보드 웹훅 카드의 토글 |
스키마 v1은 동결되었습니다 — 필드가 전혀 바뀌지 않으므로 기존 연동은 수정 없이 계속 동작합니다. 새로운 것은 모두 v2에 들어가며, 새 웹훅은 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}0과 빈 값은 생략됩니다. 값이 0인 문자열·숫자 필드(예: 사람에 대한 bot.type)는
JSON에 존재하지 않습니다 — 스키마에서 필수로 두지 말고 중첩 블록은 방어적으로
읽으세요.
bot.score와 decision.riskScore는 소수점 첫째 자리까지 표기되는 0..100
스케일의 소수입니다 — 같은 방문에 대해 대시보드가 보여주는 바로 그 숫자입니다.
(동결된 v1 스키마에서는 단위가 다릅니다: 각각 0..1 비율과 0..255입니다.)
bot.type은 인식된 봇의 이름이거나 계열 레이블입니다. 어휘는
봇 유형을 참고하세요 — 내부 검사 이름은 어떤
요금제에서도 노출되지 않습니다.
| 필드 | 타입 | 설명 |
|---|---|---|
version | number | 페이로드 스키마 버전(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" } }, // 리눅스 머신에서 실제로 측정된 데스크톱 환경("Mint 22+", "Ubuntu", // "GNOME", "KDE"). User-Agent는 배포판을 표현할 수 없음. 판별되지 않으면 // 존재하지 않으며, 대부분의 리눅스 방문과 리눅스가 아닌 모든 방문이 여기에 // 해당함. "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: // 브라우저 자체가 선호하는 언어와 시간대로, IP 주소에서 도출되는 // geo.timezone과는 다르며, 둘 사이의 불일치는 위치 위조의 흔한 신호임. // clientHints: User-Agent Client Hints — CPU 아키텍처와 비트 수, 기기 // 모델(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 / 확인 | 캡차, 이메일 또는 전화 확인 | 단계적 2FA, 재인증 | 활동이 확인될 때까지 의심으로 표시 |
review | 처리하되 검토 대기열에 넣으세요 | 제한을 두고 생성하세요 | 들여보내되 알림을 올리세요 | 검토가 끝날 때까지 지급을 보류하세요 |
deny | 거래를 처리하지 마세요 | 계정 생성을 거부하세요 | 들여보내지 마세요 | 전환을 인정하지 마세요 |
version은 규칙 집합의 버전이며 로직이 개선될 때마다 올라갑니다. 가이던스는
가산적입니다: 새로운 시나리오는 새 키로 도착하며 계약을 깨지 않습니다. 나중 단계가
이기지만 부분 권고는 예외입니다: 불완전한 입력으로 계산된 전달에는
"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", // 경보는 비컨에서 올라온다. "ato"라고 말하는 것은 eventId뿐 "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은 웹훅 시크릿을 키로 사용한 "<t>.<rawRequestBody>"의 16진 인코딩된
HMAC-SHA256입니다.타임스탬프는 서명 대상 콘텐츠의 일부이며, 이것이 재전송 공격 방지를 제공합니다.
제대로 해야 할 두 가지가 있습니다. 그렇지 않으면 프로덕션에서 검증이 실패합니다:
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를 필수로
요구하는 검증기는 테스트 전달을 통과시켜야 합니다(테스트 전달은 eventId에 :test
접미사가 붙습니다). 그렇지 않으면 프로덕션은 정상인데 대시보드에서의 테스트만
실패합니다. 같은 주의가 형식 검사에도 적용됩니다: 테스트 전달은 requestId가
test_<hex> 형태이고 visitorId는 리터럴 test_visitor이므로, 이것들을 프로덕션
형식에 비추어 검증하는 핸들러는 그 밖에는 전혀 문제가 없는 전달을 거부하게 됩니다.
교체 후 두 시크릿이 24시간 동안 유효하게 유지되고 헤더에 두 서명이 모두 담기므로, 전달을 잃지 않고 설정을 갱신할 수 있습니다. 지금 폐기 작업은 이 기간을 앞당겨 끝냅니다. 24시간 안에 사용하는 쪽의 시크릿을 갱신하세요: 기간이 닫히면 이전 시크릿은 더 이상 일치하지 않으며, 잘못된 서명에 대해 엔드포인트가 4xx로 응답한다면 그런 응답이 연속 5회 발생할 때 웹훅이 비활성화됩니다.
| 헤더 | 설명 |
|---|---|
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는 웹훅별이 아니라 이벤트별로 고유하다는 점에 유의하세요: 워크스페이스의
여러 웹훅이 같은 이벤트를 구독하고 있다면, 각각 동일한 식별자를 가진 전달을 받습니다.
이 값은 <requestId>:<phase>로 만들어지므로, 한 방문의 세 단계는 하나로 합쳐지지
않고 각각 독립적으로 중복 제거됩니다.
2xx로 응답하세요 — 전달이 수락되었음을 알리는 유일한 신호입니다.
| 응답 | 동작 |
|---|---|
2xx | 전달 완료 |
429 Too Many Requests | 실패로 세지 않고 시도를 소모하지도 않습니다. 더 긴 Retry-After는 존중됩니다 |
408, 425, 5xx, 연결 끊김 | 대기 시간을 늘려 가며 재시도합니다 |
410 Gone | 엔드포인트가 제거된 것으로 간주되어 웹훅이 즉시 비활성화됩니다 |
그 밖의 4xx | 재시도하지만 연속 5회면 웹훅이 비활성화됩니다 — 400/401/404는 재시도로 낫지 않습니다 |
재시도 일정: 5s → 30s → 2min → 10min → 30min → 2h → 6h(8회 시도). 초기 재시도는 1분 안에 들어가므로 서비스를 잠깐 재시작해도 알림을 잃지 않습니다. 각 대기 시간은 절반에서 전체 값 사이로 무작위화되어, 장애 복구 후 재시도가 한꺼번에 몰리지 않습니다.
자동 비활성화에는 임계값(연속 20회 실패 또는 5회 구성 오류)과 최소 15분간 연속된 실패가 모두 필요합니다 — 많은 전달이 큐에 쌓였더라도 잠깐의 재시작으로 연동이 끊기지는 않습니다. 15분보다 긴 공백이 생기면 카운트가 처음부터 다시 시작됩니다. 대시보드에는 응답 코드와 오류 텍스트가 담긴 사유가 표시되며, 카운터를 초기화하는 다시 활성화 버튼이 있습니다.
| 요금제 | 워크스페이스당 웹훅 수 |
|---|---|
| Free | 제공되지 않음 |
| Pro | 5 |
| Business | 20 |
| Enterprise | 100 |
엔드포인트는 공인 IP를 가진 https여야 하며 — 사설 주소와 루프백 주소는 리다이렉트
경로에서도 거부됩니다 — 리다이렉트는 2단계까지만 허용됩니다.
따라가는 리다이렉트는 307과 308뿐입니다. 301, 302, 303은 클라이언트에
GET으로 전환하고 본문을 버리라고 지시하므로 전달은 이를 따라가지 않으며, 그 시도는
실패로 계산됩니다. 로드 밸런서가 URL을 정규화한다면(www나 끝 슬래시 추가), 웹훅을
최종 URL로 바로 지정하세요.
웹훅은 대시보드에서 관리합니다. 대시보드는 워크스페이스 범위의 관리 API를
구동하며, 이 API는 애플리케이션 호스트(예: https://app.tracio.ai/api/v1)에서
제공됩니다. 아래 엔드포인트가 바로 대시보드가 호출하는 것들입니다. 모든 웹훅
엔드포인트는 /workspaces/{wsId} 아래에 있습니다.
이것은 서버 대 서버 연동면이 아닙니다. 관리 API는 워크스페이스 역할(RBAC)에 비추어 검사되는 대시보드 세션 JWT만 받아들이며,
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를 반환하고 페이로드는 비동기로 처리하세요:
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) }}웹훅의 테스트 작업(또는 POST .../webhooks/{webhookId}/test)을 사용하면 서명된
샘플 페이로드를 엔드포인트로 보내, 도달 가능한지와 서명을 올바르게 검증하는지 확인할
수 있습니다.
로컬 개발에서는 ngrok 같은 터널로 서버를 노출하세요:
ngrok http 3000# Use the generated URL as your webhook endpoint