Skip to main content

Webhooks

When the server finishes scoring an identification, it pushes the result to your configured callback URL as a POST. This is the canonical, low-latency way to receive the Risk Score and the signals behind it. This page is the reference: the envelope, the field schema, the two phases, and how to verify the signature. For a step-by-step walkthrough of configuring and testing a callback, see the webhook setup guide.
You do not poll for webhooks. The server sends them to the callback URL you set per domain (in the dashboard or via the Management API). Delivery is at-most-once with no retries, so pair it with a History API read when you cannot afford to miss a result.

The envelope

Every webhook is a JSON object with exactly two top-level keys: Data (the WebhookBody) and Assing (the signature).
{
  "Data": { "...": "WebhookBody (see below)" },
  "Assing": "9f1c2b3a4d5e6f70819a2b3c4d5e6f7081920a1b2c3d4e5f60718293a4b5c6d7"
}
Assing is the literal field name in the JSON (a misspelling of “Assign”). Do not rename it when you parse the payload. It is the HMAC-SHA256 signature of the Data object, keyed with your Secret Key. A cleaner field name is planned, but Assing is the current reality. Verify it on every webhook before trusting the contents.
The request itself:
POST https://your-server.com/webhook
Content-Type: application/json

{ "Data": { ... }, "Assing": "..." }
There is no custom signature header. The signature travels inside the body as Assing. Your endpoint must be reachable over HTTPS and return quickly (see delivery guarantees).

WebhookBody

The Data object. Field names are PascalCase.
RequestID
string (UUID)
The client-generated UUID for this identify call. This is the join key across the snapshot, the webhook, and the History API. Both the initial and update phases carry the same RequestID. Make your handler idempotent on this value.
SessionID
string (UUID)
Per-visit identifier from the browser’s sessionStorage (a 10-minute visit window). Resets each browser session or tab.
First-party cookie / localStorage identifier minted client-side. Lost when the user clears cookies or storage.
DeviceID
string (UUID)
Server-derived identity, a UUID5 of dozens of stable browser-fingerprint components. Durable: survives cleared cookies, incognito, and IP rotation within the same browser, because it is derived from the browser environment rather than stored. Browser-bound: a different browser produces a different DeviceID (no cross-browser recognition today). See Identifiers.
VisitorID
string (UUID)
Server-derived UUID5(DeviceID + CookieID). Changes when the cookie is cleared (the CookieID regenerates, so a new VisitorID is produced). Multiple VisitorIDs can map to one DeviceID. The durability claim belongs to DeviceID, not VisitorID.
IP
string
The client IP address resolved for this call.
OS
string
The operating system the browser reports (for example Windows, Mac OS X, iOS).
Country
string
Two-letter ISO country code derived from the IP (for example US).
UserHID
string
The customer’s own account id, passed in through the snippet via checkAuthenticatedUser. This must be a hashed or pseudonymous value, never a raw email or user id. Absent on anonymous calls.
Score
integer
The Risk Score, an integer from 0 to 100 (hard-capped at 100). Higher means more anonymous or more likely masked, spoofed, or abusive. The bands are Clean (0-9), Low (10-29), Medium (30-59), and High (60-100). ShieldLabs scores; your application decides allow, challenge, review, or block.
Details
array of objects
The explainable breakdown: every signal that contributed to the score, as { "Value": <int>, "Description": "<signal>" }. Value is the points the signal added; Description is the signal name. On the initial phase this is the full list. On the update phase it carries only the delta (see Phases).
LastRequestTime
string (ISO 8601)
Timestamp of the request, in RFC 3339 / ISO 8601 form (for example 2026-06-16T10:00:00Z).
Phase
string
Which delivery this is: "initial" (the first score, sent about a second after ingest, before the WebRTC real-IP result) or "update" (recomputed after the real-IP check). See Phases.

Phases

A single identification can produce up to two webhooks, both joined by the same RequestID.
PhaseTimingDetails content
initial~1s after ingest, before the WebRTC real-IP resultThe full list of signals that fired
updateAfter the real-IP (STUN) check resolves (optional)Only the delta signals, not the full list
The update webhook is optional and is suppressed if more than about 10 seconds elapsed since the snapshot was created. Treat the initial score as actionable on its own, and apply the update delta when it arrives to refine your decision. Full background on why scoring is split this way is in the Identification Flow.

Example: initial webhook

The first delivery carries the full Details list.
{
  "Data": {
    "RequestID":       "550e8400-e29b-41d4-a716-446655440000",
    "SessionID":       "7a1b2c3d-4e5f-6789-abcd-ef0123456789",
    "CookieID":        "3f2e1d0c-9b8a-7654-3210-fedcba987654",
    "DeviceID":        "6ba7b810-9dad-11d1-80b4-00c04fd430c9",
    "VisitorID":       "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "IP":              "203.0.113.10",
    "OS":              "Windows",
    "Country":         "US",
    "UserHID":         "e3b0c44298fc1c149afbf4c8996fb924",
    "Score":           25,
    "Details": [
      { "Value": 15, "Description": "VPN" },
      { "Value": 10, "Description": "Datacenter IP" }
    ],
    "LastRequestTime": "2026-06-16T10:00:00Z",
    "Phase":           "initial"
  },
  "Assing": "9f1c2b3a4d5e6f70819a2b3c4d5e6f7081920a1b2c3d4e5f60718293a4b5c6d7"
}

