Webhook ส่งเหตุการณ์การระบุตัวตนไปยังเซิร์ฟเวอร์ของคุณแบบเรียลไทม์ ทุกครั้งที่มีการ
ระบุตัวตนผู้เข้าชม TRACIO จะส่งคำขอ HTTP POST ไปยัง URL ของ webhook ที่คุณกำหนดไว้
ตัวเนื้อหาของคำขอคือเพย์โหลดของเหตุการณ์นั้นเอง
นอกจากนี้ webhook ยังเป็นช่องทางเดียวที่ส่งคำตัดสินที่มาภายหลัง — คือกรณีที่ พฤติกรรมของผู้เข้าชมพิสูจน์ว่าเป็นระบบอัตโนมัติหลังจากหน้าเว็บโหลดเสร็จไปแล้ว
ตั้งค่าได้ในแดชบอร์ดที่ Settings → Webhooks โดย webhook ต้องใช้แพ็กเกจ Pro ขึ้นไป
| เหตุการณ์ | เมื่อใด | แพ็กเกจ |
|---|---|---|
identification | ทุกการเข้าชม — เฟส primary, late และ correction | ทั้งหมด |
account_takeover | พฤติกรรมภายใต้บัญชีหนึ่งไม่ตรงกับโปรไฟล์ของเจ้าของอีกต่อไป | Business ขึ้นไป |
attack_detected | บอตพุ่งสูงผิดปกติบนเว็บไซต์ของคุณ | Business ขึ้นไป |
reputation_changed | ชื่อเสียงของบุคคลที่อยู่เบื้องหลังอุปกรณ์เปลี่ยนไป | Business ขึ้นไป |
ชื่อเหตุการณ์ใช้ขีดล่าง ไม่เคยใช้จุด — ไม่มี visitor.created หรือ session.created
ส่วน reputation_changed ต้องอาศัยชั้นข้อมูลระดับบุคคล จึงทำงานเฉพาะกับ workspace ที่
เปิดใช้การเชื่อมโยงตัวตนข้ามอุปกรณ์เท่านั้น
webhook หนึ่งตัวจะสมัครรับเฉพาะประเภทที่ระบุ ส่วนค่าพิเศษ * หมายถึง "ทุกประเภท
รวมถึงประเภทที่จะเพิ่มในภายหลัง" ประเภทที่ไม่รู้จักจะถูกปฏิเสธด้วยรหัส 400 ตอนสร้าง
หรือแก้ไขการสมัครรับ การพิมพ์ผิดจึงไม่ทำให้คุณเหลือ webhook ที่เงียบหายและไม่เคยทำงาน
identificationการเข้าชมหนึ่งครั้งทำให้เกิดการส่งได้สูงสุดสามครั้ง ซึ่งใช้ requestId เดียวกัน
primary — คำตัดสินแรกตอนหน้าเว็บโหลดlate — การเสริมข้อมูลราวเก้าวินาทีถัดมา เมื่อการตรวจสอบที่ช้าเสร็จสิ้นcorrection — การแก้ไขตามพฤติกรรม (ตัวชี้ แป้นพิมพ์ การเลื่อนหน้า)ให้จับคู่ด้วย requestId และแยกแยะด้วย phase เฟสที่มาทีหลังมีน้ำหนักเหนือกว่า
หาก primary บอกว่า human และ correction บอกว่า bot คำตอบที่ถูกต้องคืออย่างหลัง
อย่ายึดลำดับการมาถึง แต่ละเฟสถูกส่งอย่างเป็นอิสระและมีตารางการลองใหม่ของตัวเอง —
ถ้า primary เข้าสู่การลองใหม่ในขณะที่ late สำเร็จตั้งแต่ครั้งแรก คุณจะได้รับมันใน
ลำดับกลับกัน ให้ตัดสินลำดับความสำคัญจากฟิลด์ phase ไม่ใช่จากเวลาที่ได้รับ
สามเฟสนั้นคือเฟสทั้งหมดของเหตุการณ์ identification แต่ยังมีอีกหนึ่งค่าที่ส่งมาถึงคุณ
คือ account_takeover ซึ่งมี phase: "beacon" เพราะการแจ้งเตือนการยึดบัญชีถูกจุดขึ้น
จากบีคอนเชิงพฤติกรรมเสมอ
โปรดสังเกตความไม่สอดคล้องที่เกิดขึ้นตรงนี้ เพราะมันกระทบกับ idempotency การส่ง
identification บนระบบจริงจะมี eventId เท่ากับ <requestId>:<phase> พอดี แต่มีการ
ส่งสองแบบที่แหกสูตรนี้ account_takeover จะเป็น <requestId>:ato — ส่วนต่อท้ายคือ
สตริงตรงตัวว่า ato ไม่ใช่ค่าของฟิลด์ phase ส่วนการส่งทดสอบจากแดชบอร์ดจะเป็น
<requestId>:test ขณะที่ phase ในเนื้อหาแบบสคีมา 2 ยังคงอ่านได้ว่า primary และ
เนื้อหาแบบสคีมา 1 ไม่มีฟิลด์ phase เลย ส่วนต่อท้ายนั้นจึงปรากฏอยู่ในเฮดเดอร์เพียง
ที่เดียว ให้ใช้ eventId เป็นคีย์ idempotency โดยตรง และอย่าประกอบมันขึ้นใหม่จาก
requestId กับ phase เด็ดขาด ให้จับคู่เฉพาะค่าที่คุณรองรับและเพิกเฉยต่อค่าอื่น
แทนที่จะปฏิเสธการส่งนั้นทิ้ง
attack_detected เป็นเหตุการณ์ระดับ workspace ไม่มี requestId ไม่มี visitorId
และไม่มีบล็อก browser, geo, bot หรือ decision — คีย์เหล่านี้ไม่ปรากฏเลย ส่วน
account_takeover เกิดจากการเข้าชมหนึ่งครั้งโดยเฉพาะ และมาพร้อมเนื้อหาการระบุตัวตน
ครบชุดตามแพ็กเกจของคุณ บวกกับบล็อก accountAlert หากคุณแยกวิเคราะห์ทุกเหตุการณ์ใน
handler เดียว ให้ตรวจ event ก่อนแตะฟิลด์ที่เกี่ยวกับการเข้าชม
| เวอร์ชัน | สำหรับใคร | วิธีสลับ |
|---|---|---|
1 | webhook ที่สร้างก่อนจะมี v2 | ยังคงเป็นค่าเริ่มต้นสำหรับ webhook เหล่านั้น |
2 | webhook ที่สร้างใหม่ | ปุ่มสลับบนการ์ด webhook ในแดชบอร์ด |
สคีมา v1 ถูกตรึงไว้ — ไม่มีฟิลด์ใดเปลี่ยนแปลง การผสานรวมที่มีอยู่จึงทำงานต่อได้โดย ไม่ต้องแก้ไข ทุกสิ่งที่เพิ่มใหม่อยู่ใน v2 และนั่นคือสิ่งที่ webhook ใหม่ส่งออกมา
{ "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 | เวอร์ชันสคีมาของเพย์โหลด (2) |
event | string | ประเภทของเหตุการณ์ |
eventId | string | ตัวระบุการส่ง — คีย์สำหรับ idempotency |
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 / การยืนยัน | แคปต์ชา ยืนยันทางอีเมลหรือโทรศัพท์ | 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", // การแจ้งเตือนถูกจุดขึ้นจากบีคอน มีเพียง 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 ให้อัปเดต handler ของคุณเมื่อสลับ
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 timestamp (หน่วยวินาที) ตอนที่คำขอถูกลงลายเซ็นv1 คือ HMAC-SHA256 ของ "<t>.<rawRequestBody>" ในรูปเลขฐานสิบหก โดยใช้ secret
ของ webhook เป็นคีย์timestamp เป็นส่วนหนึ่งของเนื้อหาที่ถูกลงลายเซ็น จึงให้การป้องกันการเล่นซ้ำ
มีสองเรื่องที่ต้องทำให้ถูก ไม่เช่นนั้นการตรวจสอบจะล้มเหลวบนระบบจริง
v1= ตัวใดตัวหนึ่ง ระหว่างการหมุนเวียน secret เฮดเดอร์จะมี
ลายเซ็นสองชุด และตัวแยกวิเคราะห์ที่เก็บไว้เพียงชุดเดียวจะปฏิเสธการส่งที่ถูกต้อง
ตลอดทั้งช่วงการหมุนเวียน// 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>) เพิ่มมาด้วย เนื่องจากทั้งสองฝ่ายรู้ secret ของ HMAC
ลายเซ็น HMAC จึงพิสูจน์ได้เพียงว่าผู้ส่งรู้ secret แต่ไม่ได้พิสูจน์ว่าคำขอมาจาก TRACIO
จริง ลายเซ็นแบบอสมมาตรทำหน้าที่นั้น คีย์สาธารณะเผยแพร่ไว้ที่
https://api.tracio.ai/.well-known/webhook-keys โดยจัดทำดัชนีตาม kid
การส่งทดสอบจากแดชบอร์ดจะลงลายเซ็นด้วย HMAC เท่านั้น — คีย์ส่วนตัวของแพลตฟอร์มอยู่บน
โหนดที่ทำหน้าที่ส่ง และจงใจไม่เปิดให้แดชบอร์ดเข้าถึง ตัวตรวจสอบที่บังคับให้ต้องมี
Ed25519 ต้องปล่อยการส่งทดสอบผ่านไปได้ (การส่งทดสอบจะมีส่วนต่อท้าย :test ใน
eventId) มิฉะนั้นการทดสอบจากแดชบอร์ดจะล้มเหลวทั้งที่ระบบจริงทำงานปกติ ข้อควรระวัง
แบบเดียวกันนี้ใช้กับการตรวจรูปแบบด้วย การส่งทดสอบจะมี requestId ในรูป test_<hex>
และมี visitorId เป็นสตริงตรงตัวว่า test_visitor ดังนั้น handler ที่ตรวจค่าเหล่านี้
เทียบกับรูปแบบของระบบจริงจะปฏิเสธการส่งที่ถูกต้องทุกประการ
หลังการหมุนเวียน secret ทั้งสองตัวจะยังใช้ได้อีก 24 ชั่วโมง และเฮดเดอร์จะมีลายเซ็นทั้ง สองชุด คุณจึงอัปเดตการตั้งค่าได้โดยไม่สูญเสียการส่ง การกด Revoke now จะย่นช่วงเวลา นี้ให้สั้นลง ให้อัปเดต secret ฝั่งคุณภายใน 24 ชั่วโมง เมื่อช่วงเวลาปิดลง secret เดิมจะ ไม่ตรงอีกต่อไป และหากปลายทางของคุณตอบกลับลายเซ็นที่ไม่ถูกต้องด้วยรหัส 4xx การตอบเช่นนั้น ห้าครั้งติดกันจะปิดใช้งาน webhook
| เฮดเดอร์ | คำอธิบาย |
|---|---|
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 | ตัวระบุการส่ง — คีย์สำหรับ idempotency |
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 | ตัวระบุของ webhook ที่ทำให้เกิดการส่งครั้งนี้ |
การส่งอาจถูกลองใหม่ และการลองใหม่จะมี 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 ไม่ซ้ำกันในระดับเหตุการณ์ ไม่ใช่ระดับ webhook หากมี webhook
หลายตัวใน workspace สมัครรับเหตุการณ์เดียวกัน แต่ละตัวจะได้รับการส่งที่มีตัวระบุเดียวกัน
ค่านี้ถูกสร้างขึ้นเป็น <requestId>:<phase> สามเฟสของการเข้าชมเดียวกันจึงถูกขจัดรายการ
ซ้ำแยกจากกัน ไม่ยุบรวมเป็นรายการเดียว
ให้ตอบกลับด้วย 2xx — นั่นคือสัญญาณเดียวที่บอกว่าการส่งได้รับการยอมรับ
| การตอบกลับ | เกิดอะไรขึ้น |
|---|---|
2xx | ส่งสำเร็จ |
429 Too Many Requests | ไม่นับเป็นความล้มเหลวและไม่ใช้โควตาการพยายาม ค่า Retry-After ที่ยาวกว่าจะถูกเคารพ |
408, 425, 5xx, การเชื่อมต่อหลุด | ลองใหม่โดยเว้นระยะนานขึ้นเรื่อย ๆ |
410 Gone | ถือว่าปลายทางถูกลบไปแล้ว — webhook จะถูกปิดใช้งานทันที |
4xx อื่น ๆ | ลองใหม่ แต่ห้าครั้งติดกันจะปิดใช้งาน webhook — 400/401/404 ไม่หายด้วยการลองใหม่ |
ตารางการลองใหม่: 5s → 30s → 2min → 10min → 30min → 2h → 6h (8 ครั้ง) การลองใหม่ช่วงแรก อยู่ภายในหนึ่งนาที การรีสตาร์ตบริการของคุณสั้น ๆ จึงไม่ทำให้คุณเสียการแจ้งเตือน แต่ละ ช่วงรอถูกสุ่มระหว่างครึ่งหนึ่งถึงค่าเต็ม เพื่อไม่ให้การลองใหม่ยิงพร้อมกันเป็นชุดเดียว หลังเหตุขัดข้อง
การปิดใช้งานอัตโนมัติ ต้องเข้าเกณฑ์ (ล้มเหลวติดกัน 20 ครั้ง หรือข้อผิดพลาดด้านการ ตั้งค่า 5 ครั้ง) และ ต้องล้มเหลวต่อเนื่องอย่างน้อย 15 นาที — การรีสตาร์ตสั้น ๆ จึงไม่ สามารถทำลายการผสานรวมได้ แม้จะมีการส่งค้างในคิวจำนวนมาก ช่วงว่างที่ยาวกว่า 15 นาทีจะ เริ่มนับใหม่ แดชบอร์ดจะแสดงเหตุผลพร้อมรหัสการตอบกลับและข้อความแสดงข้อผิดพลาด และมีปุ่ม Re-enable ที่รีเซ็ตตัวนับ
| แพ็กเกจ | จำนวน webhook ต่อ workspace |
|---|---|
| Free | ไม่มีให้ใช้งาน |
| Pro | 5 |
| Business | 20 |
| Enterprise | 100 |
ปลายทางต้องเป็น https ที่มี IP สาธารณะ — ที่อยู่ส่วนตัวและ loopback จะถูกปฏิเสธ
รวมถึงกรณีที่พบหลังการเปลี่ยนเส้นทาง — และเปลี่ยนเส้นทางได้ไม่เกินสองระดับ
ระบบจะตามเฉพาะการเปลี่ยนเส้นทาง 307 และ 308 เท่านั้น 301, 302 และ 303
สั่งให้ไคลเอนต์เปลี่ยนไปใช้ GET และทิ้งเนื้อหาคำขอ การส่งจึงไม่ตามไป และการพยายามครั้ง
นั้นถูกนับว่าล้มเหลว หากโหลดบาลานเซอร์ของคุณทำให้ URL เป็นรูปแบบมาตรฐาน (เติม www
หรือเครื่องหมายทับท้าย) ให้ชี้ webhook ไปที่ URL สุดท้ายโดยตรง
การจัดการ webhook ทำผ่านแดชบอร์ด โดยแดชบอร์ดขับเคลื่อน API การจัดการในขอบเขต
workspace ซึ่งให้บริการอยู่บนโฮสต์ของแอปพลิเคชัน (เช่น https://app.tracio.ai/api/v1)
และปลายทางที่แสดงด้านล่างคือสิ่งที่แดชบอร์ดเรียกใช้ ปลายทางของ webhook ทั้งหมดอยู่ภายใต้
/workspaces/{wsId}
นี่ไม่ใช่ช่องทางแบบเซิร์ฟเวอร์ถึงเซิร์ฟเวอร์ 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": [] }'secret สำหรับลงลายเซ็นสร้างขึ้นโดย 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) — จะเปิดเผยเฉพาะตอนสร้างและ
ตอนหมุนเวียน secret เท่านั้น
| เมธอด | เส้นทาง | คำอธิบาย |
|---|---|---|
GET | /workspaces/{wsId}/webhooks | แสดงรายการ webhook |
PATCH | /workspaces/{wsId}/webhooks/{webhookId} | อัปเดต url / events / status |
DELETE | /workspaces/{wsId}/webhooks/{webhookId} | ลบ webhook |
POST | /workspaces/{wsId}/webhooks/{webhookId}/test | ส่งการส่งทดสอบที่มีลายเซ็น |
POST | /workspaces/{wsId}/webhooks/{webhookId}/secret/rotate | หมุนเวียน secret สำหรับลงลายเซ็น |
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) }}ใช้การกระทำ Test บน webhook (หรือ POST .../webhooks/{webhookId}/test) เพื่อส่ง
เพย์โหลดตัวอย่างที่มีลายเซ็นไปยังปลายทางของคุณ แล้วยืนยันว่าปลายทางเข้าถึงได้และ
ตรวจสอบลายเซ็นได้อย่างถูกต้อง
สำหรับการพัฒนาในเครื่อง ให้เปิดเซิร์ฟเวอร์ของคุณออกสู่ภายนอกด้วยทันเนล เช่น ngrok
ngrok http 3000# Use the generated URL as your webhook endpoint