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

> Register one or more endpoints per domain, verify the X-Shield-Signature header, and act on the score the moment a visit is scored.

A webhook pushes the Risk Score and the signals behind it to your backend the moment ShieldLabs finishes scoring a visit. Register one or more endpoints per domain, verify the signature on the `X-Shield-Signature` header, 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.

<Note>
  Webhooks are optional. If you do not register an endpoint, checks still run and still bill, but results are only available in the dashboard or through the [History API](/api/server-api). An endpoint is what makes scores real-time.
</Note>

## Register an endpoint

You can configure up to **10 endpoints per domain**. Each endpoint has its own name, HTTPS URL, and a unique signing secret (`whsec_…`). Manage them from the dashboard.

<Steps>
  <Step title="Open the domain">
    Go to [app.shieldlabs.ai](https://app.shieldlabs.ai/), open your domain, and switch to the **Webhooks** tab.
  </Step>

  <Step title="Add an endpoint">
    Click **Add endpoint**, give it a name and a public **HTTPS** URL on a route your backend controls, and save. ShieldLabs generates a dedicated signing secret (`whsec_…`) for that endpoint.
  </Step>

  <Step title="Verify it">
    Use **Verify** to send a signed ping. When your endpoint answers with a `2xx`, its status flips to **Active**. You can also use **Send test event** to deliver a sample risk event at any time.
  </Step>
</Steps>

<Tip>
  Each endpoint signs with its **own** `whsec_…` secret. Copy the secret from the endpoint row and store it as a backend-only environment variable — never in the browser or in a URL. Rotating a secret invalidates the old one immediately.
</Tip>

Endpoints can be individually **enabled** or **disabled**. A disabled endpoint stops receiving events without losing its configuration. The **Requests** counter on each row reflects live delivery volume.

## What a delivery looks like

ShieldLabs sends a `POST` with `Content-Type: application/json` to each enabled endpoint. The body is a signed **envelope** (snake\_case), and the signature travels in the `X-Shield-Signature` header.

```http theme={null}
POST https://myshop.com/webhooks/shieldlabs
Content-Type: application/json
X-Shield-Signature: sha256=9b74c98e1a3f0d2c5b6a7e8f1029384756abcdef0123456789abcdef01234567

{
  "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,
      "javascript_disabled": false,
      "incognito": false,
      "search_bot": false,
      "suspicious_paid_click": false,
      "stun_not_checked": false,
      "ip_mismatch": true
    },
    "observed_at": "2026-06-26T14:20:42Z"
  }
}
```

Correlate the delivery to the original identify call by `data.request_id`. The full field list is in the [Webhooks API reference](/api/webhooks).

This delivery is a masked session: `data.public_ip` is a US proxy exit (`203.0.113.42`) while `data.local_ip` is the visitor's real network in Germany (`198.51.100.23`). The two countries disagree, so `data.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 score, which here comes from the proxy, datacenter, and abuser signals.

<Note>
  Branch on `data.risk_score`, signal `name` slugs, and `detection_flags`.
</Note>

### One webhook per check

Each identification produces **exactly one** scored webhook per enabled endpoint, joined by `request_id`.

ShieldLabs waits for follow-up network checks when they apply to the visit. The webhook is sent as soon as those checks finish, or **no later than 60 seconds** after the check started — whichever comes first. If a follow-up never arrives, you still get one webhook with the best score available at that point.

Typical timing:

* **Simple visits** (no follow-up network check): about **1 second** after ingest.
* **Chrome with a follow-up network check**: usually within a few seconds, up to **60 seconds** in slow cases.

<Warning>
  Do not block your UX waiting for the webhook. Use the snippet callback for immediate UI; treat the webhook as the authoritative scored result for server-side decisions.
</Warning>

## Verify the signature

Every delivery is signed. The `X-Shield-Signature` header is the hex-encoded HMAC-SHA256 of the **raw request body**, keyed with that endpoint's signing secret, prefixed with `sha256=`:

```
X-Shield-Signature = "sha256=" + hex( HMAC-SHA256( key = whsec_…, msg = raw request body ) )
```

To verify: read the raw request body bytes exactly as received, compute HMAC-SHA256 over those bytes with the endpoint secret, hex-encode the result, prefix it with `sha256=`, and compare it to the `X-Shield-Signature` header using a **constant-time** comparison. Reject the request (respond `401`) on a mismatch, and do not process the body.

<CodeGroup>
  ```javascript Node.js theme={null}
  import crypto from "crypto";
  import express from "express";

  const SECRET = process.env.SHIELDLABS_WEBHOOK_SECRET; // whsec_… for this endpoint
  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 received = req.get("X-Shield-Signature") ?? "";
    const expected =
      "sha256=" +
      crypto.createHmac("sha256", SECRET).update(req.rawBody).digest("hex");

    const a = Buffer.from(expected);
    const b = Buffer.from(received);
    if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
      return res.sendStatus(401);
    }

    res.sendStatus(200);          // acknowledge fast
    if (req.body.event_type === "webhook.ping") return;
    handleScore(req.body.data);   // idempotent on request_id
  });
  ```

  ```go Go theme={null}
  package main

  import (
  	"crypto/hmac"
  	"crypto/sha256"
  	"encoding/hex"
  	"io"
  	"net/http"
  	"os"
  )

  var secret = []byte(os.Getenv("SHIELDLABS_WEBHOOK_SECRET")) // whsec_… for this endpoint

  func webhook(w http.ResponseWriter, r *http.Request) {
  	body, err := io.ReadAll(r.Body)
  	if err != nil {
  		w.WriteHeader(http.StatusBadRequest)
  		return
  	}

  	mac := hmac.New(sha256.New, secret)
  	mac.Write(body) // HMAC over the raw request body
  	expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))

  	if !hmac.Equal([]byte(expected), []byte(r.Header.Get("X-Shield-Signature"))) {
  		w.WriteHeader(http.StatusUnauthorized)
  		return
  	}

  	w.WriteHeader(http.StatusOK) // acknowledge fast
  	handleScore(body)            // idempotent on request_id
  }
  ```

  ```python Python theme={null}
  import hashlib
  import hmac
  import os
  from flask import Flask, request, abort

  SECRET = os.environ["SHIELDLABS_WEBHOOK_SECRET"].encode()  # whsec_… for this endpoint
  app = Flask(__name__)

  @app.post("/webhooks/shieldlabs")
  def webhook():
      # Read the raw request body bytes exactly as received.
      raw = request.get_data()
      received = request.headers.get("X-Shield-Signature", "")
      expected = "sha256=" + hmac.new(SECRET, raw, hashlib.sha256).hexdigest()

      if not hmac.compare_digest(expected, received):
          abort(401)

      payload = request.get_json()
      if payload.get("event_type") == "webhook.ping":
          return "", 200

      handle_score(payload.get("data"))  # idempotent on request_id
      return "", 200
  ```
</CodeGroup>

<Warning>
  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.
</Warning>

## Delivery guarantees

Webhook delivery is **at-most-once**.

<Warning>
  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 `request_id` (and by IP, VisitorID, DeviceID, UserHID, SessionID, or CookieID).
</Warning>

A practical handler pattern:

<Steps>
  <Step title="Verify">
    Constant-time compare the `X-Shield-Signature` header. Reject with `401` on a mismatch.
  </Step>

  <Step title="Acknowledge">
    Return `200` immediately once the signature is valid and the body is parsed.
  </Step>

  <Step title="Deduplicate">
    Upsert on `request_id`. A redelivery for the same id should be a no-op.
  </Step>

  <Step title="Decide">
    Map `data.risk_score` plus `data.signals` to allow, challenge, review, or block in your application logic.
  </Step>
</Steps>

## Next steps

<CardGroup cols={2}>
  <Card title="Webhooks API reference" icon="webhook" href="/api/webhooks">
    Field-by-field payload schema, timing, and signature details.
  </Card>

  <Card title="Acting on the Risk Score" icon="gauge" href="/guides/acting-on-risk-score">
    Map scores and signals to allow, challenge, review, or block.
  </Card>

  <Card title="History API" icon="clock-rotate-left" href="/api/server-api">
    Poll stored snapshots for guaranteed reads.
  </Card>

  <Card title="API keys" icon="key" href="/setup/keys">
    Where your public and secret keys come from.
  </Card>
</CardGroup>
