> ## 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

> A reference for the signed webhook envelope: event types, payload schema, delivery timing, and signature.

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 signals behind it.

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 [dashboard](/setup/webhooks). 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 visit lives under `data`.

<ResponseField name="event_type" type="string">
  Discriminator for the delivery. Real scored visits always use `identification.scored`. Service deliveries use `webhook.ping` (endpoint **Verify**). 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.

```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": null,
    "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": 30,
    "signals": [
      { "name": "proxy", "weight": 10 },
      { "name": "datacenter_ip", "weight": 10 },
      { "name": "abuser", "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": false,
      "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
    },
    "observed_at": "2026-06-26T14:20:42Z"
  }
}
```

The signature is **not** in the body. It travels in the `X-Shield-Signature` request header:

```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 for this identify call. Join key across the snapshot, 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)">
  Per-visit identifier from the browser's `sessionStorage` (short-lived). Resets each browser session or tab.
</ResponseField>

<ResponseField name="cookie_id" type="string (UUID)">
  First-party cookie / `localStorage` identifier minted client-side. Lost when the user clears cookies or storage.
</ResponseField>

<ResponseField name="device_id" type="string (UUID)">
  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.
</ResponseField>

<ResponseField name="visitor_id" type="string (UUID)">
  Server-derived from the device\_id and the cookie\_id. **Changes when the cookie is cleared**. Multiple visitor\_ids can map to one device\_id. The durability claim belongs to device\_id, not visitor\_id.
</ResponseField>

<ResponseField name="user_hid" type="string | null">
  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 carries the literal placeholder value `anonymous`. `null` when not provided.
</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 client IP resolved for this HTTP request.

  <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 real network IP observed by an optional follow-up network check (when available). May differ from `public_ip` when the visitor uses a VPN, proxy, or split tunnel. In the example above, `public_ip` is a US proxy exit (`203.0.113.42`, `US`) while `local_ip` resolves to the visitor's real network in Germany (`198.51.100.23`, `DE`); because the two disagree, `detection_flags.ip_mismatch` is `true`. ShieldLabs surfaces both IPs so your code can compare them; the difference is informational and does not add to the Risk Score.

  <Expandable title="local_ip fields">
    <ResponseField name="ip" type="string">
      The real network IP. 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 visitor (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 visit, 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), an integer from **0 to 100** (hard-capped at 100). Higher means more anonymous or more likely masked, spoofed, or abusive. ShieldLabs scores; your application decides allow, challenge, review, or block. Map bands in your own code: Clean 0–9, Low 10–29, Medium 30–59, High 60–100. A rate-limit ban can surface the marker value `999` here, so guard `risk_score > 100` before reading the band.
</ResponseField>

<ResponseField name="signals" type="array of objects">
  The **full** explainable breakdown: every scored signal that contributed to `risk_score`, as `{ "name": "<slug>", "weight": <int> }`.

  <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 [Signals](/features/anonymity-signals) reference for meaning.
    </ResponseField>

    <ResponseField name="weight" type="integer">
      Points this 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 visit.

  <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">
      First landing URL captured for the visit.
    </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 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 optional follow-up network check did not complete in time.</ResponseField>
    <ResponseField name="anti_detect_browser" type="boolean">Antidetect browser signals detected.</ResponseField>
    <ResponseField name="browser_automation" type="boolean">The browser is automation-controlled (`navigator.webdriver` is set). Scored like an anti-detect browser.</ResponseField>
    <ResponseField name="javascript_disabled" type="boolean">JavaScript unavailable (headless or blocked).</ResponseField>
    <ResponseField name="incognito" type="boolean">Private/incognito mode reported by the snippet.</ResponseField>
    <ResponseField name="search_bot" type="boolean">The visit is from a known search-engine crawler IP (Googlebot, Bingbot, and similar). Informational, not scored; it also sets the **Search bot** traffic channel.</ResponseField>
    <ResponseField name="suspicious_paid_click" type="boolean">Paid traffic channel with score ≥ 60.</ResponseField>
    <ResponseField name="ip_mismatch" type="boolean">The public IP differs from the local (network-check) 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>
  </Expandable>
</ResponseField>

<ResponseField name="observed_at" type="string (ISO 8601)">
  When the visit was scored and this payload was finalized, in RFC 3339 / ISO 8601 form.
</ResponseField>

<Note>
  Branch on `risk_score`, stable signal `name` slugs, and `detection_flags` — not on free-form labels from the History API `Description` field.
</Note>

## Delivery timing

| Scenario                              | Typical delay                                  |
| ------------------------------------- | ---------------------------------------------- |
| No follow-up network check            | \~1s after ingest                              |
| A follow-up network check is expected | When follow-ups finish, **or** at 60s deadline |

The server waits up to **60 seconds** after the check started for optional follow-up network checks. If a follow-up never arrives, the webhook is still sent with the best score available at the deadline.

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

## Service events

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

Endpoint **Verify** in the dashboard sends a minimal envelope — no `data`:

```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 visit.

### Send test event

**Send test event** delivers a full `identification.scored` envelope with sample `data` (fixed UUIDs). 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 — 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).

## 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 check 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="~1s timeout" icon="clock">
    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.
  </Card>

  <Card title="Idempotent on request_id" icon="key">
    Key your writes on `data.request_id` so a redelivery 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 your expected window (up to \~60s), call the History API by `request_id` to pull the stored snapshot.
  </Step>
</Steps>

## Acting on the payload

The webhook gives you the `risk_score` and the `signals` 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

<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 in one place.
  </Card>

  <Card title="Server API" icon="server" href="/api/server-api">
    History search by request\_id, the snapshot superset, and profile.
  </Card>

  <Card title="Risk Score" icon="gauge" href="/features/risk-scoring">
    How the 0-100 explainable score and the Clean / Low / Medium / High bands work.
  </Card>
</CardGroup>
