# Identification Flow Source: https://docs.shieldlabs.ai/api/identification-flow Learn how a visit becomes a scored result and how the webhook and History API fit together. # Identification Flow A ShieldLabs identification is **asynchronous by design**. The browser snippet collects signals and posts them. The server computes the Risk Score (0–100) a moment later and delivers it two ways: a **webhook** push and a **History API** read. There is **no synchronous score endpoint**: the POST that ingests signals returns an acknowledgment, not the score. The `RequestID` is the join key that ties the three steps together. You normally do not call `rest.shieldlabs.ai` yourself. The [JS snippet](/setup/snippet) posts to it automatically. Your server-side work is to receive the [webhook](/api/webhooks) and, when you need a guaranteed read, query the [History API](/api/server-api). ## The three steps The snippet generates a per-call `RequestID` (a client UUID) and posts the collected fingerprint to `rest.shieldlabs.ai/snapshot/{requestID}?publicKey=…`. The response is an **acknowledgment** (the client IP as a JSON string), not the score. The server enriches the signals (IP intelligence and network analysis) and computes the [Risk Score](/features/risk-scoring) with an explainable `Details` array. The server pushes the score to your callback URL as an **initial** [webhook](/api/webhooks), and may push an **update** webhook after a follow-up network check completes. You can also read the result any time from the [History API](/api/server-api) by `request_id`. ```mermaid theme={null} sequenceDiagram participant B as Browser (snippet) participant R as rest.shieldlabs.ai participant S as ShieldLabs scoring participant Y as Your server B->>R: POST /snapshot/{requestID}?publicKey=... R-->>B: 200 OK "203.0.113.10" (ack, not the score) Note over S: enrich + score (~1s) S->>Y: webhook Phase: "initial" (Score + Details) Note over S: follow-up network check completes S->>Y: webhook Phase: "update" (delta, optional) Y->>R: GET /history/request_id/{requestID} (guaranteed read) ``` ## Step 1: The snapshot POST (acknowledgment, not a score) The snippet calls this for you. It is documented here so you understand the contract and the `RequestID` lifecycle. ```http theme={null} POST https://rest.shieldlabs.ai/snapshot/{requestID}?publicKey=YOUR_PUBLIC_KEY Content-Type: application/json { ...collected fingerprint (plain JSON or AES-256-GCM encrypted)... } ``` * `{requestID}` is a **client-generated UUID**, unique per identify call. It is the join key across snapshot → webhook → history. * `publicKey` is your per-domain [Public Key](/setup/keys). It is safe to expose in the browser. * The body is the fingerprint payload. The snippet may send it plain or AES-256-GCM encrypted. Both are accepted. **The response is an acknowledgment, not the result:** ```json theme={null} "203.0.113.10" ``` The body is the client IP as a JSON string (HTTP `200`). It confirms the signals were received and billed. It does **not** contain the VisitorID, DeviceID, or Risk Score. Those are computed server-side and delivered in Step 3. This response is a receipt, not the score; read the result from the webhook or History API. In the browser, the snippet surfaces the same acknowledgment and the `requestID` through its optional callback, so you can correlate client and server records: ```js theme={null} const mod = await import('https://cdn.shieldlabs.ai/snippet.js?publicKey=YOUR_PUBLIC_KEY'); mod.checkAnonymous(undefined, (ip, requestID) => { // ip = the client IP from the snapshot acknowledgment, NOT the score. // requestID = the join key. Send it to your backend and look up the score // from the webhook you receive, or via the History API. fetch('/api/track-request', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ requestID }), }); }); ``` The [snippet install guide](/setup/snippet) has the full method list and framework examples. ## Step 2: Why scoring is asynchronous The Risk Score is not available the instant the POST lands because the strongest signals need a round of server-side enrichment. This takes roughly one second: The server looks up reputation and connection type for the client IP (VPN, proxy, datacenter, privacy relay). Network-level attributes are analyzed and compared against what the browser claims (this is what powers OS-mismatch and VPN corroboration). A follow-up network check completes shortly after the initial POST and can refine the score. Because these run concurrently and the follow-up network check resolves slightly later, ShieldLabs delivers an **initial** score quickly and then, when that evidence lands, an optional **update**. The [VPN corroboration](/features/anonymity-signals) behind a VPN signal depends on these enrichment steps agreeing, which is part of why the score is computed off the request path. ## Step 3: Receiving the score You get the score two ways. Use both: the webhook for low latency, the History API as the guaranteed-read fallback. ### Webhook (push) The server `POST`s the score to your configured callback URL. It arrives in two possible phases, both joined by `RequestID`: | Phase | Timing | `Details` content | | --------- | ----------------------------------------------------- | ----------------------------------------------------------------- | | `initial` | \~1s after ingest, before the follow-up network check | The full list of signals that fired | | `update` | After a follow-up network check (optional) | Only the **delta** signals, suppressed if more than \~10s elapsed | The webhook body uses PascalCase field names and is wrapped in a signed envelope: ```json theme={null} { "Data": { "RequestID": "550e8400-e29b-41d4-a716-446655440000", "Score": 35, "Details": [ { "Value": 15, "Description": "VPN" }, { "Value": 10, "Description": "Datacenter IP" } ], "Phase": "initial" }, "Assing": "9f1c2b3a4d5e6f70819a2b3c4d5e6f7081920a1b2c3d4e5f60718293a4b5c6d7" } ``` Full field schema: [WebhookBody](/api/models). `Assing` is the literal field name in the JSON envelope. It is the HMAC-SHA256 signature of the `Data` object, keyed with your [Secret Key](/setup/keys). Verify every webhook before trusting it, following the [verification recipe](/api/webhooks). Webhook delivery is **at-most-once with no retries** (a \~1 second timeout, no backoff, no dead-letter queue). Make your handler idempotent on `RequestID` and use the History API for anything that must not be missed. Full payload, signature verification in Node/Go/Python, and delivery guarantees are in [Webhooks](/api/webhooks). ### History API (read) You can read the scored result for any `RequestID` from the [Server API](/api/server-api). This is the authoritative, pull-based path and the right choice when you cannot risk a dropped webhook. ```http theme={null} GET https://api.shieldlabs.ai/{domain}:{secret}/history/request_id/{requestID}?limit=1 ``` The response is an array of snapshots (newest first), a superset of the webhook body that also includes connection and network fields. You can search history by other join keys too: `request_id`, `visitor_id`, `device_id`, `user_hid`, or `ip`. Each returned row bills 1 request, with the full schema and billing rules in the [Server API](/api/server-api) reference. ## The RequestID lifecycle `RequestID` is the single value that lets you stitch the asynchronous pieces together: Minted client-side as a UUID and sent in the POST path: `/snapshot/{requestID}`. Returned to your page in the snippet callback. Echoed back as `RequestID` in the `Data` body. Both the `initial` and `update` phases carry the same `RequestID`. Queryable as the `request_id` search type to read the stored snapshot any time. Persist the `requestID` early, record `Score` and `Details` idempotently when the webhook arrives, and fall back to `GET /history/request_id/{requestID}` if it never does. The full reliability pattern, with signature verification, is in [Webhooks](/api/webhooks#delivery-guarantees). ## What you do with the score ShieldLabs scores. Your code decides. The Risk Score lands in the 0–100 range and falls into four [Risk Score bands](/features/risk-scoring) you can act on, with your application the actor for allow, challenge, review, or block. Decide on **Score + Details + action context**, never the number alone: a legitimate user can score high (corporate proxy, VPN, privacy browser). The [per-band playbook](/guides/acting-on-risk-score) gives threshold guidance and worked examples. ## Next steps Full payload, `Assing` signature verification, phases, and at-most-once delivery. History search, the snapshot schema, profile, callback config, and billing. How the 0–100 explainable score and the Clean / Low / Medium / High bands work. RequestID, SessionID, CookieID, DeviceID, VisitorID, and UserHID mechanics. # Data Models Source: https://docs.shieldlabs.ai/api/models A reference for every field ShieldLabs returns and what each one means. # Data Models This page is the schema reference for every object the ShieldLabs API and webhooks return. Three objects carry the result of an identification: * **`WebhookBody`** is the payload pushed to your callback URL. * **`Snapshot`** is the richer object the [History API](/api/server-api) returns. It is a superset of `WebhookBody` with the network fields added. * **`ScoreDetail`** is one entry in the explainable `Details` array that both of the above carry. A fourth object, **`Profile`**, is the per-domain configuration you read from the Server API. ShieldLabs surfaces these objects. Your own code owns the decision. The payloads carry a [Risk Score](/features/risk-scoring) (0–100) and the [signals](/features/anonymity-signals) behind it. You decide allow / challenge / review / block in your application from that data. ShieldLabs never blocks, challenges, or allows anyone. ## Object map Pushed to your callback URL inside a signed envelope. The fastest path to the score. Returned by the History API. Everything in `WebhookBody` plus connection and network fields. One signal that fired, with the points it contributed. The `Details` array makes the score explainable. Your domain's configuration: weight balance, callback URL, and masked keys. ## WebhookBody The object delivered when ShieldLabs scores an identification. It travels inside a signed envelope (`{ "Data": { …WebhookBody… }, "Assing": "" }`), documented with its signature verification on the [Webhooks](/api/webhooks) page. Field names are **PascalCase**. ```json theme={null} { "RequestID": "550e8400-e29b-41d4-a716-446655440000", "SessionID": "7a1b2c3d-4e5f-6789-abcd-ef0123456789", "CookieID": "3f2e1d0c-9b8a-7654-3210-fedcba987654", "DeviceID": "d290f1ee-6c54-4b01-90e6-d701748f0851", "VisitorID": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "IP": "203.0.113.10", "OS": "Windows", "Country": "US", "UserHID": "e3b0c44298fc1c149afbf4c8996fb924", "Score": 42, "Details": [{ "Value": 30, "Description": "Anti-detect Browser" }], "LastRequestTime": "2026-06-16T10:00:00Z", "Phase": "initial" } ``` The per-call identifier minted client-side and sent in the snapshot POST path. It is the join key across snapshot, webhook, and History, one of the [Identifiers](/features/identification) ShieldLabs derives. Make your handler idempotent on this value. The per-visit identifier from `sessionStorage` (10-minute visit window). Resets each browser session or tab. The first-party cookie / localStorage identifier minted in the browser. It is lost when the visitor clears cookies or storage. Server-derived from dozens of stable browser and device characteristics. It **survives cookie clears, incognito, and IP rotation** because it is derived from the environment, not stored, as the [Identifiers](/features/identification) reference explains. Server-derived from DeviceID plus CookieID. It **changes when the cookie is cleared** (a new CookieID produces a new VisitorID). Multiple VisitorIDs can map to one DeviceID. The durability claim belongs to DeviceID, not VisitorID. The public client IP resolved server-side for this request. The operating system derived for the visitor, for example `Windows`, `Mac OS X`, or `IOS (iPhone)`. May be empty when the OS could not be determined. The ISO country code resolved from the public IP, for example `US`. Your own account identifier, passed in through the snippet. **Always pass a hashed or pseudonymous value, never a raw email or user id.** When absent it falls back to a placeholder. The explainable [Risk Score](/features/risk-scoring). Integer, hard-capped at 100. Higher means more anonymous, masked, spoofed, or abusive. Bands: Clean 0–9, Low 10–29, Medium 30–59, High 60–100. The internal value 999 is a rate-limit ban marker and never appears as a customer score. The list of signals that contributed to `Score`. The JSON key is literally `Details` (the Go field is `ScoreDetails`). On an `initial` webhook this is the full list of signals that fired; on an `update` webhook it carries only the **delta**. Each entry follows the [ScoreDetail](#scoredetail) shape below. `Description` is a human-readable label for visibility and logging. Branch on the `Score` and each entry's `Value`, not on the label text, which can change. Timestamp of the request this score belongs to, for example `2026-06-16T10:00:00Z`. `"initial"` or `"update"`. `initial` is the first score, delivered about a second after ingest and before a follow-up network check. `update` is the recomputed score after that check completes, and is suppressed if more than \~10 seconds elapsed. On `update`, `Details` carries only the changed signals, for the reasons the [Identification Flow](/api/identification-flow) lays out. ## ScoreDetail One entry in the `Details` array. It is what makes the Risk Score explainable: each entry is a signal that fired and the points it contributed. ```json theme={null} { "Value": 30, "Description": "Anti-detect Browser" } ``` The points this signal added to the Risk Score. The score is additive: the entries' `Value` fields sum to the total, then the total is capped at 100. Use the weight table below to interpret a `Value`. The customer-facing name of the signal, for example `"VPN"`, `"OS Mismatch"`, or `"Datacenter IP"`. Match this against the [signal reference](/features/anonymity-signals) to understand what fired. ### Interpreting `Value`: the signal-weight reference Each signal contributes a fixed number of points to the Risk Score, and a higher weight is stronger evidence of anonymity or spoofing. The full weight table lives on [Risk Scoring](/features/risk-scoring); match a `Description` against the [signal reference](/features/anonymity-signals) to see what each one covers. A high `Value` does not by itself mean fraud. A real user behind a corporate proxy, a VPN, or a privacy browser can legitimately fire these checks. Decide on the **Score plus the Details plus your own context**, never the raw number alone, following the [per-band playbook](/guides/acting-on-risk-score). On an `update` webhook, `Details` contains only the signals that changed since the `initial` score (the delta), not the full list. Reconstruct the full picture by joining on `RequestID`, or read the complete stored `Details` from the [History API](/api/server-api). ## Snapshot The object returned by the History API (`GET /{domain}:{secret}/history/{type}/{value}`). A `Snapshot` is a **superset of `WebhookBody`**: it carries the same identity and score fields and adds connection and network fields plus `Browser` and `DeviceType`. The History endpoint returns an array of these, newest first. ```json theme={null} [ { "RequestID": "550e8400-e29b-41d4-a716-446655440000", "SessionID": "7a1b2c3d-4e5f-6789-abcd-ef0123456789", "CookieID": "3f2e1d0c-9b8a-7654-3210-fedcba987654", "DeviceID": "d290f1ee-6c54-4b01-90e6-d701748f0851", "VisitorID": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "IP": "203.0.113.10", "ConnectionType": "vpn", "OS": "Windows", "Browser": "Chrome", "DeviceType": "desktop", "Country": "US", "UserHID": "e3b0c44298fc1c149afbf4c8996fb924", "Score": 15, "Details": [{ "Value": 15, "Description": "VPN" }], "LastRequestTime": "2026-06-16T10:00:00Z" } ] ``` The identity, score, and `Details` fields behave exactly as in [WebhookBody](#webhookbody). The fields a `Snapshot` adds on top: The classified connection type, one of seven values: `direct`, `mobile`, `vpn`, `proxy`, `tor`, `privacy_relay`, or `unknown`. The browser derived for the visitor, for example `Chrome` or `Safari`. The device form factor, for example `desktop` or `mobile`. The snapshot may include additional network-intelligence fields. The additional network-intelligence fields are raw network internals. They power the score; they are not meant for end-user display. Keep them on your server. History search accepts these `type` values: `request_id`, `visitor_id`, `device_id`, `user_hid`, and `ip`. Each returned row bills 1 request (an empty result still bills 1). The full query, `limit` rules, and billing are in the [Server API](/api/server-api). ## Profile The per-domain configuration object returned by `GET /{domain}:{secret}/profile`. This read is free (it does not consume requests). ```json theme={null} { "Domain": "yourapp.com", "Weight": 142850, "Callback": "https://yourapp.com/webhooks/shieldlabs", "PublicKey": "••••••••-••••-••••-••••-••••••••a1b2", "Secret": "••••••••••••••••••••3f9c", "CreatedAt": "2026-01-04T09:30:00Z" } ``` The domain this configuration belongs to. Your remaining request balance for this domain. Each identification consumes 1; each History row consumes 1, as the [Billing](/billing) page details. The webhook callback URL ShieldLabs posts scores to. Set it with `POST /{domain}:{secret}/callback` and consume it as documented under [Webhooks](/api/webhooks). Your per-domain [Public Key](/setup/keys), masked to the last four characters. The Public Key goes in the snippet URL and is safe to expose. Use the [dashboard](https://dashboard.shieldlabs.ai) to view it in full. Your per-domain [Secret Key](/setup/keys), masked to the last four characters. The Secret Key is backend-only: it verifies webhook signatures and authenticates the Server API as `{domain}:{secret}`. Never put it in the browser. When the domain configuration was created. ## Patterns are not in these payloads The [8 Patterns](/features/patterns) are a **dashboard feature**, not part of the API or webhook payloads above. They are computed server-side from your historical data, graded over a time window, and shown on the dashboard Patterns view. You retrieve them through dashboard export (CSV or JSON), not from `WebhookBody`, `Snapshot`, or any API field documented on this page. They are also conceptually different from the scoring [signals](/features/anonymity-signals): | | Signals (in these payloads) | Patterns (dashboard export only) | | -------- | ------------------------------------ | --------------------------------- | | Scope | One request, in real time | One identity, over a time window | | Output | `Details` array plus `Score` (0–100) | A graded detection per entity | | Delivery | Webhook and History API | Dashboard view and export | | Grading | Points that sum into the Risk Score | `suspicious` or `dangerous` level | For completeness, the shape an exported pattern detection follows conceptually: The pattern's machine name, for example `many_accounts_one_device` or `multiple_countries_on_account`. All 8 [Patterns](/features/patterns) are named there with what each detects. The kind of identifier flagged: `user_hid`, `visitor_id`, `device_id`, `cookie_id`, or the Local IP entity (a dashboard label). The specific identifier value that was flagged. The grade: `suspicious` or `dangerous`. An entity that never crosses the suspicious threshold is implicitly "Normal" and is not recorded. A level never downgrades on later runs. Pattern thresholds are server-side and adapt to an entity's risk. There is no in-product rules engine: ShieldLabs grades patterns and scores requests, and your own code (using the Risk Score and signals from webhooks and the API) owns the allow / challenge / review / block decision. ## Related The signed envelope, `Assing` signature verification, phases, and at-most-once delivery. History search, profile, callback config, `limit` rules, and per-row billing. How the 0–100 explainable score and the Clean / Low / Medium / High bands work. The full catalog of scoring signals, their weights, and how they combine. # API Overview Source: https://docs.shieldlabs.ai/api/overview An overview of how the ShieldLabs API is organized and how to authenticate. ShieldLabs has a small, focused API surface. You install a snippet, you receive scored results, and you read history when you need a guaranteed pull. ShieldLabs scores every visit; your own code decides whether to allow, challenge, review, or block. ## The three surfaces Collects browser, device, and network signals and posts them to `rest.shieldlabs.ai` automatically. You install it once; you do not call this endpoint yourself. Push delivery. ShieldLabs POSTs the [Risk Score](/features/risk-scoring) and signals to your callback URL about a second after a visit. Pull. Call `api.shieldlabs.ai` from your backend to read your profile, set the webhook callback, and search scored snapshots. A typical integration uses all three: the snippet runs on your pages, webhooks deliver scores in real time, and the History API is your guaranteed fallback for anything a webhook might miss. ## Hosts | Host | Purpose | Who calls it | | ------------------------------------------------ | -------------------------------- | ----------------------- | | `cdn.shieldlabs.ai` | Serves the JS snippet | The browser | | `rest.shieldlabs.ai` | Receives collected signals | The snippet (automatic) | | `webrtc.shieldlabs.ai`, `ice.shieldlabs.ai:3478` | Network check endpoints | The snippet (automatic) | | `api.shieldlabs.ai` | Server API (`{domain}:{secret}`) | Your backend | | `dashboard.shieldlabs.ai` | Dashboard UI | You | The [development hosts](/setup/environments) mirror these for local and staging work. ## Authentication Each domain has two keys. They are not interchangeable. | Key | Where it goes | What it does | | -------------- | --------------------------------- | ------------------------------------------------------------------------------------ | | **Public Key** | The snippet URL, `?publicKey=...` | Identifies the domain in the browser. Safe to expose. | | **Secret Key** | Your backend only | Authenticates the Server API as `{domain}:{secret}` and verifies webhook signatures. | ``` https://api.shieldlabs.ai/{domain}:{secret}/... ``` The Secret Key must never appear in the browser, the snippet, client logs, or a public repository. If it leaks, rotate it from the [dashboard](https://dashboard.shieldlabs.ai). The [Keys](/setup/keys) page covers where each credential belongs. ## No synchronous score endpoint (today) There is no request that returns a Risk Score in its response. Scoring is asynchronous: it combines IP reputation, network analysis, and a follow-up network check that take about a second to resolve. So the snippet posts signals, ShieldLabs scores them, and the result reaches you by [webhook](/api/webhooks) and through the [History API](/api/server-api). A cleaner synchronous server endpoint (`POST /v1/verify`) is planned but not yet available. Integrate today via webhooks plus the History API, both documented here. The [`RequestID`](/features/identification) is the join key that ties a snapshot, its webhooks, and its history rows together. ## Billing and limits * One identification consumes **1 request**. The History API consumes **1 request per returned row** (an empty result still costs 1). * Reading your profile, setting the callback, receiving webhooks, viewing the dashboard, and exporting data are **free**. * History returns at most **100 rows** per call (`limit` is capped at 100). * When the balance reaches 0, scoring and History calls return `402`, as the [Billing](/billing) page details. * Infrastructure [rate limits](/rate-limits) protect the gateway and never feed the Risk Score. ## Conventions * **Responses** are JSON. Error bodies are not uniform, so branch on the HTTP status code rather than parsing a body field. On the Server API, `401` and `402` return an empty body, while `400` and `404` return a bare JSON string. The REST ingest gateway returns an object shaped like `{ "error": "..." }`. The [Errors](/errors) page enumerates each case. * **Timestamps** are ISO 8601 UTC, for example `2026-06-16T10:00:00Z`. * **Identifiers** (`RequestID`, `DeviceID`, `VisitorID`, `SessionID`, `CookieID`) are UUIDs. `UserHID` is your own hashed user id, a free-form string. ## Next steps How signals become a score, and how the webhook and History API fit together. The webhook envelope, the `Assing` signature, and the two delivery phases. Profile, callback, and history endpoints with full request and response detail. The Snapshot, webhook body, and Score Detail schemas in one place. # Server API Source: https://docs.shieldlabs.ai/api/server-api A reference for the Server API: read scored results with the History API, plus your profile and webhook callback. # Server API The Server API is the **server-side** surface for your domain. Its primary endpoint is the **History API**, the authoritative, pull-based way to read a [Risk Score](/features/risk-scoring) and the signals behind it when you cannot risk a dropped [webhook](/api/webhooks). The same surface also lets you read your profile and balance and set your webhook callback URL. This API is keyed by your [Secret Key](/setup/keys) and must only be called from your backend. Never call it from the browser. ## Base URL and authentication Every Server API call lives under one base path that carries the credentials in the URL: ``` https://api.shieldlabs.ai/{domain}:{secret}/ ``` | Part | Value | Notes | | ---------- | ----------------------------------------- | ---------------------------------------------------- | | `{domain}` | Your registered domain, e.g. `myshop.com` | The hostname you set up in [Domains](/setup/domains) | | `{secret}` | Your per-domain Secret Key | Backend only, minted on the [Keys](/setup/keys) page | The credentials are validated against the domain's Secret Key and its enabled status. Wrong credentials, an unknown domain, or a disabled domain return `401`. The Secret Key authenticates this API and verifies webhook signatures. Keep it server-side. It must never appear in the browser, the JS snippet, client logs, or a public repository. If it leaks, rotate it from the [dashboard](https://dashboard.shieldlabs.ai). The browser-safe credential is the [Public Key](/setup/keys), which goes in the snippet, not here. ## Endpoints at a glance | Method | Path | Purpose | Cost | | ------ | --------------------------------------------------- | ------------------------------------------------------- | -------------------------------------- | | `GET` | `/{domain}:{secret}/history/{type}/{value}?limit=N` | **Read scored results** by identifier (the History API) | 1 request per returned row (minimum 1) | | `GET` | `/{domain}:{secret}/profile` | Read domain config, balance, masked keys | Free (0 requests) | | `POST` | `/{domain}:{secret}/callback` | Set the webhook callback URL | Free (0 requests) | There is no synchronous "identify" or "score" endpoint here. The score is produced asynchronously: the [JS snippet](/setup/snippet) posts signals to `rest.shieldlabs.ai`, ShieldLabs scores them in about a second, and the result is delivered by [webhook](/api/webhooks) and stored for the History API. The [Identification Flow](/api/identification-flow) walks through that asynchronous path end to end. ## GET /profile Returns your domain's configuration and current balance. The keys are masked to their last four characters. This call is **free**: it does not consume requests. ```bash theme={null} curl "https://api.shieldlabs.ai/myshop.com:YOUR_SECRET/profile" ``` ### Response ```json theme={null} { "Domain": "myshop.com", "Weight": 148230, "Callback": "https://myshop.com/webhooks/shieldlabs", "PublicKey": "•••• a3f8", "Secret": "•••• 9c2d", "CreatedAt": "2026-01-15T09:00:00Z" } ``` The registered domain this profile belongs to. Your remaining balance, measured in requests. One identification consumes 1 request, and the History API consumes 1 request per returned row. When this reaches 0, scoring and History calls return `402`, as the [Billing](/billing) page details. The webhook URL ShieldLabs posts scored results to. Empty if no callback is set. Update it with `POST /callback`. Your Public Key, masked to the last four characters. The browser-safe credential that goes in the snippet URL. Read the full value from the dashboard. Your Secret Key, masked to the last four characters. Used for this API and webhook signature verification. The full value is shown only at creation in the dashboard. ISO 8601 UTC timestamp of when the domain was created. ## POST /callback Sets (or replaces) the webhook callback URL for the domain. The request **body is the URL itself, as plain text** (not JSON). This call is **free**. ```bash theme={null} curl -X POST "https://api.shieldlabs.ai/myshop.com:YOUR_SECRET/callback" \ -H "Content-Type: text/plain" \ --data "https://myshop.com/webhooks/shieldlabs" ``` The raw HTTPS URL ShieldLabs should POST scored results to. Sent as the plain-text request body, not wrapped in a JSON object. Use a publicly reachable HTTPS endpoint. After this is set, every identification on the domain delivers an [`initial` webhook](/api/webhooks) and, when a follow-up network check completes, an optional `update` webhook. Webhook delivery is **at-most-once with no retries**, so verify the signature, make your handler idempotent on `RequestID`, and fall back to the History API for anything that must not be missed. You can also set and manage the callback from the [dashboard](https://dashboard.shieldlabs.ai). The dashboard and this endpoint write the same field. Either is fine. ## GET `/history/{type}/{value}` Searches the snapshots ShieldLabs has stored for your domain. Returns an array of [Snapshot](#the-snapshot-object) objects, **newest first**. This is the guaranteed read path for any scored result, the right choice when a webhook may have been missed. ```bash theme={null} curl "https://api.shieldlabs.ai/myshop.com:YOUR_SECRET/history/request_id/550e8400-e29b-41d4-a716-446655440000?limit=1" ``` ### Path parameters The field to search on. One of: * `ip`: client IP address (IPv4 validated) * `user_hid`: the hashed user id you passed via the snippet (free-form string) * `visitor_id`: a VisitorID (UUID validated) * `request_id`: a single identification's RequestID (UUID validated) * `device_id`: a DeviceID (UUID validated) Any other value returns `404`. A value in the wrong format (for example a non-UUID for `device_id`) returns `400`. The value to match for the chosen `type`. UUID types are UUID validated, `ip` is IPv4 validated, `user_hid` is a free string. ### Query parameters Maximum number of rows to return. Capped at **100**: a higher value is clamped to 100. Rows are ordered newest first. ### Common search patterns ```bash Read one identification theme={null} # Pull the scored result for a specific check (the guaranteed-read fallback # when a webhook may have been dropped). Bills 1 request. curl "https://api.shieldlabs.ai/myshop.com:YOUR_SECRET/history/request_id/550e8400-e29b-41d4-a716-446655440000?limit=1" ``` ```bash History for a device theme={null} # All recent snapshots for one DeviceID, newest first. Bills 1 per returned row. curl "https://api.shieldlabs.ai/myshop.com:YOUR_SECRET/history/device_id/d290f1ee-6c54-4b01-90e6-d701748f0851?limit=20" ``` ```bash History for one of your users theme={null} # Search by the hashed UserHID you passed via checkAuthenticatedUser. curl "https://api.shieldlabs.ai/myshop.com:YOUR_SECRET/history/user_hid/e3b0c44298fc1c149afbf4c8996fb924?limit=50" ``` ```bash Activity from an IP theme={null} curl "https://api.shieldlabs.ai/myshop.com:YOUR_SECRET/history/ip/203.0.113.10?limit=20" ``` ### Billing The History API bills **1 request per returned row**. An empty result still bills **1 request** (the lookup itself). So a search returning 20 snapshots costs 20 requests, and a search returning 0 costs 1. If your balance is insufficient for the result, the call returns `402`. Webhooks, dashboard views, and exports are free, with the full cost breakdown on the [Billing](/billing) page. The 1-request lookup charge is taken before the lookup is validated, so a `400` (malformed value) or `404` (unsupported `{type}`) still bills 1 request. Only `401` (bad credentials) and `402` (out of requests) cost 0. Send a well-formed `{type}` and `{value}` to avoid paying for a rejected call. To keep reads cheap and predictable, query by `request_id` with `limit=1` when you only need one scored result. Reserve the wider `device_id` / `user_hid` / `ip` searches for investigations, and set `limit` to the smallest value that answers your question. ## The Snapshot object A snapshot is a single scored identification. The History array returns a **superset of the [webhook body](/api/webhooks)**: the same identity and score fields, plus connection and network detail captured during scoring. ```json theme={null} { "RequestID": "550e8400-e29b-41d4-a716-446655440000", "SessionID": "7a1b2c3d-4e5f-6789-abcd-ef0123456789", "CookieID": "3f2e1d0c-9b8a-7654-3210-fedcba987654", "DeviceID": "d290f1ee-6c54-4b01-90e6-d701748f0851", "VisitorID": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "IP": "203.0.113.10", "OS": "Windows", "Browser": "Chrome", "DeviceType": "desktop", "Country": "US", "UserHID": "e3b0c44298fc1c149afbf4c8996fb924", "ConnectionType": "vpn", "Score": 45, "Details": [ { "Value": 30, "Description": "IP Mismatch" }, { "Value": 15, "Description": "VPN" } ], "LastRequestTime": "2026-06-16T10:00:00Z" } ``` The identity and score fields (`RequestID`, `SessionID`, `CookieID`, `DeviceID`, `VisitorID`, `UserHID`, `IP`, `OS`, `Country`, `Score`, `Details`, `LastRequestTime`) behave exactly as in the [WebhookBody schema](/api/models#webhookbody). The fields a snapshot adds on top, captured during scoring and useful for forensics and your own correlation logic: Detected browser name, e.g. `Chrome`, `Safari`. Form factor: `desktop`, `mobile`, or `tablet`. The classified connection type for the IP, one of seven values: `direct`, `mobile`, `vpn`, `proxy`, `tor`, `privacy_relay`, or `unknown`. The snapshot may include additional Network Intelligence fields. One is a server-side correlation field for the local-IP entity; keep it server-side and do not display it. The full field-by-field schema for every object lives in [Data Models](/api/models#snapshot). A snapshot is a point-in-time record of one identification. The score on a snapshot is the score as computed at that time. If a later `update` webhook changed the result (after a follow-up network check completes), the stored snapshot reflects the recomputed value. ## Reading the score ShieldLabs scores. **Your application decides.** The Risk Score is a 0 to 100 number that falls into four [Risk Score bands](/features/risk-scoring), and your code is the actor for allow, challenge, review, or block. Decide on **Score + Details + action context**, never the number alone: a legitimate user can score high behind a corporate proxy, a VPN, or a privacy browser. Tune your thresholds gradually, working from the [per-band playbook](/guides/acting-on-risk-score) and its worked examples. ## Errors Error bodies on this surface are not uniform, so branch on the HTTP status code, not on a body field. `401` and `402` return an empty body. `400` and `404` return a bare JSON string. The `429` and `503` statuses exist only on the REST ingest and WebRTC gateways, not on the Server API. | Status | Meaning | What to do | | ------ | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | | `200` | Success | Parse the response. A History search with a valid `{type}` that matches nothing returns `200` with `[]` and still bills 1 request. | | `400` | Bad parameters | A malformed value, e.g. a non-UUID where a UUID is required. Fix the request. | | `401` | Bad credentials or disabled domain | Check `{domain}:{secret}` and that the domain is enabled. | | `402` | Out of requests | Balance exhausted (History needs 1 request per returned row). Top up from the [Billing](/billing) page. | | `404` | Unsupported history type | `{type}` must be one of `ip`, `user_hid`, `visitor_id`, `request_id`, `device_id`. | | `500` | Internal error | Transient. Retry with backoff. | The [Errors](/errors) page is the full reference across every surface. ## Next steps The full Snapshot, webhook body, and Score Detail schemas in one place. The push delivery path: payload, `Assing` signature verification, and phases. How signals become a score and how the webhook and History API fit together. Public Key vs Secret Key, where each one belongs, and how to rotate. # Webhooks Source: https://docs.shieldlabs.ai/api/webhooks A reference for the signed webhook payload: its envelope, fields, two phases, and signature. # 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](/features/risk-scoring) and the signals behind it. This page is the **reference**: the envelope, the field schema, the two phases, and how to verify the signature. The [webhook setup guide](/setup/webhooks) gives a step-by-step walkthrough of configuring and testing a callback. You do not poll for webhooks. The server sends them to the callback URL you set per domain (in the [dashboard](/setup/webhooks) or via the [Server API](/api/server-api)). Delivery is **at-most-once with no retries**, so pair it with a [History API](/api/server-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](#webhookbody)) and `Assing` (the signature). ```json theme={null} { "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](/setup/keys). A cleaner field name is planned, but `Assing` is the current reality. Verify it on every webhook before trusting the contents. The request itself: ```http theme={null} 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, within the [delivery guarantees](#delivery-guarantees) below. ## WebhookBody The `Data` object. Field names are **PascalCase**. The client-generated UUID for this identify call. This is the join key across the snapshot, the webhook, and the [History API](/api/server-api). Both the `initial` and `update` phases carry the same `RequestID`. Make your handler [idempotent](#delivery-guarantees) on this value. 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. Server-derived identity, computed from stable Device Intelligence. **Durable: survives cleared cookies, incognito, and IP rotation**, because it is derived from the browser environment rather than stored, as the [Identifiers](/features/identification) reference explains. Server-derived from the DeviceID and the 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. The client IP address resolved for this call. The operating system derived for the visitor (for example `Windows`, `Mac OS X`, or `IOS (iPhone)`). May be empty when it cannot be determined. Two-letter ISO country code derived from the IP (for example `US`). 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. On an anonymous call (`checkAnonymous`), this is never omitted: it carries the literal placeholder value `anonymous`. The [Risk Score](/features/risk-scoring), 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. The explainable breakdown: every signal that contributed to the score, as `{ "Value": , "Description": "" }`. `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, as the [Phases](#phases) section describes. Points this signal contributed to the total `Score`. The customer-facing signal label, for example `VPN`, `Datacenter IP`, or `OS Mismatch`, drawn from the full [Signals](/features/anonymity-signals) list and weights. Branch on `Score` and each entry's `Value`, not on the `Description` label. See [ScoreDetail](/api/models#scoredetail). Timestamp of the request, in RFC 3339 / ISO 8601 form (for example `2026-06-16T10:00:00Z`). Which delivery this is: `"initial"` (the first score, sent about a second after ingest, before a follow-up network check) or `"update"` (recomputed after a follow-up network check), as the [Phases](#phases) section describes. ## Phases A single identification can produce up to two webhooks, both joined by the same `RequestID`. | `Phase` | Timing | `Details` content | | --------- | ----------------------------------------------------- | --------------------------------------------- | | `initial` | \~1s after ingest, before the follow-up network check | The full list of signals that fired | | `update` | After a follow-up network 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](/api/identification-flow). ### Example: initial webhook The first delivery carries the full `Details` list. ```json theme={null} { "Data": { "RequestID": "550e8400-e29b-41d4-a716-446655440000", "SessionID": "7a1b2c3d-4e5f-6789-abcd-ef0123456789", "CookieID": "3f2e1d0c-9b8a-7654-3210-fedcba987654", "DeviceID": "d290f1ee-6c54-4b01-90e6-d701748f0851", "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 a follow-up network check completes, an `update` may follow with only the **new** signals in `Details`. Here the follow-up check adds an IP-mismatch signal, raising the score from 25 to 55. ```json theme={null} { "Data": { "RequestID": "550e8400-e29b-41d4-a716-446655440000", "SessionID": "7a1b2c3d-4e5f-6789-abcd-ef0123456789", "CookieID": "3f2e1d0c-9b8a-7654-3210-fedcba987654", "DeviceID": "d290f1ee-6c54-4b01-90e6-d701748f0851", "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 that changed 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`. `Value` is **signed**. An `update` delta can be **negative**: a follow-up network check that corrects an earlier over-count lowers the score, so that entry's `Value` is below zero. Always read `Score` for the running total; never reconstruct the total by summing `Details`, and never assume an `update` only raises the number. ## Verification Verify the signature on **every** webhook before acting on it. The recipe: ```text theme={null} Assing == hex( HMAC-SHA256( key = your Secret Key, msg = raw Data bytes ) ) ``` The HMAC is computed over the marshaled `Data` object (the `WebhookBody`), not over the full `{Data, Assing}` envelope. To verify, HMAC the raw `Data` bytes exactly as received (capture them before any re-encoding), compute HMAC-SHA256 with your [Secret Key](/setup/keys), hex-encode it, and **constant-time compare** against `Assing`. Re-serializing the parsed JSON changes the bytes and the signature will not match. 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. Copy-paste verification handlers for Node, Go, and Python live in the [webhook setup guide](/setup/webhooks), each one capturing the raw `Data` bytes before any re-encoding so the signature matches. ## Delivery guarantees Webhook delivery is intentionally lightweight. Design your handler around these properties. 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. 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. The same `RequestID` can arrive twice (an `initial` and an `update`). Key your writes on `(RequestID, Phase)` so a repeat is a no-op. For anything you cannot afford to miss, read the result from the [History API](/api/server-api) by `request_id`. That is the guaranteed, pull-based path. A reliable pattern: Capture `requestID` from the snippet callback and store it with the user action you are protecting. On the `initial` phase, record `Score` and `Details` against that `RequestID`. Verify the signature first. Treat the write as idempotent. If an `update` phase arrives, merge its delta `Details` and use the recomputed `Score`. 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; your application owns the decision. The [per-band playbook](/guides/acting-on-risk-score) covers how to turn a payload into an allow, challenge, review, or block. ## Next steps The tutorial: configure your callback URL, test it, and go live. The full WebhookBody and Snapshot schemas in one place. History search by RequestID, the snapshot superset, profile, and callback config. How the 0-100 explainable score and the Clean / Low / Medium / High bands work. # Billing & Plans Source: https://docs.shieldlabs.ai/billing A guide to how ShieldLabs billing works: what counts as a request, what is free, and the plans. ShieldLabs bills per **request**, not per monthly active user. One identification is one request. You pick a plan that covers the request volume you expect, and that plan covers everything ShieldLabs does with each request: the [Risk Score](/features/risk-scoring), the [anonymity signals](/features/anonymity-signals) behind it, the persistent [identifiers](/features/identification), and the [patterns](/features/patterns) computed over time. Manage your plan and payment method under **Settings** in the [dashboard](/features/traffic-analytics). ## What counts as a request Each `checkAnonymous` or `checkAuthenticatedUser` call that the snippet posts and the server scores is **one request**. The score, its `Details`, and any update webhook for that call are all part of that single request. Every row returned by the [History API](/api/server-api) bills **one request**. A call that returns 20 rows bills 20. An empty result still bills one. Delivery of the initial and update [webhooks](/setup/webhooks) for a scored call costs nothing extra. They are part of the identification you already paid for. Reading the [dashboard](/features/traffic-analytics), every chart and breakdown, and exporting your records to CSV or JSON do **not** consume requests. The Server API `profile` and `callback` endpoints are also free. The simple rule: a **new** score costs a request, and **re-reading** an old score through the History API costs a request per row. Everything you read in the dashboard, and every webhook ShieldLabs pushes to you, is free. ## Plans | Plan | Price | Included requests | Best for | | ----------- | -------- | ------------------------------ | ------------------------------------------------------------------------------------- | | **Free** | \$0 | 5,000 one-time identifications | An initial traffic-quality check and a first look at anonymous-visitor identification | | **Starter** | \$99/mo | 25,000 | Ongoing anonymous-visitor identification and traffic-quality monitoring | | **Growth** | \$399/mo | 150,000 | Abuse and fraud detection at scale. **Most popular** | | **Scale** | \$999/mo | 500,000 | High-volume platforms that also want priority support | The Free plan is a one-time allowance of 5,000 identifications. Starter, Growth, and Scale each give you a request balance for your billing cycle. ### Billing frequency Pick monthly, quarterly, or yearly. Longer commitments cost less per request: | Frequency | Discount | | --------- | -------------- | | Monthly | Standard price | | Quarterly | **10% off** | | Yearly | **20% off** | Change your plan or billing frequency any time under **Settings → Plans & Requests** in the [dashboard](/features/traffic-analytics). ## What every plan includes Free, Starter, and Growth ship the **same** feature set. Scale adds priority support on top. Visitor identification, anonymous detection, and the [Device Intelligence](/features/identification) that ties them to a persistent [identifier](/features/identification). Traffic and visitor [Risk Scoring](/features/risk-scoring), plus pattern-based identity risk scoring. IP and Location Intelligence, OS and Network Intelligence, and the mismatch detection behind [anonymity detection](/features/anonymity-signals). Cross-entity correlation and ready-made [patterns](/features/patterns) over your historical data. A real-time [dashboard](/features/traffic-analytics) and data export to CSV or JSON, free of charge. [API access](/api/overview) and [webhooks](/setup/webhooks), so your own code can act on the score. | Feature | Free | Starter | Growth | Scale | | ----------------------------------- | :--: | :-----: | :----: | :---: | | Visitor identification | ✓ | ✓ | ✓ | ✓ | | Anonymous detection | ✓ | ✓ | ✓ | ✓ | | Traffic & Visitor Risk Scoring | ✓ | ✓ | ✓ | ✓ | | Device Intelligence | ✓ | ✓ | ✓ | ✓ | | IP & Location Intelligence | ✓ | ✓ | ✓ | ✓ | | OS & Network Intelligence | ✓ | ✓ | ✓ | ✓ | | Mismatch detection | ✓ | ✓ | ✓ | ✓ | | Cross-entity correlation | ✓ | ✓ | ✓ | ✓ | | Ready-made patterns | ✓ | ✓ | ✓ | ✓ | | Pattern-based identity risk scoring | ✓ | ✓ | ✓ | ✓ | | Real-time dashboard | ✓ | ✓ | ✓ | ✓ | | Data export (CSV / JSON) | ✓ | ✓ | ✓ | ✓ | | API access | ✓ | ✓ | ✓ | ✓ | | Webhooks | ✓ | ✓ | ✓ | ✓ | | Priority support | | | | ✓ | ## Estimating your monthly requests Your request usage is driven by how often you identify visitors and how much history you re-read: Most integrations run one identification per visit (or per sensitive action, such as a login or checkout). That is one request per call. If you call `forceCheckAnonymous` again before a high-stakes action, that is a second request. Acting on the [webhook](/setup/webhooks) you already received is free. Reach for the [History API](/api/server-api) only when you need to look an entity up after the fact, since each returned row bills a request. Analytics, breakdowns, and exports are free, so reporting and review work never adds to your bill. A login flow that identifies once per sign-in and reacts to the webhook spends one request per login. The same flow that also pulls 10 rows of [history](/api/server-api) for context spends 11. ## When you run out of requests If your request balance is exhausted, you get **HTTP 402 (out of requests)** in two places: when the snippet posts an identification to be scored, and on the Server API. The fix is to upgrade your plan or add to your request balance. **402** means "out of requests," a billing state. It is separate from **429** rate limiting, a per-IP gateway protection that has nothing to do with your plan balance and follows its own [gateway thresholds](/rate-limits). The [API overview](/api/overview) carries the full error list. ## Next steps Compare features and pick a plan under **Settings** in the [dashboard](/features/traffic-analytics). To wire the score into your own decisions, read [Acting on the Risk Score](/guides/acting-on-risk-score) and the [webhooks](/setup/webhooks) setup. The [Server API](/api/server-api) and the [API overview](/api/overview) document programmatic reads and the exact endpoints that do and do not bill. # Browser support Source: https://docs.shieldlabs.ai/browser-support An overview of the browsers and devices ShieldLabs supports. ShieldLabs runs as a single JavaScript snippet, so it works wherever a modern browser does. The same code path runs on Chrome, Firefox, and Safari alike, and the same browser keeps its DeviceID across repeat visits. The product is web only: a single [snippet](/setup/snippet) loaded from the CDN, with no native SDK or npm package today. Mobile and platform support is covered in [Mobile and platforms](#mobile-and-platforms) below. ## Supported browsers The snippet recognizes every mainstream desktop and mobile browser, and identification runs the same way in all of them: a visitor returning in the **same** browser keeps the same DeviceID. A different browser produces a different DeviceID for the same person, so identity-based counts are best read within the [identifier boundaries](/features/identification). | Browser | Notes | | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | | **Chrome** | Fullest anonymity-signal coverage. | | **Microsoft Edge** | Fullest anonymity-signal coverage. | | **Brave** | [Privacy hardening](#brave) can block the snippet host or randomize some attributes. | | **Opera** | Fullest anonymity-signal coverage. | | **Samsung Internet** | Android supported. Fullest anonymity-signal coverage. | | **Firefox** | Identification works the same way. Some anonymity signals are collected differently and may contribute fewer entries to the Risk Score. | | **Safari (desktop and mobile)** | Identification works the same way. [Shortens cookie lifetime](#safari) by default. | Other mainstream browsers, such as Vivaldi and Yandex Browser, are recognized and identify the same way. A browser that reports an unfamiliar user agent still gets a DeviceID; it is simply labeled as unknown rather than by name. ShieldLabs does not publish minimum browser versions. Identification works on current releases of every browser above. Support is detected by capability, not by a spoofable user-agent string, so an anti-detect browser cannot fake its way into a different support tier. Some anonymity signals are available on Chrome, Edge, Opera, Brave, and Samsung Internet that Firefox and Safari do not expose. The same masked visitor can therefore score slightly lower on Firefox or Safari. That is expected coverage, not a setup error: the identity is unaffected, only the anonymity signals available to the score differ. ## How persistence behaves per browser Browsers differ most in how long they keep first-party cookies and site storage. That matters because two identifiers depend on storage and one does not. * **DeviceID** is derived on the server from dozens of stable browser and device characteristics. Nothing about it is stored in the browser, so no privacy setting can expire it. * **VisitorID** is built from the DeviceID combined with the cookie-based CookieID. When a browser clears or shortens cookie storage, the CookieID resets and a new VisitorID is minted. So privacy defaults reset the VisitorID but leave the durable DeviceID intact, as the full [identifier model](/features/identification) lays out. ### Safari Safari is privacy hardened by default. Apple caps the lifetime of script-writable storage and first-party cookies set by JavaScript, so the CookieID expires sooner than on other browsers. * **Affects the VisitorID.** A returning Safari visitor whose cookie storage has expired gets a fresh CookieID, and therefore a fresh VisitorID. * **Does not affect the DeviceID.** VisitorID resets, DeviceID unaffected, so returning visitors are not miscounted as new. This applies to desktop and mobile Safari, and to other browsers on iOS, which behave the same way. ### Brave Brave is a privacy-hardened browser that behaves like Chrome running an aggressive ad and tracker blocker. * Brave may block the snippet host or randomize some browser attributes. * Identification still works once the snippet host is allowed, one of the steps for [keeping accuracy high](#keeping-identification-accuracy-high) below. * Some anonymity signals may contribute fewer entries when attributes are randomized. The DeviceID remains stable. ### Incognito and private mode Private and incognito windows are fully supported. A private window is the same browser on the same machine, so the DeviceID is stable across normal and private sessions on that browser. ShieldLabs does not market incognito or private-mode detection as a feature. The point is the opposite: a returning visitor keeps the same DeviceID whether or not they use a private window. ## Keeping identification accuracy high A few setup choices keep [accuracy](/features/accuracy) as high as possible, especially on privacy-hardened browsers. * **Allow the snippet host.** Make sure `cdn.shieldlabs.ai` and the data endpoints are not blocked by an ad blocker or a [Content Security Policy](/setup/csp). A blocked snippet collects nothing, so the DeviceID comes back all-zero, which [Troubleshooting](/troubleshooting) covers in full. Treat an all-zero DeviceID as "nothing collected," not as a returning visitor. * **Keep first-party storage available.** Clearing or stripping cookies does not change the DeviceID, but it does reset the VisitorID and visit counts. Leave first-party storage in place where you can. * **Pass a hashed UserHID for signed-in users.** Once a visitor logs in, call the [`checkAuthenticatedUser`](/setup/snippet) export with a hashed account id. This ties anonymous activity to a known account across visits and browsers. * **Use the framework integration.** The React, Vue, Angular, Preact, Svelte, and native-JS wrappers all load the same module from the CDN. They keep the import in your app code where it is easy to maintain. Identity-based counts are best read as estimated, not exact, within the [identity boundaries](/features/identification). ## Mobile and platforms Mobile web is a first-class target. The snippet runs in mobile Chrome, mobile Safari, Samsung Internet, and other mobile browsers exactly as it does on desktop. There is **no native mobile SDK** today. ShieldLabs identifies users inside the mobile browser, not inside a native app. A WebView that runs standard browser JavaScript can load the snippet, but a fully native app screen has no browser context to collect from. | Surface | Supported | How | | ------------------------- | ------------- | ----------------------------------------------------------------------------------------------------- | | Desktop browsers | Yes | The snippet, loaded from the CDN. | | Mobile web browsers | Yes | The same snippet. Mobile Safari and Android browsers included. | | Native iOS / Android apps | No native SDK | Use a WebView that runs the snippet, or call the [API](/api/overview) from your own collection layer. | ## Next steps The six identifiers, and why the DeviceID outlives cleared cookies. How corroborating multiple signals gets identification up to 99%. The exact directives that keep ad blockers and CSP from blocking the snippet. # Build with AI Source: https://docs.shieldlabs.ai/build-with-ai Copy-paste prompts that get ShieldLabs working fast in ChatGPT, Claude, or Cursor, plus how to feed the whole docs to your AI tool as context. Most of a ShieldLabs integration is glue code: load the snippet, verify a webhook, turn a score into a decision. The prompts below are written so you can paste one into your AI assistant, fill in the placeholders, and get working code back. Each one already carries the product facts the model needs, so it does not guess. **Two ways to give your AI tool the full context.** * Every page in these docs has a menu in the top-right to **Copy page**, **View as Markdown**, or open it directly in **ChatGPT** or **Claude** with the page preloaded. * The entire documentation set is published as a single file at [`/llms-full.txt`](https://docs.shieldlabs.ai/llms-full.txt) (with a short index at [`/llms.txt`](https://docs.shieldlabs.ai/llms.txt)). Paste either URL into your assistant to load all of ShieldLabs as background before you ask. ## Install the snippet Replace the placeholders, then paste into ChatGPT, Claude, or Cursor. ```text theme={null} You are helping me integrate ShieldLabs visitor identification into my web app. Stack: . Public key: . How ShieldLabs loads: - It is a browser ES module loaded from a CDN. It is NOT an npm package and not a native SDK. - Load and run it like this: const mod = await import('https://cdn.shieldlabs.ai/snippet.js?publicKey=YOUR_PUBLIC_KEY'); mod.checkAnonymous(); - It runs in the browser, posts signals to ShieldLabs automatically, requests no permissions, and must not block page load (use a dynamic import, run it async). - For a signed-in user, call mod.checkAuthenticatedUser('') instead, passing a hash of my user id, never the raw id. Write the integration for my stack, show exactly where the code goes, and load it on the pages I want to identify visitors on. ``` ## Verify a webhook ```text theme={null} Write a webhook handler for ShieldLabs. Delivery format: ShieldLabs POSTs JSON shaped like { "Data": { ...identification fields..., "Score": <0-100>, "Details": [ { "Value": , "Description": "" } ], "Phase": "initial" }, "Assing": "" } Verification: - "Assing" is an HMAC-SHA256 of the raw "Data" JSON, keyed with my Secret Key . - Compute the HMAC over the exact received bytes (do not re-serialize) and compare in constant time. Reject the request if it does not match. Reliability: - Delivery is at-most-once with no retries and a ~1 second timeout. - Make the handler idempotent on Data.RequestID and return 200 quickly. Give me the full handler with signature verification and an idempotency guard. ``` ## Turn the Risk Score into a decision ```text theme={null} I receive a ShieldLabs Risk Score per visit and want to turn it into an action. Facts: - Score is 0-100. Bands: Clean 0-9, Low 10-29, Medium 30-59, High 60-100. - Details is an array of { Value, Description }: the signals that built the score and the points each one added. - Branch on the Score band and on the Details Value, NOT on exact Description text. The Description strings are human-readable and not a stable API contract. - ShieldLabs does not block anything. My code owns the decision: allow, challenge (step-up or 2FA), send to manual review, or block. - A legitimate user can score high (corporate VPN, privacy browser), so weigh the score against how sensitive the action is. Write a function decide(score, details, actionSensitivity) that returns one of allow | challenge | review | block, with sensible thresholds I can tune per action. ``` ## Read a visitor's history ```text theme={null} Write a function that reads a visitor's history from the ShieldLabs Server API. Endpoint base: https://api.shieldlabs.ai/:/ - The History endpoint returns an array of past snapshots, newest first, each with the identifiers, Country, and Score for that visit. - Auth is the {domain}:{secret} segment in the path. Keep the secret server-side only. - Billing: a History read bills one request per returned row, so cap how far back I read. Give me the function plus a short example that fetches the last few snapshots for one VisitorID and prints how its Score changed over time. ``` ## Keep the model honest When you paste generated code back, sanity-check it against the real product: The real exports, framework examples, and what the browser collects. The `{ Data, Assing }` envelope and HMAC verification in Node, Go, and Python. The 0-100 score, its bands, and the `Details` breakdown to branch on. History, profile, and callback endpoints with full request and response shapes. If a model invents an endpoint, an npm package, or a `Description` value to switch on, it is guessing. Re-prompt it with the page above (Copy page, then paste) and it will correct. # Changelog Source: https://docs.shieldlabs.ai/changelog What recently changed in the ShieldLabs docs and product. What changed across the ShieldLabs platform and these docs. Newest first. ## June 2026 ### Documentation rebuilt from the production codebase The developer docs were rewritten end to end to match exactly what ships in production. The previous docs described features and a score range that the product does not have. If you integrated against the old pages, review the highlights below. The score is the **Risk Score**, a value from **0 to 100**. There is no "Trust Score" and no 0 to 999 scale. There is no in-product rules engine: ShieldLabs returns a score and the signals behind it, and your own code decides whether to allow, challenge, review, or block. **What the rebuilt docs now cover accurately:** Explainable score with a `Details` array. Every entry is the signal that fired and the points it contributed. Bands: Clean (0 to 9), Low (10 to 29), Medium (30 to 59), High (60 to 100). An `initial` webhook (first score, about 1 second) and an `update` webhook (recomputed after a follow-up network check). Both signed with HMAC-SHA256 in the `Assing` field. At-most-once delivery, so make handlers idempotent on `RequestID`. Relationship patterns computed server-side and surfaced on the dashboard Patterns tab as Suspicious or Dangerous detections. An entity below the Suspicious threshold simply is not flagged. They are separate from the per-request scoring signals. Estimated unique Visitors and New Visitors counted by a fingerprint-derived identity, plus Traffic Sources that rank each channel by the anonymous-traffic share it delivers. Read past results with the History API, manage your callback URL, and fetch your domain profile. Server-side auth uses `{domain}:{secret}` in the path. DeviceID, VisitorID, CookieID, SessionID, RequestID, and the UserHID you pass in (hash it yourself; ShieldLabs does not). DeviceID is durable because it is derived from the browser environment, not stored. **Corrections from the previous docs:** * Only four band labels exist: **Clean, Low, Medium, High**. There is no "Bot" or "Banned" band. * The dashboard feature is the **Patterns** catalog, not "Behavior Patterns" or a "velocity" pattern. * 999 is an internal rate-limit sentinel, never a customer Risk Score. New to the platform? Start with the [Quickstart](/quickstart), then read [How it works](/overview) and [Acting on the Risk Score](/guides/acting-on-risk-score). # Cookbook Source: https://docs.shieldlabs.ai/cookbook Worked, copy-pasteable recipes for the most common fraud and traffic checks. # Cookbook Each recipe is an end-to-end pattern: where to call the snippet, what arrives on the webhook, and how your own code turns the Risk Score and its `Details` into an allow, challenge, review, or block decision. ShieldLabs scores the request; your application owns the verdict. There is no in-product rules engine, so every threshold below lives in your code where you can read it, log it, and tune it. The recipes share the same building blocks: the [snippet](/setup/snippet) collects 100+ signals, a [webhook](/setup/webhooks) delivers the explainable **Risk Score (0-100)** with its `Details`, and you key your business logic off the [Clean / Low / Medium / High bands](/features/risk-scoring). Read [Acting on the Risk Score](/guides/acting-on-risk-score) first if you want the decision skeleton these recipes reuse. Call `forceCheckAuthenticatedUser` at login and step up to 2FA when the session scores Medium or High. Re-identify right before payment, then challenge or hold orders carrying strong anonymity signals. Reconstruct a buyer's device history into evidence against friendly-fraud chargebacks. Catch multi-accounting and farm signups by joining the Risk Score with the DeviceID at registration. See one account spread across many devices and countries, and enforce your own sharing policy. Score login anonymity and throttle on the durable DeviceID so rotated IPs stop resetting your limits. Compare the login DeviceID against the account's history and step up when a known account hits a new device or country. Rank referral and promo conversions by anonymous-traffic share so masked clicks do not get paid out. Score every visit by source and channel to measure cost per real visitor, not per click. Count the accounts and redemptions tied to one device to stop signup-bonus and free-trial farming. Key bans to the durable DeviceID so a cleared cookie or a fresh account does not let someone back in. Meter free views on the DeviceID, which clearing cookies or opening incognito cannot reset. Read the anonymity signals and the IP country to catch VPN-masked region switching before you discount. Recognize a clean returning device to cut friction for trusted visitors, the inverse of the fraud checks. The decision pattern behind every recipe: read Score plus Details, then branch on the band in your own code. ## How every recipe is shaped Load the [snippet](/setup/snippet) on the relevant page. For sensitive actions (login, payment, withdrawal) call `forceCheckAnonymous` or `forceCheckAuthenticatedUser` to clear the session and re-score on the spot. Always pass a hashed account id (UserHID) to the authenticated calls, never a raw email. ShieldLabs scores asynchronously (about a second) and posts a `Phase: "initial"` body, then an optional `Phase: "update"` body after a follow-up network check. Verify the `Assing` HMAC and make your handler idempotent on `RequestID`, all covered in the [webhooks setup](/setup/webhooks). Branch on the band, and read each detail's numeric `Value` for the action context. A payment that lands in the High band warrants more than the raw number alone. Persist `RequestID` plus your decision so the verdict is auditable. A typical webhook body the recipes act on: ```json theme={null} { "Data": { "RequestID": "8f1d0c2a-7b3e-4a9c-9d2f-1e6a5b4c3d21", "VisitorID": "c4a2e9b1-5f8d-4c3a-8e7b-2a1f0d9c8b76", "DeviceID": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", "IP": "203.0.113.42", "OS": "Mac OS X", "Country": "US", "UserHID": "8a9f...hashed-account-id", "Score": 70, "Details": [ { "Value": 60, "Description": "OS Mismatch" }, { "Value": 10, "Description": "Datacenter IP" } ], "Phase": "initial" }, "Assing": "1f3c9a...hex-hmac-sha256" } ``` Here the score is 70 because two additive signals stack: an OS mismatch (60) and a datacenter IP (10). `Description` is a human-readable label for visibility and logging. Branch on the `Score` and each entry's `Value`, not on the label text, which can change. The shared decision skeleton, in your backend: ```js theme={null} // Bands: Clean 0-9, Low 10-29, Medium 30-59, High 60-100. function actionFor(score, details) { // A rate-limit-banned snapshot can reach you scored 999. Guard the band logic. if (score > 100) return "block"; // The weight any single signal contributed is in its numeric Value. // Use the band, never the human-readable Description label, to drive decisions. const topSignal = Math.max(0, ...details.map((d) => d.Value)); if (score >= 60) return "challenge"; // High: block, review, or require verification if (score >= 30) return "review"; // Medium: step-up challenge or a second look if (score >= 10) return topSignal >= 30 ? "review" : "allow_log"; // Low, but a heavy single signal is worth a second look return "allow"; // Clean: pass through, no friction } ``` The Risk Score is **0-100**, hard-capped at 100, in four bands: **Clean (0-9)**, **Low (10-29)**, **Medium (30-59)**, **High (60-100)**. A higher score means more anonymous or masked traffic, not a confirmed verdict. A legitimate visitor can score high (a corporate proxy, a VPN, or a privacy browser), so decide on Score plus `Details` plus action context, never the number alone, and tune thresholds gradually. Webhooks are at-most-once with no retries and a roughly 1-second timeout. Make handlers idempotent on `RequestID`, and for guaranteed reads poll the [History API](/api/server-api) instead of relying on a single delivery. The webhook delivery is free; each History row you read back bills 1 request, so reserve those reads for the decisions you cannot afford to miss. ## The shared webhook-cache helper Every recipe receives the score the same way: verify the `Assing` HMAC, respond fast, store the result by `RequestID`, and let the request path read it back with a short timeout. This is the canonical `scoreCache` and `waitForScore` helper the individual recipes link to instead of repeating it. ```js webhook-cache.js theme={null} const scoreCache = new Map(); // use a shared store or your datastore in production // Webhook handler: verify, respond fast, then store keyed by RequestID. app.post('/shieldlabs/webhook', express.json(), (req, res) => { const { Data, Assing } = req.body; // Verify the Assing HMAC over Data first. The Node, Go, and Python recipes // live in /setup/webhooks. The field is literally spelled "Assing". if (!verifyWebhook(Data, Assing)) return res.status(401).end(); // Idempotent on RequestID: an "update" Phase or a redelivery can repeat it, // so storing is safe but do not double-apply business effects. res.status(200).end(); scoreCache.set(Data.RequestID, { score: Data.Score, // 0 to 100 details: Data.Details, // array of { Value, Description } ip: Data.IP, country: Data.Country, userHID: Data.UserHID, deviceID: Data.DeviceID, phase: Data.Phase, // "initial" | "update" }); setTimeout(() => scoreCache.delete(Data.RequestID), 5 * 60 * 1000); }); // Read path: poll the cache briefly, then fall back to a History API read. async function waitForScore(requestId, timeoutMs) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (scoreCache.has(requestId)) return scoreCache.get(requestId); await sleep(100); } return null; // fall back to a History API read by request_id } ``` ## Where to go next If you have not wired up the snippet and a webhook yet, start with the [Quickstart](/quickstart) and [Setup](/setup). To understand what the score and its anonymity signals mean, read [Risk Score](/features/risk-scoring), [Signals](/features/anonymity-signals), and the dashboard [Patterns](/features/patterns). The [API overview](/api/overview) has the full payloads and endpoints behind these recipes. # Account Sharing Source: https://docs.shieldlabs.ai/cookbook/account-sharing Learn how to spot one account spread across many devices and enforce your policy. Account sharing has a recognizable shape: one account, many devices, sometimes many countries in a short window. ShieldLabs gives you two layers to see it, and your own code decides the policy (a paid seat is fine, a credential resold to fifty people is not). On every authenticated session, the [webhook](/setup/webhooks) carries the **DeviceID** and **Country** for that account's `UserHID`. Compare them against the account's known devices in real time. Server-side [Patterns](/features/patterns) that link an account across sessions: "Many Devices on One Account", "Multiple Countries on One Account", "New Device and New Country on One Account". Read on the [dashboard](/features/patterns). ShieldLabs links and surfaces. It never blocks anyone. Your code reads the device and country spread, then decides: allow, require re-authentication on the new device, enforce a concurrent-session limit, or flag the account for review. Account sharing is a policy question, not a fraud verdict. A family plan, a shared team login, and a resold credential can all look like "many devices on one account." ShieldLabs tells you the spread; your terms of service decide what is allowed. ## How the two layers work together | Layer | What it answers | Where you read it | Latency | | ---------------------- | --------------------------------------------------------------------- | ----------------------------------------------------------- | --------------------- | | Per-session device/geo | "Is this account on a new device or in a new country right now?" | [Webhook](/setup/webhooks) / [History API](/api/server-api) | Real time (\~1s) | | Patterns | "Has this account spread across many devices or countries over time?" | [Dashboard Patterns](/features/patterns) + export | Background (\~10 min) | ## Layer 1: check the device on every authenticated session Call `checkAuthenticatedUser` with the account's hashed id (UserHID) on login and on sensitive actions. The webhook then carries the `DeviceID`, `Country`, and `UserHID` for that session, so your backend can compare against what it already knows about the account. ```html app.html theme={null} ``` Your backend reads the scored result for that `RequestID` from the webhook cache (or the [History API](/api/server-api)), then checks the DeviceID against the account's known devices. ```js api/session-check.js theme={null} app.post('/api/session-check', async (req, res) => { const { accountId, shieldRequestId } = req.body; // Pull the ShieldLabs result for this session. const shield = await waitForScore(shieldRequestId, 2000); const deviceId = shield?.DeviceID; const country = shield?.Country; // A blocked or JS-disabled session can return an all-zero DeviceID. // That is "device unknown", not a new device: weigh it with IP + account // context instead of treating it as one more device or auto-allowing. const ZERO_DEVICE = '00000000-0000-0000-0000-000000000000'; if (!deviceId || deviceId === ZERO_DEVICE) { return res.json({ action: 'review', reason: 'device_unknown' }); } // Compare against what you already know about this account. const known = await knownDevicesFor(accountId); // your own store if (!known.devices.has(deviceId)) { // A device this account has never used. Your call: re-auth, notify, or just log. if (known.devices.size >= YOUR_DEVICE_LIMIT) { return res.json({ action: 'reauth_required', reason: 'new_device_over_limit' }); } await rememberDevice(accountId, deviceId, country); return res.json({ action: 'notify_new_device' }); } return res.json({ action: 'allow' }); }); ``` A person using two browsers (Chrome then Safari) shows up as two devices, so "many devices" can include one person's own browsers, a nuance the [identifiers reference](/features/identification) explains. Weigh it with the country spread, the IP, and your own context before you treat it as sharing. A session that blocks the fingerprint or runs with JavaScript disabled can arrive with an all-zero `DeviceID` (`00000000-0000-0000-0000-000000000000`), because no stable device characteristics were collected. Treat that as "device unknown", not as a brand-new device: weigh it with the IP and the account context rather than auto-allowing it or counting it as one more device in the spread. A run of all-zero DeviceIDs on one account is itself worth a second look. ## Layer 2: see the spread with Patterns A single new device is easy to read. The harder signal is an account that quietly appears on a dozen devices, or from several countries within an hour. That is what [Patterns](/features/patterns) surface, grading each account **Suspicious or Dangerous** as the linked count crosses thresholds in a rolling window. An account below the first threshold is simply the unflagged baseline, never recorded as a grade. One account used from many different devices. The core account-sharing and account-resale shape. The grouping identity is the **UserHID**; the spread is counted in distinct DeviceIDs over a rolling window (default 30 days). The same account active from several countries in a short window (24 hours). Catches a credential shared across regions, or one used behind rotating VPN exits. Note that a real traveler can trip this, so read it with the device spread. An existing account suddenly appears from a device and a country it has never used together. A strong account-takeover or hand-off signal, distinct from steady sharing. You read these on the [dashboard Patterns tab](/features/patterns), where each account shows its grade and the identifiers behind it. Levels never downgrade: once an account is flagged Dangerous, new clean activity does not clear it. ### Reconstruct an account's spread programmatically You can also compute the spread yourself from the [History API](/api/server-api). Search by `user_hid` and count the distinct devices and countries. ```bash Read one account's history theme={null} curl "https://api.shieldlabs.ai/{domain}:{secret}/history/user_hid/8a9f-hashed-account-id?limit=100" ``` ```js Count devices and countries per account theme={null} async function accountSpread(userHid) { const rows = await shieldHistory('user_hid', userHid, 100); const devices = new Set(rows.map((r) => r.DeviceID).filter(Boolean)); const countries = new Set(rows.map((r) => r.Country).filter(Boolean)); return { devices: devices.size, countries: countries.size }; } // Enforce your own policy. const { devices, countries } = await accountSpread('8a9f-hashed-account-id'); if (devices >= YOUR_DEVICE_LIMIT || countries >= YOUR_COUNTRY_LIMIT) { flagForReview(accountId, { devices, countries }); } ``` The History API bills 1 request per returned row (an empty result still bills 1), whereas the webhook delivery is free. For routine enforcement, prefer the pre-computed pattern export from the dashboard as your watchlist, and reserve live `user_hid` reads for the accounts you are actively investigating. ## Putting it together [Sign up for free](https://dashboard.shieldlabs.ai) and get 5,000 identifications, or log in if you already have an account. Register the domain you want to identify visitors on, then open the [Keys](/setup/keys) page. Use the **Public Key** to initialize the snippet in the browser, and create a **Secret Key** for your backend. Keep the secret server-side only; it verifies webhook signatures and authenticates the [Server API](/api/server-api) as `{domain}:{secret}`. Call `checkAuthenticatedUser` with the hashed UserHID on login and sensitive actions, wired in as part of the [snippet setup](/setup/snippet). On each webhook, record the `DeviceID` and `Country` against the account in your own store, so you can tell a new device from a familiar one. Allow a known device. For a new one over your limit, require re-authentication or notify the account owner. Your policy, your thresholds. Pull "Many Devices on One Account" and "Multiple Countries on One Account" from the [dashboard](/features/patterns) and review the Dangerous accounts. A streaming service tolerates more devices than a single-seat B2B tool. Start in logging-only mode, see how your real accounts distribute, then set limits that match your terms. ## Recommended starting policy A guide, not a rule. The right device and country limits depend entirely on your product. | Signal | Suggested action | | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | | Known device, known country | Allow | | New device, within your device limit | Notify the account owner, remember the device | | New device, over your device limit | Require re-authentication on the new device | | "Multiple Countries on One Account" (Suspicious or Dangerous) | Step up verification, review against your sharing policy | | "New Device and New Country" (Dangerous) | Treat as possible takeover: force re-auth with the [Login and 2FA](/cookbook/login-2fa) step-up pattern | The full decision playbook, including how to combine identity spread with the per-session Risk Score and its Details. # New Account Fraud Source: https://docs.shieldlabs.ai/cookbook/account-signup Learn how to catch fake and farmed signups at registration. Account farms and multi-accounting share one tell: many accounts created from the same hardware or the same network. ShieldLabs gives you two layers to catch this, and your own code decides what to do at the signup endpoint. A per-request [Risk Score](/features/risk-scoring) (0–100) on the anonymity of the session creating the account. Live in the webhook, in real time. Server-side [Patterns](/features/patterns) that link accounts across sessions: "Many Accounts on One Device", "Many Accounts on One Local IP". Surfaced in the [dashboard](/features/patterns). ShieldLabs scores and links. It never blocks anyone. Your signup handler reads the score and the pattern membership, then your code decides: allow, require extra verification, or reject. The bands below are a guide, not a rule. The Risk Score is computed per request and delivered in real time. Patterns are computed in the background (the worker runs about every 10 minutes) and are read from the dashboard or its export, not from the webhook payload. ## How the two layers work together | Layer | What it answers | Where you read it | Latency | | ---------- | ------------------------------------------------------------ | ----------------------------------------------------------- | --------------------- | | Risk Score | "Is this session masked, spoofed, or anonymous right now?" | [Webhook](/setup/webhooks) / [History API](/api/server-api) | Real time (\~1s) | | Patterns | "Has this device or local IP already created many accounts?" | [Dashboard Patterns](/features/patterns) + export | Background (\~10 min) | Layer 1 stops the obviously anonymous signup at the moment it happens. Layer 2 catches the slow farm that spreads creation over hours or days, each individual signup looking clean on its own. You want both. ## Layer 1: score the signup session Add the snippet to your signup page. Pass the user's hashed account id once you have one (for example after the form is filled but before submit). Never pass a raw email or user id, always a pseudonymous hash. ```html signup.html theme={null}
``` The signup endpoint then reads the score for that `requestID`. The score arrives on the [webhook](/setup/webhooks); cache it indexed by `RequestID`, or fall back to a [History API](/api/server-api) read by `request_id`. ```js api/signup.js theme={null} app.post('/api/signup', async (req, res) => { const { email, password, shieldRequestId } = req.body; // 1. Your normal validation first. if (await emailExists(email)) { return res.status(409).json({ error: 'Email already in use' }); } // 2. Look up the ShieldLabs Risk Score for this session. // waitForScore reads your webhook cache (with a short timeout). const shield = await waitForScore(shieldRequestId, 2000); const score = shield?.Score ?? 0; // 0–100, default to 0 if not yet in const signals = shield?.Details ?? []; // explainable: [{ Value, Description }] // 3. YOUR code owns the decision. Bands are a guide. if (score >= 60) { // High: strong anonymity signals. Require email + a second factor. return res.status(200).json({ requireVerification: true, reason: 'extra_verification', }); } if (score >= 30) { // Medium: one moderate signal or several overlapping. Email-verify before activating. return createPendingAccount(email, password, res); } // Clean / Low: create the account normally. return createAccount(email, password, res); }); ``` A high score is not proof of fraud. A legitimate user behind a corporate proxy, a VPN, or a privacy browser can score in the High band. Decide on the Score plus the `Details` plus your own context, never on the number alone. Tune your thresholds gradually. ### Reading the signals Every score ships with a `Details` array so you can see exactly why it fired. Each entry carries a numeric `Value` (its contribution) and a `Description` (a human-readable label). `Description` is a human-readable label for visibility and logging. Branch on the `Score` and each entry's `Value`, not on the label text, which can change. At signup, the decision belongs to the band, not to any one label. Drive friction from the `Score` and its band, and use each entry's `Value` to see which contributions are pulling the number up. The full per-band playbook lives in [Acting on the Risk Score](/guides/acting-on-risk-score), and the [Signals](/features/anonymity-signals) reference explains what each signal means. ## Layer 2: catch the farm with Patterns A single anonymous signup is easy to score. The harder problem is the farm that creates 50 accounts over a week, each one looking clean in isolation. That is what [Patterns](/features/patterns) are for. ShieldLabs links sessions over time by identifier and grades flagged entities **Suspicious or Dangerous**. An entity that crosses no threshold stays the unflagged baseline, which is never recorded or emitted. Two patterns target multi-accounting at signup directly: One device linked to many different accounts. The classic multi-accounting / account-farm shape. The grouping identity is the **DeviceID**, which is durable: it survives a cookie clear, an incognito window, and an IP rotation. Clearing storage does not reset the link. Many accounts created through the same local IP. Catches farms behind one router or one NAT, even when each session uses a fresh cookie and a different public IP. Both grade up as the count of linked accounts crosses thresholds in a rolling window (default 30 days). Levels never downgrade: once an entity is flagged Dangerous, it stays Dangerous as new activity confirms it. You read these on the [dashboard Patterns tab](/features/patterns). ### Why DeviceID is the right key A farm clears cookies and goes incognito between signups specifically to look new every time, but the [DeviceID](/features/identification) is derived from the browser environment rather than stored, so "Many Accounts on One Device" links those cleared-cookie signups back to one machine. A determined operator using several separate browsers shows up as several devices, as the [identifiers reference](/features/identification) details. Pair the device pattern with "Many Accounts on One Local IP" to catch what spans browsers but shares a network. ### Feed flagged entities into your own rules Export the flagged entities from the [dashboard](/features/patterns) (CSV or JSON), then enforce them in your signup endpoint. You can also reconstruct a device's history programmatically: read the [History API](/api/server-api) by `device_id` to see every account that device has touched. ```bash Read a device's history theme={null} curl "https://api.shieldlabs.ai/{domain}:{secret}/history/device_id/5eb7fd5c-1c5e-4a9f-9b21-7d2e8c0a1234?limit=50" ``` The response is an array of snapshots (newest first), each carrying the `UserHID` for that session. Distinct `UserHID` values on one `device_id` are distinct accounts behind that machine. ```js Gate signup on device history theme={null} // Build a denylist from your pattern export (the flagged DeviceIDs / UserHIDs), // or compute account count per device live from the History API. const flaggedDevices = await loadFlaggedDeviceIds(); // from dashboard export async function deviceIsKnownFarm(deviceId) { if (flaggedDevices.has(deviceId)) return true; // Optional live check: how many distinct accounts on this device? const rows = await shieldHistory('device_id', deviceId, 100); const accounts = new Set(rows.map((r) => r.UserHID).filter(Boolean)); return accounts.size >= YOUR_ACCOUNT_THRESHOLD; } app.post('/api/signup', async (req, res) => { // ... validation + Layer 1 score check above ... // Layer 2: pattern membership / device history. if (shield?.DeviceID && (await deviceIsKnownFarm(shield.DeviceID))) { return res.status(200).json({ requireVerification: true, reason: 'device_linked_to_many_accounts', }); } return createAccount(email, password, res); }); ``` The History API bills 1 request per returned row (an empty result still bills 1), whereas the webhook delivery is free. For high-volume signup flows, prefer the pre-computed pattern export as your denylist and reserve live `device_id` reads for the borderline cases. ## Putting it together [Sign up for free](https://dashboard.shieldlabs.ai) and get 5,000 identifications, or log in if you already have an account. Register the domain you want to identify visitors on, then open the [Keys](/setup/keys) page. Use the **Public Key** to initialize the snippet in the browser, and create a **Secret Key** for your backend. Keep the secret server-side only; it verifies webhook signatures and authenticates the [Server API](/api/server-api) as `{domain}:{secret}`. Load it from `cdn.shieldlabs.ai` and call `checkAnonymous`, following the [snippet setup](/setup/snippet). Configure your callback URL and verify the HMAC, both detailed in the [webhooks setup](/setup/webhooks). Read the score for the session and branch on its band. Your code, your thresholds; the starting map is in the table below. Pull the "Many Accounts on One Device" and "Many Accounts on One Local IP" entities from the [dashboard](/features/patterns) and check new signups against them. Start in a logging-only mode, watch how your real traffic distributes across the bands, then raise friction where the data justifies it. ## Recommended starting thresholds The four bands and their ranges are defined in [Risk Scoring](/features/risk-scoring), and the per-band action playbook lives in [Acting on the Risk Score](/guides/acting-on-risk-score). Mapped to a signup gate: | Risk Score band | Suggested signup action | | ------------------ | ------------------------------------------------------- | | **Clean** (0–9) | Create the account, no friction | | **Low** (10–29) | Create the account, log the session | | **Medium** (30–59) | Require email verification before activating | | **High** (60–100) | Require a second factor, or reject and route to support | Where you draw each action line is yours. Layer the pattern signal on top: if the session also belongs to "Many Accounts on One Device" or "Many Accounts on One Local IP", escalate one step (a Medium session on a flagged device becomes a High-friction signup). The full per-band decision playbook, including Details-aware decisioning and how to combine the score with specific signals. # Account Takeover Source: https://docs.shieldlabs.ai/cookbook/account-takeover Learn how to catch a known account being accessed from a new device. Account takeover is a known, legitimate account suddenly accessed by someone else: the right password from the wrong place. The shape on the wire is a `UserHID` you have seen many times before, arriving on a **DeviceID you have never seen for it**, often from a new country or behind a datacenter, VPN, or Tor session. ShieldLabs gives you the durable [DeviceID](/features/identification) to recognize the device and the [Risk Score](/features/risk-scoring) to read the session's anonymity, so your login code can step up to a second factor exactly when the device or location does not fit the account. The server-derived [DeviceID](/features/identification) is durable: it survives cleared cookies, incognito, and IP rotation, so a returning customer keeps the same id and an intruder's machine shows up as a new one. The per-request [Risk Score](/features/risk-scoring) folds datacenter, VPN, proxy, Tor, and anti-detect [signals](/features/anonymity-signals) into one number, raising the takeover risk when the session is masked. ShieldLabs scores the session and links the device to the account over time. Your login endpoint owns the verdict: allow, challenge, hold for review, or block. The play here is narrow and high-signal, so reserve the strongest friction for the logins where both the device and the location are new. This recipe is the device-and-location half of login security. The [step-up 2FA](/cookbook/login-2fa) recipe owns the threshold ladder that turns a risky login into a second-factor challenge, and the [credential stuffing](/cookbook/credential-stuffing) recipe owns throttling the flood of attempts by DeviceID. This page assumes both and only carries the device-comparison logic that is unique to takeover. ## The takeover signal: a new device on a known account A first-time login looks the same to your password check whether it is the real owner on a new laptop or an intruder with a stolen password. The difference is in the history. Before issuing the session for an established account, pull the devices and countries that `UserHID` has used before and compare them to the one in front of you right now. The cheap, durable comparison key is the **DeviceID**. It is derived server-side from stable device characteristics, so it stays the same when the visitor clears cookies, opens an incognito window, or rotates their IP, and an intruder on a different machine cannot reproduce it. A `UserHID` that has only ever appeared on one or two DeviceIDs, now logging in from a third, is the core takeover shape. ```js Compare the login device against the account's history theme={null} // Runs after your password check passes, before you issue the session. async function takeoverRisk(userHid, shield) { const score = shield?.score ?? 0; // 0-100, this login's anonymity const deviceId = shield?.DeviceID; const country = shield?.country; // Pull the account's recent sessions. Each returned row bills 1 request, // so cap the limit and reserve this read for accounts worth protecting. const rows = await shieldHistory('user_hid', userHid, 50); const knownDevices = new Set(rows.map((r) => r.DeviceID).filter(Boolean)); const knownCountries = new Set(rows.map((r) => r.Country).filter(Boolean)); // The all-zero DeviceID is "no device", not a new one. Do not treat it as // unseen, or every blocked or JS-disabled login looks like a fresh device. const NIL = '00000000-0000-0000-0000-000000000000'; const usableDevice = deviceId && deviceId !== NIL; const newDevice = usableDevice && !knownDevices.has(deviceId); const newCountry = country && knownCountries.size > 0 && !knownCountries.has(country); return { score, newDevice, newCountry }; } ``` `shieldHistory` is the same `GET /history/{type}/{value}` read the other recipes use, here keyed by `user_hid` to pull the account's own recent sessions. The response is an array of snapshots, newest first, each carrying the `DeviceID`, `Country`, and `Score` for that session. ```bash The account's recent devices and countries theme={null} curl "https://api.shieldlabs.ai/{domain}:{secret}/history/user_hid/a1b2c3d4hasheduserid?limit=50" ``` A blocked or JavaScript-disabled browser returns the all-zero DeviceID (`00000000-0000-0000-0000-000000000000`) and a fixed [Risk Score](/features/risk-scoring) of 90, since the device characteristics needed to derive a stable id were never collected. Route that login to verification on the score alone, and skip the new-device comparison for it. The all-zero id is the absence of a device, so never count it as a new one. ## Wire it into your login gate The verdict combines the device comparison with the session's anonymity. A new DeviceID on its own can be a real customer's new phone. A new device **and** a new country, or a new device **and** datacenter or Tor signals on the session, is the combination worth a step-up. The [step-up 2FA](/cookbook/login-2fa) recipe owns the band ladder; here it is the inputs that change the rung. ```js api/login.js escalate a takeover-shaped login theme={null} app.post('/api/login', async (req, res) => { const { username, password, shieldRequestId } = req.body; // 1. Your normal credential check first. const user = await verifyPassword(username, password); if (!user) return res.status(401).json({ error: 'invalid_credentials' }); // 2. Wait briefly for the score; fall back to a History read by request_id. // waitForScore and the webhook handler are the shared Cookbook helper. const shield = await waitForScore(shieldRequestId, 2000); if (!shield) { // Missing data is not "clean". Default an established account to a second factor. return res.status(200).json({ status: 'require_2fa', reason: 'verifying' }); } // 3. The device-and-location comparison unique to takeover. const { score, newDevice, newCountry } = await takeoverRisk(user.hashedId, shield); // 4. Combine. A new device plus a new country, or a new device on an // anonymized session, escalates. Branch on the Score band and the // boolean facts above, never on a Details Description label string. if (newDevice && (newCountry || score >= 60)) { await alertAccountOwner(user.id, shield); // notify the real owner return res.status(200).json({ status: 'verify', method: 'strong' }); } if (newDevice || score >= 30) { return res.status(200).json({ status: 'require_2fa', method: 'otp' }); } // Known device, clean session: issue the session, no extra friction. return issueSession(user, res); }); ``` Step up, do not hard-block. A real customer buys a new laptop, travels, or signs in over a corporate VPN, and every one of those raises the same flags. A second factor or a re-verification keeps the genuine owner in while still stopping an intruder who only has the password. Reserve an outright block for an account already under an active attack, and tune the thresholds against your own login traffic. ## Watch the takeover patterns on the dashboard The History read above reconstructs one account's device history on demand. For the standing view across all accounts, the [dashboard Patterns](/features/patterns) compute the same shapes historically and grade each flagged account **Suspicious**, then **Dangerous**, as the evidence accumulates. Below the Suspicious threshold an account is the unflagged baseline, which is never recorded or emitted. Patterns are dashboard-only; they do not ride on the webhook. An account appearing from a `(device, country)` combination it has never used before. The closest single pattern to a takeover, and the one to watch first: pull its flagged UserHIDs and feed them into a step-up watchlist. One account reached from many distinct devices over the window. A spread that can mean a shared or resold account, and at the high end an account being worked from a string of new machines. The same account appearing from several countries in a short span, the impossible-travel shape that VPN hopping and a hijacked session both produce. An account whose DeviceID or VisitorID keeps changing, a hint of anti-detect tooling cycling its environment between attempts to look like a new visitor each time. Export the flagged entities from the [dashboard Patterns tab](/features/patterns) as CSV or JSON and use the Dangerous UserHIDs as a step-up watchlist at your login gate: any session for one of those accounts gets a second factor regardless of the per-login score. Identity continuity rests on the DeviceID, not the VisitorID. The DeviceID is durable across a cookie clear; the [VisitorID](/features/identification) is recomputed from the device and a browser cookie, so clearing cookies gives the same browser a fresh VisitorID. Compare the device a `UserHID` arrives on against the DeviceIDs it has used before, and treat a VisitorID change as a weaker hint, not the primary key. ## Recommended starting policy A guide, not a rule. Layer the conditions: a takeover login trips more than one, and friction should rise as they stack. | Condition for an established account | Suggested login action | | --------------------------------------------------------------- | -------------------------------------------- | | Known DeviceID, clean session (Score under 30) | Allow | | New DeviceID, otherwise clean | Require a second factor | | New DeviceID **and** new Country | Require strong verification, alert the owner | | New DeviceID **and** High-band anonymity (datacenter, VPN, Tor) | Require strong verification, alert the owner | | All-zero DeviceID (blocked or JS-disabled, fixed Score 90) | Route to verification on the score alone | | UserHID on the "New Device and New Country" Dangerous watchlist | Step up on every session until reviewed | ## Next The threshold ladder this recipe escalates into: when a risky login becomes a second-factor challenge. The other login defense: throttle the flood of attempts on the durable DeviceID before takeover is even on the table. # Affiliate Fraud Source: https://docs.shieldlabs.ai/cookbook/affiliate-fraud Learn how to pay affiliates for real visitors, not masked or duplicated clicks. Affiliate and paid-acquisition programs pay per click, install, or conversion. The incentive to inflate those events with masked, recycled, or coordinated traffic is built in. ShieldLabs does not tell you "this affiliate is fraud." It tags every visit with where it came from, attaches an explainable [Risk Score (0-100)](/features/risk-scoring), and links visits to a durable [DeviceID](/features/identification) so the same device cannot pose as many fresh visitors. You rank your sources by that, and your own payout code decides what to pay, hold, or review. This is a traffic-quality and identity problem, not a bot-detection one. ShieldLabs scores how anonymous or masked a visit is and links it to an identity. It never blocks a click or "catches" an affiliate. Your code owns every withhold/review/pay decision. ## The two questions you can finally answer Every visit is tagged with channel, referrer, and UTM, and carries a Risk Score. Rank affiliates and campaigns by their anonymous-traffic share on the [Traffic Sources](/features/traffic-analytics) view. One device rotating IPs and clearing cookies through a single affiliate still resolves to one [DeviceID](/features/identification), so it cannot be counted (and paid) as a crowd. The Risk Score answers "is this visit masked, spoofed, or anonymous right now?" The DeviceID answers "have I seen this exact device before, no matter how many cookies and IPs it cycled through?" An affiliate-quality program needs both: the score grades the traffic, the identity stops a single device from being counted (and paid) as a crowd. ## Bands you will reference The Risk Score is 0-100 in four fixed bands, Clean (0-9), Low (10-29), Medium (30-59), and High (60-100), each defined in full on the [Risk Score](/features/risk-scoring) page. For affiliate quality the band of one visit matters less than the band mix across a source. A high score is not a verdict of fraud. A real person behind a corporate proxy, a VPN, or a privacy browser can land in the High band. For affiliate quality you are not judging one visit, you are reading the **distribution across a source**: a partner that sends 95% Clean and one that sends 45% High can report identical click counts, and the score is what separates them. Decide on the shape of the source over many visits, plus the [Details](/features/risk-scoring) behind the scores, never on a single number. ## Step 1: capture the source on every visit Install the snippet on the landing pages that affiliate and paid traffic arrives on. The snippet already records channel, referrer, and UTM attribution for every request, so the [Traffic Sources](/features/traffic-analytics) tables populate with no extra work. You only need to stash the `requestID` so a later conversion can be tied back to the scored visit. ```html landing.html theme={null} ``` Make sure your inbound affiliate links carry UTM parameters. ShieldLabs records `utm_source`, `utm_medium`, `utm_campaign`, `utm_content`, `utm_term`, the `referrer_domain`, and the resolved `channel` for each request. The [snippet reference](/setup/snippet) carries the React, Vue, Angular, Svelte, and Preact variants, and the [CSP headers](/setup/csp) cover what the snippet needs. Channels resolve to a fixed set on the dashboard: Google Ads, Meta, TikTok, LinkedIn, X, Organic Search, Referral, Direct, and Other. Most affiliate traffic lands under **Referral**, then you drill into the individual referrer host or `utm_source` to find the specific partner. ## Step 2: rank sources by risk on the dashboard Rank each source by the risk and anonymous-traffic share it delivers on [Traffic Sources](/features/traffic-analytics), so you measure cost per real visit instead of cost per click. The Channels table, the per-referrer / per-UTM drill-down, and the free per-request export are walked through in [Measure Traffic Quality](/cookbook/traffic-quality); this page covers the payout decision built on top of it. ## Step 3: tie a conversion to its scored visit When a conversion fires, read the score for the `requestID` you stashed, and record it alongside the affiliate and UTM you captured on the client. The score arrives on the [webhook](/setup/webhooks); cache it indexed by `RequestID`, or fall back to a [History API](/api/server-api) read by `request_id`. That payload carries the score, `Details`, `DeviceID`, and `Country`, so pair it with the client-captured attribution. Your payout logic then withholds or routes to review instead of paying automatically. ```js api/conversion.js theme={null} app.post('/api/conversion', async (req, res) => { const { affiliateId, eventType } = req.body; const shieldRequestId = req.cookies.shield_rid; // Read the score for this visit (webhook cache, History API fallback). // This payload carries Score, Details, DeviceID, Country, UserHID. const shield = shieldRequestId ? await getScore(shieldRequestId) : null; const score = shield?.Score ?? 0; // 0-100, default 0 if not yet in const details = shield?.Details ?? []; // explainable: [{ Value, Description }] // Record the conversion with its quality context. Always store, never drop. const conversion = await db.conversions.create({ affiliateId, eventType, shieldScore: score, shieldDetails: details, deviceId: shield?.DeviceID, country: shield?.Country, // Channel/UTM are not on the Shield payload. They were captured client-side // at landing and stashed in the cookie. The dashboard and Data export also // carry the resolved channel and UTM per request. utmSource: req.cookies.shield_utm, }); // YOUR payout logic. Bands are a guide, the thresholds are yours. if (score >= 60) { // High: strong anonymity signals. Withhold and queue for manual review. await holdForReview(conversion.id, 'high_risk_visit'); } else if (score >= 30) { // Medium: one moderate or several overlapping signals. Approve on a delay // so a later chargeback or pattern flag can claw it back. await approveWithClawbackWindow(conversion.id); } else { // Clean / Low: pay out on your normal schedule. await approvePayout(conversion.id); } return res.json({ ok: true }); }); ``` `Description` is a human-readable label for visibility and logging. Branch on the Score and each entry's `Value`, not on the label text, which can change. Store the score and `Details` on every conversion, even the ones you pay. The value of this data is in aggregate: a single Medium visit is noise, but a partner whose conversions are 40% Medium-and-High is a reweighting decision. You cannot rank sources you did not record. ## Step 4: dedup one device arriving under rotated IPs The hardest affiliate abuse to see is a single device that clears cookies and rotates its public IP between events, so each visit looks like a fresh user from a fresh location. Cookie-based and IP-based dedup both fail here: new cookie, new IP, new "user" every time. The **DeviceID** defeats that. It is server-derived from stable browser and device characteristics, not stored in a cookie. Because it is derived from the browser environment rather than stored, the same browser produces the same DeviceID after a cookie clear, an incognito window, or an IP rotation. Dedup conversions on DeviceID inside your attribution window, not on IP or cookie. ```js api/webhook.js theme={null} app.post('/shieldlabs/webhook', async (req, res) => { // Verify the HMAC first (see /setup/webhooks), then ack fast. const { Data } = req.body; res.status(200).end(); if (!Data?.DeviceID) return; // Has this exact device already converted for this affiliate recently? const key = `affiliate:${affiliateForRequest(Data.RequestID)}:device:${Data.DeviceID}`; const firstSeen = await store.setIfAbsent(key, Data.RequestID, { ttlSeconds: 86400 }); if (!firstSeen) { // Same DeviceID, same affiliate, inside the window: this is a repeat device, // not a new visitor. Mark it so payout does not double-count. await db.conversions.markRepeatDevice(Data.RequestID, Data.DeviceID); } }); ``` An operator who spreads across several separate browsers shows up as several devices, with the full mechanics in the [identifiers reference](/features/identification). Pair device dedup with the source-level risk ranking above so coordinated traffic that shares a network still surfaces, even when it spans browsers. Use **DeviceID for the durable cross-session link**, not VisitorID: VisitorID changes the moment the cookie is cleared, which is exactly what an inflating device does between events. ## Step 5: rank affiliates from the recorded conversions With the score and DeviceID stored on every conversion, a daily job ranks each affiliate by the quality it actually delivered. This feeds back into your manual review queue and your payout terms, not into any ShieldLabs configuration. There is no in-product rules engine: the ranking and the action both live in your code. ```js jobs/rank-affiliates.js theme={null} // Daily: summarize each affiliate's last 24h of conversions. async function rankAffiliates() { const affiliates = await db.conversions.groupBy('affiliateId', { last24h: true, select: { total: true, avgScore: true, mediumOrHigh: { where: { shieldScore: { gte: 30 } } }, repeatDevices: { where: { repeatDevice: true } }, distinctDevices: { distinct: 'deviceId' }, }, }); return affiliates .map((a) => ({ affiliateId: a.affiliateId, conversions: a.total, avgScore: a.avgScore, // average Risk Score maskedShare: a.mediumOrHigh / a.total, // Medium + High share deviceInflation: 1 - a.distinctDevices / a.total, // repeat-device rate })) .sort((x, y) => y.maskedShare - x.maskedShare); // worst sources first } ``` A partner at the top of that list, high masked share and high device inflation, is the one to move from auto-pay to manual review, renegotiate, or hold. The decision and the threshold are yours. Cross-check the dashboard before you act. If a `utm_source` ranks badly in your job, open [Traffic Sources](/features/traffic-analytics), filter to that source, and confirm the Risk Badge and the [Patterns](/features/patterns) tab agree. Two independent views landing on the same partner is a much stronger basis for a payout change than one number. ## Read a device's full history when you need it For a borderline source, reconstruct what a single device has been doing across your whole site. Read the [History API](/api/server-api) by `device_id`. The response is an array of snapshots, newest first, each carrying the `Score`, the `Details`, the network and device fields, the `Country`, and the `UserHID` for that session. ```bash Read a device's history theme={null} curl "https://api.shieldlabs.ai/myshop.com:YOUR_SECRET_KEY/history/device_id/5eb7fd5c-1c5e-4a9f-9b21-7d2e8c0a1234?limit=50" ``` Distinct `UserHID` values on one DeviceID can mean one device behind many accounts, and many countries on one DeviceID can mean a single operator masking location across visits. For the source-level read of which affiliate links one device cycled through, the channel and UTM attribution live on the [Traffic Sources](/features/traffic-analytics) view and the [Data](/features/traffic-analytics) export, not on the History payload. The [Patterns](/features/patterns) "Many Devices on One Account" and "Many Accounts on One Device" surface this shape automatically on the dashboard. The History API bills 1 request per returned row, and an empty result still bills 1. Webhooks, dashboard views, and exports are free. For high-volume affiliate flows, lean on the free webhook stream and dashboard export, and reserve `device_id` history reads for the cases you are actually about to action. ## Where this fits Compute cost per real visitor by source, the reporting layer this payout logic sits on top of. Rank channels, referrers, and UTM by risk badge and anonymous-traffic share. Background detection of one-device-many-accounts and many-devices-one-account shapes. The full per-band decision playbook, including Details-aware decisioning. # Ban Enforcement Source: https://docs.shieldlabs.ai/cookbook/ban-evasion Learn how to keep banned users out after they clear cookies, switch accounts, or mask the session behind a VPN or anti-detect browser. A banned user comes back two ways, and ShieldLabs covers both. The first is to mask the session and look like someone else, behind a VPN, a proxy, or an anti-detect browser. That masking is itself detectable: the [Risk Score](/features/risk-scoring) and the [anonymity signals](/features/anonymity-signals) behind it flag the return even when the device has changed. The second is to look new without hiding, by clearing cookies, opening an incognito window, or registering a fresh account. Each resets the VisitorID, but the [DeviceID](/features/identification) does not move: it is derived server-side from stable device characteristics, so the same browser returns the same DeviceID after a cookie wipe, in incognito, and across an IP rotation. Ban the device, and read the score on whatever comes back. When an evader hides the session behind a VPN, proxy, or anti-detect browser, the [Risk Score](/features/risk-scoring) and [anonymity signals](/features/anonymity-signals) flag it, so a masked comeback stands out even on a new device. Record the **DeviceID** (and the local IP) on a device-level banlist when you ban someone, and compare every incoming DeviceID against it. That is the key a cookie-keyed ban lacks. ShieldLabs gives you the durable identifier and the [Risk Score](/features/risk-scoring); your own code owns the verdict. The play here is one banlist lookup at the start of a session, keyed on something the returning user cannot easily change. The DeviceID is browser-bound. A different browser on the same machine, or a wiped or materially changed device, can produce a new DeviceID, so treat your device-level counts as estimates and pair the DeviceID with the local IP. The honest limits are spelled out below. ## Ban the device, not the cookie When you ban a user, write down what travels with them. The cookie and the account both reset on demand; the DeviceID and the local IP are what a returning user has to keep using. ```js When you issue a ban theme={null} // At ban time you already have the offender's last scored session. // Persist the durable identifiers, not just the account id. async function banUser(accountId, shieldRequestId, reason) { const shield = await waitForScore(shieldRequestId, 2000); await markAccountBanned(accountId, reason); // Record the device-level keys so a cleared cookie or a fresh account // does not buy a clean slate. A blocked / all-zero DeviceID is not a key. const ZERO_DEVICE = '00000000-0000-0000-0000-000000000000'; if (shield?.DeviceID && shield.DeviceID !== ZERO_DEVICE) { await banlist.addDevice(shield.DeviceID, { accountId, reason }); } if (shield?.IP) { await banlist.addLocalIp(shield.IP, { accountId, reason }); } } ``` `waitForScore` is the shared webhook-cache read (poll the cache, then fall back to a History API read by `request_id`) that the [Cookbook](/cookbook) defines once, so this recipe only carries the banlist logic that is unique to ban evasion. ## Check the banlist on every visit Run the lookup at the start of the session, before you trust the cookie or even know which account this is. A banned DeviceID arriving under a fresh VisitorID is the tell that someone cleared storage to get back in. ```js Gate the session on the device banlist theme={null} app.post('/api/session-open', async (req, res) => { const { shieldRequestId } = req.body; const shield = await waitForScore(shieldRequestId, 2000); const deviceId = shield?.DeviceID; const localIp = shield?.IP; // All-zero DeviceID = "device unknown" (see below): route to review, never auto-ban. const ZERO_DEVICE = '00000000-0000-0000-0000-000000000000'; if (!deviceId || deviceId === ZERO_DEVICE) { return res.json({ action: 'review', reason: 'device_unknown' }); } // The core check: is this device on the banlist, whatever the cookie says? if (await banlist.hasDevice(deviceId)) { return res.json({ action: 'block', reason: 'banned_device_returned' }); } // Defense in depth: same local IP as a banned session, on a new device. // Weaker on its own (a shared router, an office NAT), so review, do not block. if (localIp && (await banlist.hasLocalIp(localIp))) { return res.json({ action: 'review', reason: 'banned_local_ip' }); } return res.json({ action: 'allow' }); }); ``` A returning banned DeviceID is a strong signal, the local IP is a soft one. Block on the device match; only review on the local IP match, since a banned user and an innocent one can share an office router or a home NAT. Keep the verdict in your code where you can log it and tune it. ## Why the DeviceID holds when the cookie does not The VisitorID lives in browser storage, so clearing cookies or opening an incognito window resets it, exactly the move a returning banned user makes. The DeviceID is computed on the server from stable browser and device characteristics rather than read from a cookie. Because it is derived and not stored, the same browser hands back the same DeviceID after the storage is gone. That durability across cookie clears, incognito, and IP rotation is what makes it a banlist key, and the [identification reference](/features/identification) covers how the two identifiers differ. ## Where the all-zero DeviceID comes from A session that blocks the fingerprint or runs with JavaScript disabled cannot produce stable device characteristics, so the server has nothing to derive a real DeviceID from. It returns the all-zero DeviceID (`00000000-0000-0000-0000-000000000000`) and scores it 90, in the High band. That high score reflects an evasive or non-cooperating client, not a confirmed ban. Never auto-ban the all-zero DeviceID. Every blocked or JS-disabled visitor shares it, so banning it would lock out an entire class of clients at once and collapse many distinct visitors onto one key. Send the all-zero case to manual review, weigh it with the local IP and your own context, and let a human decide. ## Spot the evasion pattern in the dashboard The per-visit banlist stops a known device at the door. The [dashboard Patterns](/features/patterns) show you the evasion shape over time, grading each flagged entity **Suspicious or Dangerous** as the count crosses a threshold in a rolling window. An entity below the first threshold is simply the unflagged baseline, never recorded as a grade. One account whose VisitorID or DeviceID keeps changing, a hint of cleared storage, anti-detect tooling, or an environment cycled between visits to look new each time. One device tied to many accounts, the shape of a banned user who keeps registering fresh accounts from the same machine to get back in. Multiple accounts sharing one local IP across different devices and public IPs, a sign of coordinated returns or a ban dodged from behind shared infrastructure. You read these on the [dashboard Patterns tab](/features/patterns), where each entity shows its grade and the identifiers behind it. Levels never downgrade: once an entity is flagged Dangerous, new clean activity does not clear it. Export the Dangerous DeviceIDs and local IPs (CSV or JSON) and feed them straight into your banlist as a watchlist. ### Reconstruct a device's history programmatically You can also check what a device has done from the [History API](/api/server-api). Read by `device_id` to see every session and account that machine has touched, newest first. ```bash Read one device's history theme={null} curl "https://api.shieldlabs.ai/{domain}:{secret}/history/device_id/5eb7fd5c-1c5e-4a9f-9b21-7d2e8c0a1234?limit=100" ``` ```js Confirm a banned device's return theme={null} async function deviceHistory(deviceId) { const rows = await shieldHistory('device_id', deviceId, 100); const accounts = new Set(rows.map((r) => r.UserHID).filter(Boolean)); return { sessions: rows.length, accounts: accounts.size }; } // A banned device coming back under brand new accounts confirms the evasion. const { accounts } = await deviceHistory(deviceId); if (await banlist.hasDevice(deviceId)) { flagForReview(deviceId, { accounts, note: 'banned device active under new accounts' }); } ``` The History API bills 1 request per returned row (an empty result still bills 1), whereas the webhook delivery is free. Lean on the per-visit banlist lookup and the pre-computed pattern export for routine enforcement, and reserve live `device_id` reads for the cases you are actively confirming. ## Putting it together [Sign up for free](https://dashboard.shieldlabs.ai) and get 5,000 identifications, or log in if you already have an account. Register the domain you want to identify visitors on, then open the [Keys](/setup/keys) page. Use the **Public Key** to initialize the snippet in the browser, and create a **Secret Key** for your backend. Keep the secret server-side only; it verifies webhook signatures and authenticates the [Server API](/api/server-api) as `{domain}:{secret}`. Load the [snippet](/setup/snippet) and run an identification at the start of the visit so the DeviceID and local IP are available before you trust the cookie. When you ban a user, persist their DeviceID and local IP on a device-level banlist, not just the account id, as covered in the [webhooks setup](/setup/webhooks) for getting those fields. On every visit, look up the incoming DeviceID first. Block a banned device; review a banned local IP on a new device. A blocked or JS-disabled session returns the all-zero DeviceID and scores 90. Never auto-ban it. Send it to manual review with the IP and account context. Pull "Changing IDs on One Account", "Many Accounts on One Device", and "Shared Local IP Across Multiple Accounts" from the [dashboard](/features/patterns), feed the Dangerous entities into your banlist, and start in logging-only mode before you turn on hard blocks. ## Recommended starting policy A guide, not a rule. The local IP is a soft key on purpose, since legitimate visitors share networks. | Condition | Suggested action | | -------------------------------------------------------------------------- | ------------------------------------------- | | Incoming DeviceID on your banlist | Block, the banned device has returned | | New device, but local IP matches a banned session | Review, could be a shared network | | All-zero DeviceID (blocked or JS-disabled, score 90) | Manual review, never auto-ban | | "Changing IDs on One Account" or "Many Accounts on One Device" (Dangerous) | Add the entity to your banlist, then review | | Clean device, no banlist match | Allow | The companion pattern for the registration step: catch the fresh accounts a banned user opens before they get created. # Chargeback Dispute Source: https://docs.shieldlabs.ai/cookbook/chargeback-dispute Learn how to reconstruct a buyer's device history into chargeback-dispute evidence. A chargeback is the opposite problem from a real-time fraud check. The sale already happened, the goods already shipped, and weeks later the cardholder tells their bank the charge was unauthorized. When that "friendly fraud" claim lands, the burden flips to you: prove the purchase was the genuine account holder. This recipe builds that proof from the buyer's device history so your dispute team has a defensible evidence package to attach to the representment. This is the after-the-sale play. For scoring the payment as it happens and gating the charge in the moment, the [checkout recipe](/cookbook/checkout) is the real-time half and is not repeated here. ShieldLabs surfaces the evidence. Your dispute team owns the response and the bank decides the outcome. ShieldLabs does not win or lose the dispute, and it does not prove identity. What it gives you is device continuity: a record that the same device, from the same country, with clean Risk Scores, made both the disputed order and earlier purchases that were never contested. ## The pattern At each purchase, persist the visit's `VisitorID`, `DeviceID`, and `UserHID` next to the order record. This is the snapshot of who bought, captured at buy time, that you replay later. Nothing happens until a chargeback arrives. The evidence sits in your own database, costing nothing, until you need it. When a dispute lands, pull that buyer's prior visits from the [History API](/api/server-api) by `user_hid` or `device_id`, newest first, to show the disputed order and the undisputed ones came from the same device. Assemble the matching rows into a CSV or JSON evidence package and attach it to your dispute response. Your team writes the representment; ShieldLabs only supplies the device record. ## Step 1: store the identity at purchase The real-time score belongs to the [checkout recipe](/cookbook/checkout). Here the only extra work is durable: when the order is confirmed, save the three identifiers from that visit's scored result alongside the order. The [shared webhook-cache helper](/cookbook) (`waitForScore` plus `scoreCache`) already hands you these fields, so this recipe shows only what to persist. ```js order-stamp.js theme={null} // Inside your order-confirmation handler, after the charge succeeds. // `shield` is the scored result the shared waitForScore helper returns. async function recordOrder(order, shield) { await db.orders.update(order.id, { // The identity stamp you will replay if this charge is ever disputed. shield_visitor_id: shield.visitorID, // VisitorID for this visit shield_device_id: shield.DeviceID, // server-derived, durable, browser-bound shield_user_hid: shield.userHID, // the hashed account id you passed in shield_score: shield.score, // 0 to 100 at buy time shield_country: shield.country, // IP country at buy time shield_request_id: shield.requestID, // ties back to the exact identification }); } ``` The `DeviceID` is server-derived, durable, and bound to the browser that made the visit. It is the field that gives the evidence its weight: a returning buyer on the same browser keeps the same `DeviceID` across sessions, which is exactly the continuity a dispute response wants to demonstrate. ## Step 2: reconstruct the buyer on dispute When the chargeback notification arrives, look up the disputed order, then query the [History API](/api/server-api) for that buyer's other visits. Search by `user_hid` to gather every visit tied to the account, or by `device_id` to gather every visit from that exact device. Both come back as an array of snapshots, newest first. ```js dispute-evidence.js theme={null} // `dispute` carries the order id your processor (Stripe, Adyen, etc.) reported. async function buildEvidence(dispute) { const order = await db.orders.get(dispute.orderId); const domain = 'myshop.com'; const secret = process.env.SHIELDLABS_SECRET; // Pull this account's visit history, newest first. Search by user_hid to // capture the account, or by device_id to capture the exact device. // Each returned row bills 1 request; an empty result still bills 1. const res = await fetch( `https://api.shieldlabs.ai/${domain}:${secret}` + `/history/user_hid/${order.shield_user_hid}?limit=50` ); const snapshots = await res.json(); // [] when nothing matches // Keep the visits that share the disputed order's device. Same DeviceID // across purchases is the continuity claim your representment makes. const sameDevice = snapshots.filter( (s) => s.DeviceID === order.shield_device_id ); return sameDevice.map((s) => ({ when: s.LastRequestTime, device: s.DeviceID, visitor: s.VisitorID, country: s.Country, score: s.Score, // a clean score here strengthens the case browser: s.Browser, device_type: s.DeviceType, })); } ``` Each snapshot carries the `Score` and its `Details` (an array of `{ Value, Description }`), the `DeviceID`, `VisitorID`, `UserHID`, `IP`, `Country`, `Browser`, `DeviceType`, and `LastRequestTime`. Read the `Score` and each detail's numeric `Value`, never the human-readable `Description` label, which can change. A run of clean or low scores from one device, across the disputed order and earlier undisputed ones, is the pattern that makes the package persuasive. The History API bills **1 request per returned row**, and a lookup that matches nothing still bills **1 request**. A `device_id` or `user_hid` search returning 50 snapshots costs 50 requests. Set `limit` to the smallest value that covers the order history you need to cite. Webhooks and dashboard exports stay free; the [Billing](/billing) page has the full breakdown. ## Step 3: export the evidence package You do not have to script the export at all. The dashboard [Data tab](/using-the-dashboard) lets you search by `user_hid`, `visitor_id`, or `device_id`, filter by date and score, and export the matching rows to CSV or JSON for free. That export is the attachment your dispute team hands to the processor. For an automated pipeline, serialize the rows from Step 2 into the same CSV or JSON your representment workflow expects. A package that holds up tends to show, side by side: | Evidence in the export | What it argues | | ------------------------------------------------------ | ------------------------------------------------------------ | | Same `DeviceID` on the disputed order and prior orders | The same physical device made the purchases, not a stranger. | | Consistent `Country` across those visits | No sudden geography change that would suggest a stolen card. | | Clean or low `Score` on each visit | None of these purchases carried strong anonymity signals. | | Earlier orders that were never disputed | A pattern of legitimate use from this device. | | `LastRequestTime` spanning weeks or months | A relationship, not a one-off hit-and-run. | ## What this evidence is, and is not Device continuity is **supplementary** evidence. It strengthens a representment; it does not stand alone, and it is honest about its limits. * **It is not proof of identity.** A `DeviceID` ties activity to a browser on a device, not to a named person. The same household member or a borrowed laptop produces the same `DeviceID`. * **A different browser or device is a different `DeviceID`.** A genuine buyer who switched laptops between purchases will not show device continuity, and its absence is not proof of fraud. * **ShieldLabs does not decide the dispute.** It supplies the device record. Your team writes the representment and the cardholder's bank rules on it. Pair the device history with the rest of your case (the AVS and CVV result, delivery confirmation, login history, prior order fulfillment) so the package argues from several angles, not one. ## Next The real-time half: score the payment before the charge and gate it on the band. Catch the new-device login that often precedes the fraudulent purchase in the first place. # Checkout Source: https://docs.shieldlabs.ai/cookbook/checkout Learn how to add friction at checkout only when a payment looks anonymized. The payment step is where anonymity matters most. A masked or spoofed session at checkout is a stronger signal than the same session browsing a catalog. This pattern gets you a **fresh** Risk Score right before the charge, then branches on that Score and its band so your checkout code can decide what to do. ShieldLabs scores. Your code decides. ShieldLabs never declines a payment or blocks a buyer. It returns a [Risk Score](/features/risk-scoring) (0 to 100) and the [anonymity signals](/features/anonymity-signals) behind it. The allow, step-up, hold-for-review, or block decision is logic you write in your checkout flow. ## The pattern Call `forceCheckAuthenticatedUser` as the buyer reaches the payment step. `forceCheck*` clears the session and runs immediately, so you score the session as it is at payment time, not a stale score cached from page load. Correlate the browser `requestID` with the webhook your server receives, with a short timeout fallback to the [History API](/api/server-api). Branch on the Score and its band, which already fold in the anonymity signals. At the payment step the same band usually warrants a harder response than it would on a low-stakes page, so draw your lines tighter here. Allow, step up to verification (3DS, OTP), hold for manual review, or block. You own the verdict. ## Step 1: force a fresh check at the payment step On page load you may already run `checkAuthenticatedUser` for analytics. At the payment step you want a current read, so use the `forceCheck*` variant. It clears the session and runs a new identify call immediately. Always pass a **hashed or pseudonymous** user id, never a raw email or account id. ```html checkout.html theme={null}
``` The snippet POSTs the signals to `rest.shieldlabs.ai` automatically and returns a Promise. The optional callback fires with `(serverResponse, requestID)` once the POST completes. Keep `requestID`: it ties this browser check to the webhook. [Installing the snippet](/setup/snippet) covers the React, Vue, Angular, Preact, and Svelte versions of the same dynamic-import pattern. ## Step 2: receive the webhook and index it by RequestID Within about 1 second of the check, ShieldLabs POSTs a `{ Data, Assing }` envelope to your callback URL. Verify the `Assing` HMAC first, then cache the result keyed by `RequestID` so the checkout request can look it up. This is the same `verify, respond fast, store, expire` handler every recipe shares, so the [Cookbook](/cookbook) defines it once as the `scoreCache` and `waitForScore` helper. A few delivery facts that matter at checkout: * **The webhook can fire twice.** `Phase: "initial"` is the first score, and an optional `Phase: "update"` follows with only the changed signals in `Details`. Apply the update if it lands before you finalize the charge. * **There are no retries.** Delivery is at-most-once, so for a guaranteed read at the payment step, fall back to the [History API](/api/server-api) by `request_id`. Each returned row bills 1 request, while the webhook is free, so reserve the History read for the charge you cannot afford to miss. The [webhooks delivery model](/setup/webhooks) spells out the full behavior. ## Step 3: gate the charge on Score plus signals Wait briefly for the score, then decide. The Score and its band are what you branch on, because the Score already folds in the masked-traffic signals. At the payment step, draw the band lines tighter than elsewhere. ```js checkout.js theme={null} app.post('/api/checkout', async (req, res) => { const { shieldRequestId, paymentData, userId } = req.body; // Wait up to ~2s for the webhook; fall back to the History API. const shield = await waitForScore(shieldRequestId, 2000); // No result yet is not the same as "clean". Hold for review rather than // silently letting a payment through on missing data. if (!shield) { return res.status(202).json({ status: 'review', reason: 'verifying' }); } const score = shield.score; // Branch on the Score and its band, never on the Description label text. // The Score already folds in masked-traffic signals, so the band is what // tells you "is this anonymous enough to act on" at the payment step. if (score >= 60) { // High band: your code decides to block or hard-challenge. // ShieldLabs only surfaces the Score and the signals behind it. await flagForReview(userId, shield); return res.status(202).json({ status: 'verify', reason: 'Extra verification is required to complete this payment.', }); } if (score >= 30) { // Medium band: step up to 3DS / OTP before the charge. return res.status(202).json({ status: 'step_up', method: '3ds' }); } // Clean / Low band: proceed with the charge in your own flow. return processPayment(paymentData, userId, res); }); ``` `waitForScore` is the shared webhook-cache read (poll the cache, then fall back to a History API read by `request_id`); the [Cookbook](/cookbook) defines it once alongside the handler that fills the cache. The actions above (block, step up, hold for review) run in **your** application. ShieldLabs returns the Score and `Details`. It does not decline payments, challenge buyers, or stop anyone. ## Reading the Risk Score at checkout The four bands and their ranges are defined in [Risk Scoring](/features/risk-scoring), and the full action playbook is in [Acting on the Risk Score](/guides/acting-on-risk-score). The payment step is a good place to draw the same band tighter than you would elsewhere: | Band | At a low-stakes page | At checkout | | ------------------ | -------------------- | ------------------------------------------------------------- | | **Clean** (0–9) | Pass through | Allow, charge | | **Low** (10–29) | Allow, log | Allow, log the `Details` | | **Medium** (30–59) | Second look | Step up to 3DS or OTP before the charge | | **High** (60–100) | Review or challenge | Hold for review or require verification, decided by your code | ## Signals worth weighting at the payment step The Risk Score already folds these in, so your code branches on the Score and its band rather than on the label text. The names below are the human-readable labels that appear in `Details` for visibility and logging, and they can change, so treat them as illustrative. The [Signals](/features/anonymity-signals) reference lists the full set with what each one means. | Signal in `Details` | Why it matters at payment | | ----------------------- | ----------------------------------------------------------------------------------------------------------------------- | | **Tor** | Connection exits through the Tor network. Rare for legitimate buyers. Usually a hard challenge or block decided by you. | | **Anti-detect Browser** | Fingerprint-spoofing indicators. Common in coordinated payment abuse. | | **Proxy** | IP flagged as a proxy. One signal among several; weigh with the rest. | | **Datacenter IP** | IP is in a hosting range. Unusual for a real shopper on a personal device. | | **Abuser Flag** | IP or device appears on an abuse reputation list. Treat as high risk even at a moderate Score. | | **OS Mismatch** | The OS the browser claims does not match other evidence. A spoofing indicator. | A legitimate buyer can score high. Corporate VPNs, privacy browsers, and iCloud Private Relay all raise the Risk Score for real customers. Decide on **Score plus `Details` plus the action context**, never the number alone, and tune your thresholds gradually. A `VPN` or `Privacy Relay` signal alone is weaker evidence than `Tor` or `Abuser Flag`. Read the bands as guidance, not verdicts. ## After the charge For high-value orders or a follow-up withdrawal, run another `forceCheck*` at that moment. Each sensitive action deserves its own fresh check rather than a reused score. ```js theme={null} mod.forceCheckAuthenticatedUser('a1b2c3d4hasheduserid', (serverResponse, requestID) => { fetch('/api/post-purchase', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ requestID, action: 'high_value_order' }), }); }); ``` ## Next steps Turn the Score and `Details` into allow, challenge, review, and block logic in your app. Every signal that can appear in `Details`, in plain language, with its weight. How the 0 to 100 score is built, what `Details` carries, and the band definitions. The same pattern applied to suspicious logins: step up only when the session warrants it. # Credential Stuffing Source: https://docs.shieldlabs.ai/cookbook/credential-stuffing Learn how to slow down credential stuffing without punishing real users. Credential stuffing is the same stolen-password list tried against many accounts, usually from anonymized infrastructure and usually rotating IPs to stay under per-IP limits. ShieldLabs gives you two things an attacker cannot easily rotate away: the **anonymity of each login session** and the **link between the many accounts one device or one local IP touches**. Your code uses both to add friction on top of your own login rate limits. A per-request [Risk Score](/features/risk-scoring) on each login attempt. Datacenter, VPN, proxy, Tor, and anti-detect signals are common on stuffing traffic and arrive in real time on the [webhook](/setup/webhooks). Server-side [Patterns](/features/patterns) that link one source to many accounts: "Many Accounts on One Device", "Many Accounts on One Local IP". Read on the [dashboard](/features/patterns). ShieldLabs scores and links. Your login endpoint owns the action: allow, throttle, require a CAPTCHA or second factor, or reject. The point is not a single verdict but raising the cost of each attempt until the attack is no longer worth running. Bring **your own** failed-attempt counters and login rate limits. ShieldLabs supplies what they cannot: how anonymous each session is, and which accounts a single device or local IP has touched. Combine the two, and keep the throttling logic in your backend. ## Rate-limit on the DeviceID, not just the IP The reason per-IP limits fail against stuffing is that attackers rotate IPs cheaply (proxy pools, residential proxies, a new exit per request). The [DeviceID](/features/identification) is harder to move: it is derived from dozens of stable browser components, so it stays the same across an IP rotation. Keying your throttle on the DeviceID (alongside the IP and the account) makes rotation stop working. ```js Throttle by DeviceID across rotated IPs theme={null} app.post('/api/login', async (req, res) => { const { username, password, shieldRequestId } = req.body; const shield = await waitForScore(shieldRequestId, 1500); const score = shield?.Score ?? 0; // 0-100 const deviceId = shield?.DeviceID; // Your own counter, keyed on the durable DeviceID (survives IP rotation). const attempts = await bumpAttemptCount(`dev:${deviceId}`); // 15 min window // 1. One device hammering many logins, even across rotated IPs and accounts. if (deviceId && attempts > YOUR_DEVICE_ATTEMPT_LIMIT) { return res.status(429).json({ action: 'rate_limited' }); } // 2. A raised score is common on stuffing traffic and raises the cost of a try. // Branch on the Score band, not on individual label strings. if (score >= 60) { return res.status(200).json({ action: 'step_up_2fa' }); // High band } if (score >= 30) { return res.status(200).json({ action: 'require_captcha' }); // Medium band } return continueLogin(username, password, res); }); ``` `waitForScore` is the shared webhook-cache read (poll the cache, then fall back to a History API read by `request_id`) that the [Cookbook](/cookbook) defines once, so this recipe only carries the DeviceID throttle that is unique to stuffing. `Description` is a human-readable label for visibility and logging. Branch on the `Score` and each entry's `Value`, not on the label text, which can change. A blocked or JavaScript-disabled browser can return an all-zero DeviceID (`00000000-0000-0000-0000-000000000000`), since the device components needed to derive a stable ID were never collected. Treat that as "no DeviceID": skip the device key for it and fall back to the IP and account context, so many distinct attempts do not collapse onto one zero key. Combine keys for defense in depth: throttle on the DeviceID (survives IP rotation), on the local IP, and on the account being targeted. A stuffing run trips at least one even when it rotates the others. ## Layer 1: score the login session A login arriving from a datacenter range, a VPN, Tor, or an anti-detect browser is not proof of an attack, but it is exactly the profile stuffing traffic tends to carry. The `Score` already rolls those anonymity signals into one number, so branch on its band. If you need to drive product-specific copy from a single entry, read that entry's `Value`, never its `Description` label. ```js theme={null} function loginFriction(shield) { const score = shield?.Score ?? 0; // 0-100 if (score >= 60) return 'strong'; // High band: CAPTCHA + 2FA if (score >= 30) return 'medium'; // Medium band: CAPTCHA return 'none'; } ``` The per-band playbook lives in [Acting on the Risk Score](/guides/acting-on-risk-score), and the [Signals](/features/anonymity-signals) reference explains what each signal means. Honest framing: a legitimate user on a corporate VPN logs in every day. Anonymity raises friction (a CAPTCHA, a second factor), it does not justify a hard block on its own. Reserve outright rejection for the combination of anonymity, a high failed-attempt count, and a device or IP that is already fanning out across accounts. ## Layer 2: catch the fan-out across accounts The defining shape of stuffing is one source touching many accounts. ShieldLabs links sessions over time and grades each entity **Suspicious** then **Dangerous** as that count climbs. Below the Suspicious threshold an entity is simply the unflagged baseline, which is never recorded or emitted. One device attempting or reaching many different accounts. The grouping identity is the durable **DeviceID**, so it holds even as the attacker rotates IPs and clears cookies between attempts. Many accounts reached through the same local IP. Catches a single machine or NAT fanning out across accounts behind a rotating public IP. The same account whose VisitorID or DeviceID keeps changing, a hint of scripted attempts or anti-detect tooling cycling its environment between tries. Pull the flagged entities from the [dashboard Patterns tab](/features/patterns) (CSV or JSON) and feed the Dangerous DeviceIDs and local IPs into your login throttle as a watchlist. You can also reconstruct a device's fan-out live from the [History API](/api/server-api). ```bash How many accounts has this device touched? theme={null} curl "https://api.shieldlabs.ai/{domain}:{secret}/history/device_id/5eb7fd5c-1c5e-4a9f-9b21-7d2e8c0a1234?limit=100" ``` ```js Escalate a known fan-out device theme={null} async function deviceFanOut(deviceId) { const rows = await shieldHistory('device_id', deviceId, 100); return new Set(rows.map((r) => r.UserHID).filter(Boolean)).size; // distinct accounts } if ((await deviceFanOut(deviceId)) >= YOUR_ACCOUNT_FANOUT_LIMIT) { // This device is reaching many accounts: require a second factor or block the device. return res.status(200).json({ action: 'step_up_2fa' }); } ``` Each row this History read returns bills 1 request (an empty result still bills 1). The webhook stream and the dashboard export are free, so lean on those for the bulk of the work and reserve the live fan-out read for a device you are about to act on. ## Putting it together [Sign up for free](https://dashboard.shieldlabs.ai) and get 5,000 identifications, or log in if you already have an account. Register the domain you want to identify visitors on, then open the [Keys](/setup/keys) page. Use the **Public Key** to initialize the snippet in the browser, and create a **Secret Key** for your backend. Keep the secret server-side only; it verifies webhook signatures and authenticates the [Server API](/api/server-api) as `{domain}:{secret}`. Call `checkAuthenticatedUser` (or `checkAnonymous` before the account is known) at the login step, wired in as part of the [snippet setup](/setup/snippet). Key your failed-attempt counter on the DeviceID as well as the IP and the account, so rotating IPs no longer resets the limit. Require a CAPTCHA on Medium-band scores (30 to 59) and a second factor on High-band scores (60+). The Score already rolls the datacenter, VPN, proxy, Tor, and anti-detect signals into one number, so branch on the band rather than on individual labels. Export "Many Accounts on One Device" and "Many Accounts on One Local IP" from the [dashboard](/features/patterns) and block or step up the Dangerous entities. Start in logging-only mode, watch where your real logins land, then turn on friction for the highest-risk combinations first. ## Recommended starting policy A guide, not a rule. Layer the conditions: friction should rise as more of them stack. | Condition | Suggested login action | | ------------------------------------------------------------ | ----------------------------------------------- | | Clean session (Score under 30), normal attempt count | Allow | | Score in the Medium band (30 to 59) | Require a CAPTCHA | | Score in the High band (60+) | Require a second factor | | DeviceID over your failed-attempt limit (across rotated IPs) | Rate-limit (HTTP 429) | | Device or local IP flagged "Many Accounts on One..." | Step up to 2FA or block the device, then review | The step-up authentication pattern that pairs with this throttle: when to escalate a risky login to a second factor. # Login and 2FA Source: https://docs.shieldlabs.ai/cookbook/login-2fa Learn how to step up to a second factor only when a login looks risky. Most logins are routine. A few are not: a session arriving through Tor, an anti-detect browser, or a brand-new device in a new country for an existing account. This pattern scores the login session in real time, then your auth code uses a threshold ladder to decide who passes, who gets a second factor, and who gets your hardest verification path. ShieldLabs scores. Your code decides. ShieldLabs never blocks a login or challenges anyone. It returns a [Risk Score](/features/risk-scoring) (0 to 100) and the [anonymity signals](/features/anonymity-signals) behind it. The allow, require-2FA, or hold-for-verification decision is logic you write in your login flow. ## The pattern Call `checkAuthenticatedUser(hashedUserId)` once you know which account is logging in, or `forceCheckAuthenticatedUser` right at submit for a fresh read of a sensitive action. Verify the webhook and cache the result by `RequestID`. For a guaranteed read, fall back to the [History API](/api/server-api) by `request_id` or `user_hid`. Your auth code reads the band plus the `Details` and decides: below 30 allow, 30 to 59 require a second factor, 60 and up route to your strongest verification or hold for review. Issue the session, trigger your existing 2FA, or hold and alert. You own the verdict. ShieldLabs only surfaces the signals. ## Step 1: check the login session Add the snippet to your login page. Once you know which account is signing in (for example after the username field), pass that account's **hashed or pseudonymous** id to `checkAuthenticatedUser`. Never pass a raw email or user id, always a hash. For a routine login, `checkAuthenticatedUser` is enough. For a high-value account or a sensitive flow, use `forceCheckAuthenticatedUser` at submit: it clears the session and runs a new identify call immediately, so you score the session as it is at login time rather than a stale read from page load. ```html login.html theme={null}
``` The snippet POSTs the signals to `rest.shieldlabs.ai` automatically and returns a Promise. The optional callback fires with `(serverResponse, requestID)` once the POST completes. Keep `requestID`: it ties this browser check to the webhook. [Installing the snippet](/setup/snippet) covers the React, Vue, Angular, Preact, and Svelte versions of the same dynamic-import pattern. ## Step 2: receive the webhook and index it by RequestID Within about 1 second of the check, ShieldLabs POSTs a `{ Data, Assing }` envelope to your callback URL. Verify the `Assing` HMAC first, then cache the result keyed by `RequestID` so the login request can look it up. That handler is the same `verify, respond fast, store, expire` shape every recipe uses, so the [Cookbook](/cookbook) carries it once as the shared `scoreCache` and `waitForScore` helper; the `DeviceID`, `Country`, and `UserHID` you will compare below all ride in on the same `Data` object. A few delivery facts that matter at login: * **The webhook can fire twice.** `Phase: "initial"` is the first score, and an optional `Phase: "update"` follows with only the changed signals in `Details`. Apply the update if it lands before you finalize the session. * **There are no retries.** Delivery is at-most-once, so for a guaranteed read at the login gate, fall back to the [History API](/api/server-api) by `request_id` (or by `user_hid` to also pull the account's recent sessions). Each returned row bills 1 request, while the webhook is free, so reserve the History read for the borderline logins. The [webhooks delivery model](/setup/webhooks) spells out the full behavior. ## Step 3: apply the threshold ladder Wait briefly for the score, then walk your ladder. The band is your starting point; the `Details` array refines it. Below is a three-rung ladder you can tune to your own traffic. ```js api/login.js theme={null} app.post('/api/login', async (req, res) => { const { username, password, shieldRequestId } = req.body; // 1. Your normal credential check first. const user = await verifyPassword(username, password); if (!user) return res.status(401).json({ error: 'invalid_credentials' }); // 2. Wait up to ~2s for the webhook; fall back to the History API by request_id. const shield = await waitForScore(shieldRequestId, 2000); // 3. No result yet is not the same as "clean". Default to requiring 2FA // rather than letting a login through on missing data. if (!shield) { return res.status(200).json({ status: 'require_2fa', reason: 'verifying' }); } const score = shield.score; // YOUR threshold ladder. Branch on the Score and its band, never on the // Description label text, which is a human-readable string that can change. // The bands are a guide, not a rule. if (score >= 60) { // High: strong anonymity signals folded into the score. Require // your strongest factor, or hold and alert the account owner. await alertAccountOwner(user.id, shield); return res.status(200).json({ status: 'verify', method: 'strong' }); } if (score >= 30) { // Medium: one moderate signal or several overlapping. Require a second factor. return res.status(200).json({ status: 'require_2fa', method: 'otp' }); } // Clean / Low: issue the session, no extra friction. return issueSession(user, res); }); ``` `waitForScore` is the shared webhook-cache read (poll the cache, then fall back to a History API read by `request_id`); the [Cookbook](/cookbook) defines it once alongside the handler that populates the cache. The actions above (require 2FA, route to strong verification, hold and alert) run in **your** application. ShieldLabs returns the Score and `Details`. It does not block logins, challenge users, or stop anyone. There is no rules engine to configure inside ShieldLabs: the threshold ladder lives in your code. ## The threshold ladder, band by band The four bands and their ranges are defined once in [Risk Scoring](/features/risk-scoring), and the cross-scenario action playbook lives in [Acting on the Risk Score](/guides/acting-on-risk-score). Mapped to a login gate, a sensible starting ladder is: | Band | Suggested login action | | ------------------ | ------------------------------------------------------------------------- | | **Clean** (0–9) | Issue the session, no friction | | **Low** (10–29) | Allow, log the `Details` | | **Medium** (30–59) | Require a second factor (OTP, authenticator) | | **High** (60–100) | Route to your strongest verification, or hold and alert the account owner | Where you draw each line is yours, and a high-value account is a good place to draw it tighter. ## Signals worth weighting at login The Risk Score already folds these in. If you want to raise friction even at a moderate score when a heavily weighted signal is present, look at each entry's `Value` (its numeric weight) rather than matching the `Description` label text, which can change. The names below are the customer-facing labels that appear in the array, shown for context, and the [Signals](/features/anonymity-signals) reference lists the full set with what each one means. | Signal in `Details` | Why it matters at login | | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | **Tor** | Connection exits through the Tor network. Rare for a legitimate sign-in. Usually a strong-verification path decided by you. | | **Anti-detect Browser** | Fingerprint-spoofing indicators. A common shape behind credential-stuffing follow-up and account takeover. | | **Abuser Flag** | IP or device on an abuse reputation list. Treat as high risk even at a moderate score. | | **OS Mismatch** | The OS the browser claims does not match other evidence. A spoofing indicator. | | **VPN** / **Privacy Relay** | Common for legitimate, privacy-conscious users. Weaker evidence on its own. Weigh with the rest of the `Details`, do not gate on it alone. | Pair the score with what you already know about the account. A Medium score on a login from a brand-new `DeviceID` and a new `Country` for that `UserHID` is a stronger takeover signal than the same score on a familiar device. You can pull the account's recent sessions with a [History API](/api/server-api) read by `user_hid` to compare the current device and country against its history. ```bash Pull an account's recent sessions theme={null} curl "https://api.shieldlabs.ai/{domain}:{secret}/history/user_hid/a1b2c3d4hasheduserid?limit=50" ``` The response is an array of snapshots (newest first), each carrying the `DeviceID`, `Country`, and `Score` for that session. A new `DeviceID` and `Country` combined on an established account is the shape behind the account-takeover [pattern](/features/patterns). The History API bills 1 request per returned row, so reserve these reads for the borderline logins. ## Honest caveat A legitimate user can score high. Corporate VPNs, privacy browsers, and iCloud Private Relay all raise the Risk Score for real people signing in. A blanket block on the High band will lock out genuine customers. Requiring a **second factor** rather than a hard block on the upper rungs keeps real users in while still slowing an attacker who only has the password. Decide on **Score plus `Details` plus the action context**, never the number alone, and tune your thresholds gradually. Start in a logging-only mode, watch how your real logins distribute across the bands, then raise friction where the data justifies it. ## Next steps The full per-band decision playbook, including Details-aware decisioning and how to combine the score with specific signals. Every signal that can appear in `Details`, in plain language, with its weight. How the 0 to 100 score is built, what `Details` carries, and the band definitions. The same pattern applied to the payment step, where anonymity signals warrant a harder response. # Paywall Enforcement Source: https://docs.shieldlabs.ai/cookbook/paywall Learn how to meter free views by device so clearing cookies cannot reset the count. A metered paywall lets a reader see a few free articles, then asks them to subscribe. The classic dodge is to reset the count: clear cookies, open an incognito window, and the meter starts over from zero. That works because most meters key off a first-party cookie, and a cookie is the one thing the reader controls. ShieldLabs gives you a counter the reader cannot reset that way. The [DeviceID](/features/identification) is derived on the server from the device environment, not stored in the browser, so it holds through cleared cookies, a private window, and a rotated IP. Count metered views per DeviceID and the meter survives every reset trick that defeats a cookie. ShieldLabs returns the identity; your own code owns the paywall. This is a metering policy, not a fraud verdict. A reader hitting the wall after their free views is doing nothing wrong, they have simply read what the plan allows. The DeviceID lets you count honestly across cookie resets; what the limit is and what the wall says are yours. ## Why the cookie meter resets and the DeviceID meter does not A cookie meter and a DeviceID meter behave identically until the reader tries to game them. Then they diverge. | Reader action | Cookie / VisitorID meter | Per-DeviceID meter | | ------------------------------- | ------------------------ | ------------------------------------------ | | Reads another article | Count goes up | Count goes up | | Clears cookies | Count resets to 0 | Same DeviceID, count holds | | Opens an incognito window | Count resets to 0 | Same DeviceID, count holds | | Switches networks or uses a VPN | Often resets to 0 | Same DeviceID, count holds | | Switches to a different browser | New count | New DeviceID, new count (see limits below) | The [CookieID](/features/identification) is minted in the browser and lost the moment cookies are cleared. The [VisitorID](/features/identification) is built partly from that cookie, so it resets too. That is exactly why you do not meter on a cookie or on the VisitorID. The DeviceID is the durable, browser-bound handle, and metering on it is what closes the reset loophole. ## Count metered views per DeviceID Load the [snippet](/setup/snippet) on every article page and run an anonymous identify. The DeviceID arrives on your [webhook](/setup/webhooks); you increment a per-DeviceID counter and compare it against your free limit before rendering the article. ```html article.html theme={null} ``` Your backend reads the scored result for that `RequestID` from the shared webhook cache, then bumps the counter for the DeviceID it carries. This reuses the canonical `waitForScore` and `scoreCache` helper from the [cookbook overview](/cookbook); it is not redefined here. ```js api/meter.js theme={null} const FREE_LIMIT = 5; // your free articles per device per period // uuid.Nil: a blocked or JS-disabled view returns the all-zero DeviceID // and scores 90. You cannot meter an identity that was never built. const ZERO_DEVICE = '00000000-0000-0000-0000-000000000000'; app.post('/api/meter', async (req, res) => { const { articleId, shieldRequestId } = req.body; // Pull the ShieldLabs result for this view from the shared cache. const shield = await waitForScore(shieldRequestId, 2000); const deviceId = shield?.DeviceID; // No DeviceID, or the all-zero one: fall back to a cookie or IP meter // for this view rather than serving it free forever. See the limits below. if (!deviceId || deviceId === ZERO_DEVICE) { return res.json(meterByCookieOrIp(req, articleId)); } // Per-DeviceID counter in your own store. Clearing cookies or opening // incognito does not change the DeviceID, so this count is not resettable // that way. const views = await incrementDeviceViews(deviceId, articleId); if (views > FREE_LIMIT) { return res.json({ action: 'paywall', reason: 'free_limit_reached', views }); } return res.json({ action: 'allow', reason: 'within_free_limit', views }); }); ``` The counter lives in your datastore keyed by DeviceID. Because clearing cookies and opening incognito both keep the same DeviceID, the reader cannot zero the count by resetting their browser storage. A cookie meter would have started fresh on either action. Decrementing your domain balance is automatic: each `checkAnonymous` call is one request. Metering reads the DeviceID straight off the webhook, which is free, so a busy article page does not also rack up [History API](/api/server-api) charges. Reserve History reads for the occasional audit of one reader's view history. ## Handle the cases the DeviceID meter cannot cover The DeviceID closes the cookie-reset loophole, but it is not a universal counter. Two cases need an explicit fallback, and pretending otherwise leaks free views. A view that blocks the snippet or runs with JavaScript disabled comes back with the all-zero `DeviceID` (`00000000-0000-0000-0000-000000000000`) and scores **90**, because no stable device characteristics were collected. You cannot meter an identity that was never built. Do not serve those views free indefinitely: fall back to a cookie or IP counter for the all-zero case, and treat a run of all-zero views from one IP as someone reading with scripts off. The other honest limit is by design. The DeviceID is **browser-bound**: the same reader on Chrome and on Firefox is two DeviceIDs, and a brand-new device is a new DeviceID. So a determined reader can still earn fresh free views by switching browsers or machines. That is a far higher bar than clearing cookies, and it is the boundary of what device-derived metering can do. If you need to raise the bar further, pair the per-DeviceID counter with a per-IP cap and a soft cookie meter, and require a free account for continued access once any of them trips. | Reset attempt | Per-DeviceID meter result | | ------------------------------- | -------------------------------------------- | | Clear cookies | Blocked, count holds | | Incognito or private window | Blocked, count holds | | New network or VPN | Blocked, count holds | | Different browser, same machine | New count (browser-bound DeviceID) | | Different device | New count (new DeviceID) | | Scripts off / snippet blocked | All-zero DeviceID, fall back to cookie or IP | ## Putting it together [Sign up for free](https://dashboard.shieldlabs.ai) and get 5,000 identifications, or log in if you already have an account. Register the domain you want to identify visitors on, then open the [Keys](/setup/keys) page. Use the **Public Key** to initialize the snippet in the browser, and create a **Secret Key** for your backend. Keep the secret server-side only; it verifies webhook signatures and authenticates the [Server API](/api/server-api) as `{domain}:{secret}`. Load the [snippet](/setup/snippet) on metered pages and call `checkAnonymous`. Stash the `requestID` so your backend can resolve the DeviceID for the view. On each webhook, read the `DeviceID` and increment that device's view count in your own store. This is the count a reader cannot reset by clearing cookies or going incognito. When a device passes your free limit, return the paywall instead of the article. The limit and the wall copy are yours. Route an all-zero DeviceID to a cookie or IP counter so a scripts-off reader is not waved through. Add a per-IP cap if you want to slow browser-switching. Start by logging the per-DeviceID distribution of your real readers before you enforce, so your free limit matches how people actually read rather than guessing. ## Recommended starting policy A guide, not a rule. The right limit depends on your content and your conversion goals. | Situation | Suggested action | | ----------------------------------------- | ----------------------------------------------------------------- | | Device under the free limit | Serve the article, increment the count | | Device at or over the free limit | Show the paywall | | All-zero DeviceID (scripts off / blocked) | Meter by cookie or IP for this view, do not serve free forever | | Many all-zero views from one IP | Treat as one reader with scripts off, apply the IP cap | | Same reader across two browsers | Accept as the browser-bound limit, or add a per-IP cap to slow it | ## Where to go next To understand why the DeviceID holds through cookie clears and incognito while the VisitorID does not, read [Identification](/features/identification). The [Billing](/billing) page covers the per-request model, including why webhook-driven metering is free and History reads bill per row. If you also want to grade where your readers come from, [Measure Traffic Quality](/cookbook/traffic-quality) scores each acquisition source by its anonymous-traffic share. # Promo Abuse Source: https://docs.shieldlabs.ai/cookbook/promo-abuse Learn how to stop one person from farming signup bonuses, coupons, and free trials. Signup farms exist for one payoff: the reward. The same operator spins up fresh accounts to claim a signup bonus, burn through a coupon code, or restart a free trial. The catch happens not when the account is born but when it reaches for the reward, so this recipe lives at the redemption endpoint. Joining accounts at registration is a separate job that the [signup recipe](/cookbook/account-signup) already covers, so wire that up for the create-account moment and treat this page as the reward-time gate on top of it. The per-request [Risk Score](/features/risk-scoring) (0-100) on the session reaching for the reward, live in the webhook. The [Patterns](/features/patterns) that tie many accounts to one device or one local IP, read from the dashboard or reconstructed from history. ShieldLabs scores the session and links the accounts. It never blocks anyone. Your redemption handler reads the score and the account count behind the device, then your code decides: grant the reward, require verification first, or deny it. The thresholds below are a starting point, not a rule. ## Where this differs from signup The [signup recipe](/cookbook/account-signup) scores the account at the moment it is created. That stops the obviously anonymous registration, but a patient farm creates accounts slowly and quietly, each one clean on its own, and only cashes them in later. The reward moment is where the farm reveals itself: ten "different" customers redeeming the same coupon from one machine is a shape that no single clean signup ever shows. So run both. Score at signup to thin the farm early, and check again at redemption to count how many accounts that one device or local IP has actually used to claim the reward. ## Step 1: score the redemption session Add the snippet to the page where the reward is claimed (the cart with the coupon applied, the "start trial" screen, the bonus-claim button). Re-identify on the action itself so you score the session that is redeeming, not a stale page load. Pass the account's hashed id, never a raw email. ```html redeem.html theme={null}
``` The redemption endpoint reads the score for that `requestID`. The score arrives on the [webhook](/setup/webhooks), so cache it by `RequestID` and read it back with the shared `waitForScore` helper defined in the [cookbook overview](/cookbook), or fall back to a [History API](/api/server-api) read by `request_id`. ```js api/redeem.js theme={null} app.post('/api/redeem', async (req, res) => { const { accountId, couponCode, shieldRequestId } = req.body; // 1. Your normal redemption checks first (code valid, not already used by // this account, within campaign window). if (!(await couponIsRedeemable(couponCode, accountId))) { return res.status(409).json({ error: 'Coupon not redeemable' }); } // 2. Look up the ShieldLabs Risk Score for this session. const shield = await waitForScore(shieldRequestId, 2000); const score = shield?.Score ?? 0; // 0-100, default to 0 if not yet in const signals = shield?.Details ?? []; // explainable: [{ Value, Description }] // 3. YOUR code owns the verdict. Branch on the band, never on a label string. if (score >= 60) { // High band: strong anonymity signals on the redeeming session. // Hold the reward and require verification before granting it. return res.status(200).json({ requireVerification: true, reason: 'anonymity' }); } // Clean / Low / Medium: carry on to the account-count check in Step 2. return grantOrGate(req, res, shield); }); ``` A high score is not a fraud verdict. A real customer on a corporate proxy, a VPN, or a privacy browser can land in the High band. Decide on the Score plus its `Details` plus your own redemption context, never on the number alone, and tune thresholds gradually. Anti-detect browser, proxy, and VPN signals often ride along with farms, but each one can be innocent in isolation, so treat them as weight, not proof. The score tells you whether this one session looks masked. It does not, on its own, tell you how many accounts sit behind the device. That is the next step. ## Step 2: count the accounts behind the device and the local IP A bonus farm clears cookies and goes incognito between accounts to look new every time. The score on each individual redemption can look fine. What gives it away is the count: how many distinct accounts has this one device, or this one local IP, already used to claim the reward? Two [Patterns](/features/patterns) answer exactly that, and ShieldLabs grades each flagged entity **Suspicious or Dangerous**. An entity that crosses no threshold stays the unflagged baseline and is never recorded. One device linked to many different accounts: the classic bonus-farm shape. The grouping identity is the **DeviceID**, which is durable and browser-bound. It grades Suspicious, then Dangerous, as the account count on one device climbs over a rolling window. Many accounts redeeming through the same local IP, even when each session uses a fresh cookie and a different public IP. Catches the farm sitting behind one router or one NAT. It grades Suspicious, then Dangerous, as the account count climbs across short and long rolling windows. These patterns grade up as the count crosses the thresholds in a rolling window, and levels never downgrade once flagged. You read them on the [dashboard Patterns tab](/features/patterns) and export the flagged entities as CSV or JSON. ### Count it live at redemption For the reward decision you often want the count right now, not on the next pattern run. Read the [History API](/api/server-api) by `device_id` to reconstruct every account that device has touched. Each snapshot carries the `UserHID` for that session, so the number of distinct `UserHID` values is the number of accounts behind the machine. ```bash Read a device's history theme={null} curl "https://api.shieldlabs.ai/{domain}:{secret}/history/device_id/5eb7fd5c-1c5e-4a9f-9b21-7d2e8c0a1234?limit=50" ``` ```js Gate the reward on account count theme={null} // Prefer the pre-computed pattern export as a fast denylist, and reserve live // device_id reads for the borderline redemptions that are worth the cost. const flaggedDevices = await loadFlaggedDeviceIds(); // from dashboard export async function accountsBehindDevice(deviceId) { const rows = await shieldHistory('device_id', deviceId, 100); // Distinct hashed accounts seen on this one machine. return new Set(rows.map((r) => r.UserHID).filter(Boolean)).size; } async function grantOrGate(req, res, shield) { const deviceId = shield?.DeviceID; // Known farm device from the pattern export: hold the reward. if (deviceId && flaggedDevices.has(deviceId)) { return res.status(200).json({ requireVerification: true, reason: 'device_linked_to_many_accounts' }); } // Live count for this redemption. YOUR_ACCOUNT_LIMIT is your policy. if (deviceId && (await accountsBehindDevice(deviceId)) >= YOUR_ACCOUNT_LIMIT) { return res.status(200).json({ requireVerification: true, reason: 'reward_already_claimed_on_device' }); } // Clear: grant the reward. return grantReward(req, res); } ``` The History API bills 1 request per returned row, and an empty result still bills 1, while the webhook delivery is free. For high-volume redemption flows, lean on the pattern export as your denylist and reserve live `device_id` reads for the rewards that are expensive to give away by mistake. ### Weigh the device count with the local IP A determined operator who uses several genuinely separate browsers shows up as several devices, because the DeviceID is browser-bound. That is the gap the device count alone leaves open. Close it by weighing three things together: the "Many Accounts on One Device" signal, the "Many Accounts on One Local IP" signal, and your own redemption limits on the code or campaign. A reward claimed by ten accounts that share one local IP is a strong shape even when each one reports a different device, and a coupon you have capped at one redemption per customer does not need a perfect farm detector to hold the line. ## Putting it together [Sign up for free](https://dashboard.shieldlabs.ai) and get 5,000 identifications, or log in if you already have an account. Register the domain you want to identify visitors on, then open the [Keys](/setup/keys) page. Use the **Public Key** to initialize the snippet in the browser, and create a **Secret Key** for your backend. Keep the secret server-side only; it verifies webhook signatures and authenticates the [Server API](/api/server-api) as `{domain}:{secret}`. Join accounts to the DeviceID at registration with the [signup recipe](/cookbook/account-signup) so the farm is already thinned before it reaches the reward. Load the snippet on the claim page and call `checkAuthenticatedUser` on the action, following the [snippet setup](/setup/snippet). Receive the webhook, verify the HMAC, and read the result with the shared `waitForScore` helper from the [cookbook overview](/cookbook). Check the redeeming device against the "Many Accounts on One Device" and "Many Accounts on One Local IP" exports, or count distinct `UserHID` values from a live `device_id` history read. Grant, verify, or deny in your own code. Start in a logging-only mode, watch how real redemptions distribute, then raise friction where the data justifies it. ## Recommended starting thresholds The four bands are defined in [Risk Scoring](/features/risk-scoring), and the per-band playbook lives in [Acting on the Risk Score](/guides/acting-on-risk-score). Mapped to a reward gate, with the account count layered on top: | Signal at redemption | Suggested reward action | | ----------------------------------------- | ----------------------------------------------------- | | **Clean / Low** score, no pattern flag | Grant the reward | | **Medium** score, no pattern flag | Grant, but log and watch the device | | **High** score | Require verification before granting | | Device or local IP flagged **Suspicious** | Require verification, regardless of the session score | | Device or local IP flagged **Dangerous** | Deny the reward and route to review | Where you draw each line is yours, and your own per-code or per-campaign redemption caps sit alongside these as a second, simpler backstop. The full per-band decision playbook, including Details-aware decisioning and how to combine the score with specific signals. # Regional Pricing Source: https://docs.shieldlabs.ai/cookbook/regional-pricing Learn how to catch VPN and proxy region spoofing before you grant a cheaper price. Regional pricing rewards buyers in lower-cost markets with a cheaper rate. The catch is that anyone can sit behind a VPN, a proxy, or iCloud Private Relay, point their session at a discount region, and claim the lower price from anywhere. This pattern reads the network `Country` and the anonymity signals on the visit, then lets your pricing code decide whether the claimed region is trustworthy enough to honor. ShieldLabs scores. Your code decides. ShieldLabs never picks a price or rejects a buyer. It returns a [Risk Score](/features/risk-scoring) (0 to 100), the [anonymity signals](/features/anonymity-signals) behind it, and the two-letter ISO `Country` derived from the IP. Whether to show the regional price, ask for verification, or fall back to the standard price is logic you write in your checkout flow. ## The pattern Load the [snippet](/setup/snippet) on the checkout or plan-selection page. When the buyer selects or confirms a region, call `forceCheckAuthenticatedUser` so you score the session as it is at the moment of the price decision, not a stale read from page load. Correlate the browser `requestID` with the webhook your server receives, with a short timeout fallback to the [History API](/api/server-api). The body carries `Country`, the `Score`, and the `Details`. If the session carries anonymity signals, or the network `Country` does not match the region the buyer claims, treat the claimed region as unverified. Show the real-region price, ask for verification, or hold for review. You gate a price, not a person, and you own the decision. ## Step 1: identify when the region is set On a pricing page you may already run `checkAnonymous` for analytics. When the buyer selects a region or reaches checkout with a regional price applied, force a current read with the `forceCheck*` variant. It clears the session and runs a new identify call immediately, so the country and signals reflect the session at decision time. Always pass a **hashed or pseudonymous** user id to the authenticated call, never a raw email or account id. ```html pricing.html theme={null}
``` ## Step 2: receive the webhook and read Country plus signals Within about 1 second of the check, ShieldLabs POSTs a `{ Data, Assing }` envelope to your callback URL. Verify the `Assing` HMAC first, then cache the result keyed by `RequestID` so the price request can look it up. This is the same `verify, respond fast, store, expire` handler every recipe shares, so the [Cookbook](/cookbook) defines it once as the `scoreCache` and `waitForScore` helper. That helper already stores `Country` alongside the score, so nothing extra is needed here. A geo-pricing body looks like this. The `Country` field is the two-letter ISO code resolved from the IP, and the anonymity signals ride in `Details`: ```json theme={null} { "Data": { "RequestID": "8f1d0c2a-7b3e-4a9c-9d2f-1e6a5b4c3d21", "DeviceID": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", "IP": "203.0.113.42", "Country": "US", "Score": 15, "Details": [ { "Value": 15, "Description": "VPN" } ], "Phase": "initial" }, "Assing": "1f3c9a...hex-hmac-sha256" } ``` Here `Country` resolves to `US` while a VPN signal is present, so a session claiming a Brazil price is masking its location. The Risk Score on its own is only Low (15), which is exactly why the country mismatch matters: a low score is not a clean signal for a regional-price claim. `Description` is a human-readable label for visibility and logging. Branch on whether an anonymity signal is **present** in `Details` and on the `Country` value, not on the exact label text, which can change. ## Step 3: gate the price on country and anonymity Wait briefly for the score, then compare three things: is the session anonymized, does the network country match the claimed region, and how strong is the Score. The presence of any anonymity signal, or a country that does not line up with the claim, is enough to stop honoring the discount and fall back to your standard price. ```js price.js theme={null} // Anonymizing signals that make a claimed region unverifiable. Match on // presence in Details, never on exact label text, which can change. const ANON_SIGNALS = ['vpn', 'proxy', 'privacy relay', 'datacenter', 'tor']; function isAnonymized(details) { return details.some((d) => ANON_SIGNALS.some((s) => d.Description.toLowerCase().includes(s)) ); } app.post('/api/price', async (req, res) => { const { shieldRequestId, claimedRegion, userId } = req.body; // Wait up to ~2s for the webhook; fall back to the History API by request_id. const shield = await waitForScore(shieldRequestId, 2000); // No result yet is not the same as "verified". Default to the standard // price rather than handing out a discount on missing data. if (!shield) { return res.json({ price: standardPrice(userId), region: 'standard', reason: 'verifying' }); } const anonymized = isAnonymized(shield.details); const countryMatches = shield.country === claimedRegion; // Masked session: the claimed region cannot be trusted. Your code falls // back to the standard price. ShieldLabs only surfaced the signals. if (anonymized) { return res.json({ price: standardPrice(userId), region: 'standard', reason: 'region_unverified_anonymized', }); } // Network country does not match the claimed region. Ask for verification // (a billing-address or payment-country check) before honoring the discount. if (!countryMatches) { return res.json({ price: standardPrice(userId), region: 'standard', reason: 'region_mismatch', verify: true, }); } // Country lines up and the session is not masked: honor the regional price. return res.json({ price: regionalPrice(claimedRegion), region: claimedRegion }); }); ``` `waitForScore` is the shared webhook-cache read (poll the cache, then fall back to a History API read by `request_id`); the [Cookbook](/cookbook) defines it once alongside the handler that fills the cache. The fallback, hold, and verification steps above run in **your** application. ShieldLabs returns the `Country`, the `Score`, and `Details`. It does not block buyers, decline a price, or pick a region for you. ## Reading the signals for a price decision For a checkout decision you weigh the Score and its bands. For a regional-price claim the country comparison carries most of the weight, because a masked session can score Low yet still be hiding its true location. The four bands and their ranges are defined in [Risk Scoring](/features/risk-scoring); here is how to read the two inputs together. | Network `Country` vs claimed region | Anonymity signal in `Details` | Reasonable action | | ----------------------------------- | ----------------------------------- | ---------------------------------------------------------------- | | Match | None | Honor the regional price | | Match | VPN / Proxy / Privacy Relay present | Verify before discount; a corporate VPN can match by coincidence | | Mismatch | None | Standard price, ask for verification | | Mismatch | Any present | Standard price, or hold for review | | Unknown (no webhook yet) | Unknown | Standard price until verified | ## Signals that make a region unverifiable The `Country` tells you where the network exits. The anonymity signals tell you whether that exit can be trusted as the buyer's real location. The names below are the human-readable labels that appear in `Details` for visibility and logging; they can change, so match on presence rather than exact text. | Signal in `Details` | Why it breaks a region claim | | ------------------- | ------------------------------------------------------------------------------------------------ | | **Tor** | Connection exits through the Tor network, so the country is the exit node, not the buyer. | | **VPN** | Traffic routes through a VPN, so the exit country is chosen, not where the buyer sits. | | **Proxy** | IP flagged as a proxy. The geolocated country reflects the proxy, not the person. | | **Privacy Relay** | iCloud Private Relay relays the connection, so the visible country can differ from the real one. | | **Datacenter IP** | IP is in a hosting range. A real shopper on a personal device rarely exits from a datacenter. | A legitimate buyer can trip this. A traveler abroad, a corporate VPN, or a privacy browser all detach the network country from where the customer actually lives and pays. This is why the play gates a **price decision, not a ban**: fall back to the standard price, ask for a billing-country or payment check, or hold for review. Decide on the `Country`, the anonymity signals, and your own verification step together, never on one input alone, and let the buyer prove their region rather than locking them out. ## Next steps Every anonymity signal that can appear in `Details`, in plain language, with its weight. How the 0 to 100 score is built, what `Details` carries, and the band definitions. The fresh-check pattern at the payment step, where the same signals gate the charge. Turn the Score, `Country`, and `Details` into allow, verify, and hold logic in your app. # Returning Visitor Source: https://docs.shieldlabs.ai/cookbook/returning-visitor Learn how to recognize a trusted returning device and cut friction for it. Most recipes in this Cookbook use the Risk Score to raise friction on a suspicious visit. This one runs the same machinery in the opposite direction: a visit that arrives clean and on a device you already trust is a good moment to **remove** friction, not add it. You can skip a redundant check, restore preferences, or smooth the path for someone who has been here before. The two ingredients are already in every webhook: a low [Risk Score](/features/risk-scoring) and a durable **DeviceID**. When both point at a device you have associated with a verified account, treat the visit as a recognized return. When either is missing, fall back to your normal flow with no downside. This recognizes a **device**, not a person. It is a convenience signal, not a credential, and you should read the guardrails at the end of this page before you wire it to anything sensitive. ## Why the DeviceID makes this work The DeviceID is server-derived and durable. ShieldLabs computes it from the browser itself, so it stays stable when a visitor clears cookies, opens an incognito window, or rotates their IP. A returning person on the same browser keeps the same DeviceID even after their cookie resets, which is exactly the case where a cookie-only "remember me" falls apart. The [Identification](/features/identification) reference covers how the DeviceID and VisitorID differ and which one survives what. That durability is what lets you recognize a return without asking the visitor to log in again first. The DeviceID arrives on the [webhook](/setup/webhooks), or you can read it back from the [History API](/api/server-api) by `request_id`, alongside the Risk Score for that same visit. ## The pattern Load the [snippet](/setup/snippet) where the return matters, for example a returning visitor landing on your app or starting a routine action. Keep the `requestID` to join the browser check to the webhook. Verify the webhook and read the visit back by `RequestID`. The same `Data` object carries both the `Score` and the `DeviceID` you will match on. If the score lands in the **Clean** band with no anonymity signals, and the DeviceID is one you have already associated with a verified account, treat it as a recognized returning device. Skip a redundant step, restore preferences, or shorten the path. If the device is new or the score is not clean, run your normal flow unchanged. ## Step 1: associate a DeviceID with a trusted account Recognition only works once you have something to recognize. The association is yours to build: whenever a visitor completes a real, authenticated action you trust (a login behind your own auth, a verified purchase, an email confirmation), store the DeviceID that arrived on that visit against that account. ```js Build the trust list on a verified action theme={null} // Call this from a flow you already trust: a successful login, a confirmed // purchase, an email verification. The DeviceID rides in on the same webhook. async function rememberTrustedDevice(accountId, shield) { // Only remember devices seen on a clean, signal-free visit. Anything else // is noise you do not want to recognize later. if (shield.score > 9 || shield.details.length > 0) return; await trustedDevices.add({ accountId, deviceId: shield.DeviceID, firstSeen: Date.now(), }); } ``` Over time each account accumulates a small set of devices it has genuinely used. That set is the list you match against on the next visit. ## Step 2: recognize the return On the next visit, wait briefly for the score, then check two things together: the band and the device. A clean score on its own is an ordinary visit. A known DeviceID on its own is not enough either. The recognition is the **intersection**. ```js api/enter.js theme={null} app.post('/api/enter', async (req, res) => { const { accountId, shieldRequestId } = req.body; // Poll the cache, then fall back to a History API read by request_id. const shield = await waitForScore(shieldRequestId, 2000); // No score yet is not the same as "trusted". When in doubt, run the full flow. if (!shield) return res.json({ recognized: false }); // A Clean band (0-9) with no anonymity signals is a low-risk, ordinary visit. const isClean = shield.score <= 9 && shield.details.length === 0; // Is this DeviceID one we already tied to this verified account? const isKnownDevice = await trustedDevices.has(accountId, shield.DeviceID); if (isClean && isKnownDevice) { // Recognized returning device: lighten the experience. // Skip a redundant check, restore preferences, smooth the path. return res.json({ recognized: true, deviceId: shield.DeviceID }); } // New device, or a score that is not clean: your normal flow, unchanged. return res.json({ recognized: false }); }); ``` `waitForScore` is the shared webhook-cache read (poll the cache, then fall back to a History API read by `request_id`); the [Cookbook](/cookbook) defines it once alongside the handler that populates the cache, so this recipe does not repeat it. Match on the **band plus the DeviceID**, never the DeviceID alone. A returning device with a Medium or High score is a returning device on a riskier connection, and the riskier connection is the part that should drive your decision. ## What "lighten the experience" can mean Recognition buys you room to remove friction for a known-good return. A few safe places to spend it: * **Skip a redundant check.** A second-factor prompt that the same trusted device already cleared this week can be relaxed, while staying on for anything sensitive. * **Restore preferences.** Re-apply the visitor's layout, language, or saved cart before they ask. * **Shorten the path.** Pre-fill what you already know for this device, or drop a returning visitor straight onto the screen they last used. * **Soften rate limits.** A recognized device earns more headroom than an anonymous one on the same endpoint. Each of these is a convenience, and each one degrades gracefully: if recognition fails, the visitor simply gets your normal flow. ## Guardrails A returning device is a convenience signal, not a credential. State the limits plainly and design around them. * **This recognizes a device, not a person.** Anyone using that browser inherits the recognition. A shared family laptop or a borrowed machine will match. * **It is probabilistic, an estimate, not proof of identity.** ShieldLabs reports [Accuracy](/features/accuracy) as up to 99%, which is high enough to smooth a path and far too low to be the only thing standing between a stranger and an account. * **Never make it the sole gate for anything sensitive.** Money movement, password and email changes, and data exports must always sit behind real authentication. Pair recognition with a login, a second factor, or a re-verification step. Use it to remove a redundant step, never to remove the only step. * **Fail closed.** No score, a new device, or a non-clean band all fall back to your full flow. Recognition is the bonus, not the baseline. Read this the way you would a "remember this device" checkbox: it makes the common case pleasant for people you already trust, and it carries no authority of its own. ## Next Tighten the friction direction with [Step-up 2FA on Risky Logins](/cookbook/login-2fa), which uses the same Score and DeviceID to raise friction when a session is **not** clean. # Traffic Quality Source: https://docs.shieldlabs.ai/cookbook/traffic-quality Learn how to rank your traffic sources by the real visitors they send. Standard analytics measures **volume**: how many sessions, how many pageviews, how many "new" users. It cannot tell you how much of that traffic is masked, spoofed, or coordinated. ShieldLabs measures **quality**: every visit carries an explainable [Risk Score (0-100)](/features/risk-scoring) and the anonymity signals behind it, so you can split a noisy number like "10,000 visits" into traffic you can trust and traffic you cannot. This recipe shows how to read the dashboard to grade traffic, how to rank each acquisition source by the anonymous-traffic share it delivers, and how to export the raw [Data](/features/traffic-analytics) into your own BI to compute cost per real visitor. ShieldLabs scores the request; your reporting and budget decisions stay yours. The Risk Score is **0-100**, hard-capped at 100, in four bands: **Clean (0-9)**, **Low (10-29)**, **Medium (30-59)**, **High (60-100)**. A higher score means more anonymous or masked traffic, not a confirmed verdict: a legitimate visitor can score high behind a corporate proxy, a VPN, or a privacy browser. For traffic-quality reporting you are looking at the shape of the distribution across many requests, where individual false positives wash out. ## Volume vs quality, in one number A dashboard that only reports volume treats every session as equal. ShieldLabs attaches a per-request risk dimension, so the same 10,000 visits become a quality breakdown you can act on. | | Standard analytics | ShieldLabs | | ----------------------------------------------- | ---------------------- | ------------------------------------------------- | | Unit counted | Sessions, pageviews | Identified requests + Risk Score | | "10,000 visits" means | 10,000 equal sessions | A distribution across Clean / Low / Medium / High | | Can it see VPN, proxy, Tor, anti-detect routing | No | Yes, as named anonymity signals in `Details` | | Returning visitor after cleared cookies | Counted as new | Recognized by DeviceID (same browser) | | Per-source verdict | Volume and conversions | Volume, conversions, **and risk share** | For quick reporting it helps to collapse the four bands into three plain buckets. This is a reporting convention, not a product feature: the bands are still Clean / Low / Medium / High everywhere in the product. | Bucket | Bands | What is usually in it | | -------------- | ------------------------ | -------------------------------------------------------------------------------------------- | | **Clean** | Clean (0-9), Low (10-29) | Direct connections, ordinary browsers, the bulk of healthy traffic | | **Suspicious** | Medium (30-59) | VPN, proxy, privacy relay, datacenter IP, timezone mismatch (one or two overlapping signals) | | **Dangerous** | High (60-100) | Tor, anti-detect browsers, OS mismatch (strong anonymity signals) | A campaign sending 95% Clean traffic and one sending 40% Dangerous traffic can report identical visit counts in Google Analytics or Vercel Analytics. The Risk Score is what tells them apart. ## Read the dashboard Two cards on the [Overview](/features/traffic-analytics) tab give you the whole-traffic picture, and [Traffic Sources](/features/traffic-analytics) breaks it down by where the traffic came from. Filter both by project and date range using the controls at the top of the page. On [Overview](/features/traffic-analytics), the **Traffic Score** card shows a gauge with the band label, a **Traffic Risk** metric (the average request risk for the period, where 0 is ideal and 100 is very bad), and **Requests Checked** (how many requests were analyzed). Below the gauge, a stacked bar and legend show the count and percent of requests in each band: Clean, Low Risk, Medium Risk, High Risk. This is your quality split for all traffic at a glance. Open [Traffic Sources](/features/traffic-analytics). The **Channels** table lists Google Ads, Meta, TikTok, LinkedIn, X, Organic Search, Referral, Direct, and Other. Each row shows requests, traffic share, and a **Risk Badge** rendered as ` ` (for example `71 High` or `8 Clean`). Sort by Traffic Risk to find the channel sending the most masked traffic. The **Source details** table toggles between **Referrers** and **UTM Parameters**. Under UTM you can inspect Source, Medium, Campaign, Term, or Content, each with its own request share and Risk Badge. This is how you isolate the single affiliate, creative, or campaign delivering the anonymous traffic inside an otherwise healthy channel. Go to the [Data](/features/traffic-analytics) tab and use **Export** to pull the per-request records as JSON or CSV. Exports are free (they do not bill requests). Load them into your warehouse or BI tool to join risk against spend, conversions, and revenue. The next section covers the columns you get. The Traffic Score card counts in **requests**, while the Patterns Summary card on the same Overview tab counts in **unique visitors and sessions**. They use different denominators on purpose, so do not expect their totals to reconcile. For "how risky is my traffic," read Traffic Score; for "which identities cross abuse thresholds," read [Patterns](/features/patterns). ## Why these counts beat cookie analytics Google Analytics and Vercel Analytics count by a first-party cookie or client id. Clear cookies, open an incognito window, switch devices, or rotate your IP, and they count a brand-new user every time. That inflates "new visitors" and quietly loses your returning ones. ShieldLabs counts by a fingerprint-derived identity. The **DeviceID** is derived from dozens of stable browser and device characteristics, not stored in a cookie, so a returning person keeps the same identity even after clearing cookies or using incognito. They are not miscounted as new. | | GA / Vercel count by | ShieldLabs counts by | Result | | --------------- | ------------------------------ | ----------------------------------------------- | ----------------------------------- | | Identity basis | First-party cookie / client id | DeviceID (derived from the browser environment) | Survives cookie clear and incognito | | Cleared cookies | New user each time | Same DeviceID | Returning visitors stay returning | | Rotated IP | Often a new user | Same DeviceID | One person, one identity | One honest caveat to keep in your reporting: the dashboard tooltip itself calls these **estimated** unique visitors, so do not promise exact counts. The [identifiers reference](/features/identification) has the full mechanics, and [the Visitors view](/features/traffic-analytics) shows how "new" is determined. ## Capture the source on every visit The snippet already collects channel, referrer, and UTM attribution for every request, so the Traffic Sources tables populate without extra work. You only need the standard install on the page that receives the traffic. ```html theme={null} ``` Make sure your UTM parameters are on the inbound links. ShieldLabs records `utm_source`, `utm_medium`, `utm_campaign`, `utm_content`, `utm_term`, the `referrer_domain`, and the resolved `channel` for each request. The [snippet reference](/setup/snippet) has the framework variants, and the [CSP](/setup/csp) page lists the header requirements. ## Compute cost per real visitor The point of grading traffic by source is to stop paying click prices for masked traffic. "Cost per real visitor" reweights spend against the share of a source's traffic you can actually trust. Export the [Data](/features/traffic-analytics) records, group by source, and divide spend by the Clean-bucket count instead of the raw count. ```js theme={null} // One row per source for a reporting window. `spend` comes from your // ad platform; `requests` and `bands` come from the ShieldLabs export. const sources = [ { source: "google_ads", spend: 4000, requests: 10000, bands: { clean: 8800, low: 700, medium: 350, high: 150 } }, { source: "affiliate_x", spend: 3000, requests: 9000, bands: { clean: 2200, low: 600, medium: 2400, high: 3800 } }, ]; function trafficQuality({ source, spend, requests, bands }) { // Reporting convention: Clean bucket = Clean + Low bands. const realVisitors = bands.clean + bands.low; const dangerous = bands.high; return { source, requests, realVisitors, cleanShare: realVisitors / requests, dangerousShare: dangerous / requests, costPerClick: spend / requests, costPerRealVisitor: spend / Math.max(realVisitors, 1), }; } for (const s of sources) { const q = trafficQuality(s); console.log( `${q.source}: $${q.costPerClick.toFixed(2)}/click -> ` + `$${q.costPerRealVisitor.toFixed(2)}/real visitor ` + `(${(q.cleanShare * 100).toFixed(0)}% clean, ` + `${(q.dangerousShare * 100).toFixed(0)}% dangerous)` ); } // google_ads: $0.40/click -> $0.42/real visitor (95% clean, 2% dangerous) // affiliate_x: $0.33/click -> $1.07/real visitor (31% clean, 42% dangerous) ``` On a cost-per-click basis `affiliate_x` looks cheaper. On a cost-per-real-visitor basis it is more than twice as expensive, because most of its traffic is masked. That is the reallocation decision standard analytics cannot surface. The same pattern applies to paying out conversions, covered in [Affiliate Fraud](/cookbook/affiliate-fraud). ## Pull the data programmatically You have two programmatic paths, and they carry different fields. The **Data export** (JSON or CSV from the [Data](/features/traffic-analytics) tab) is the one that includes the per-request **source attribution**: `channel`, `referrer_domain`, and the `utm_*` fields, alongside the Score and anonymity-signal columns. That is the export to join against ad spend for cost-per-real-visitor reporting. Exports are free. The **[History API](/api/server-api)** is for reading scored records by identifier in real time. Query by identifier and the response returns newest-first snapshots with the Score, the `Details` behind it, and the network-intelligence fields, but **not** the channel/UTM attribution. Use it to reconcile or enrich, then join back to the export on `RequestID` when you need source attribution. The History API bills one request per returned row (an empty result still bills one), while dashboard views and exports are free. ```bash theme={null} # Snapshots for one visitor, newest first. Each row carries Score, # the Details behind the score, and the network-intelligence fields. curl "https://api.shieldlabs.ai/myshop.com:YOUR_SECRET_KEY/history/visitor_id/c4a2e9b1-5f8d-4c3a-8e7b-2a1f0d9c8b76?limit=50" ``` Each snapshot is a superset of the webhook body and adds the network-intelligence fields you can keep server-side: ```json theme={null} { "RequestID": "8f1d0c2a-7b3e-4a9c-9d2f-1e6a5b4c3d21", "VisitorID": "c4a2e9b1-5f8d-4c3a-8e7b-2a1f0d9c8b76", "DeviceID": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", "IP": "203.0.113.42", "Country": "US", "Score": 90, "Details": [ { "Value": 60, "Description": "OS Mismatch" }, { "Value": 30, "Description": "Browser VPN/Proxy" } ], "ConnectionType": "proxy", "Browser": "Chrome", "DeviceType": "desktop", "LastRequestTime": "2026-06-16T18:00:21.685Z" } ``` `Description` is a human-readable label for visibility and logging. Branch on the Score and each entry's `Value`, not on the label text, which can change. For source-level reporting, lead with the Data export (it carries `channel` and `utm_*`) and use the History API to pull fresh per-identifier records as needed. If you would rather build the report in real time as traffic arrives, consume the [webhook](/setup/webhooks) instead and aggregate the score per source in your own store, capturing the attribution from the inbound request yourself. Webhooks are at-most-once with no retries, so make the handler idempotent on `RequestID`, and for guaranteed completeness reconcile against the History API. Two scores can arrive for one visit. The `Phase: "initial"` webhook fires about a second after the request, and an optional `Phase: "update"` fires after a follow-up network check refines the result. When you aggregate for reporting, key on `RequestID` and use the latest phase so a single visit is not double-counted. ## What to do with the answer * A channel or campaign with a high Dangerous share is the first place to cut or renegotiate spend, especially affiliate and referral sources. * A source that looks expensive per click but is mostly Clean may be your best traffic once reweighted to cost per real visitor. * Rising Medium and High share over time on Direct or Organic Search is a signal to look at the [Patterns](/features/patterns) tab for coordinated activity behind the volume. * Pair this report with the [Visitors view](/features/traffic-analytics) to separate genuinely returning people from cookie-churned "new" sessions, so your retention numbers reflect reality. ## Where to go next If you have not installed the snippet and a webhook yet, start with the [Quickstart](/quickstart). To understand the score and the named anonymity signals that drive the quality split, read [Risk Score](/features/risk-scoring) and [Signals](/features/anonymity-signals). Per-source payout decisions build on top of this in [Affiliate Fraud](/cookbook/affiliate-fraud), and the [Billing](/billing) page covers the per-request model. # Errors & Troubleshooting Source: https://docs.shieldlabs.ai/errors A reference for every HTTP status code ShieldLabs returns and how to fix it. # Errors & Troubleshooting Error bodies are not uniform, so branch on the HTTP status code, not on a body field. The two surfaces behave differently: * **REST ingest and WebRTC gateways** return an object like `{ "error": "description of the problem" }` for `429` and `503`. * **Server API** returns an empty body for `401` and `402`, and a bare JSON string (for example `"request_id is not supported"`) for `400` and `404`. ## HTTP status codes | Status | Meaning | What to do | | ------ | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `200` | Success | Parse the response. | | `400` | Bad parameters | Server API only. A malformed value, for example a non-UUID where a UUID is required. The body is a bare JSON string. Fix the request. | | `401` | Bad credentials or disabled domain | The body is empty. Check `{domain}:{secret}` (Server API) or `?publicKey=` (snippet), and that the domain is enabled. | | `402` | Out of requests | The body is empty. Your balance is exhausted. The History API bills 1 request per returned row, and a 0-row lookup still bills 1. Top up your [billing](/billing) balance. | | `404` | Unsupported history `type` | Server API only. The history `type` is not supported. The body is a bare JSON string. `type` must be one of `ip`, `user_hid`, `visitor_id`, `request_id`, `device_id`. A supported `type` that matches no records returns `200` with `[]` (and still bills 1 request), not `404`. | | `429` | Rate-limited | REST ingest and WebRTC gateways only, not the Server API. The body is `{ "error": "..." }`. You hit an infrastructure [rate limit](/rate-limits). Back off and retry. | | `500` | Internal error | Transient. Retry with backoff. | | `503` | Server busy | REST ingest and WebRTC gateways only, not the Server API. The body is `{ "error": "..." }`. A concurrency cap was hit. Retry with backoff. | `429` and `503` come from the REST ingest and WebRTC gateways, not the Server API. `429` is an **infrastructure [rate limit](/rate-limits)**, not a Risk Score. It protects the gateway and never feeds the score. The Risk Score is always 0 to 100. The same [rate-limits](/rate-limits) page documents the 256KB request body-size limit, and explains why a `999` in your logs is a ban marker rather than a customer score. ## Troubleshooting A score of 0 is often correct. It means no meaningful signals fired: a residential IP, a consistent OS, and a network check that passed. Score 0 is the [Clean band](/features/risk-scoring). Investigate only if you see 0 on traffic you expect to be risky. Common causes: * **The visit really is clean.** Test from a VPN, an anti-detect setup, or a datacenter IP to confirm signals fire. * **A network check is blocked.** A strict [CSP](/setup/csp) or network can suppress a network check. Confirm `connect-src` allows `https://webrtc.shieldlabs.ai` and `stun:ice.shieldlabs.ai:3478`. * **The snippet is not running.** Check the browser console for load errors and confirm `checkAnonymous()` or `checkAuthenticatedUser()` is actually called. Webhook delivery is **at-most-once with no retries** and a short timeout, so a slow or failing endpoint silently drops the delivery. Check, in order: 1. **Your endpoint returns `200` quickly** (well under a second) and is publicly reachable over HTTPS with a valid certificate. 2. **A callback URL is set** for the domain, in the [dashboard](https://dashboard.shieldlabs.ai) or via `POST /{domain}:{secret}/callback`. 3. **The domain has balance** (a `402` state means no identifications are processed). 4. **Signature verification is not rejecting valid payloads.** Recompute HMAC-SHA256 over the `Data` object only, keyed by your Secret Key, and compare to `Assing` as the [Webhooks](/setup/webhooks) page details. 5. **Your logs show the POST.** If they do not, the delivery never reached you. For anything that must not be missed, read it from the [History API](/api/server-api) by `request_id`. This is expected. ShieldLabs sends an `initial` webhook about a second after the visit, and may send an `update` webhook after a follow-up network check completes (with a delta of new signals). The [two-phase webhook model](/api/webhooks) means you handle both, make your handler idempotent on `RequestID`, and apply the latest score. * **Public Key.** Confirm `?publicKey=` matches the key for this exact domain. * **CSP and ad-block.** A strict [Content Security Policy](/setup/csp) or a content blocker can stop the module. Allow `https://cdn.shieldlabs.ai` and `https://cdn.jsdelivr.net` in `script-src`. * **Module loading.** The snippet is an ES module, so the [snippet install guide](/setup/snippet) loads it with ` ``` The snippet POSTs the signals automatically and returns a Promise. The browser computes no VisitorID, DeviceID, or Risk Score. Those come back server-side. The signature is `checkAnonymous(userHID?, callback?)` and `checkAuthenticatedUser(userHID, callback?)`. The optional callback fires after the POST and gives you the client `ip` and the `requestID`. Use the `requestID` to correlate this check with the webhook you receive. The first argument is the client IP, not the score. The Risk Score arrives later by webhook. ```js theme={null} // Anonymous: pass undefined for userHID so the callback lands in the right slot. mod.checkAnonymous(undefined, (ip, requestID) => { console.log('ShieldLabs requestID', requestID); }); // Authenticated: // mod.checkAuthenticatedUser('a1b2c3d4hasheduserid', (ip, requestID) => { // console.log('ShieldLabs requestID', requestID); // }); ``` Copy-paste versions for Native JS, React, Angular, Vue, Preact, and Svelte live at [Install the snippet](/setup/snippet). Within about 1 second of the browser check, ShieldLabs POSTs a JSON envelope to your endpoint. It has two fields: `Data` (the result) and `Assing` (the signature over `Data`). ```json theme={null} { "Data": { "RequestID": "13f84f05-2c4a-4d8e-9b1a-6f2e7c9d0a55", "SessionID": "7a1b2c3d-e89f-4a1b-9c2d-3e4f5a6b7c8d", "CookieID": "3f2e1d0c-b9a8-7f6e-5d4c-3b2a1f0e9d8c", "DeviceID": "d290f1ee-6c54-4b01-90e6-d701748f0851", "VisitorID": "161dfbad-e5f6-7890-abcd-ef1234567890", "IP": "37.214.25.112", "OS": "Windows", "Country": "BY", "UserHID": "a1b2c3d4hasheduserid", "Score": 40, "Details": [ { "Value": 30, "Description": "Browser VPN/Proxy" }, { "Value": 10, "Description": "Timezone Mismatch" } ], "LastRequestTime": "2026-06-16T18:00:21.685Z", "Phase": "initial" }, "Assing": "3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e" } ``` Each entry's `Description` is a human-readable label for visibility and logging. Branch on the `Score` and each entry's `Value`, not on the label text, which can change. Always verify `Assing` before trusting the payload. It is the hex HMAC-SHA256 of the raw `Data` bytes, keyed by your Secret Key. Recompute it over the raw request body and compare in constant time. Capture the raw request body (for example with `express.raw({ type: 'application/json' })`) so you keep the exact bytes. The signature covers the raw bytes of the `Data` object, so locate that object inside the raw body and HMAC the original bytes rather than a re-serialized copy. ```js Node.js theme={null} import crypto from 'crypto'; const SECRET = process.env.SHIELDLABS_SECRET; // req.body is the raw Buffer of the request as received (express.raw()). function verify(rawBody) { const text = rawBody.toString('utf8'); // Slice the raw bytes of the "Data" object straight from the body. const start = text.indexOf('"Data"'); const open = text.indexOf('{', start); let depth = 0, end = open; for (let i = open; i < text.length; i++) { if (text[i] === '{') depth++; else if (text[i] === '}' && --depth === 0) { end = i + 1; break; } } const dataBytes = Buffer.from(text.slice(open, end), 'utf8'); const { Assing } = JSON.parse(text); const expected = crypto .createHmac('sha256', SECRET) .update(dataBytes) .digest('hex'); return ( Assing.length === expected.length && crypto.timingSafeEqual(Buffer.from(Assing, 'hex'), Buffer.from(expected, 'hex')) ); } ``` HMAC the raw request body bytes as received. Re-serializing the parsed JSON (`JSON.stringify`) changes the bytes and the signature will not match. The signature field is literally spelled **`Assing`** in the JSON. That is today's real field name (a cleaner name is planned). Match it exactly. Two behaviors to handle up front: * **The webhook can fire twice.** `Phase: "initial"` is the first score. `Phase: "update"` may follow with refined signals, and its `Details` carry only the changes. * **There are no retries.** Delivery is at-most-once. Make your handler idempotent on `RequestID`, and poll the [History API](/api/server-api) for guaranteed reads. More detail lives in [Webhooks](/setup/webhooks). The Risk Score is 0 to 100, capped at 100. Higher means more anonymous, more likely masked, spoofed, or abusive. Every score ships with `Details`, so you see which signals fired. Decide what to do per band. These are recommendations, not enforced rules. Your code owns the verdict. | Band | Range | Meaning | What your code might do | | ---------- | ------ | ------------------------------------------ | ----------------------------------------- | | **Clean** | 0–9 | No meaningful signals | Pass through, no friction | | **Low** | 10–29 | One minor signal | Allow, worth logging | | **Medium** | 30–59 | Several overlapping or one moderate signal | Step-up challenge, second look, or review | | **High** | 60–100 | Strong anonymity signals | Block, review, or require verification | ```js theme={null} async function handleScore(data) { const { RequestID, Score, Details, UserHID } = data; // Idempotent: an "update" Phase or a redelivery may carry the same RequestID. await db.checks.upsert({ requestID: RequestID, score: Score, details: Details }); if (Score >= 60) { await requireStepUp(UserHID); // High: verify before a sensitive action } else if (Score >= 30) { await flagForReview(UserHID, Score); // Medium: a second look } // Clean / Low: allow. } ``` A legitimate user can score high (corporate proxy, VPN, or a privacy browser). Decide on **Score + Details + action context**, never the number alone. Tune thresholds gradually. ## Next steps Persistent VisitorID and DeviceID, and how they survive cleared cookies. VPN, proxy, Tor, and anti-detect browser signals, with the scoring reference. How the 0 to 100 score is built, what `Details` contains, and the bands. Ready-made patterns computed across your historical traffic. Rank every source by the risk and anonymous-traffic share it delivers. Turn the score and signals into allow, challenge, review, and block logic. # Rate limits Source: https://docs.shieldlabs.ai/rate-limits Learn the rate limits ShieldLabs enforces and how to stay within them. These limits protect the ingest infrastructure. They are **not** part of the Risk Score and they never change how a visitor is scored. The [Risk Score](/features/risk-scoring) is `0-100` (Clean / Low / Medium / High) and is driven entirely by the signals collected from a visitor. This page is about keeping the gateway healthy under load. In normal use you rarely hit these limits. The [snippet](/setup/snippet) manages its own call cadence (one identify per visit, throttled by a session window), so a single visitor on a single page does not generate a burst of requests. Limits exist to absorb abusive traffic, not legitimate integration patterns. ## The limits at a glance The snippet posts collected signals to `rest.shieldlabs.ai`. That gateway enforces three protections: | Protection | Scope | Threshold | Response when exceeded | | ----------------- | ------------------------ | ---------------------------- | --------------------------------------------- | | Per-IP rate limit | Source IP, per minute | 10 requests / minute | `429 Too Many Requests`, then a 1-hour IP ban | | Concurrency cap | Whole gateway, in-flight | 512 simultaneous connections | `503 Service Unavailable` | | Request body size | Per request | 256 KB | Request rejected | All three are pure infrastructure guards. None of them feed the score, and none of them appear in the webhook or History API payloads as a signal. ## Per-IP rate limit and the 1-hour ban A single source IP may make up to **10 requests per minute** to the REST ingest endpoint. Cross that and the gateway returns: ```json theme={null} HTTP/1.1 429 Too Many Requests Content-Type: application/json { "error": "too many requests" } ``` After the threshold is crossed, that IP is **banned for 1 hour**. During the ban window, further requests from the same IP continue to be rejected with `429`. The ban clears automatically; there is no manual unban step and nothing to configure. Because the limit is **per source IP**, be careful in environments where many real users share one egress IP (a corporate NAT, a mobile carrier gateway, a shipping/CI proxy). In those setups a normal crowd of users can look like one busy IP. If you proxy snippet traffic through your own backend, you collapse every user onto your server's IP and will hit this limit fast. Let the snippet post directly from the browser instead. ### Guard against the "999" ban marker When an IP is banned, the **browser receives the `429`**, but your backend can still see a snapshot for that request: ShieldLabs writes a sentinel value of `999` to mark the banned request, and that snapshot can reach you on a [webhook](/setup/webhooks) delivery or a [History API](/api/server-api) row. The `999` is not capped to 100 on the ban path, so it arrives as-is. You may therefore receive a payload where `Data.Score` is `999`. **Guard for `999` before you read the band.** A rate-limit ban is a gateway event, not a high-risk visitor, but its `999` marker can land in a webhook or History row uncapped. Drop those rows at the top of your handler: ```js theme={null} // The Risk Score is 0-100. Anything above 100 is the rate-limit ban marker, not a score. if (Data.Score > 100) return; ``` Branch your decision logic only on a `Data.Score` in the `0-100` range. Read a `999` as "this IP was rate-limited at the gateway," and look at the `429` status, not the number. ## Concurrency cap (503) Independent of the per-IP limit, the gateway caps the number of **simultaneous in-flight requests** across all traffic. When that cap (512 connections) is saturated, new requests get: ```json theme={null} HTTP/1.1 503 Service Unavailable Content-Type: application/json { "error": "server is busy" } ``` A `503` here means "try again shortly," not "you did something wrong." It is transient back-pressure. The snippet does not need special handling for this; if you call the [Server API](/api/server-api) server-side and hit a `503`, retry with a short backoff. ## Request body size (256 KB) Each request body to the REST gateway is capped at **256 KB**. The signal payload the snippet sends is well under this in normal operation, so you will not approach the cap unless something is wrong upstream (for example, a payload being duplicated or wrapped before it reaches the gateway). Oversized bodies are rejected before scoring. ## Billing exhaustion is separate (402) Running out of request balance is **not** a rate limit. It is a billing condition with its own status code: ```json theme={null} HTTP/1.1 402 Payment Required ``` A `402` means the domain has no remaining request balance, so the call cannot be processed. This is unrelated to how fast you are sending traffic. Top up or upgrade the plan to clear it. The [Billing](/billing) page covers how requests are counted (one identify = one request), and the [Errors](/errors) page lists the full status-code reference. ## How to stay within the limits The [snippet](/setup/snippet) is designed to call `rest.shieldlabs.ai` directly from each visitor's browser, so requests are naturally spread across many client IPs. Do not relay snippet traffic through a single server, which would funnel everyone onto one IP and trip the 10/minute limit. The snippet runs one identify per visit within a session window rather than on every interaction. You generally do not need to add your own debouncing. Use `forceCheckAnonymous` / `forceCheckAuthenticatedUser` only at meaningful moments (right after login, before a sensitive action), not in a loop. Get scores from the [webhook](/setup/webhooks) (delivered automatically) or, when you need a guaranteed read, from the [History API](/api/server-api). Do not re-fire identify calls to "refresh" a score. For any server-side call, treat `503` as transient and retry after a short, jittered delay. Treat `429` as a hard stop for that IP until the ban window passes. ## Quick reference | Status | Cause | What it means for you | | ------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------ | | `429` | More than 10 requests/minute from one IP | That IP is banned for 1 hour. Spread traffic across client IPs; do not proxy through one server. | | `503` | Gateway concurrency cap reached | Transient back-pressure. Retry with a short backoff. | | `402` | Domain request balance exhausted | A [billing](/billing) condition, not a rate limit. Top up or upgrade. | | Rejected body | Request body over 256 KB | Payload too large for the ingest endpoint. | The [Errors](/errors) page documents every status code ShieldLabs can return and how to handle it. # Security Source: https://docs.shieldlabs.ai/security Learn how ShieldLabs protects your data in transit and how to verify what you receive. # Security Securing a ShieldLabs integration comes down to four things: verify that every webhook really came from us, keep your secret key on the server, run everything over HTTPS, and know exactly what the snippet's payload protection does and does not give you. This page is the reference for each. ShieldLabs scores visits; your code owns the decision. Nothing here changes that: these are integration security controls, not a verdict engine. The Risk Score is `0-100` (Clean / Low / Medium / High) and you act on it in your own backend. ## Webhook authenticity (HMAC-SHA256) Your callback URL is a public endpoint. Anyone who learns it can POST to it. The only thing that proves a delivery actually came from ShieldLabs is its signature, so **verify every webhook before you trust the body.** The signature is the `Assing` field: an HMAC-SHA256 of the `Data` object keyed with your domain's secret key, constant-time compared, rejecting with `401` on a mismatch. The exact formula, the Node, Go, and Python handlers, the raw-bytes gotcha, the two-phase delivery model, and idempotency on `RequestID` all live on the [webhooks](/setup/webhooks) page. ## Key handling Every domain has one key set: a **public key** and a **secret key**, issued together and scoped to that single domain. They have opposite trust levels. | Key | Where it belongs | What it can do | Safe in the browser? | | -------------- | ---------------------------- | ----------------------------------------------------------------------------------- | -------------------- | | **Public key** | The snippet URL on your site | Identifies the domain so the server knows which account a fingerprint belongs to | Yes, by design | | **Secret key** | Your server only | Verifies webhook signatures and authenticates the Server API as `{domain}:{secret}` | No, never | The public key is meant to be visible. It ships in your page source as the `?publicKey=` parameter and cannot read data, change settings, or authenticate against the Server API. A request is only accepted when the public key matches the domain it is served from, so a key lifted from your page will not work on someone else's site. The secret key is the opposite. It is the HMAC key for webhook verification and the credential for every Server API call. **Anyone holding it can forge webhook signatures and read your domain's history**, so it must never reach the browser. Never put the secret key in client-side code, the snippet, a public repository, a build artifact, or any place a browser can reach. Store it in an environment variable or a secrets manager. Use a different key set for every domain so a leak is contained to one site. ### Rotate when exposed If a secret may have leaked (a committed `.env`, a log line, an offboarded teammate), rotate the domain's key set from the dashboard right away. Rotation issues a fresh public key and secret key, shows the new secret in full once, and invalidates the old set immediately, so update the snippet and your server together. The step-by-step rotation flow, masking, and the Profile health check live on the [API keys](/setup/keys) page. ## Transport security (HTTPS / TLS) Everything moves over HTTPS. * **ShieldLabs hosts** (`cdn.shieldlabs.ai`, `rest.shieldlabs.ai`, `webrtc.shieldlabs.ai`, `api.shieldlabs.ai`, `dashboard.shieldlabs.ai`) are served over TLS. The snippet also performs a network check against the `webrtc.shieldlabs.ai` gateway to gather Network Intelligence (such as the client's local IP). * **Your webhook callback URL must be HTTPS.** It receives signed scores and identifiers, so terminate TLS in front of your handler. * **Your Server API calls must be HTTPS.** The path carries `{domain}:{secret}`, so a plaintext request would put your secret on the wire. Always call `https://api.shieldlabs.ai/...`. The snippet requires a **secure context**: it uses the Web Crypto API (`crypto.subtle`), which browsers only expose over HTTPS (and on `localhost` for local development). On an insecure `http://` page the payload-encryption step is unavailable and the snippet cannot run as intended. Serve any page that loads the snippet over HTTPS. ## Payload protection (honest scope) The snippet wraps its signal payload with **AES-256-GCM** before POSTing it to `rest.shieldlabs.ai`. This is worth understanding precisely, because it is easy to overstate. The encryption key is **derived from your public key**, and the public key is the same value that travels in cleartext in the snippet URL. So the wrapping protects payload **integrity and obfuscates it in transit on top of TLS**. It is not a secret-key scheme, and it is **not end-to-end encryption.** Do not describe ShieldLabs payloads as end-to-end encrypted. The wrapping key comes from a public value, so it cannot provide confidentiality against someone who has the public key. The actual confidentiality on the wire comes from **TLS**, which is why HTTPS is mandatory. Treat AES-256-GCM here as tamper-resistance and obfuscation layered on top of TLS, nothing more. There is nothing for you to configure. The snippet handles wrapping automatically in a secure context, and the server accepts both wrapped and plain payloads. Your job is to keep the page on HTTPS so TLS does the real confidentiality work. ## Data handling on your side A few practices keep the data you exchange with ShieldLabs clean. * **Pass a hashed `UserHID`, never a raw identifier.** When you call `checkAuthenticatedUser`, send a hashed or pseudonymous account id, not a real email or user id. It is echoed back in webhooks and history, so keep it opaque. * **The public key is the only credential in the browser.** Identifiers like the client cookie id and session id live client-side by design and break on storage clear. The durable identity (DeviceID) and the Risk Score are derived server-side and reach you through signed webhooks and the [Server API](/api/server-api), never assembled in the page. * **Keep raw signals server-side.** Read scores and signals from your verified webhook handler or the History API, and apply your allow / challenge / review / block logic in your backend. There is no in-product rules engine to leak through. The [privacy](/privacy) page covers what is and is not collected, and who controls retention. ## Responsible disclosure If you find a security issue in ShieldLabs, report it privately to **[security@shieldlabs.ai](mailto:security@shieldlabs.ai)**. Please include enough detail to reproduce it, and give us a reasonable window to confirm and fix before any public disclosure. We do not pursue good-faith researchers who follow coordinated disclosure. ## Security checklist Constant-time compare `Assing` against an HMAC-SHA256 of the received `Data`. Reject with `401` on a mismatch. Secret key in an environment variable or secrets manager, never in the browser. One key set per domain. Rotate the key set the moment a secret may have leaked, then update the snippet and your server together. Snippet pages, your callback URL, and your Server API calls all over TLS. The snippet needs a secure context to run. Send only a hashed or pseudonymous account id to `checkAuthenticatedUser`. ## Related pages Two-phase delivery, signature verification in Node, Go, and Python, and idempotency on `RequestID`. Public and secret key lifecycle, masking, and rotation. The exact `script-src` and `connect-src` directives the snippet needs. What is and is not collected, and who controls retention. # Setup Source: https://docs.shieldlabs.ai/setup An overview of everything you wire up to run ShieldLabs: the snippet, keys, webhooks, and domains. # Setup ShieldLabs is a snippet plus a webhook. The browser snippet collects 100+ browser, device, and network signals and posts them to ShieldLabs automatically. The server derives a persistent identity and an explainable **Risk Score (0-100)**, then delivers it to your backend by webhook (and you can read it back via the [Server API](/api/server-api)). Your own code reads the score plus its `Details` and decides allow, challenge, review, or block. ShieldLabs scores; you decide. Most teams get their first Risk Score in about **5 minutes**. ## Integration checklist [Sign up for free](https://dashboard.shieldlabs.ai) and get 5,000 identifications, or log in if you already have an account, then register the domain you want to identify visitors on. ShieldLabs issues a **Public Key** (per domain, safe to put in the browser) and a **Secret Key** (backend only, used for webhook verification and Server API auth as `{domain}:{secret}`). The [Keys](/setup/keys) and [Domains](/setup/domains) pages cover both. Load the ES module from `cdn.shieldlabs.ai` with your Public Key and call `checkAnonymous()`. It is a dynamic `import()`, not an npm package and not a native SDK, web only. The [Snippet](/setup/snippet) page has framework examples for Native JS, React, Angular, Vue, Preact, and Svelte. ```html theme={null} ``` If you run a Content Security Policy, allow the ShieldLabs CDN and the endpoints the snippet posts to per the [CSP](/setup/csp) directives. Without this the snippet is blocked and you receive no signals. ```http theme={null} Content-Security-Policy: script-src 'self' https://cdn.shieldlabs.ai https://cdn.jsdelivr.net; connect-src 'self' https://rest.shieldlabs.ai https://webrtc.shieldlabs.ai stun:ice.shieldlabs.ai:3478; ``` Point ShieldLabs at a backend URL so scores are pushed to you as they are computed. Set it in the dashboard, or `POST` the URL as the plain-text body to the Server API. Each delivery is a `{ "Data": { ... }, "Assing": "" }` envelope, where `Assing` is the HMAC-SHA256 signature you verify with your Secret Key as the [Webhooks](/setup/webhooks) guide details. ```bash theme={null} curl -X POST https://api.shieldlabs.ai/YOUR_DOMAIN:YOUR_SECRET/callback \ --data 'https://yourapp.com/webhooks/shieldlabs' ``` Trigger an identify call (load a page with the snippet), confirm your webhook receives a `Phase: "initial"` body in about a second, and verify the signature. Read the `Score` and the `Details`, then your code decides what to do. There is no in-product rules engine: the decision lives in your application, where [acting on the Risk Score](/guides/acting-on-risk-score) walks the allow, challenge, review, or block paths. Want the fastest path end to end? The [Quickstart](/quickstart) walks the same five steps with copy-paste snippets and a working webhook handler. ## Setup pages Install the ES-module snippet, the `checkAnonymous` and `checkAuthenticatedUser` exports, framework examples, and what gets collected. Public Key vs Secret Key: where each one goes, what it authenticates, and how to keep the secret out of the browser. Register a callback URL, the `{ Data, Assing }` envelope, HMAC verification in Node, Go, and Python, and the initial and update phases. The exact `script-src` and `connect-src` directives the snippet needs. Register a domain, how the snippet ties traffic to it, and managing multiple domains under one account. Separate staging and production with distinct domains and keys so test traffic never mixes with live data. ## What you receive Once the snippet is live and your webhook is registered, every identify call produces a webhook body like this. The `Details` array names each signal that fired and the points it contributed, so the score is explainable, not a black box. ```json theme={null} { "Data": { "RequestID": "8f1d0c2a-7b3e-4a9c-9d2f-1e6a5b4c3d21", "VisitorID": "c4a2e9b1-5f8d-4c3a-8e7b-2a1f0d9c8b76", "DeviceID": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", "IP": "203.0.113.42", "OS": "Mac OS X", "Country": "US", "UserHID": "hashed-account-id", "Score": 40, "Details": [ { "Value": 30, "Description": "OS not Detected" }, { "Value": 10, "Description": "Datacenter IP" } ], "Phase": "initial" }, "Assing": "1f3c9a...hex-hmac-sha256" } ``` The Score runs 0 to 100, capped at 100, and sorts into four bands (Clean, Low, Medium, High) that the [Risk Score](/features/risk-scoring) page defines in full. The bands are a guide; the action per band is yours to write in your own code. A legitimate visitor can score high (a corporate proxy, a VPN, or a privacy browser). Decide on the Score plus the `Details` plus your action context, never the number alone, and the playbook for [acting on the Risk Score](/guides/acting-on-risk-score) shows how to tune your thresholds gradually. [Webhooks](/setup/webhooks) are **at-most-once** with no retries and a roughly 1-second timeout. Make your handler idempotent on `RequestID`, and for guaranteed reads poll the [History API](/api/server-api) rather than relying on the webhook alone. ## Where to go next With the snippet installed and a webhook verified, read the [Overview](/overview) to understand the identifiers and the [Risk Score](/features/risk-scoring), then jump into the [Cookbook](/cookbook) for worked examples like [login and 2FA](/cookbook/login-2fa), [checkout](/cookbook/checkout), and [affiliate fraud](/cookbook/affiliate-fraud). The [API overview](/api/overview) has the full payloads and endpoints. # Content Security Policy Source: https://docs.shieldlabs.ai/setup/csp Learn which Content-Security-Policy directives let the ShieldLabs snippet load and report. If your application sends a `Content-Security-Policy` header, the browser will block the ShieldLabs snippet unless you add its hosts to your allowlists. The snippet loads a module from a CDN, pulls in a dependency, and POSTs the collected signals to a few `shieldlabs.ai` endpoints. Each of those needs a directive. If you have not added the snippet yet, start with [Add the snippet](/setup/snippet) and come back here once it loads. ## Required directives Add these two directives to your existing policy. Merge the hosts into directives you already have rather than duplicating them. ``` script-src 'self' https://cdn.shieldlabs.ai https://cdn.jsdelivr.net; connect-src 'self' https://rest.shieldlabs.ai https://webrtc.shieldlabs.ai stun:ice.shieldlabs.ai:3478; ``` ## What each host is for `script-src` covers the code that runs. `connect-src` covers where the snippet sends data. | Directive | Host | Why it is needed | | ------------- | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------- | | `script-src` | `https://cdn.shieldlabs.ai` | Serves the ShieldLabs snippet module (`snippet.js`) and its supporting modules. | | `script-src` | `https://cdn.jsdelivr.net` | A required runtime dependency the snippet imports from jsDelivr. Omit this host and the snippet cannot finish loading. | | `connect-src` | `https://rest.shieldlabs.ai` | The snippet POSTs the collected signal snapshot here. This is the main data endpoint. | | `connect-src` | `https://webrtc.shieldlabs.ai` | A ShieldLabs data endpoint the snippet connects to. | | `connect-src` | `stun:ice.shieldlabs.ai:3478` | A ShieldLabs data endpoint the snippet connects to. | `https://cdn.jsdelivr.net` belongs in `script-src` because that dependency is fetched as a module, not in `connect-src`. The three `shieldlabs.ai` data endpoints belong in `connect-src` because the snippet connects to them to send data. The snippet uses no `eval` and no `new Function`. It loads code by dynamic `import()` only, so `'unsafe-eval'` is never required, even under a strict policy. ## Inline scripts: HTML method vs framework method The two install methods from [Add the snippet](/setup/snippet) have different inline-script requirements. The HTML method runs an inline ` ``` Because the bootstrap code is inline, a policy that forbids inline scripts will block it. You have two options: 1. Move the bootstrap into an external module file you serve from `'self'` (no inline code), or 2. Use the framework component method instead (next tab), which has no inline script at all. Avoid `'unsafe-inline'` if you can. The framework method removes the need for it entirely. In React, Vue, Angular, Svelte, or Preact the `import()` lives inside a component or service in your application code, which is already covered by `script-src 'self'` (or your bundler host). There is no inline script, so you do not need `'unsafe-inline'`: ```jsx theme={null} import { useEffect } from "react"; export function ShieldlabsTracker({ publicKey }) { useEffect(() => { let cancelled = false; (async () => { const mod = await import( `https://cdn.shieldlabs.ai/snippet.js?publicKey=${publicKey}` ); if (!cancelled) mod.checkAnonymous(); })(); return () => { cancelled = true; }; }, [publicKey]); return null; } ``` This is the cleanest fit for a strict CSP. The full set of framework examples is on [Add the snippet](/setup/snippet). ## Full-header examples Drop these into your stack and adjust the surrounding directives to match your app. Only the two ShieldLabs directives are required. ```nginx Nginx theme={null} add_header Content-Security-Policy " default-src 'self'; script-src 'self' https://cdn.shieldlabs.ai https://cdn.jsdelivr.net; connect-src 'self' https://rest.shieldlabs.ai https://webrtc.shieldlabs.ai stun:ice.shieldlabs.ai:3478; style-src 'self' 'unsafe-inline'; base-uri 'self'; frame-ancestors 'none' " always; ``` ```js Next.js (next.config.js) theme={null} const csp = ` default-src 'self'; script-src 'self' https://cdn.shieldlabs.ai https://cdn.jsdelivr.net; connect-src 'self' https://rest.shieldlabs.ai https://webrtc.shieldlabs.ai stun:ice.shieldlabs.ai:3478; `; module.exports = { async headers() { return [ { source: "/(.*)", headers: [ { key: "Content-Security-Policy", value: csp.replace(/\n/g, " ").trim() }, ], }, ]; }, }; ``` ```html HTML meta tag theme={null} ``` ## A very strict CSP can skip the local-network check The snippet also runs a local-network check that feeds the [Risk Score](/features/risk-scoring). A very strict `connect-src` that blocks the local-network connection stops this one check from running. This is acceptable degradation. The main snapshot still posts, identification still works, and the visitor is still scored. When the local-network check cannot run, it is recorded as "not checked", which carries a small penalty, so the Score can shift slightly upward rather than down. It does not lower the Score. ShieldLabs only surfaces the anonymity signals and the Risk Score. Your own code still owns whether to allow, challenge, review, or block. ## Verify it works After deploying your policy: Open a page where the snippet runs and open your browser dev tools Console. A blocked host shows a `Refused to load` or `Refused to connect` error naming the directive and the host. If you see one, add that host to the directive it names. In the Network tab, confirm a request to `rest.shieldlabs.ai` succeeded. Once it does, the server scores the visit and delivers your [webhook](/setup/webhooks). ## Related * [Add the snippet](/setup/snippet): the HTML and framework install methods these directives support. * [API keys](/setup/keys): the public key that goes in the snippet URL. * [Webhooks](/setup/webhooks): where the score and signals arrive after the snapshot posts. # Domains Source: https://docs.shieldlabs.ai/setup/domains Learn how to add and verify the sites you want to protect. # Domains A **domain** is the unit of integration in ShieldLabs. You add each site you want to identify visitors on as its own domain, and that domain gets its own key set, its own webhook callback, and its own request totals. Nothing is shared across domains: a key set issued for one domain does not work on another. If you run a single site, you have one domain. If you run several sites (or staging and production), each is a separate domain with its own configuration. ## Add a domain Domains are created in the dashboard, on the **Integration** tab. Go to [dashboard.shieldlabs.ai](https://dashboard.shieldlabs.ai) and open the **Integration** tab. Enter the hostname you want to identify visitors on, for example `myshop.com`. Adding it provisions the domain's **public key**, **secret key**, and an empty **callback** (webhook) slot. Drop the snippet onto that domain with its public key in the URL, following [Install the snippet](/setup/snippet) for the full client setup. ```html theme={null} ``` Point the domain's [webhook callback](/setup/webhooks) at a handler on your server so scores are delivered to you. You can set it in the dashboard, or with the Server API. Adding a domain mints a fresh key set on the spot. The [secret key](/setup/keys) is shown in full only at that moment. Capture it then and store it server-side. ## What every domain carries | Field | What it is | | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **PublicKey** | Goes in the snippet URL as `?publicKey=`, safe to expose in the browser. Identifies which domain a fingerprint belongs to. See [keys](/setup/keys) for the format. | | **Secret** | Server-side only. Verifies the `Assing` webhook signature and authenticates Server API calls. See [keys](/setup/keys) for the format and `{domain}:{secret}` auth. | | **Callback** | The webhook URL each score is POSTed to. Empty until you set it. | | **Enabled** | Whether the domain is active. A disabled domain rejects identify calls and Server API auth with `401`. | | **Weight** | The domain's remaining request balance, returned by the Profile endpoint. Across multiple domains, each carries its own balance. Billing is per request, counted per domain. | You can read the live configuration for a domain at any time with the [Profile endpoint](/api/server-api). It returns both keys masked (see [keys](/setup/keys)), so you can confirm a domain without exposing its credentials: ```bash theme={null} curl "https://api.shieldlabs.ai/myshop.com:9b74c98e1a3f0d2c5b6a7e8f10293847/profile" ``` ```json theme={null} { "Domain": "myshop.com", "Weight": 148230, "Callback": "https://myshop.com/webhooks/shieldlabs", "PublicKey": "****************************8c7d", "Secret": "****************************3847", "CreatedAt": "2025-11-21T18:00:21Z" } ``` The Profile call is free: it does not consume any of the domain's requests. Use it as a quick health check that a domain is enabled and pointed at the right callback. ## Verification is automatic You do not add a DNS record or upload a file to verify a domain. Verification happens on its own once live snippet traffic is seen. The public key only works on the domain it was issued for. The server resolves the domain from the request `Origin`, then `Referer`, then `Host`, and checks it against the public key. Load a page that runs the snippet. The first fingerprint that arrives for that domain marks it as verified in the dashboard. The domain flips to verified on the **Integration** tab once that first call is recorded. If a public key is served from a host it was not issued for, the identify call is rejected with `401`, and the domain stays unverified. A key lifted from your page source will not work on someone else's site. ## Subdomains and host matching A key set is scoped to the exact host you registered. The server resolves the domain from the request `Origin`, then `Referer`, then `Host`, and looks it up against the registered domains. The match is exact, with one normalization: a leading `www.` is stripped, so `www.myshop.com` and `myshop.com` resolve to the same domain. Subdomains do not inherit a parent domain's key set. `app.myshop.com` and `checkout.myshop.com` are distinct hosts: each one you want to identify visitors on needs its own domain entry, with its own public key, secret key, and request totals. Register each subdomain you serve the snippet on as its own domain. That keeps every host on its own key set and its own request balance, and lets you point each one at a different callback if you need to. ## Per-domain isolation Every domain is a self-contained unit. Each domain has its own public key and secret key. A key set issued for one domain authenticates only that domain. Rotating one domain's keys never touches another's. Each domain delivers its scores to its own callback URL. Point them at the same handler or different handlers, as you prefer. With multiple domains you can see how the request balance splits across them. Disabling one domain stops its identify calls and Server API access without affecting the others. ## Next steps With the domain added, wire its public key into the [snippet](/setup/snippet), keep its secret key on your server, and point its callback at a handler that verifies signatures per the [webhooks](/setup/webhooks) guide. # Environments Source: https://docs.shieldlabs.ai/setup/environments Learn how to keep development and production traffic apart. # Environments ShieldLabs has no concept of a built-in "test mode." You separate development from production the same way you separate any other configuration: with **separate domains and separate key sets**, plus the matching snippet host. The API shape is identical in every environment. A webhook from a development domain and a webhook from a production domain carry the same fields, the same `RequestID` join key, and the same Risk Score (0-100) with its `Details`. Nothing about the integration changes between environments except the credentials and the host you load. ## Two things change per environment Register a separate domain for each environment (for example `dev.example.com` and `example.com`). Each domain gets its own public key, secret key, callback URL, and request balance. Production loads the snippet from `cdn.shieldlabs.ai`. Development loads it from `dev.cdn.shieldlabs.ai`. Same module, same exports, same call pattern. ## Snippet hosts The only host that differs in your page code is the CDN that serves the module. | Environment | Snippet host | | --------------- | ------------------------------------------ | | **Production** | `https://cdn.shieldlabs.ai/snippet.js` | | **Development** | `https://dev.cdn.shieldlabs.ai/snippet.js` | Both serve the same ES module with the same `checkAnonymous`, `checkAuthenticatedUser`, and `forceCheck*` exports that [Install the snippet](/setup/snippet) covers for the full client setup. ```html Production theme={null} ``` ```html Development theme={null} ``` ## Use a separate domain and key set per environment Register each environment as its own [domain](/setup/domains) in [dashboard.shieldlabs.ai](https://dashboard.shieldlabs.ai). Every domain you add gets an independent [public key, secret key](/setup/keys), callback URL, and request balance. | Environment | Registered domain | Snippet host | Callback URL | | --------------- | ----------------- | ----------------------- | -------------------------- | | **Development** | `dev.example.com` | `dev.cdn.shieldlabs.ai` | your tunnel or staging URL | | **Production** | `example.com` | `cdn.shieldlabs.ai` | your production handler | Keeping them separate buys you: * **Clean data.** Development traffic never lands in your production [dashboard](/features/traffic-analytics), so your visitor counts, [traffic sources](/features/traffic-analytics), and [patterns](/features/patterns) reflect real users only. * **Isolated webhooks.** Each domain points its [callback](/setup/webhooks) at a different URL, so test deliveries hit your local handler and never your production endpoint. * **Independent balances.** Test runs draw down the development domain's request balance, not your paid production balance. Billing is [per request](/billing). * **Blast-radius control.** A leaked development secret cannot forge webhooks or call the [Server API](/api/server-api) for your production domain. Keys are scoped to a single domain. Do not reuse one key set across environments. The public key is bound to the domain it is served from (resolved from the `Origin`, `Referer`, or `Host`), so a production key on `dev.example.com` will be rejected with a `401`. And a single secret shared across environments means a development leak compromises production. ## Wire the host and keys through config Load the host and public key from environment config instead of hardcoding them, so the same build runs in both environments. Keep the **secret key server-side only**: it is used for [webhook verification](/setup/webhooks) and [Server API](/api/server-api) auth and must never reach the browser. ```bash .env.development theme={null} SHIELDLABS_SNIPPET_HOST=https://dev.cdn.shieldlabs.ai SHIELDLABS_PUBLIC_KEY=YOUR_DEV_PUBLIC_KEY SHIELDLABS_DOMAIN=dev.example.com SHIELDLABS_SECRET=YOUR_DEV_SECRET # server-side only ``` ```bash .env.production theme={null} SHIELDLABS_SNIPPET_HOST=https://cdn.shieldlabs.ai SHIELDLABS_PUBLIC_KEY=YOUR_PROD_PUBLIC_KEY SHIELDLABS_DOMAIN=example.com SHIELDLABS_SECRET=YOUR_PROD_SECRET # server-side only ``` A framework component then reads the host and public key from config and loads the module the same way in every environment: ```jsx ShieldTracker.jsx theme={null} 'use client'; import { useEffect } from 'react'; // Public values, safe to expose in the browser build. const HOST = process.env.NEXT_PUBLIC_SHIELDLABS_SNIPPET_HOST; const PUBLIC_KEY = process.env.NEXT_PUBLIC_SHIELDLABS_PUBLIC_KEY; export function ShieldTracker({ hashedUserId }) { useEffect(() => { let cancelled = false; (async () => { const mod = await import(`${HOST}/snippet.js?publicKey=${PUBLIC_KEY}`); if (cancelled) return; hashedUserId ? mod.checkAuthenticatedUser(hashedUserId) : mod.checkAnonymous(); })(); return () => { cancelled = true; }; }, [hashedUserId]); return null; } ``` Your strict [Content-Security-Policy](/setup/csp) must allow whichever snippet host that environment uses. A development build pointed at `dev.cdn.shieldlabs.ai` needs that origin in `script-src`, not `cdn.shieldlabs.ai`. Keep the jsDelivr dependency host `cdn.jsdelivr.net` in `script-src` in both environments, and the `connect-src` endpoints (`rest.shieldlabs.ai`, `webrtc.shieldlabs.ai`, `stun:ice.shieldlabs.ai:3478`) stay the same in both. A very strict `connect-src` may skip the local-network check; the main snapshot and the Risk Score are unaffected. ## Receiving webhooks in development Your development callback needs a publicly reachable URL. Run a tunnel to your local server and point the development domain's callback at it: ```bash theme={null} # expose your local server ngrok http 3000 # -> https://abc123.ngrok.app ``` Set that tunnel as the development domain's callback. Either configure it in the [dashboard](https://dashboard.shieldlabs.ai), or set it through the Server API with the URL as the plain-text body, using the **development** domain and secret: ```bash theme={null} curl -X POST "https://api.shieldlabs.ai/dev.example.com:YOUR_DEV_SECRET/callback" \ -H "Content-Type: text/plain" \ --data "https://abc123.ngrok.app/webhooks/shieldlabs" ``` Verify the `Assing` signature in development with the **development** domain's secret, exactly as you will in production. The verification code is identical: only the secret differs. The [Webhooks](/setup/webhooks) guide carries the verification logic, the two-phase (`initial` and `update`) delivery, and the at-most-once delivery caveat. Webhooks are at-most-once with no retries, so a tunnel that is down means a missed delivery. When your local handler is offline, read results back with the [History API](/api/server-api) using the same development domain and secret. Make handlers idempotent on `RequestID` either way. ## Test with real traffic before production Risk Scores reflect the actual connection and browser environment of whoever loads the page, so the most useful test is **real traffic on a real development domain**, not synthetic requests. Register `dev.example.com`, drop the `dev.cdn.shieldlabs.ai` snippet onto a staging or preview deployment, and let real visits flow through it. Confirm visits appear under your development domain in the [dashboard](/features/traffic-analytics) and that webhooks reach your tunnel. Check the `Details` array to see which [signals](/features/anonymity-signals) fired and why. Decide what your code does at each band (Clean 0-9, Low 10-29, Medium 30-59, High 60-100) and verify the behavior on this real traffic, the way [acting on the Risk Score](/guides/acting-on-risk-score) lays out. Swap the host to `cdn.shieldlabs.ai` and the keys to the production domain's key set through config. Nothing else in your code changes. A legitimate visitor can score high (a corporate proxy, a VPN, a privacy browser). Testing with real traffic on a development domain is the best way to see that distribution before you wire scores into a decision your application acts on. Decide on Score plus `Details` plus context, and tune thresholds gradually. ## Promotion checklist Switch the import URL from `dev.cdn.shieldlabs.ai` to `cdn.shieldlabs.ai`, or flip the `SHIELDLABS_SNIPPET_HOST` config value. Use the production domain's public key in the snippet URL. Point the production domain's callback at your production webhook handler, not the tunnel. Load the production secret on your server for [webhook verification](/setup/webhooks) and [Server API](/api/server-api) auth. It must never reach the browser. Confirm your production [Content-Security-Policy](/setup/csp) allows `cdn.shieldlabs.ai` in `script-src`. ## Next steps Where each key lives and why the secret never ships to the browser. Register a domain per environment and manage its callback. Verify the signature and handle the two delivery phases. Turn the bands into allow, challenge, review, or block in your own code. # Public and Secret Keys Source: https://docs.shieldlabs.ai/setup/keys Learn what the public and secret keys are for and where each one belongs. # Public and Secret Keys Every domain you add to ShieldLabs gets **one key set**: a **public key** and a **secret key**. They are issued together, scoped to that single domain, and they serve opposite purposes. | Key | Where it lives | What it does | Safe in the browser? | | -------------- | ---------------------------- | ------------------------------------------------------------------------------------- | -------------------- | | **Public key** | The snippet URL on your site | Identifies the domain so the server knows which account a fingerprint belongs to | Yes | | **Secret key** | Your server only | Verifies webhook signatures and authenticates Server API calls as `{domain}:{secret}` | No, never | Both keys are a 32-character lowercase hexadecimal string. ## Public key The public key is the only credential that ships to the browser, and exposing it there is by design. ``` a3f8c2d1e9b0476a8c5d2f1e0b9a8c7d ``` * Goes in the snippet URL as the `?publicKey=` query parameter. * Tells `rest.shieldlabs.ai` which domain a fingerprint belongs to. * Safe to expose. It is visible in your page source and cannot read data, change settings, or authenticate against the Server API. * The request is only accepted when the public key matches the domain it is served from (resolved from the `Origin`, `Referer`, or `Host`). A public key lifted from your page will not work on someone else's domain. ```html theme={null} ``` [Install the snippet](/setup/snippet) covers the full client setup. ## Secret key The secret key is the credential that proves a request really came from your backend, so it must never leave your server. ``` 9b74c98e1a3f0d2c5b6a7e8f10293847 ``` The secret key has two jobs, both server-side: The secret is the HMAC-SHA256 key used to verify the `Assing` signature on every incoming webhook. Recompute the HMAC over the raw `Data` object bytes as received and compare it in constant time before you trust the payload. Server API calls authenticate by putting `{domain}:{secret}` in the path, for example `GET https://api.shieldlabs.ai/myshop.com:9b74c98e.../profile`. The runnable handlers in Node, Go, and Python, the two-phase delivery, and idempotency on `RequestID` all live on the [webhooks](/setup/webhooks) page. Never put the secret key in client-side code, a snippet, a mobile app bundle, a public repository, or any place a browser can reach. Anyone with the secret can forge webhook signatures and call the Server API for that domain. Store it in environment variables or a secrets manager, and use a different key set for every domain. ## Keys are masked once issued The full secret is shown **only at the moment it is created or rotated**, in the dashboard. After that, you cannot read it back in plaintext anywhere. The free [Profile endpoint](/api/server-api) returns both keys masked to the last four characters, so you can confirm *which* key a domain is using without exposing it. It costs 0 requests, so use it as a quick health check that a domain is enabled and pointed at the right callback URL. Treat the masked tails as a fingerprint for matching, not as a way to recover the key. ```bash theme={null} curl "https://api.shieldlabs.ai/myshop.com:9b74c98e1a3f0d2c5b6a7e8f10293847/profile" ``` The [Server API](/api/server-api) documents the full Profile response object. If you lose the secret, you cannot retrieve it. Rotate the key set and update your server with the new value. ## Rotating keys You can rotate a domain's key set from the dashboard. Rotation issues a fresh public key and secret key, and the new secret is displayed in full once, at that moment, so capture it then. After you rotate: Replace the `?publicKey=` value in your snippet (or the environment variable that feeds it) with the new public key. Swap the secret used for webhook HMAC verification and Server API auth (`{domain}:{secret}`) to the new secret. Keep it in your secrets manager, not in code. Call the [Profile endpoint](/api/overview) and check that the masked tails match the new keys. Rotate keys whenever a secret may have been exposed (a leaked log, a committed `.env`, an offboarded teammate) and on a routine schedule for sensitive domains. Plan a brief window where you update the snippet and your server together, since the old keys stop working as soon as rotation completes. ## Where to find your keys Open [dashboard.shieldlabs.ai](https://dashboard.shieldlabs.ai), select your domain, and view its key set. The secret is shown in full only at creation and rotation. To confirm an existing key set programmatically, use the masked [Profile endpoint](/api/overview). ## Next steps Once your keys are in place, wire the public key into the [snippet](/setup/snippet), point your callback at a handler that verifies the secret per the [webhooks](/setup/webhooks) guide, and explore what your server can read back through the [Server API](/api/overview). # Optimize usage and cost Source: https://docs.shieldlabs.ai/setup/optimizing-usage Learn how to place snippet calls to control cost and how to trace an unexpected usage spike. ShieldLabs bills per **request**, per **domain**. Every `checkAnonymous`, `checkAuthenticatedUser`, `forceCheckAnonymous`, or `forceCheckAuthenticatedUser` call runs a full identification and draws one request from that domain's prepaid balance. There is no client-side result you can re-read for free: the [snippet](/setup/snippet) keeps only a short visit session (about 10 minutes, governing the SessionID), and the identification itself re-runs and bills on every call. So the only cost lever is **where** and **how often** you call, never reusing a stored id to dodge a billed call. This page has two parts: tune your integration before it ships, and localize a usage spike if one shows up. ## Optimize proactively The goal is one billed identification at each point you actually act on a result, with the connection already warm so that call is fast. ### Warm the connection early ShieldLabs talks to two hosts: the module loads from `cdn.shieldlabs.ai`, and the identification posts to `rest.shieldlabs.ai` (plus a local-network check). Hint both early so the TLS handshake is done before you ever call. Put these in ``: ```html theme={null} ``` These hints cost nothing against your request balance. They only shave latency off the call you are about to make. If your site sends a strict Content-Security-Policy, the same two hosts already need allowlisting under [Content Security Policy](/setup/csp). ### Pre-warm the module, trigger the call at the decision point `import()` and the identification are two separate steps, and only the identification bills. Pre-warm the module on load so it is parsed and ready, then call `checkAnonymous` or `forceCheck*` only at the moment you act on the result, the login submit or the checkout click. That way you never spend a request on a page where you read nothing. ```js theme={null} // On page load: warm the module only. This does NOT identify and does NOT bill. const shieldlabs = import( 'https://cdn.shieldlabs.ai/snippet.js?publicKey=YOUR_PUBLIC_KEY' ); // At the decision point (e.g. the login submit): now run the billed identification. loginForm.addEventListener('submit', async () => { const mod = await shieldlabs; mod.forceCheckAuthenticatedUser(hashedUserId, (ip, requestID) => { // forward requestID to your backend, read the score from the webhook or History API }); }); ``` Do not call `checkAnonymous` or `checkAuthenticatedUser` unconditionally at the top of every page. That bills an identification on every page load, including pages where you never look at the score. Pre-warming the module is free; calling the function is what spends a request. ### Call only where you act on the result Map each touchpoint to one call and trigger it at the action, not on render. Each row below is one request. | Touchpoint | Call | Trigger | Expected volume | | --------------------------------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------ | --------------------- | | Logged-out browsing you want to grade | `checkAnonymous` | Once per visit, on the entry page you care about | 1 request per visit | | Sign-up or login | `forceCheckAuthenticatedUser(hashedUserId)` | On submit, after you know the user | 1 request per sign-in | | Checkout or payment | `forceCheckAnonymous` or `forceCheckAuthenticatedUser` | On the place-order / pay click | 1 request per attempt | | Other sensitive action (withdrawal, password change, device approval) | `forceCheck*` | On the action, not on page view | 1 request per action | The `forceCheck*` variants reset the visit session so the call starts a fresh SessionID, which is what you want right after login or before a high-stakes action. They do the same full identification as the plain calls and bill the same one request. Reach for them at meaningful moments, not in a loop. The four exports are detailed on the [snippet](/setup/snippet) page. ## Diagnose a usage spike If the request count climbs faster than your traffic explains, it is almost always one of three things: an implementation that calls too often, a key used from a domain you do not own, or a genuine traffic burst. Work through them in that order. ### Implementation causes Most unexpected usage is a call firing more often than intended. Check for: * **Firing on every page load.** An unconditional `checkAnonymous` in a shared layout or on every route runs an identification on every navigation. Move the call to the specific touchpoint where you act on the result. * **Duplicate or looping calls.** A component-lifecycle effect without a stable dependency re-runs the identify on every re-render. Pin the dependency list and guard against double invocation so each mount calls once. The [snippet](/setup/snippet) framework examples show the mount-once pattern. * **Calling where the result is never used.** Any page that identifies but never reads the score is pure spend. If you do not branch on it, do not call it there. ### Key-misuse cause Public keys are domain-scoped. A key used from a domain you did not register is rejected with **401**, but the attempt still shows up as traffic against that key. If you see requests you cannot attribute to your own pages, your public key may be in use on a domain that is not yours. A 401 from a domain mismatch is the server refusing an unregistered origin, not a scoring event. If a public key has leaked onto a site you do not control, rotate that domain's key set so the old key stops working. Each domain has its own [public and secret keys](/setup/keys) and its own [domain](/setup/domains) registration. ### Traffic cause If the integration is clean and the keys are yours, a spike is real volume: a campaign, a referral surge, or a wave of automated traffic all inflate the count the same way, because each identification bills regardless of who is behind it. This is the case where the usage is legitimate and the answer is capacity, not a code fix. ### How to investigate The dashboard Data table is one row per identification, and reading or exporting it is free. Use it to localize the spike: On the [dashboard](/using-the-dashboard) Data tab, filter by the date range of the spike and export the result to CSV or JSON. Reads and exports never consume requests. In your own tooling, group the export by identifier (visitor ID, device ID, IP, or user HID), by entry URL, and by traffic source. A handful of identifiers or a single URL dominating the count points at a loop or a hot page; a broad spread across many IPs points at a real traffic surge. Track the domain's remaining request balance. When a domain's balance is exhausted, the identification POST and the [Server API](/api/server-api) both return **HTTP 402**, separate from rate limiting. The [Billing](/billing) page covers the balance, and the [rate limits](/rate-limits) page covers the gateway 429. Searching the Data table by a single identifier (request ID, session ID, cookie ID, user HID, visitor ID, device ID, or IP) and filtering by score range and date is the fastest way to confirm whether a suspect entity is generating the extra calls. The [troubleshooting](/troubleshooting) page covers the broader integration issues you might surface along the way. ## Next Wire the score into your own decisions with [acting on the Risk Score](/guides/acting-on-risk-score), and confirm what does and does not count against your balance on the [Billing](/billing) page. # Planning your implementation Source: https://docs.shieldlabs.ai/setup/planning The key decisions to settle before you wire ShieldLabs into your product. A good ShieldLabs rollout starts with a short plan, not a snippet. ShieldLabs gives you the signals; your code decides what to do with them. This page walks the decisions to make first, so the integration goes in clean and you start acting on the Risk Score quickly. Work through five questions in order: 1. **Where** will you identify visitors? 2. **What** will you do with the Risk Score and patterns? 3. **How** will you receive results reliably? 4. **Where** does the data and the decision live? 5. **How** does the snippet install, and which call do you use? ## Pick your identification touchpoints Identify visitors at the moments where the answer changes what you do next. Adding the check everywhere is wasteful; adding it at the decision points is where it pays off. Common high-value touchpoints: * **Signup** - flag many accounts forming on one device or one local network. * **Login** - recognize the returning device, spot logins from an unfamiliar one. * **Checkout and payments** - score a high-value action before money moves. * **Sensitive account changes** - password resets, email or payout changes, new-device approvals. For each touchpoint, decide whether the visitor is anonymous or signed in. That choice maps directly to [which snippet call you use](#map-the-install-and-the-call) below. Start with one touchpoint. Login or checkout is usually the fastest to instrument and the easiest to measure. Add the others once the first one is acting on real scores. ## Decide what to do with the score ShieldLabs returns a **Risk Score** from 0 to 100 plus a `Details` array naming every signal behind it. You write the logic that turns that into an action. There is no rules engine in the product, so the decision lives entirely in your code. Map each of the four bands the [Risk Score](/features/risk-scoring) defines (Clean 0-9, Low 10-29, Medium 30-59, High 60-100) to an action that fits the touchpoint: pass low bands through, and step up, review, or block as the score climbs. The [acting on the Risk Score](/guides/acting-on-risk-score) guide details the per-band playbook. **Start in log-only mode.** Record the score and `Details` for every check, take no action, and watch real traffic for a week or two. This shows you what normal looks like for your platform before any score gates a real user. Move to enforcement once your thresholds are calibrated. A legitimate visitor can score high (a corporate proxy, a VPN, a privacy browser). Decide on the **Score plus the `Details` plus the action context**, never the number alone. The full decision toolkit lives in [Acting on the Risk Score](/guides/acting-on-risk-score). [Patterns](/features/patterns) are a second, dashboard-only input. They point to abuse and fraud across many visits, not on one request: many accounts on one device, many devices on one account, and similar clusters. Use them to spot coordinated activity that a single score will not show. ## Plan how you receive results The browser never returns the Risk Score. It returns a `requestID`; the scored result is delivered server-side. Plan for two delivery paths and use both. * **[Webhook](/setup/webhooks) (primary).** ShieldLabs POSTs the scored result to your endpoint as soon as it is ready. This is the real-time path, and webhook delivery is free. * **History API (fallback).** Read the same result on demand from the [Server API](/api/server-api), keyed by `request_id`, `user_hid`, `device_id`, and more. Each row a lookup returns bills one request, and a lookup that returns zero rows still bills one, so lean on the webhook for volume and use History where you need a guaranteed read. Build the receiver to two rules: * **Idempotent on `RequestID`.** The same call can produce two webhooks: an initial score and an update. Key your storage and your decision on `RequestID` so a repeat is a no-op. * **At-most-once delivery.** Webhooks have no retries, so a missed POST is gone. For any decision you cannot afford to miss, fall back to the [History API](/api/server-api) for a guaranteed read. The full delivery contract is on [Webhooks](/setup/webhooks). Use the webhook for speed and the History API for certainty. A common pattern: act on the webhook when it arrives within your time budget, and poll History by `request_id` if it does not. ## Plan storage and where decisions live The decision logic runs on **your backend**, not in ShieldLabs. Plan to store what your application needs to act and to audit later. * **Verify first.** Confirm the [webhook HMAC](/setup/webhooks) before you trust the payload, then store. Verification belongs server-side, with your secret key. * **Store the join key.** Keep `RequestID` against your own session, user, or order so you can reconcile the asynchronous result with the action that triggered it. * **Store the evidence.** Persist the `Score` and `Details` you acted on. When you review a decision later, the signals are the explanation. * **Own your retention.** ShieldLabs holds identification history for reads via the API; if your business needs a longer record, keep your own copy on your side and set retention to your own policy. Track an outcome metric from day one, for example the share of high-risk logins you challenged, or chargebacks on checkouts you allowed. Reviewing it is how you tune thresholds with evidence instead of guesswork. ## Map the install and the call Integration is one JavaScript snippet plus your server reading the result over the API and webhooks. The snippet is an ES module loaded from the CDN. It is not an npm package and not a native mobile SDK. Full install steps are in [Install the snippet](/setup/snippet). Pick the call per touchpoint: | Call | Use it when | Touchpoint | | ----------------------------- | ------------------------------------------------- | ----------------------------------------------------- | | `checkAnonymous` | The visitor is not signed in | Landing pages, anonymous signup start | | `checkAuthenticatedUser` | The visitor is signed in (pass a hashed id) | Logged-in pages, account area | | `forceCheckAnonymous` | You need a fresh check, ignoring the visit window | Before a sensitive anonymous action | | `forceCheckAuthenticatedUser` | You need a fresh check right now | Right after login, before checkout or a payout change | The `forceCheck*` calls clear the visit session and run immediately. Reach for them at the exact moment a decision is made, so the score is keyed to that action. Always pass a **hashed or pseudonymous** id to the authenticated calls, never a raw email or account id. ## Plan for CSP and ad blockers Two environment factors can stop the snippet from loading or reaching the data endpoints. Plan for both before launch. * **Content Security Policy.** If your site sends a strict CSP header, allowlist the ShieldLabs snippet host and data endpoints, or the module will be blocked. The exact `script-src` and `connect-src` entries are in [Content Security Policy](/setup/csp). * **Ad blockers.** Some blockers drop third-party requests, which can suppress identification for a slice of your traffic. [Serving the snippet from your own subdomain](/setup/csp) reduces this. Test the snippet behind your real CSP and with a common ad blocker enabled before you rely on the scores. A blocked snippet looks like silent traffic, not an error. ## Next steps Go from an empty dashboard to a verified webhook carrying a live Risk Score. Add the ES module, choose your call, and read the requestID in your framework. Register your endpoint, verify the HMAC, and handle the initial and update phases. Turn the score and its Details into an allow, challenge, review, or block path. # Install the JS Snippet Source: https://docs.shieldlabs.ai/setup/snippet Learn how to install the JavaScript snippet and start identifying visitors. The ShieldLabs client is a single **ES module** you load by dynamic `import()` from `cdn.shieldlabs.ai`. There is no npm package and no native mobile SDK. It runs in the browser only. The module collects 100+ browser, device, and network signals and posts them to ShieldLabs automatically. It does **not** compute a VisitorID, DeviceID, or Risk Score in the browser. Those are derived on the server and delivered by [webhook](/setup/webhooks) and the [Server API](/api/server-api). You correlate the browser call to the server result using the `requestID` from the optional callback. The snippet keeps its own first-party id in a browser cookie (and `localStorage`) named `visitorID`. That value is sent to ShieldLabs as `CookieID` and surfaces in the payload as `CookieID`, not as `VisitorID`. If you inspect your own site's cookies, do not confuse the `visitorID` cookie with the payload's `VisitorID`. ## Install (HTML) Add this near the top of ``. Use `type="module"` (the snippet relies on `import.meta.url` and top-level dynamic import, so it cannot run as a classic script). ```html theme={null} ``` `publicKey` is your site's public key, one per registered domain. It is safe to expose in page source. Grab yours from [API Keys](/setup/keys). The call is fully **async and non-blocking**. Several checks run in parallel and never block page render. The Promise resolves when the snapshot POST completes; the identification result arrives separately (see below). ## The four exports The module exports four functions. They all run a full identification and bill one request per call; they differ only in whether you tag a user id and whether they reset the visit session first. | Export | What it does | Use it for | | ------------------------------------------------- | -------------------------------------------------------- | --------------------------------------------------------------- | | `checkAnonymous(userHID?, callback?)` | Identifies an untagged visitor | A visitor you cannot identify yet (logged-out traffic) | | `checkAuthenticatedUser(userHID, callback?)` | Tags the visit with your UserHID | A logged-in user. Pass a hashed/pseudonymous id | | `forceCheckAnonymous(callback?)` | Resets the visit session first, then identifies | Before a sensitive action when you need a fresh anonymous check | | `forceCheckAuthenticatedUser(userHID, callback?)` | Resets the visit session first, tagged with your UserHID | Right after login, or before a high-risk action | Always pass a **hashed or pseudonymous** id to `checkAuthenticatedUser` and `forceCheckAuthenticatedUser`. Never pass a raw email or a real account id. ShieldLabs stores this value as your UserHID to correlate activity. It should not be reversible to a real identity. ### `forceCheck*`: clear the session and run now For `checkAnonymous` and `checkAuthenticatedUser`, the 10-minute visit window only governs the SessionID: calls inside the same window share one SessionID, while a call after it expires gets a fresh one. The work itself (collecting signals and posting the snapshot for scoring) always runs. `forceCheckAnonymous` and `forceCheckAuthenticatedUser` do the same full identification, but they reset the visit session first so this call starts a new SessionID. Reach for them when the moment matters: * **Right after login.** Re-run the check now that you know who the user is, so the server links this session to the account. * **Before a sensitive action** (checkout, withdrawal, password change, a new device approval). Get a fresh score keyed to a `requestID` you can act on. ```js theme={null} // after a successful login mod.forceCheckAuthenticatedUser(hashedUserId, (ip, requestID) => { // store requestID, then read the score server-side via webhook or History API }); ``` ## The optional callback Each export accepts an optional callback as its **last** argument. It fires once, after the snapshot POST resolves. The callback always comes after the user id slot, so for an anonymous call pass `undefined` first: ```js theme={null} mod.checkAnonymous(undefined, (ip, requestID) => { // ip: the client IP the server saw for this call (not a score) // requestID: the UUID for this call. Your join key to the server result. }); ``` The browser does **not** return the VisitorID, DeviceID, or Risk Score. Those are computed on the server. Send the `requestID` to your backend, then read the result from the [webhook](/setup/webhooks) payload or the [History API](/api/server-api) (query by `request_id`). The flow: 1. The snippet collects signals and POSTs them to `rest.shieldlabs.ai`. 2. Your callback fires with `(ip, requestID)`. 3. The server scores asynchronously (\~1s) and delivers the **initial** webhook, then an **update** webhook after a follow-up network check. 4. Your backend matches the webhook (or History API row) to the browser call by `requestID`. ## Framework integrations The HTML method above works anywhere. In a framework, put the same dynamic `import()` inside a lifecycle hook so it runs once on mount. Pass your public key in from config or props. ```html theme={null} ``` ```jsx theme={null} import { useEffect } from 'react'; export function ShieldlabsTracker({ publicKey, hashedUserId }) { useEffect(() => { let cancelled = false; async function run() { const mod = await import( `https://cdn.shieldlabs.ai/snippet.js?publicKey=${publicKey}` ); if (cancelled) return; if (hashedUserId) { mod.checkAuthenticatedUser(hashedUserId); } else { mod.checkAnonymous(); } } run(); return () => { cancelled = true; }; }, [publicKey, hashedUserId]); return null; // renders nothing } ``` ```ts theme={null} import { Injectable } from '@angular/core'; @Injectable({ providedIn: 'root' }) export class ShieldlabsService { // memoize so the module loads only once across the app private modulePromise?: Promise; async check(publicKey: string, hashedUserId?: string) { if (!this.modulePromise) { this.modulePromise = import( /* @vite-ignore */ `https://cdn.shieldlabs.ai/snippet.js?publicKey=${publicKey}` ); } const mod = await this.modulePromise; if (hashedUserId) { mod.checkAuthenticatedUser(hashedUserId); } else { mod.checkAnonymous(); } } } ``` ```vue theme={null} ``` ```jsx theme={null} import { useEffect } from 'preact/hooks'; export function ShieldlabsTracker({ publicKey, hashedUserId }) { useEffect(() => { let cancelled = false; async function run() { const mod = await import( `https://cdn.shieldlabs.ai/snippet.js?publicKey=${publicKey}` ); if (cancelled) return; if (hashedUserId) { mod.checkAuthenticatedUser(hashedUserId); } else { mod.checkAnonymous(); } } run(); return () => { cancelled = true; }; }, [publicKey, hashedUserId]); return null; } ``` ```svelte theme={null} ``` Memoize the import (the Angular example caches `modulePromise`) so the module loads once even if you mount the wrapper in several places. The framework wrappers are thin: they call the same CDN module as the HTML method, just from your app code instead of an inline script. ## Capturing the result with a callback Pass a callback in any framework to grab the `requestID` and hand it to your backend: ```jsx theme={null} useEffect(() => { let cancelled = false; async function run() { const mod = await import( `https://cdn.shieldlabs.ai/snippet.js?publicKey=${publicKey}` ); if (cancelled) return; mod.checkAuthenticatedUser(hashedUserId, async (ip, requestID) => { // forward requestID to your server, which reads the score from the // webhook or the History API and decides allow / challenge / review / block. await fetch('/api/shieldlabs/checked', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ requestID }), }); }); } run(); return () => { cancelled = true; }; }, [publicKey, hashedUserId]); ``` ShieldLabs surfaces the score and signals. Your own code owns the decision, and [acting on the Risk Score](/guides/acting-on-risk-score) shows how to turn the score and its `Details` into an allow / challenge / review / block path in your application. ## Next steps Allowlist the ShieldLabs snippet hosts if your site sends a strict CSP header. Find your domain's public key for the snippet and the secret key for the server. Receive the scored result keyed by requestID, with the initial and update phases. Follow a single identify call from the browser snapshot to the server score. # Webhooks Source: https://docs.shieldlabs.ai/setup/webhooks Learn how to register a callback, verify its signature, and act on the score the moment a visit is scored. # Webhooks A webhook pushes the Risk Score and the signals behind it to your backend the moment ShieldLabs finishes scoring a visit. Register one callback URL per domain, verify the signature, and act on the score in your own code. This page is the how-to. The [Webhooks API reference](/api/webhooks) has the exact field-by-field payload schema. Webhooks are optional. If you do not register a callback URL, checks still run and still bill, but results are only available in the dashboard or through the [History API](/api/server-api). A callback URL is what makes scores real-time. ## Register a callback URL You configure one callback URL per domain. There are two ways to set it. Open [dashboard.shieldlabs.ai](https://dashboard.shieldlabs.ai), go to your domain, and set the **Webhook / Callback URL**. Use an HTTPS URL on a route your backend controls. `POST` to the Server API `/callback` route. The request body is the **plain URL** (not JSON), authenticated with `{domain}:{secret}` in the path. ```bash theme={null} curl -X POST "https://api.shieldlabs.ai/myshop.com:YOUR_SECRET/callback" \ -H "Content-Type: text/plain" \ --data "https://myshop.com/webhooks/shieldlabs" ``` Setting the callback URL costs 0 requests. The [Server API](/api/server-api) has the full route reference. The `{secret}` in the path is your domain's **[secret key](/setup/keys)**: backend only, never in the browser. The same secret is the HMAC key you use to verify deliveries below. ## What a delivery looks like ShieldLabs sends a `POST` with `Content-Type: application/json` to your callback URL. The body is an envelope: the scored `Data` plus its signature. ```json theme={null} { "Data": { "RequestID": "13f84f05-7c2a-4e9b-9f1d-2a6b8c0e4d11", "SessionID": "a7c1e2f0-3b4d-4a5e-8c9f-0d1e2f3a4b5c", "CookieID": "9f8e7d6c-5b4a-4392-8170-6e5d4c3b2a19", "DeviceID": "5eb7fd5c-2a1b-4c3d-9e8f-7a6b5c4d3e2f", "VisitorID": "161dfbad-8e7f-4a6b-9c5d-0e1f2a3b4c5d", "IP": "37.214.25.112", "OS": "Windows", "Country": "Belarus", "UserHID": "u_8f3c9a21", "Score": 30, "Details": [ { "Value": 10, "Description": "Proxy" }, { "Value": 10, "Description": "Datacenter IP" }, { "Value": 10, "Description": "Abuser" } ], "LastRequestTime": "2026-06-16T18:00:21.685Z", "Phase": "initial" }, "Assing": "9b74c98e1a3f0d2c5b6a7e8f1029384756abcdef0123456789abcdef01234567" } ``` Correlate the delivery to the original identify call by `RequestID`. The full field list (and the richer History snapshot superset) is in the [Webhooks API reference](/api/webhooks). Branch on the `Score` and each entry's `Value`, not on the `Description` label. See [Score and Detail](/api/models#scoredetail). ### Two phases per check A single visit produces up to two webhooks. Both carry the same `RequestID`. Branch on the `Phase` field. Sent about one second after the snippet posts its signals, based on the IP and network analysis. `Phase` is `"initial"` and `Details` carries the full list of signals that fired. Sent after a follow-up network check completes and the score is recomputed. `Phase` is `"update"`, and `Details` carries **only the delta**: the signals that changed since the initial score, not the full list. The update is suppressed if more than 10 seconds elapsed since the check started, so do not block waiting for it. Treat the `update` phase as additive, not authoritative on its own. Apply the delta in `Details` on top of the `initial` payload you already stored, and act on the recomputed `Score`. If you only ever see `initial`, that is normal: the follow-up network check may not complete in time, and the initial score is still a complete result. ## Verify the signature Every delivery is signed. The `Assing` field is the hex-encoded HMAC-SHA256 of the `Data` object, keyed with your domain's secret: ``` Assing = hex( HMAC-SHA256( key = secret, msg = JSON(Data) ) ) ``` The field is literally spelled **`Assing`** in the JSON. It is the HMAC signature for the payload. A cleaner field name is planned, but `Assing` is what ships today, so verify against that key. To verify: take the raw bytes of the `Data` object as received, compute HMAC-SHA256 over those bytes with your secret, hex-encode the result, and compare it to `Assing` using a **constant-time** comparison. Reject the request (respond `401`) on a mismatch, and do not process the body. The signature is not sent in an HTTP header: it travels inside the JSON body as `Assing`. ```javascript Node.js theme={null} import crypto from "crypto"; import express from "express"; const SECRET = process.env.SHIELDLABS_SECRET; const app = express(); // Keep the raw body so the bytes you hash match the bytes that were signed. app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } })); app.post("/webhooks/shieldlabs", (req, res) => { const { Assing } = req.body; // Slice the raw "Data" object bytes out of the raw body as received. // Do not re-serialize the parsed object: that changes the bytes and // breaks verification. The signature covers the Data object only. const raw = req.rawBody; const start = raw.indexOf('"Data":') + '"Data":'.length; let depth = 0, end = -1; for (let i = start; i < raw.length; i++) { const c = raw[i]; if (c === 0x7b) depth++; // { else if (c === 0x7d && --depth === 0) { end = i + 1; break; } // } } const dataBytes = raw.subarray(start, end); const expected = crypto .createHmac("sha256", SECRET) .update(dataBytes) .digest("hex"); const a = Buffer.from(expected, "hex"); const b = Buffer.from(Assing ?? "", "hex"); if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) { return res.sendStatus(401); } res.sendStatus(200); // acknowledge fast handleScore(JSON.parse(dataBytes)); // idempotent on Data.RequestID }); ``` ```go Go theme={null} package main import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "net/http" "os" ) var secret = []byte(os.Getenv("SHIELDLABS_SECRET")) type envelope struct { Data json.RawMessage `json:"Data"` // keep raw bytes; do not re-marshal Assing string `json:"Assing"` } func webhook(w http.ResponseWriter, r *http.Request) { var env envelope if err := json.NewDecoder(r.Body).Decode(&env); err != nil { w.WriteHeader(http.StatusBadRequest) return } mac := hmac.New(sha256.New, secret) mac.Write(env.Data) // HMAC over the Data object bytes expected := hex.EncodeToString(mac.Sum(nil)) if !hmac.Equal([]byte(expected), []byte(env.Assing)) { w.WriteHeader(http.StatusUnauthorized) return } w.WriteHeader(http.StatusOK) // acknowledge fast handleScore(env.Data) // idempotent on RequestID } ``` ```python Python theme={null} import hashlib import hmac import json import os from flask import Flask, request, abort SECRET = os.environ["SHIELDLABS_SECRET"].encode() app = Flask(__name__) @app.post("/webhooks/shieldlabs") def webhook(): # Read the raw request body bytes exactly as received. raw = request.get_data() envelope = json.loads(raw) received = envelope["Assing"] # Slice the raw "Data" object bytes out of the raw body. Do not # re-serialize the parsed object: that changes the bytes and breaks # verification. The signature covers the Data object only. start = raw.index(b'"Data":') + len(b'"Data":') depth, end = 0, None for i in range(start, len(raw)): c = raw[i] if c == 0x7B: # { depth += 1 elif c == 0x7D: # } depth -= 1 if depth == 0: end = i + 1 break data_bytes = raw[start:end].lstrip() expected = hmac.new(SECRET, data_bytes, hashlib.sha256).hexdigest() if not hmac.compare_digest(expected, received): abort(401) data = json.loads(data_bytes) handle_score(data) # idempotent on data["RequestID"] return "", 200 ``` HMAC the raw request body bytes as received. Re-serializing the parsed JSON can reorder keys or change spacing, which changes the bytes you hash, so the signature will not match. Sign over the exact `Data` bytes as received: the Go example keeps `Data` as `json.RawMessage`, and the Node and Python examples slice the `Data` object out of the raw body. ## Delivery guarantees Webhook delivery is **at-most-once**. There are **no retries** and the send has a **\~1 second timeout**. If your endpoint is slow, down, or returns a non-2xx response, that delivery is dropped and not resent. Do not rely on webhooks for guaranteed delivery: for guaranteed reads, poll the [History API](/api/server-api), which returns stored snapshots by `RequestID` (and by IP, UserHID, VisitorID, or DeviceID). A practical handler pattern: Constant-time compare `Assing`. Reject with `401` on a mismatch. Return `200` immediately once the signature is valid and the body is parsed. Upsert on `RequestID`. If you already recorded a result for it, merge the `update` delta instead of re-applying effects. Map the recomputed `Score` plus `Details` to allow, challenge, review, or block in your application logic. ## Next steps Field-by-field payload schema, phases, and signature details. Map scores and signals to allow, challenge, review, or block. Poll stored snapshots for guaranteed reads. Where your public and secret keys come from. # Troubleshooting Source: https://docs.shieldlabs.ai/troubleshooting Fixes for the issues you are most likely to hit while integrating ShieldLabs. # Troubleshooting A fast path from a symptom to its fix. Each entry names the likely cause and the one change that resolves it, then points you to the page with the full detail. For the meaning of a specific status code, the [Errors](/errors) reference is the canonical table; for short answers to common questions, start with the [FAQ](/faq). ## Install and data flow **Symptom.** Nothing reaches your dashboard or webhook, and the browser never posts a snapshot. **Cause.** One of three things is blocking the snippet before it can run: * A **Content Security Policy** is refusing the hosts the snippet needs. The module and its dependency load under `script-src`, and the snapshot and network checks post under `connect-src`. A policy missing those hosts stops the snippet cold. * An **ad blocker or content blocker** is dropping the CDN host, so the module never downloads. * The page is **not served over HTTPS**. The snippet collects signals in a secure context only, so it does not run on plain `http://` or a non-secure origin. **Fix.** Open the browser dev tools. A CSP block shows a `Refused to load` or `Refused to connect` error naming the directive and host, which is your signal to add that host. The exact directives and host list live on the [CSP setup](/setup/csp) page, and the [snippet install guide](/setup/snippet) shows the HTML and framework methods. Confirm the page loads over HTTPS, then load it with any blockers disabled to rule the extension in or out. Loading the module is not enough on its own: you must also call `checkAnonymous()` or `checkAuthenticatedUser(hashedId)`. **Symptom.** The check runs and bills, the score lands in the dashboard, but your endpoint never receives the POST. **Cause.** Either no callback URL is set for the domain, or the delivery was dropped. Webhook delivery is at-most-once with no retries and a short timeout, so a slow, down, or non-2xx endpoint silently loses that delivery, and there is no resend. **Fix.** Confirm a callback URL is set for the domain, from the dashboard or by posting it to the Server API `/callback` route, as the [webhook setup](/setup/webhooks) covers. Make your handler return `200` fast, then do slow work asynchronously so you stay inside the timeout. Because a single delivery can always be lost, treat the [History API](/api/server-api) as the guaranteed read: look the result up by `request_id` whenever it must not be missed. **Symptom.** The payload looks correct, but your HMAC check rejects it. **Cause.** You are hashing a re-serialized copy of the JSON. Parsing the body and re-encoding the `Data` object reorders keys or changes spacing, so the bytes you hash no longer match the bytes that were signed. **Fix.** Compute the HMAC over the **raw `Data` bytes exactly as received**, not over a parsed-then-re-encoded object, and not over the whole envelope. Keep the raw body around, slice out the `Data` object bytes, and compare with a constant-time check. The [webhook setup](/setup/webhooks) page has working Node, Go, and Python examples that do this correctly. ## Reading the result **Symptom.** A result carries `DeviceID` of `00000000-0000-0000-0000-000000000000`, and that visit scores 90. **Cause.** No stable device characteristics reached the server, so no identity could be built. This happens when the snippet was blocked or JavaScript was disabled. The visit still scores, and a visit with nothing to identify scores 90. **Fix.** Route a null or all-zero DeviceID to review rather than auto-allowing it. You cannot recognize a returning person from an identity that was never collected, so treat the absence as a signal in its own right. The handling and the all-zero case are described on the [Identification](/features/identification) page. **Symptom.** A real customer lands in the Medium or High band with no wrongdoing. **Cause.** A corporate VPN, a proxy, or a privacy-focused browser raises the Risk Score on its own. The signals are real, but they describe the connection, not the person's intent. **Fix.** Decide on the **Score plus its `Details` plus the action context**, never the number alone. A withdrawal warrants a stricter line than a page view, and the `Details` tell you which signals fired so you can weigh them. Working from the four bands, the guide to [acting on the Risk Score](/guides/acting-on-risk-score) walks through tuning thresholds gradually so legitimate VPN users are not punished. **Symptom.** You want the history for a device, visitor, account, or IP, or its earliest and latest sighting. **Cause.** There is no dedicated first-seen field on a result. History is a list of point-in-time snapshots, and each one carries `LastRequestTime`, the moment that snapshot was recorded. **Fix.** Read the [History API](/api/server-api) by `device_id`, `visitor_id`, `user_hid`, or `ip`. Rows come back newest first, so the first row is the most recent activity and the last row in a full result is the earliest you have stored. The `LastRequestTime` on any row is when that visit was seen. Each returned row bills 1 request, so set `limit` to the smallest value that answers your question. ## Status codes and limits **Symptom.** The snippet's snapshot post, or a Server API call, returns `402`. **Cause.** Your request balance is exhausted. A `402` appears in two places when you run out: when the snippet posts an identification to be scored, and on the Server API. The History part of the Server API bills one request per returned row, so a wide search can drain a low balance quickly. **Fix.** Top up your request balance or upgrade your plan, as the [Billing](/billing) page lays out. A `402` is a billing state and is unrelated to rate limiting. **Symptom.** The gateway returns `429`, or a webhook or History row arrives with `Score` of `999`. **Cause.** Both come from the per-IP rate limit, a gateway protection rather than a verdict. The browser receives the `429`, and a rate-limit-banned request can also surface a `999` marker on a webhook delivery or a History row. That `999` is not capped to 100, so it arrives as-is. **Fix.** Guard for it at the very top of your handler so it never reaches your decision logic: ```js theme={null} // 999 is the rate-limit ban marker, never a customer score. if (Data.Score > 100) return; ``` The Risk Score is always 0 to 100. Read `999` as "this IP was rate-limited," and spread traffic across client IPs rather than proxying through one server. The thresholds and the ban window are on the [rate limits](/rate-limits) page. **Symptom.** You want a liveness probe for monitoring or a load balancer health check. **Cause.** You need a lightweight endpoint that confirms a gateway is serving, without spending a request or running a scoring path. **Fix.** Each gateway exposes a `GET /health` endpoint that returns `200` with `{ "status": "ok" }`. Point your uptime monitor or orchestrator liveness probe at it. It does not authenticate and does not bill. ## Still stuck If a status code is the question, the [Errors](/errors) page is the full per-surface reference, and the [FAQ](/faq) answers the questions developers ask most about keys, requests, identifiers, and a score of 0. # Using the dashboard Source: https://docs.shieldlabs.ai/using-the-dashboard A quick tour of the ShieldLabs dashboard and where to find each thing. The dashboard at [dashboard.shieldlabs.ai](https://dashboard.shieldlabs.ai) is where you read what the server has already scored. It renders results and never blocks, challenges, or decides anything. Your own code owns every allow, challenge, review, or block decision using the [Risk Score](/features/risk-scoring) and [anonymity signals](/features/anonymity-signals) you receive over [webhooks](/setup/webhooks). This page is the map. Each area below has a deep page of its own. ## Overview The Overview is the first thing you land on. It summarizes the selected period at a glance: a Traffic Risk gauge for the average Risk Score, the count of requests checked, a breakdown across the Clean, Low, Medium, and High bands, and the visitor and source cards described next. Pick a date range at the top and every number on the tab moves with it. ## Traffic Analytics The visitor and source cards on the Overview tab are Traffic Analytics. Visitors and New Visitors count real people by a durable identity rather than a cookie, so they are confident estimates, not an audited tally. Traffic Sources ranks every channel, referrer, and campaign by the anonymous-traffic share and risk it delivers, so you can see which spend buys real visitors. The full breakdown of every metric, badge, and tooltip lives on the [Traffic Analytics](/features/traffic-analytics) page. ## Patterns The Patterns tab surfaces abuse that only shows up across many visits, like many accounts on one device. It grades a flagged entity, a device, account, visitor, or local IP, as Suspicious or Dangerous, and lets you export the flagged IDs to act on in your own systems. The eight patterns, the grading windows, and the workflow are documented on the [Patterns](/features/patterns) page. ## Data table The Data tab is the evidence behind every summary: one row per identification call, each carrying its identifiers, country, signals, and Risk Score. You can search by a single identifier (request ID, session ID, cookie ID, user HID, visitor ID, device ID, or IP), filter by project, score range, and date, sort by any column, and export the result to CSV or JSON. This is where a High-scoring source becomes the exact requests that pushed its average up, ready to load into your own tooling. ## Settings and Integration Your keys, callback, and domains live on the Integration tab, while Settings holds your plan and request balance. Adding a [domain](/setup/domains) issues its [public and secret keys](/setup/keys) and an empty callback slot to point at your handler, and the keys are scoped to that one site. Settings is where you pick a plan, switch billing frequency, and watch your remaining requests, all covered on the [Billing](/billing) page. ## Reading the dashboard is free Everything here is read-only and free. Opening the dashboard, every chart and breakdown, and exporting your records to CSV or JSON do not consume requests. Billing is per identification only, so reporting and review never add to your bill. ## Next steps Visitors counted by identity, and every source ranked by the risk it delivers. Turn the score you read here into a decision in your own code.