> ## Documentation Index
> Fetch the complete documentation index at: https://docs.shieldlabs.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhook reference

> Receive each identification's Risk Score and risk signals on your server as soon as it is scored.

When the server finishes scoring an identification, it pushes the result to each configured endpoint as a `POST`. This is the canonical, low-latency way to receive the [Risk Score](/features/risk-scoring) and the risk signals behind it.

Each `identification.scored` delivery is one identification, the event layer under your users, devices, visitors and IPs. It carries exactly one visitor, one device and one public IP, and at most one user and one local IP. Group deliveries by `user_hid`, `device_id`, `visitor_id`, `public_ip.ip` and `local_ip.ip` to follow an account, a device, a visitor or an IP over time; the [History API](/api/server-api#read-every-identification-of-one-account) reads the same history on demand by `user_hid`, `device_id`, `visitor_id` or public `ip`. High-Risk Events are detected on your users and are available in the analytics dashboard, the API and webhooks.

This page is the **reference**: the envelope schema, delivery timing, and how to verify the signature. The [webhook setup guide](/setup/webhooks) gives a step-by-step walkthrough of configuring and testing an endpoint.

<Info>
  You do not poll for webhooks. The server sends them to the endpoints you configure per domain (up to 10) in the analytics dashboard under **Integration > Webhooks** (see the [setup guide](/setup/webhooks) and [Integration](/dashboard/integration)). 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.
</Info>

## Envelope

Each POST body is a JSON **envelope** in snake\_case. Event metadata lives at the top level; the scored identification lives under `data`.

<ResponseField name="event_type" type="string">
  Discriminator for the delivery. Every scored identification uses `identification.scored`. Service deliveries use `webhook.ping` (**Verify** on an endpoint). Ignore unknown types until you add support.
</ResponseField>

<ResponseField name="schema_version" type="string">
  Contract version, for example `2026-06-01`. Check this before parsing `data` so you can branch when ShieldLabs ships a new schema.
</ResponseField>

<ResponseField name="created_at" type="string (ISO 8601)">
  When this webhook event was created and signed, in RFC 3339 form.
</ResponseField>

<ResponseField name="data" type="object">
  Present on `identification.scored` events. Absent on `webhook.ping`. See [Scored data](#scored-data) below.
</ResponseField>

### Scored delivery (`identification.scored`)

Each scored identification produces **one** webhook per enabled endpoint. This example is an anonymous check, so `user_hid` is `"anonymous"`.

```json theme={null}
{
  "event_type": "identification.scored",
  "schema_version": "2026-06-01",
  "created_at": "2026-06-26T14:20:42Z",
  "data": {
    "request_id": "13f84f05-7c2a-4e9b-9f1d-2a6b8c0e4d11",
    "visitor_id": "161dfbad-8e7f-4a6b-9c5d-0e1f2a3b4c5d",
    "device_id": "5eb7fd5c-2a1b-4c3d-9e8f-7a6b5c4d3e2f",
    "session_id": "7a1b2c3d-4e5f-6789-abcd-ef0123456789",
    "cookie_id": "3f2e1d0c-9b8a-7654-3210-fedcba987654",
    "user_hid": "anonymous",
    "domain": "example.com",
    "public_ip": {
      "ip": "203.0.113.42",
      "country": "US"
    },
    "local_ip": {
      "ip": "198.51.100.23",
      "country": "DE"
    },
    "connection_type": "proxy",
    "os": "Windows",
    "browser": "Chrome",
    "device_type": "desktop",
    "traffic_source": {
      "channel": "Google Ads",
      "referrer_domain": "google.com",
      "landing_url": "https://example.com/lp?gclid=abc123",
      "click_id_type": "gclid",
      "utm_source": "google",
      "utm_medium": "cpc",
      "utm_campaign": "summer_sale",
      "utm_content": "ad_a",
      "utm_term": "buy shoes"
    },
    "risk_score": 40,
    "signals": [
      { "name": "proxy", "weight": 10 },
      { "name": "datacenter_ip", "weight": 10 },
      { "name": "abuser", "weight": 10 },
      { "name": "timezone_mismatch", "weight": 10 }
    ],
    "detection_flags": {
      "vpn": false,
      "privacy_relay": false,
      "browser_vpn_proxy": false,
      "tor": false,
      "proxy": true,
      "datacenter_ip": true,
      "abuser": true,
      "os_mismatch": false,
      "os_not_detected": false,
      "timezone_mismatch": true,
      "anti_detect_browser": false,
      "browser_automation": false,
      "ip_mismatch": true,
      "incognito": false,
      "search_bot": false,
      "suspicious_paid_click": false,
      "javascript_disabled": false,
      "stun_not_checked": false,
      "check_incomplete": false
    },
    "observed_at": "2026-06-26T14:20:42Z"
  }
}
```

The signature travels in the `X-Shield-Signature` request header, outside the body:

```http theme={null}
POST https://your-server.com/webhook
Content-Type: application/json
X-Shield-Signature: sha256=9f1c2b3a4d5e6f70819a2b3c4d5e6f7081920a1b2c3d4e5f60718293a4b5c6d7

{ "event_type": "identification.scored", "schema_version": "2026-06-01", "data": { ... } }
```

## Scored data

Fields inside `data` on `identification.scored` events.

<ResponseField name="request_id" type="string (UUID)">
  The client-generated UUID of this identification. Join key across the snippet call, the webhook, and the [History API](/api/server-api). Make your handler [idempotent](#delivery-guarantees) on this value.
</ResponseField>

<ResponseField name="session_id" type="string (UUID)">
  The Session ID: the browsing session, written by the snippet to `localStorage` (legacy `sessionStorage` keys are read once and migrated), so it is shared across tabs rather than tied to one tab.
</ResponseField>

<ResponseField name="cookie_id" type="string (UUID)">
  The Cookie ID: a first-party cookie / `localStorage` identifier minted in the browser. Lost when the user clears cookies or storage.
</ResponseField>

<ResponseField name="device_id" type="string (UUID)">
  The device. Server-derived from device intelligence, so it **holds through cleared cookies, incognito mode and IP changes**; another browser on the same machine gets its own Device ID. Read every identification of a device from the [History API](/api/server-api) by `device_id`. The [Identifiers](/features/identification) reference explains the model.
</ResponseField>

<ResponseField name="visitor_id" type="string (UUID)">
  The visitor: one device plus one cookie, server-derived from `device_id` and `cookie_id`. **Changes when the cookie is cleared**, so one `device_id` can have several `visitor_id` values. Read a visitor's identifications by `visitor_id`.
</ResponseField>

<ResponseField name="user_hid" type="string | null">
  The User HID: your account key, passed with `checkAuthenticatedUser`. Always pass a hashed or pseudonymous value, never a raw email or user id. Anonymous checks (`checkAnonymous`) carry the literal `anonymous`; `null` appears when an empty string is passed, and on the fixed sample of a test delivery. Pass a hashed User HID with `checkAuthenticatedUser` on every signed-in page. Users, account-level risk and all four High-Risk Events are built on it. Read an account's identifications by `user_hid`.
</ResponseField>

<ResponseField name="domain" type="string">
  The site domain this identification belongs to (your registered domain key).
</ResponseField>

<ResponseField name="public_ip" type="object">
  The public IP of this identification, resolved for this HTTP request. Read every identification from one public IP from the [History API](/api/server-api) by `ip`.

  <Expandable title="public_ip fields">
    <ResponseField name="ip" type="string">
      Dotted IPv4 address seen on the request path.
    </ResponseField>

    <ResponseField name="country" type="string">
      Two-letter ISO country code derived from that IP, for example `US`.
    </ResponseField>
  </Expandable>
</ResponseField>

<ResponseField name="local_ip" type="object">
  The local IP: the address the browser itself reports, captured by an optional follow-up network check when available. It can differ from `public_ip` when the user is behind a VPN, a proxy, or a split tunnel. In the example above, `public_ip` is a US proxy exit (`203.0.113.42`, `US`) while `local_ip` resolves to the user's own network in Germany (`198.51.100.23`, `DE`); because the two addresses differ, `detection_flags.ip_mismatch` is `true`. ShieldLabs returns both IPs so you can compare them; the difference is informational and adds nothing to the Risk Score. To group accounts or devices by local IP, keep `local_ip.ip` from each webhook; the History API searches by the public `ip`. In the analytics dashboard, local IPs appear on user, device, visitor and IP cards as linked local IPs, with the identifications behind each.

  <Expandable title="local_ip fields">
    <ResponseField name="ip" type="string">
      The address the browser reports. Empty when the follow-up check captured none.
    </ResponseField>

    <ResponseField name="country" type="string">
      Country resolved from the local IP. Empty when unknown.
    </ResponseField>
  </Expandable>
</ResponseField>

<ResponseField name="os" type="string">
  The operating system derived for the device (for example `Windows`, `Mac OS X`, or `IOS (iPhone)`). May be empty when it cannot be determined.
</ResponseField>

<ResponseField name="browser" type="string">
  The browser family detected for this identification, for example `Chrome` or `Firefox`.
</ResponseField>

<ResponseField name="device_type" type="string">
  Device class: `desktop`, `mobile`, or `tablet`.
</ResponseField>

<ResponseField name="connection_type" type="string">
  The detected network connection type, one of `direct`, `mobile`, `vpn`, `proxy`, `tor`, `privacy_relay`, `browser_vpn_proxy`, or `unknown` (when the type could not be resolved).
</ResponseField>

<ResponseField name="risk_score" type="integer">
  The [Risk Score](/features/risk-scoring) of this identification, an integer from **0 to 100**. Higher means riskier: more likely masked, spoofed, or abusive. The payload carries the number, with no band field. Map it to a band in your backend: Trusted 0-29, Suspicious 30-59, Dangerous 60-100. The only value above 100 is `999`, the rate-limit marker written during a per-IP ban, so guard `risk_score > 100` before reading the band.
</ResponseField>

<ResponseField name="signals" type="array of objects">
  The **full** explainable breakdown: every risk signal with a non-zero weight, as `{ "name": "<slug>", "weight": <int> }`. A slug can appear twice when an earlier verdict is carried forward with a partial weight, so branch on slugs and `detection_flags` and read `risk_score` for the total.

  <Expandable title="Signal entry">
    <ResponseField name="name" type="string">
      Stable machine slug, for example `vpn`, `datacenter_ip`, or `os_mismatch`. Branch on this value and on `detection_flags`; see the [Risk Signals](/features/risk-signals) reference for meaning.
    </ResponseField>

    <ResponseField name="weight" type="integer">
      The weight this risk signal contributed to the total `risk_score`. Can be negative when a follow-up check corrects an earlier over-count.
    </ResponseField>
  </Expandable>
</ResponseField>

<ResponseField name="traffic_source" type="object">
  Resolved traffic attribution for the identification.

  <Expandable title="traffic_source fields">
    <ResponseField name="channel" type="string">
      Marketing channel, for example `Direct`, `Google Ads`, or `Meta`.
    </ResponseField>

    <ResponseField name="referrer_domain" type="string">
      Referrer hostname when present.
    </ResponseField>

    <ResponseField name="landing_url" type="string">
      Landing URL captured for the identification.
    </ResponseField>

    <ResponseField name="click_id_type" type="string">
      Detected click-id parameter type when present, for example `gclid`. Empty when none was found.
    </ResponseField>

    <ResponseField name="utm_source" type="string">
      UTM source when present on the landing URL.
    </ResponseField>

    <ResponseField name="utm_medium" type="string">
      UTM medium when present.
    </ResponseField>

    <ResponseField name="utm_campaign" type="string">
      UTM campaign when present.
    </ResponseField>

    <ResponseField name="utm_content" type="string">
      UTM content when present.
    </ResponseField>

    <ResponseField name="utm_term" type="string">
      UTM term when present.
    </ResponseField>
  </Expandable>
</ResponseField>

<ResponseField name="detection_flags" type="object">
  Boolean flags for each detection dimension. Use these for quick branching; use `signals` for the explainable Risk Score breakdown. **Not every flag contributes to the Risk Score**: some are informational (for example `ip_mismatch` and `incognito`). The scored subset and their weights are in the [Risk Scoring](/features/risk-scoring) table.

  <Expandable title="detection_flags fields">
    <ResponseField name="vpn" type="boolean">VPN detected on the public IP.</ResponseField>
    <ResponseField name="privacy_relay" type="boolean">Apple Private Relay detected.</ResponseField>
    <ResponseField name="browser_vpn_proxy" type="boolean">Browser extension VPN/proxy detected.</ResponseField>
    <ResponseField name="tor" type="boolean">Tor exit node detected.</ResponseField>
    <ResponseField name="proxy" type="boolean">Proxy detected.</ResponseField>
    <ResponseField name="datacenter_ip" type="boolean">Datacenter/hosting IP detected.</ResponseField>
    <ResponseField name="abuser" type="boolean">IP flagged as abuser.</ResponseField>
    <ResponseField name="os_mismatch" type="boolean">OS mismatch between network and browser signals.</ResponseField>
    <ResponseField name="os_not_detected" type="boolean">OS could not be determined.</ResponseField>
    <ResponseField name="timezone_mismatch" type="boolean">Browser timezone differs from IP timezone.</ResponseField>
    <ResponseField name="stun_not_checked" type="boolean">The network check did not complete.</ResponseField>
    <ResponseField name="anti_detect_browser" type="boolean">Anti-detect browser detected.</ResponseField>
    <ResponseField name="browser_automation" type="boolean">The browser is automation-controlled (a bad bot).</ResponseField>
    <ResponseField name="javascript_disabled" type="boolean">A headless or automated client.</ResponseField>
    <ResponseField name="incognito" type="boolean">Private/incognito mode reported by the snippet.</ResponseField>
    <ResponseField name="search_bot" type="boolean">The identification comes from a known search-engine crawler (a good bot, for example Googlebot or Bingbot). Its `risk_score` is set to 0, and the traffic channel is **Search bot**.</ResponseField>
    <ResponseField name="suspicious_paid_click" type="boolean">An identification on the Google Ads, Meta, TikTok, LinkedIn, X, Pinterest or Microsoft Ads channel (paid or organic) with a Risk Score of 60 or more. Informational; does not change `risk_score`.</ResponseField>
    <ResponseField name="ip_mismatch" type="boolean">The public IP differs from `local_ip`. A raw difference can be benign (mobile networks often route over different paths), so if location matters, compare `public_ip.country` with `local_ip.country` rather than acting on this flag alone.</ResponseField>
    <ResponseField name="check_incomplete" type="boolean">One or more follow-up checks did not finish before the webhook was sent. Informational; does not change `risk_score`.</ResponseField>
  </Expandable>
</ResponseField>

<Note>
  Bots on the wire: `search_bot` marks a known search-engine crawler (a good bot, with `risk_score` set to 0), `browser_automation` marks an automation-controlled browser (a bad bot, weight 60), and `javascript_disabled` marks a headless or automated client (weight 90).
</Note>

<ResponseField name="observed_at" type="string (ISO 8601)">
  When the identification was scored and this payload was built, in RFC 3339 / ISO 8601 form. Same value as `created_at`.
</ResponseField>

<Note>
  Branch on `risk_score`, stable signal `name` slugs, and `detection_flags`, not on the free-text `Description` entries of History API `score_details`.
</Note>

## Delivery timing

| Scenario | Typical delay |
| - | - |
| No follow-up network check | About 300 ms after the check in the browser |
| A follow-up network check is expected | When the follow-ups finish, **or** at the 10-second deadline |

The server waits at most about **10 seconds** after the check for optional follow-up network checks. If a follow-up never arrives, the webhook is still sent with the best Risk Score available at the deadline.

Full background is in the [Identification Flow](/api/identification-flow).

## Service events

### Verify ping (`webhook.ping`)

**Verify** on an endpoint in the analytics dashboard (**Integration > Webhooks**) sends a minimal envelope with no `data`. A `2xx` answer marks the endpoint active:

```json theme={null}
{
  "event_type": "webhook.ping",
  "schema_version": "2026-06-01",
  "created_at": "2026-06-26T14:20:42Z"
}
```

Use it only to confirm URL reachability and signature verification. Do not treat it as a scored identification.

### Test delivery

**Test** on the endpoint in the analytics dashboard (**Integration > Webhooks**) sends a full `identification.scored` envelope with sample `data`. The sample always carries the same `request_id` and a `null` `user_hid`. Parse it like production traffic; deduplicate on `data.request_id` if you replay tests.

## Verification

Verify the signature on **every** webhook before acting on it. The recipe:

```text theme={null}
X-Shield-Signature == "sha256=" + hex( HMAC-SHA256( key = endpoint secret, msg = raw request body ) )
```

The HMAC is computed over the **raw request body bytes** exactly as received (capture them before any re-encoding), keyed with that endpoint's signing secret (`whsec_…`). Hex-encode it, prefix with `sha256=`, and **constant-time compare** against the `X-Shield-Signature` header. Re-serializing the parsed JSON changes the bytes and the signature will not match.

<Warning>
  The signing secret is backend-only. Never put it in the browser, in client-side code, or in the snippet. If a request to your endpoint has a missing or mismatched `X-Shield-Signature`, reject it. Each endpoint has its own secret, so verify with the secret that belongs to the endpoint that received the call.
</Warning>

Copy-paste verification handlers for Node, Go, and Python live in the [webhook setup guide](/setup/webhooks).

The analytics dashboard also shows verification samples in six languages under **Integration > Webhooks**; [Integration](/dashboard/integration) describes the screen.

## Delivery guarantees

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

<CardGroup cols={2}>
  <Card title="At-most-once" icon="arrow-right">
    Each identification produces one send attempt per endpoint. There is **no retry, no backoff, and no dead-letter queue**. A dropped network connection means that webhook is gone.
  </Card>

  <Card title="1-second timeout" icon="clock">
    The sender waits one second for your endpoint, then moves on. Acknowledge with a fast `2xx` and do heavy work asynchronously, off the request path.
  </Card>

  <Card title="Idempotent on request_id" icon="key">
    Key your writes on `data.request_id` so a repeated delivery, such as a replayed test, is a no-op.
  </Card>

  <Card title="Read fallback" icon="server">
    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.
  </Card>
</CardGroup>

A reliable pattern:

<Steps>
  <Step title="Persist the request_id early">
    Capture `requestID` from the snippet callback and store it with the user action you are protecting.
  </Step>

  <Step title="Apply the webhook">
    On delivery, verify the signature, check `event_type === "identification.scored"`, then record `data.risk_score` and `data.signals` against `data.request_id`. Treat the write as idempotent.
  </Step>

  <Step title="Fall back to a read">
    If no webhook arrives within about 10 seconds, call the History API by `request_id` to read the stored identification.
  </Step>
</Steps>

## Acting on the payload

The webhook gives you the `risk_score` of one identification and the risk signals behind it. Before a sensitive action, read the account behind it too: its identifications from the [History API](/api/server-api#read-every-identification-of-one-account) by `user_hid` give its worst band and the devices and IPs it is linked to. When a High-Risk Event arrives for the user, or when you review it on the user's [card](/dashboard/entity-card) in the analytics dashboard, act on the account; the Risk Score and risk signals of this identification remain the input at signup, login, checkout or withdrawal. You choose the action for each case (allow, step up, review or block) and act on the result in your backend. The [per-band playbook](/guides/acting-on-risk-score) covers each band.

## Next steps

<CardGroup cols={2}>
  <Card title="Set up a webhook" icon="gear" href="/setup/webhooks">
    The tutorial: configure your endpoints, test them, and go live.
  </Card>

  <Card title="Data Models" icon="table-cells" href="/api/models">
    The full envelope and Snapshot schemas, and the identity each identifier maps to.
  </Card>

  <Card title="Server API" icon="server" href="/api/server-api">
    Read one identification by request ID, or every identification of one account, device, visitor or public IP.
  </Card>

  <Card title="Risk Score" icon="gauge" href="/features/risk-scoring">
    How the explainable 0-100 Risk Score and the Trusted, Suspicious and Dangerous bands work.
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.