Webhooks deliver identification events to your server in real time. Every time a
visitor is identified, TRACIO sends an HTTP POST request to your configured
webhook URL. The request body is the event payload — there is no wrapping
envelope.
The body is a flat, camelCase JSON document. It is a curated public subset of
the internal event — no fingerprint hashes or internal detection markers.
{ "requestId": "1710432000_abc123def", "phase": "primary", "visitorId": "X7fh2Hg9LkMn3pQr", "linkedId": "user_12345", "tag": "login", "timestamp": "2024-03-12T16:00:00Z", "url": "https://your-app.com/login", "ip": "94.142.239.124", "userAgent": "Mozilla/5.0 …", "browser": { "name": "Chrome", "version": "120.0" }, "os": { "name": "macOS", "version": "14.3" }, "device": "desktop", "geo": { "country": "CZ", "city": "Prague", "lat": 50.05, "lon": 14.4, "timezone": "Europe/Prague", "isp": "Example ISP" }, "network": { "vpn": false, "proxy": false, "tor": false, "datacenter": false, "connectionType": "wifi" }, "bot": { "result": "human", "type": "", "score": 0.02 }, "identification": { "confidence": 0.95, "incognito": false, "visitType": "returning" }, "decision": { "action": "allow", "riskScore": 4, "suspectScore": 0.08 }}| Field | Type | Description |
|---|---|---|
requestId | string | Unique event identifier (also an idempotency key) |
phase | string | primary or late — see below |
visitorId | string | Stable visitor identifier |
linkedId | string | Linked identifier supplied by the client |
tag | string | Custom tag supplied by the client |
timestamp | string | Event time (RFC 3339) |
url | string | Page URL where the event was captured |
ip | string | Client IP address |
userAgent | string | Raw client user-agent string |
browser.name / .version | string | Detected browser |
os.name / .version | string | Detected operating system |
device | string | Device class (e.g. desktop, mobile) |
geo | object | IP geolocation: country, city, lat, lon, timezone, isp |
network | object | vpn, proxy, tor, datacenter (booleans) and connectionType |
bot.result | string | human, bot, or uncertain |
bot.type | string | Free-form automation label when a bot is detected |
bot.score | number | Bot probability score |
identification.confidence | number | Model confidence (0.0–1.0) |
identification.incognito | boolean | Private/incognito browsing context |
identification.visitType | string | Visit classification |
decision.action | string | Recommended action |
decision.riskScore | number | Aggregate risk score (0–100) |
decision.suspectScore | number | Fine-grained suspicion score |
phase distinguishes two deliveries that share the same requestId:
primary — sent immediately when the visitor is identified.late — a follow-up enrichment event (a refined bot verdict and
behavioral signals captured slightly later).Correlate the two by requestId and distinguish them by phase.
Every delivery includes an X-Tracio-Signature header:
X-Tracio-Signature: t=1710432000,v1=5257a869e7ecebed...t is the Unix timestamp (seconds) when the request was signed.v1 is the hex-encoded HMAC-SHA256 of "<t>.<rawRequestBody>", keyed with
your webhook secret.The timestamp is part of the signed content, which gives replay protection —
verify against the raw request body Buffer (do not use the parsed/
re-serialized JSON, or the signature will not match). Build the signed content
as bytes: the "<t>." prefix concatenated with the raw body buffer, then HMAC
that.
// Express.js exampleimport express from "express"import crypto from "crypto"
const app = express()
// Capture the raw body so we can verify the signature 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 { // Parse "t=...,v1=..." const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=") as [string, string])) const t = parts["t"] const v1 = parts["v1"] if (!t || !v1) return false
// Sign the raw bytes: "<t>." prefix + the raw request body buffer. const signed = Buffer.concat([Buffer.from(`${t}.`, "utf8"), rawBody]) const expected = crypto.createHmac("sha256", secret).update(signed).digest("hex")
const a = Buffer.from(v1, "hex") const b = Buffer.from(expected, "hex") return a.length === b.length && crypto.timingSafeEqual(a, b)}
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")})You may also want to reject deliveries whose t is too far from the current
time (for example, more than five minutes of skew) as an extra replay guard.
| Header | Description |
|---|---|
Content-Type | application/json |
X-Tracio-Signature | t=<unix>,v1=<hmac_sha256_hex> over "<t>.<rawBody>" |
X-Tracio-Event-Id | The event's requestId — use it as an idempotency key |
X-Tracio-Webhook-Id | Identifier of the webhook that produced this delivery |
Webhooks are managed through the workspace-scoped API (the same API the dashboard uses — see the Server API Reference for the auth model). You can also manage them visually in the dashboard.
curl -X POST https://app.tracio.ai/api/v1/subscriptions/:id/webhooks \ -H "Authorization: Bearer <clerk-session-jwt>" \ -H "Content-Type: application/json" \ -d '{ "url": "https://your-server.com/webhook/tracio", "events": [] }'The secret is generated by TRACIO and returned once on creation. Store it securely — it is the key you use to verify signatures.
{ "ok": true, "data": { "id": "wh_abc123", "workspaceId": "b201f2ba-…", "url": "https://your-server.com/webhook/tracio", "events": [], "secret": "f3a9…<64 hex chars>", "enabled": true, "failedCount": 0, "createdAt": "2024-03-12T16:00:00Z" }}| Method | Path | Description |
|---|---|---|
GET | /subscriptions/:id/webhooks | List webhooks |
GET | /subscriptions/:id/webhooks/:webhookId | Fetch a webhook |
PUT | /subscriptions/:id/webhooks/:webhookId | Update url / events / enabled |
DELETE | /subscriptions/:id/webhooks/:webhookId | Delete a webhook |
POST | /subscriptions/:id/webhooks/:webhookId/test | Send a signed test delivery |
GET | /subscriptions/:id/webhooks/:webhookId/deliveries | List recent delivery attempts |
Deliveries may be retried, so your handler should be idempotent. Deduplicate on
the X-Tracio-Event-Id header (the requestId):
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")})Return a 2xx response as fast as possible and process the payload
asynchronously to avoid timeouts. Non-2xx responses (and connection errors)
are retried; repeated failures can auto-disable the webhook.
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) }}Use the Test action on a webhook (or POST .../webhooks/:webhookId/test)
to send a signed sample payload to your endpoint and confirm it is reachable and
verifying signatures correctly.
For local development, expose your server with a tunnel such as ngrok:
ngrok http 3000# Use the generated URL as your webhook endpoint