Skip to main content

How It Works

One snippet on your page collects browser, device, and network signals. ShieldLabs scores them server-side and returns a persistent identity (VisitorID and DeviceID) plus an explainable Risk Score (0 to 100) and the signals behind it. The key idea to hold onto: identification and scoring happen on the server, not in the browser. The snippet only gathers raw signals and posts them. It does not compute a VisitorID, a DeviceID, or a Risk Score. And ShieldLabs only scores. Your own code reads the result and decides allow, challenge, review, or block.
ShieldLabs surfaces signals. Your application owns the decision. There is no in-product rules engine and nothing is ever blocked or challenged on our side. You act on the Risk Score and Details in your own backend.

The flow at a glance

1

The snippet collects signals in the browser

The ES module snippet loaded from cdn.shieldlabs.ai gathers 100+ browser, device, and network signals (canvas, WebGL, audio, fonts, screen, navigator, timezone, and more) plus a local anonymity probe. Nothing is scored here.
2

It POSTs the signals to rest.shieldlabs.ai automatically

When you call checkAnonymous() or checkAuthenticatedUser(), the snippet POSTs the collected payload to rest.shieldlabs.ai on its own. You never call this host directly. The POST is fire-and-forget: it returns quickly and does not carry the score.
3

The server scores asynchronously (about 1 second)

A background pipeline enriches and scores the request: IP reputation, the TCP and TLS network fingerprint, a real-IP (STUN) check, and server-side device derivation. It writes a snapshot, then computes the Risk Score with an explainable Details array.
4

You receive the initial webhook

ShieldLabs POSTs the initial webhook to your configured callback URL, with Phase: "initial", about a second after ingest. This carries the identifiers, score, and details. See Webhooks.
5

A second update webhook may follow after the WebRTC real-IP check

Once the WebRTC real-IP (STUN) discovery completes, the server may recompute the score and POST an update webhook (Phase: "update") carrying only the delta. The update is skipped if more than 10 seconds have passed.
6

You can also read results from the History API

Webhooks are at-most-once with no retries. For guaranteed reads, poll the History API by request_id, device_id, visitor_id, user_hid, or ip. Results also appear in the dashboard.

The diagram

Solid arrows are the always-on path. Dotted arrows are the WebRTC second phase, which may or may not fire depending on the network and timing.

Step by step

1. The snippet collects signals (in the browser, no scoring)

The snippet is an ES module you load from cdn.shieldlabs.ai with your domain’s public key. When you call one of its functions, it collects:
  • Browser and device signals via the fingerprint library: navigator, screen, WebGL, canvas, audio, fonts, timezone, touch support, and related attributes. The server later derives a stable DeviceID from these.
  • Browser and OS classification: a friendly browser name and OS, plus JavaScript-feature flags (WebRTC, WebGL, audio availability, automation flags).
  • A local anonymity probe for anti-detect browsers, automation tooling, and remote-access tooling, run via local network signals.
  • Private-browsing (incognito) detection.
  • Traffic-attribution inputs: the entry URL and referrer, used server-side for Traffic Sources.
The browser also manages three client-side identifiers it ships with the payload: a per-call requestID, a 10-minute sessionID, and a long-lived cookieID. The DeviceID and VisitorID are derived on the server, not in the browser.
The browser computes no Risk Score, no bands, and no VisitorID or DeviceID. It collects raw signals and posts them. The identity and score come back from the server by webhook and API. Correlate them with the requestID.

2. The snippet POSTs to rest.shieldlabs.ai automatically

You do not POST anything yourself. After collecting signals, the snippet sends them to rest.shieldlabs.ai for you. The payload may be plain JSON or obfuscated in transit on top of TLS. Each call carries a client-generated requestID (a UUID) that becomes the join key across the snapshot, the webhook, and the History API. The POST is fire-and-forget: it returns a quick acknowledgment, not the score. The snippet’s optional callback fires with (serverResponse, requestID) so you can capture the requestID and correlate later. The authoritative result (VisitorID, DeviceID, Risk Score) arrives server-side.
mod.checkAnonymous((serverResponse, requestID) => {
  // Keep requestID so your backend can match the webhook or History row.
  // This callback does NOT contain the Risk Score.
  console.log("ShieldLabs requestID:", requestID);
});

3. The server scores asynchronously (about 1 second)

The request enters a background pipeline. In roughly a second it:
  1. Fetches IP reputation (VPN, proxy, datacenter, abuser, privacy relay, timezone).
  2. Pulls the TCP and TLS network fingerprint to corroborate the connection type.
  3. Waits briefly for real-IP (STUN) evidence from the WebRTC path.
  4. Derives the DeviceID server-side from the stable fingerprint components, then the VisitorID from DeviceID + CookieID.
  5. Computes the Risk Score by summing the signal weights, capping at 100, and recording each contributing signal as a Details entry.
  6. Stores a snapshot and pushes the initial webhook.
The VPN “2-of-3” rule is part of this step: VPN is asserted when at least two of three sources agree (IP reputation, TCP and network fingerprint, and a failed real-IP STUN check). That corroboration surfaces VPNs that pure IP blocklists miss.

4. The initial webhook is delivered

