ShieldLabs Python SDK
Read identification verdicts, verify webhooks and turn risk scores into decisions on your Python backend.
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
- 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.
- 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.
- Decision. Your backend acts on
risk_score, the three risk bands,detection_flagsand the identifiers (for example, how many accounts share onedevice_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.1waits 0.1, 0.2, 0.4 and 0.6 s, then every 0.8 s;poll_interval=1waits 1 s, then every 2 s; andpoll_interval=3polls 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 isNone. - After a
429. The next wait is the longest of the ladder step, 1 second (the History API limit is counted per second) andRetry-Aftercapped at 10 seconds. ARetry-Afterof0or 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 cappedRetry-Afteris longer than the time left, theRateLimitErroris raised at once. - Errors that stop at once. A
400,401,403or404ends the wait immediately and raisesBadRequestError,AuthenticationErrororNotFoundError. wait=Falsemakes one lookup with the client's regular retries and returnsNonewhen there is no row yet.request_idmust be a UUID. An invalid value raisesValidationErrorbefore 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.
secretcan 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_eventreturnsIdentificationScoredEvent,WebhookPingEventorUnknownWebhookEvent, 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_version2026-06-01. Other versions are parsed with aShieldLabsWarning. - 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
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)
| File | Size | Uploaded | |
|---|---|---|---|
| shieldlabs-1.0.0.tar.gz | 75.5 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| 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 logRelease 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