Example: update webhook (delta)

When the real-IP check completes, an update may follow with only the new signals in Details. Here the real-IP comparison adds an IP-mismatch signal, raising the score from 25 to 55.
{
  "Data": {
    "RequestID":       "550e8400-e29b-41d4-a716-446655440000",
    "SessionID":       "7a1b2c3d-4e5f-6789-abcd-ef0123456789",
    "CookieID":        "3f2e1d0c-9b8a-7654-3210-fedcba987654",
    "DeviceID":        "6ba7b810-9dad-11d1-80b4-00c04fd430c9",
    "VisitorID":       "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "IP":              "203.0.113.10",
    "OS":              "Windows",
    "Country":         "US",
    "UserHID":         "e3b0c44298fc1c149afbf4c8996fb924",
    "Score":           55,
    "Details": [
      { "Value": 30, "Description": "IP Mismatch" }
    ],
    "LastRequestTime": "2026-06-16T10:00:01Z",
    "Phase":           "update"
  },
  "Assing": "1a2b3c4d5e6f70819a2b3c4d5e6f7081920a1b2c3d4e5f60718293a4b5c6d7e8"
}
On the update phase, Score is the full recomputed value (0-100), but Details lists only the signals added since the initial delivery. To keep a complete picture, merge the update deltas into the Details you stored from the initial webhook, keyed by RequestID.

Verification

Verify the signature on every webhook before acting on it. The recipe:
Assing == hex( HMAC-SHA256( key = your Secret Key, msg = JSON(Data) ) )
The HMAC is computed over the marshaled Data object (the WebhookBody), not over the full {Data, Assing} envelope. To verify, take the received Data, re-serialize it (or HMAC the raw Data bytes as received), compute HMAC-SHA256 with your Secret Key, hex-encode it, and constant-time compare against Assing.
The Secret Key is backend-only. Never put it in the browser, in client-side code, or in the snippet. If a request to your callback URL has a missing or mismatched Assing, reject it.
import express from "express";
import crypto from "crypto";

const SECRET = process.env.SHIELDLABS_SECRET; // backend only

const app = express();

// Capture the raw body so the HMAC matches the exact bytes received.
app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }));

function verify(rawBody, assing) {
  const { Data } = JSON.parse(rawBody);
  const expected = crypto
    .createHmac("sha256", SECRET)
    .update(JSON.stringify(Data))
    .digest("hex");
  // Constant-time compare.
  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(assing, "hex");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

app.post("/webhook", (req, res) => {
  const { Data, Assing } = req.body;
  if (!verify(req.rawBody, Assing)) {
    return res.status(401).send("bad signature");
  }

  // Idempotent on RequestID: ignore a phase you have already applied.
  upsertScore(Data.RequestID, Data.Phase, Data.Score, Data.Details);

  // Respond fast. The sender times out in ~1s and does not retry.
  res.sendStatus(200);
});

app.listen(3000);
HMAC over re-serialized JSON depends on byte-for-byte field order matching the sender. The most robust approach is to HMAC the raw Data bytes exactly as received (capture them before any re-encoding), as the Node and Go samples do. If you re-serialize, keep the same key order and no extra whitespace.

Delivery guarantees

Webhook delivery is intentionally lightweight. Design your handler around these properties.

At-most-once

Each phase is sent in a single fire-and-forget attempt. There is no retry, no backoff, and no dead-letter queue. A dropped network connection means that webhook is gone.

~1s timeout

The sender waits about one second for your endpoint, then moves on. Acknowledge with a fast 2xx and do heavy work asynchronously, off the request path.

Idempotent on RequestID

The same RequestID can arrive twice (an initial and an update). Key your writes on (RequestID, Phase) so a repeat is a no-op.

Read fallback

For anything you cannot afford to miss, read the result from the History API by request_id. That is the guaranteed, pull-based path.
Do not assume at-least-once delivery. If a webhook never arrives (your server was down, the connection dropped, or the request exceeded the timeout), the score still exists. Fetch it with GET /{domain}:{secret}/history/request_id/{requestID} on the Management API.
A reliable pattern:
1

Persist the RequestID early

Capture requestID from the snippet callback and store it with the user action you are protecting.
2

Apply the initial webhook

On the initial phase, record Score and Details against that RequestID. Verify the signature first. Treat the write as idempotent.
3

Merge the update delta

If an update phase arrives, merge its delta Details and use the recomputed Score.
4

Fall back to a read

If no webhook arrives within your expected window, call the History API by request_id to pull the stored snapshot.

Acting on the payload

The webhook gives you the Score and the Details behind it. ShieldLabs surfaces signals; your application owns the decision. A legitimate user can score high (a corporate proxy, a VPN, a privacy browser), so decide on Score + Details + the action context, never the number alone.
BandRangeRecommended customer action (a guide, not a rule)
Clean0-9Pass through, no friction
Low10-29Allow, worth logging
Medium30-59Step-up challenge, second look, or review
High60-100Block, review, or require verification
For threshold guidance and worked examples, see Acting on the Risk Score.

Next steps

Set up a webhook

The tutorial: configure your callback URL, test it, and go live.

Data Models

The full WebhookBody and Snapshot schemas in one place.

Management API

History search by RequestID, the snapshot superset, profile, and callback config.

Risk Score

How the 0-100 explainable score and the Clean / Low / Medium / High bands work.