Skip to main content

ShieldLabs Python SDK

Read identification verdicts, verify webhooks and turn risk scores into decisions on your Python backend.

CI License: MIT PyPI

ShieldLabs identifies visitors and scores risk with device intelligence. New to ShieldLabs? Start free and read the docs at docs.shieldlabs.ai.

How it fits

 1. Browser            2. Your backend                          3. Decision
 ShieldLabs agent ---> receives requestId with the signup,  ---> allow, step up,
 returns requestId     login or checkout; reads the verdict      review or refuse
                       with this SDK (History API) or gets it
                       as a signed identification.scored webhook
  1. Browser. The ShieldLabs agent runs an identification and hands your page a request ID. The browser never sees a Risk Score, a visitor ID or a device ID.
  2. Your backend. It receives the request ID together with the protected action and reads the verdict for it from the History API with this SDK, or receives the verdict by a signed webhook.
  3. Decision. Your backend acts on risk_score, the three risk bands, detection_flags and the identifiers (for example, how many accounts share one device_id).

Install

pip install shieldlabs

Python 3.9 or newer. The only dependency is httpx.

Quick start

import os
from typing import Optional

from shieldlabs import ShieldLabs, evaluate_identification, webhooks

client = ShieldLabs(api_key=os.environ["SHIELDLABS_API_KEY"])  # Private API Key, sec_...
used_request_ids: set[str] = set()  # use your database or cache in production


def allow_signup(request_id: str) -> bool:
    # 1. Read the identification for the request ID the browser sent with the form.
    #    Scoring is asynchronous, so this waits (up to 10 s by default) for the verdict.
    identification = client.identifications.get(request_id)

    # 2. Evaluate it: missing, reused, stale, rate-limited, automated or dangerous is refused.
    verdict = evaluate_identification(identification, is_replay=lambda rid: rid in used_request_ids)
    if identification is not None:
        used_request_ids.add(identification.request_id)
    return verdict.ok


def on_webhook(raw_body: bytes, signature_header: Optional[str]) -> None:
    # 3. Verify and parse a delivery: the raw body bytes and the X-Shield-Signature header.
    event = webhooks.construct_event(
        raw_body,
        signature_header,
        os.environ["SHIELDLABS_WEBHOOK_SECRET"],  # whsec_...
    )
    print(event.event_type)

A runnable FastAPI app that does all of this is in examples/.

Guide

Wait for the verdict

Scoring is asynchronous. The History row for an identification appears about 1-3 seconds after the browser call and can be refined for up to about 10 seconds while follow-up checks finish. Start the identification in the browser when the user begins the action (for example when they start filling in the signup form) rather than on submit, so the verdict is usually ready when your backend asks for it. An identification older than max_age (5 minutes by default) counts as stale, so start a new one when the user comes back later.

identifications.get polls the History API by request_id until the row appears and returns that first version:

identification = client.identifications.get(request_id)  # wait up to 10 s
identification = client.identifications.get(request_id, timeout=5)  # shorter budget
identification = client.identifications.get(request_id, wait=False)  # one lookup only

if identification is None:
    ...  # not scored in time: treat it as unverified, never as clean