About a second after ingest, ShieldLabs POSTs the initial webhook to your callback URL. The envelope wraps the body plus an HMAC signature:
{
  "Data": {
    "RequestID": "550e8400-e29b-41d4-a716-446655440000",
    "SessionID": "7a1b2c3d-e89f-4a1b-9c2d-3e4f5a6b7c8d",
    "CookieID": "3f2e1d0c-b9a8-7f6e-5d4c-3b2a1f0e9d8c",
    "DeviceID": "d8f1c2a4-3b6e-5e7a-9c1d-2f8b4a6e0c11",
    "VisitorID": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "IP": "203.0.113.42",
    "OS": "Mac OS X",
    "Country": "US",
    "UserHID": "anonymous",
    "Score": 40,
    "Details": [
      { "Value": 30, "Description": "Browser VPN/Proxy" },
      { "Value": 10, "Description": "Datacenter IP" }
    ],
    "LastRequestTime": "2026-06-16T12:00:01Z",
    "Phase": "initial"
  },
  "Assing": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
}
The signature field is literally named Assing (it is the HMAC-SHA256 signature). Verify every webhook before trusting it:
import crypto from "node:crypto";

function verify(envelope, secret) {
  const expected = crypto
    .createHmac("sha256", secret)
    .update(JSON.stringify(envelope.Data))
    .digest("hex");
  return crypto.timingSafeEqual(
    Buffer.from(expected),
    Buffer.from(envelope.Assing)
  );
}
Assing is computed over the marshaled Data object only, not the full envelope. Recompute the HMAC over the Data you received and compare in constant time. See Webhooks for the full field reference and signature serialization notes.

5. The update webhook (second phase, delta only)

ShieldLabs uses a two-phase webhook:
PhaseWhenWhat Details carries
"initial"About 1 second after ingest, before WebRTC completesThe full list of signals that fired
"update"After the WebRTC real-IP (STUN) check resolvesOnly the delta: the signals added or removed by the real-IP result
The WebRTC path runs in parallel. When the client’s real IP is discovered through STUN, the server can recompute the score (for example, adding an IP Mismatch signal, or removing “STUN not Checked”). If the recomputed score changes, you get an update webhook whose Details lists only what changed since the initial score.
The update webhook is suppressed if more than 10 seconds have elapsed since the snapshot was created. Treat the initial score as the value you can always rely on, and the update as a same-request refinement when the network allows it. Apply the delta on top of the initial Details keyed by the shared RequestID.

6. Read results from the History API or the dashboard

Webhook delivery is at-most-once with no retries and a roughly 1-second timeout. Do not assume at-least-once delivery. For anything that must not be missed, read from the History API:
curl "https://api.shieldlabs.ai/yourdomain.com:YOUR_SECRET/history/request_id/550e8400-e29b-41d4-a716-446655440000?limit=1"
You can search history by request_id, device_id, visitor_id, user_hid, or ip, newest first. History rows are a superset of the webhook body. The same snapshots also power the dashboard: Visitors, Traffic Sources, Patterns, and the raw Data view.
The History API bills one request per returned row (an empty result still bills one). The identify POST itself bills one request. Webhooks and dashboard views are free. See Billing.

Idempotency on RequestID

Because the same identify call can reach you up to twice (an initial webhook and possibly an update webhook), and because you may also re-read the same row from the History API, your handler must be idempotent on RequestID. A safe pattern:
  1. Use RequestID as the unique key for the identification event in your store.
  2. On the initial webhook, write or upsert the full record under that RequestID.
  3. On the update webhook, find the same RequestID and apply the Details delta to the existing record, then recompute your decision.
  4. If a webhook is missing, fall back to a History API read by request_id. Re-processing the same RequestID must not double-count or double-act.
async function handleWebhook(envelope) {
  if (!verify(envelope, SECRET)) return reject();

  const { RequestID, Phase, Score, Details } = envelope.Data;

  if (Phase === "update") {
    // Delta: merge into the existing record, do not replace it.
    await store.applyDelta(RequestID, { Score, Details });
  } else {
    // initial: upsert is idempotent on RequestID.
    await store.upsert(RequestID, envelope.Data);
  }

  // Your code owns the decision. ShieldLabs only scores.
  const record = await store.get(RequestID);
  decide(record); // allow / challenge / review / block in YOUR app
}
Pair idempotency with the score bands. A legitimate user can score high (corporate proxy, VPN, privacy browser), so decide on Score plus Details plus your action context, never the number alone. See Acting on the Risk Score.

What runs where

StageWhere it runsWhat it produces
Signal collectionThe visitor’s browser (the snippet)A raw signal payload plus client identifiers
Transportrest.shieldlabs.aiAn acknowledged POST with the requestID
Identity derivationShieldLabs serverDeviceID and VisitorID
Real-IP discoverywebrtc.shieldlabs.ai and STUNA real-IP signal that may trigger the update
ScoringShieldLabs serverThe Risk Score and Details
DeliveryInitial and update webhooks plus History APIThe result your code reads
DecisionYour applicationallow / challenge / review / block

Next steps

Install the snippet

Load the ES module from the CDN and run your first check.

Configure webhooks

Set your callback URL and verify the Assing signature.

Understand the Risk Score

The 0 to 100 score, the Clean / Low / Medium / High bands, and the Details array.

Act on the score

Turn Score and Details into allow, challenge, review, or block in your code.