How the wait works:

  • Total budget. timeout (10 seconds by default) is the time budget of the whole call, not of one request.
  • Schedule. The first poll runs immediately, then after waits of 0.25 s, 0.5 s, 1 s and 1.5 s, then every 2 s. The last poll runs at the deadline. poll_interval (p, 0.25 s by default) sets the ladder: waits of p, 2p, 4p, 6p and 8p, then 8p again, each capped at 2 seconds, or at p when p is longer. For example, poll_interval=0.1 waits 0.1, 0.2, 0.4 and 0.6 s, then every 0.8 s; poll_interval=1 waits 1 s, then every 2 s; and poll_interval=3 polls every 3 s.
  • One attempt per poll. Each poll is a single HTTP request, never retried inside the poll. Its timeout is the client timeout, cut to the time left before the deadline but never shorter than 1 second.
  • Transient errors keep polling. A 429, a 5xx response, a connection error or a timeout does not end the wait: the next poll follows the schedule. If the last poll fails, its error is raised; if it answers without a row, the result is None.
  • After a 429. The next wait is the longest of the ladder step, 1 second (the History API limit is counted per second) and Retry-After capped at 10 seconds. A Retry-After of 0 or a date in the past counts as 0, so the 1-second minimum still applies. A wait that would pass the deadline is cut, and the last poll runs at the deadline. When the capped Retry-After is longer than the time left, the RateLimitError is raised at once.
  • Errors that stop at once. A 400, 401, 403 or 404 ends the wait immediately and raises BadRequestError, AuthenticationError or NotFoundError.
  • wait=False makes one lookup with the client's regular retries and returns None when there is no row yet.
  • request_id must be a UUID. An invalid value raises ValidationError before any request.
  • For the refined state (for example in a later review job), read the row again with client.history.search("request_id", request_id, limit=1).

Decide with evaluate_identification

evaluate_identification applies the checks our tutorials use before a protected action, in this order, and reports the first one that fails:

reason Refused when
missing there is no identification (unverified, never clean)
replayed is_replay(request_id) returns True (one identification authorizes one action)
stale observed_at is older than max_age (default 300 seconds)
rate_limited the Risk Score is the rate-limit marker (above 100, in practice 999)
no_device_signals the device ID is the all-zero UUID 00000000-0000-0000-0000-000000000000
blocked_flag a flag in block_flags is set (default browser_automation, javascript_disabled)
blocked_band the risk band is in block_bands (default dangerous)
from datetime import timedelta

from shieldlabs import evaluate_identification

verdict = evaluate_identification(
    identification,
    max_age=timedelta(minutes=5),
    block_bands=["suspicious", "dangerous"],
    block_flags=["browser_automation", "javascript_disabled", "anti_detect_browser"],
    is_replay=replay_store.seen_before,
)
# Evaluation(ok=False, reason='blocked_flag', band='dangerous', flag='anti_detect_browser')

The defaults are a starting point: tune the bands, flags and freshness window for each action. The SDK stores nothing, so keep used request IDs in your own store (for example a Redis SET key NX EX 600) and pass a lookup as is_replay.

A visitor IP that sends too many identifications is blocked for 10 minutes. The block can show up once as a separate identification with the marker 999 (rate_limited) and its own request ID. Request IDs that the browser receives during the block get no History row at all, so they end up as missing.

Risk bands are computed on the client from the score:

Band Risk Score
trusted 0-29
suspicious 30-59
dangerous 60-100
rate_limited above 100: the rate-limit marker, not a score
from shieldlabs import is_rate_limited, risk_band

risk_band(45)  # 'suspicious'
is_rate_limited(999)  # True
identification.risk_band  # same helpers as properties

Branch on risk_score and detection_flags. Risk signal names (identification.signals) are for display and logging: the set is open (SignalName lists known values), names can repeat, and weights can be negative, so never add weights up yourself.

The Identification model

Webhook deliveries and History API rows are normalized into one frozen dataclass with the webhook field names:

Field Type Notes
request_id, visitor_id, device_id, session_id, cookie_id str UUIDs; the all-zero UUID is possible
user_hid str or None your User HID; "anonymous" for anonymous checks
domain str the registered domain
public_ip, local_ip IpInfo(ip, country) IPv4 or ""; country is an English country name such as "Germany", or ""
connection_type str direct, mobile, vpn, proxy, tor, privacy_relay, browser_vpn_proxy, unknown (unknown values are kept)
os, browser, device_type str
traffic_source TrafficSource channel, referrer_domain, landing_url, click_id_type, utm_*; "" when absent
risk_score int 0-100, or 999 for the rate-limit marker
signals tuple[Signal, ...] Signal(name, weight, description); description is set on History rows
detection_flags DetectionFlags 19 booleans; .active() lists the set ones
observed_at datetime or None timezone-aware UTC
source "webhook" or "history"
raw Mapping the original webhook data object or History row

identification.to_dict() returns a JSON-ready dict and Identification.from_dict() reads it back.

Search history for account-abuse checks

history.search reads one page of identifications that share one identifier, newest first. history.iter walks every page for you, removes rows repeated between pages (new identifications can shift offsets) and stops at total, at an empty page or after max_items.

page = client.history.search("user_hid", account_hid, limit=50)
print(page.total, len(page.data))

# How many accounts signed in from this device?
# User HID values that name no account (anonymous checks and sentinel values):
NOT_ACCOUNTS = (None, "anonymous", "fail", "-1", "unknown")

if identification.has_device_signals:  # the all-zero device ID matches unrelated rows
    accounts = {
        item.user_hid
        for item in client.history.iter("device_id", identification.device_id, max_items=500)
        if item.user_hid not in NOT_ACCOUNTS
    }
    if len(accounts) > 2:
        send_to_review(identification.request_id, accounts)
type value
request_id, device_id, visitor_id, session_id, cookie_id a UUID (sent lowercase)
user_hid a non-empty string, matched exactly and case-sensitively
ip a dotted IPv4 address

Arguments are validated before any request (ValidationError): unknown types, malformed UUIDs, IPv6 addresses, limit outside 1-100, a negative offset, and a user_hid that is empty, contains / or is . or ... Those User HIDs cannot be matched in the request path, so the SDK refuses them instead of returning an empty page. Every other user_hid is percent-encoded in the form the History API matches.

User HID

Pass a stable, pseudonymous account ID to the browser agent instead of an email address or raw account ID. user_hid derives one on your server:

from shieldlabs import user_hid

hid = user_hid(str(account.id), user_hid_secret)  # 64 lowercase hex characters

It is HMAC-SHA256 keyed with a secret of your own (any server-side secret, separate from your ShieldLabs keys). Keep it private and stable: changing it changes every User HID. The hex output is always searchable with history.search("user_hid", ...); if you build User HIDs another way, avoid / (standard base64 contains it, base64url does not).

Webhooks

ShieldLabs sends identification.scored to every enabled endpoint (register them in the analytics dashboard under Integration > Webhooks). Each delivery carries X-Shield-Signature: sha256=<hex HMAC-SHA256 of the raw body>, keyed with the endpoint signing secret including its whsec_ prefix.

from typing import Optional

from shieldlabs import (
    IdentificationScoredEvent,
    SignatureVerificationError,
    WebhookParseError,
    WebhookPingEvent,
    webhooks,
)


def handle_delivery(raw_body: bytes, signature: Optional[str]) -> int:
    try:
        event = webhooks.construct_event(raw_body, signature, [current_secret, previous_secret])
    except SignatureVerificationError:
        return 401
    except WebhookParseError:
        return 400
    if isinstance(event, IdentificationScoredEvent):
        if already_processed(event.data.request_id):
            return 200
        enqueue(event.data)  # do slow work after responding
    elif isinstance(event, WebhookPingEvent):
        pass  # sent by the Verify button
    return 200  # unknown event types: acknowledge and ignore
  • Verify the raw bytes exactly as received, before parsing. Re-serialized JSON does not match.
  • secret can be a list: a delivery is valid when any secret matches, so you can rotate an endpoint secret without downtime.
  • ShieldLabs sends one delivery per identification and endpoint, with a 1-second timeout and no retries. Respond with a 2xx within 1 second and do slow work afterwards.
  • Make handlers idempotent on data.request_id: a future release retries deliveries, and a retry resends identical bytes.
  • Use the History API for guaranteed reads and for the latest state: a delivery that fails is not sent again, and a History row can be refined after its webhook was sent.
  • construct_event returns IdentificationScoredEvent, WebhookPingEvent or UnknownWebhookEvent, and never raises for an unknown event type. The Test delivery sent from the analytics dashboard parses like production traffic.

webhooks.verify_signature(payload, signature_header, secret) returns a bool when you only need the check.

Management API: domain profile

from shieldlabs import ShieldLabsManagement

management = ShieldLabsManagement(
    secret_key=os.environ["SHIELDLABS_SECRET_KEY"],
    # Normalized before use: "https://www.Example.com/" becomes "example.com".
    domain=os.environ["SHIELDLABS_DOMAIN"],
)
profile = management.get_profile()
profile.remaining_identifications  # negative when the account is over its included volume
profile.public_key_masked  # "****************************a3f8"

The Management API allows about 15 requests per minute per caller IP and then blocks that IP for 10 minutes. The client never retries a 429, so call it sparingly and cache the profile.

Rate limits

API Limit What the SDK does
History API about 15 requests per second per domain, shared by all your callers identifications.get spaces its polls, and inside its wait a 429 waits at least 1 second (and at least Retry-After, up to 10 seconds). Ordinary calls (history.search, history.iter, identifications.get with wait=False) follow Retry-After as sent, up to 10 seconds, and wait at least 1 second after a 429 without it
Management API about 15 requests per minute per IP, then a 10-minute block raises RateLimitError without retrying

Async

AsyncShieldLabs and AsyncShieldLabsManagement mirror the sync clients:

from shieldlabs import AsyncShieldLabs

async with AsyncShieldLabs() as client:  # reads SHIELDLABS_API_KEY
    identification = await client.identifications.get(request_id)
    async for item in client.history.iter("user_hid", account_hid, max_items=200):
        ...

Configuration

Option Default Notes
api_key SHIELDLABS_API_KEY Private API Key sec_...; a key of another shape triggers a ShieldLabsWarning
base_url SHIELDLABS_API_BASE_URL, else https://account.shieldlabs.ai the origin; a trailing /api is removed
secret_key, domain SHIELDLABS_SECRET_KEY, SHIELDLABS_DOMAIN Management client
base_url (Management) SHIELDLABS_MANAGEMENT_BASE_URL, else https://api.shieldlabs.ai
timeout 10.0 seconds per HTTP attempt
max_retries 2 retries for connection errors, timeouts, 429 (History only) and 5xx
http_client a new httpx.Client / httpx.AsyncClient pass your own for proxies or custom transports; it is not closed for you

Base URLs must use https. Plain http:// is accepted only for localhost, 127.0.0.1 and [::1] (local test servers), because every request carries a key.

Clients are safe to share across threads (sync) or tasks (async): create one per process and reuse it. Use them as context managers or call close() / aclose().

For development and staging, register a separate domain (for example dev.example.com) and use its keys with the default hosts.

Reference

Call Returns
ShieldLabs(api_key=None, base_url=None, timeout=10.0, max_retries=2, http_client=None) History API client
client.identifications.get(request_id, wait=True, timeout=10.0, poll_interval=0.25) Identification or None; timeout is the total wait in seconds; poll_interval p sets the waits p, 2p, 4p, 6p, 8p, then 8p again, each at most 2 seconds, or p when p is longer
client.history.search(type, value, limit=20, offset=0) HistoryPage(data, total)
client.history.iter(type, value, page_size=100, max_items=None) iterator of Identification; max_items=0 yields nothing
AsyncShieldLabs(...) same methods as coroutines; history.iter is an async iterator
ShieldLabsManagement(secret_key=None, domain=None, base_url=None, timeout=10.0, max_retries=2, http_client=None) Management API client
management.get_profile() DomainProfile
AsyncShieldLabsManagement(...) same method as a coroutine
webhooks.verify_signature(payload, signature_header, secret) bool
webhooks.construct_event(payload, signature_header, secret) IdentificationScoredEvent, WebhookPingEvent or UnknownWebhookEvent
evaluate_identification(identification, *, max_age=300.0, now=None, block_bands=("dangerous",), block_flags=("browser_automation", "javascript_disabled"), is_replay=None) Evaluation(ok, reason, band, flag)
risk_band(score) "trusted", "suspicious", "dangerous" or "rate_limited"
is_rate_limited(score) bool
user_hid(user_id, secret) 64-character lowercase hex str

Models: Identification, IpInfo, TrafficSource, Signal, SignalName, DetectionFlags, HistoryPage, DomainProfile, Evaluation. Type aliases: LookupType, RiskBand, EvaluationReason, WebhookEvent.

Errors and retries

Exception When
ShieldLabsError base class of everything below
ApiError any non-2xx response; has status, message, body (parsed JSON or text) and headers
BadRequestError 400
AuthenticationError 401 or 403: wrong, rotated or disabled key
QuotaExceededError 402. Neither the History API nor the Management API returns it today: an account over its included volume shows a negative remaining_identifications
NotFoundError 404: usually a wrong base URL or path prefix
RateLimitError 429; retry_after holds seconds when the server sent Retry-After
ServerError 5xx
APIConnectionError DNS, TCP, TLS or protocol failure
APITimeoutError an attempt exceeded timeout
SignatureVerificationError a webhook signature is missing or wrong
WebhookParseError a verified webhook body is not an event envelope
ValidationError an invalid argument, raised before any request (also a ValueError)

Only GET requests are sent, and they are retried on connection errors, timeouts, 429 and 5xx with exponential backoff and jitter (0.5 s base, doubling, capped at 8 s), up to max_retries times. Retry-After is followed as sent, up to 10 seconds (0 retries at once), and a 429 without it waits at least 1 second (the History API limit is counted per second). 400, 401, 402, 403 and 404 are never retried, and the Management client never retries 429. Inside identifications.get a poll is never retried: the wait polls again on its schedule instead (see Wait for the verdict).

from shieldlabs import ApiError, AuthenticationError, RateLimitError

try:
    page = client.history.search("device_id", device_id)
except AuthenticationError:
    ...  # check SHIELDLABS_API_KEY
except RateLimitError as exc:
    ...  # exc.retry_after
except ApiError as exc:
    print(exc.status, exc.message)

Keys and request bodies are never logged. Each request carries User-Agent: shieldlabs-python/<version> with the Python and httpx versions.

Compatibility

  • Python 3.9, 3.10, 3.11, 3.12 and 3.13 (CPython), tested in CI.
  • httpx 0.25 or newer, below 1.0. The async client runs on asyncio and trio.
  • Webhook schema_version 2026-06-01. Other versions are parsed with a ShieldLabsWarning.
  • Unknown fields and enum values in responses are tolerated and kept in raw.
  • The package follows semantic versioning and ships type information (py.typed).

Development

python3 -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"

ruff check . && ruff format --check . && mypy --strict src
pytest -q --cov=shieldlabs --cov-report=term-missing

tests/data/ holds the shared test fixtures (History rows, webhook bodies, signature vectors, error responses) that every ShieldLabs server SDK passes. See CONTRIBUTING.md. Questions and security reports: contact@shieldlabs.ai.

License

MIT

Metadata

Release files for shieldlabs 1.0.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for shieldlabs 1.0.0
File Size Uploaded
shieldlabs-1.0.0.tar.gz 75.5 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for shieldlabs 1.0.0
File Interpreter ABI Platform
shieldlabs-1.0.0-py3-none-any.whl Python 3 none any Details

Total release size: 116.3 kB

Release files / shieldlabs-1.0.0.tar.gz

Download URL shieldlabs-1.0.0.tar.gz
Size 75.5 kB
Tags Source
SHA-256 checksum
How to use checksums
6dffb330ba00490e4bf36d28e4d0c0d91f65d1e3c0f8f046e20bd1f887e43009
BLAKE2b-256 checksum
How to use checksums
0ebbe5d74ce85947ebf4fde5ca8fc163146a7d404ec850702bef388a85512849
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 1, 2026.

Transparency log

Release files / shieldlabs-1.0.0-py3-none-any.whl

Download URL shieldlabs-1.0.0-py3-none-any.whl
Size 40.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
4085151d046bce7241fb8993f6f26b7dca91b556b8c1813d54ca2438088c0ecc
BLAKE2b-256 checksum
How to use checksums
034ca1c4fa868e93c50203d087565988e09937cf7a92c13236da864169c88577
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 1, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

1.0.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page