From 0fc66ee2f247f3c4f4a93df97767341d86714ec0 Mon Sep 17 00:00:00 2001 From: Konstantin Serebryakov <97249355+KonstantinSerebryakov@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:03:05 +0300 Subject: [PATCH 1/3] Rename the public identification key to identification_id. History search and webhook parsers still accept request_id. Database columns stay request_id. The browser snippet callback remains requestID. --- CHANGELOG.md | 2 +- README.md | 42 ++++----- examples/README.md | 6 +- examples/fastapi_app.py | 22 ++--- .../api/history_api_api.py | 12 +-- .../models/history_row.py | 6 +- .../models/identification_scored_data.py | 6 +- .../models/legacy_snapshot.py | 6 +- resources/shieldlabs-api.yaml | 80 ++++++++--------- src/shieldlabs/__init__.py | 2 +- src/shieldlabs/_client.py | 32 +++---- src/shieldlabs/_helpers.py | 4 +- src/shieldlabs/_models.py | 16 ++-- src/shieldlabs/_validation.py | 7 +- tests/_support.py | 6 +- tests/data/history-page.json | 10 +-- tests/data/normalization-cases.json | 32 +++---- tests/data/webhook-identification-scored.json | 2 +- .../webhook-identification-scored.raw.txt | 2 +- tests/data/webhook-rate-limited.json | 2 +- tests/data/webhook-signature-vectors.json | 90 +++++++++---------- tests/data/webhook-test-delivery.json | 2 +- tests/test_async.py | 16 ++-- tests/test_errors.py | 22 ++--- tests/test_example_app.py | 8 +- tests/test_helpers.py | 6 +- tests/test_history.py | 38 ++++---- tests/test_normalization.py | 14 +-- tests/test_polling.py | 4 +- tests/test_readme.py | 4 +- tests/test_webhooks.py | 2 +- 31 files changed, 255 insertions(+), 248 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ea66d6..2ebf93c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,7 @@ First stable release. It replaces the 0.1.0 preview package completely. ### Added - `ShieldLabs` and `AsyncShieldLabs` History API clients: `history.search`, `history.iter` - (de-duplicated on `request_id`) and `identifications.get`, which waits for the verdict: + (de-duplicated on `identification_id`) and `identifications.get`, which waits for the verdict: - `timeout` (10 s by default) is the total budget of the call. Polls run immediately, then after waits of `poll_interval` times 1, 2, 4, 6 and 8, then 8 again, each capped at 2 s, or at `poll_interval` when that is longer (0.25 s, 0.5 s, 1 s, 1.5 s and every 2 s by default; diff --git a/README.md b/README.md index 0be23b7..3bbe7bc 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,8 @@ ShieldLabs identifies visitors and scores risk with device intelligence. New to ``` 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 + ShieldLabs agent ---> receives identificationId with the signup, ---> allow, step up, + returns identificationId login or checkout; reads the verdict review or refuse with this SDK (History API) or gets it as a signed identification.scored webhook ``` @@ -46,18 +46,18 @@ 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 +used_identification_ids: set[str] = set() # use your database or cache in production -def allow_signup(request_id: str) -> bool: +def allow_signup(identification_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) + identification = client.identifications.get(identification_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) + verdict = evaluate_identification(identification, is_replay=lambda rid: rid in used_identification_ids) if identification is not None: - used_request_ids.add(identification.request_id) + used_identification_ids.add(identification.identification_id) return verdict.ok @@ -84,13 +84,13 @@ start filling in the signup form) rather than on submit, so the verdict is usual 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 +`identifications.get` polls the History API by `identification_id` until the row appears and returns that first version: ```python -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 +identification = client.identifications.get(identification_id) # wait up to 10 s +identification = client.identifications.get(identification_id, timeout=5) # shorter budget +identification = client.identifications.get(identification_id, wait=False) # one lookup only if identification is None: ... # not scored in time: treat it as unverified, never as clean @@ -121,9 +121,9 @@ How the wait works: 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. +- `identification_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)`. + `client.history.search("identification_id", identification_id, limit=1)`. ### Decide with `evaluate_identification` @@ -133,7 +133,7 @@ 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) | +| `replayed` | `is_replay(identification_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` | @@ -192,7 +192,7 @@ field names: | Field | Type | Notes | |---|---|---| -| `request_id`, `visitor_id`, `device_id`, `session_id`, `cookie_id` | `str` | UUIDs; the all-zero UUID is possible | +| `identification_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 `""` | @@ -230,12 +230,12 @@ if identification.has_device_signals: # the all-zero device ID matches unrelate if item.user_hid not in NOT_ACCOUNTS } if len(accounts) > 2: - send_to_review(identification.request_id, accounts) + send_to_review(identification.identification_id, accounts) ``` | `type` | `value` | |---|---| -| `request_id`, `device_id`, `visitor_id`, `session_id`, `cookie_id` | a UUID (sent lowercase) | +| `identification_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 | @@ -288,7 +288,7 @@ def handle_delivery(raw_body: bytes, signature: Optional[str]) -> int: except WebhookParseError: return 400 if isinstance(event, IdentificationScoredEvent): - if already_processed(event.data.request_id): + if already_processed(event.data.identification_id): return 200 enqueue(event.data) # do slow work after responding elif isinstance(event, WebhookPingEvent): @@ -301,7 +301,7 @@ def handle_delivery(raw_body: bytes, signature: Optional[str]) -> int: 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 +- Make handlers idempotent on `data.identification_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. @@ -345,7 +345,7 @@ for 10 minutes. The client never retries a `429`, so call it sparingly and cache from shieldlabs import AsyncShieldLabs async with AsyncShieldLabs() as client: # reads SHIELDLABS_API_KEY - identification = await client.identifications.get(request_id) + identification = await client.identifications.get(identification_id) async for item in client.history.iter("user_hid", account_hid, max_items=200): ... ``` @@ -376,7 +376,7 @@ its keys with the default hosts. | 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.identifications.get(identification_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 | diff --git a/examples/README.md b/examples/README.md index 134305c..3a5d58e 100644 --- a/examples/README.md +++ b/examples/README.md @@ -4,12 +4,12 @@ A FastAPI app that does both halves of a server integration: -- `POST /signup` reads the `requestId` sent by the browser, waits for the verdict with +- `POST /signup` reads the `identificationId` sent by the browser, waits for the verdict with `identifications.get`, and refuses the signup when the identification is missing, reused, stale, carries the rate-limit marker, has no device signals, shows browser automation or disabled JavaScript, or falls in the dangerous band (`evaluate_identification` defaults). - `POST /webhooks/shieldlabs` verifies `X-Shield-Signature` over the raw body, parses the event, - handles each `request_id` once (so a retried delivery is harmless), and logs it. + handles each `identification_id` once (so a retried delivery is harmless), and logs it. Run it from the repository root: @@ -26,7 +26,7 @@ Try the signup route with a request ID from your page: ```bash curl -X POST http://localhost:8000/signup \ -H 'content-type: application/json' \ - -d '{"email": "user@example.com", "requestId": "8f14e45f-ceea-4c1e-a3b2-1d2c3b4a5f60"}' + -d '{"email": "user@example.com", "identificationId": "8f14e45f-ceea-4c1e-a3b2-1d2c3b4a5f60"}' ``` To receive webhooks locally, expose port 8000 with an HTTPS tunnel and register diff --git a/examples/fastapi_app.py b/examples/fastapi_app.py index 3ba6cc2..3a6a287 100644 --- a/examples/fastapi_app.py +++ b/examples/fastapi_app.py @@ -46,8 +46,8 @@ # one request ID authorizes one protected action, and the webhook handler stays idempotent on # the request ID (one delivery per identification today; a future release retries deliveries # with identical bytes). -used_request_ids: set[str] = set() -processed_request_ids: set[str] = set() +used_identification_ids: set[str] = set() +processed_identification_ids: set[str] = set() def _webhook_secrets() -> list[str]: @@ -71,7 +71,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: class SignupForm(BaseModel): email: str - request_id: str = Field(alias="requestId") + identification_id: str = Field(alias="identificationId") @app.post("/signup") @@ -81,9 +81,9 @@ async def signup(form: SignupForm, request: Request) -> JSONResponse: # Waits for the verdict (up to 10 s): the History row appears about 1-3 s after the # browser call, so the page starts the identification when the user starts filling in # the form rather than on submit. - identification = await client.identifications.get(form.request_id) + identification = await client.identifications.get(form.identification_id) except ValidationError: - return JSONResponse({"error": "invalid_request_id"}, status_code=400) + return JSONResponse({"error": "invalid_identification_id"}, status_code=400) except ShieldLabsError: # Unverified is never clean: refuse and let the user retry. logger.exception("ShieldLabs lookup failed") @@ -94,10 +94,10 @@ async def signup(form: SignupForm, request: Request) -> JSONResponse: # and the dangerous band. Tune it for your product. verdict = evaluate_identification( identification, - is_replay=lambda request_id: request_id in used_request_ids, + is_replay=lambda identification_id: identification_id in used_identification_ids, ) if identification is not None: - used_request_ids.add(identification.request_id) + used_identification_ids.add(identification.identification_id) if not verdict.ok: logger.info( "signup refused: reason=%s band=%s flag=%s", verdict.reason, verdict.band, verdict.flag @@ -122,14 +122,14 @@ async def shieldlabs_webhook(request: Request) -> Response: if isinstance(event, IdentificationScoredEvent): data = event.data - if data.request_id in processed_request_ids: + if data.identification_id in processed_identification_ids: return Response(status_code=200) # already handled: acknowledge and stop - processed_request_ids.add(data.request_id) + processed_identification_ids.add(data.identification_id) # Answer fast (under 1 s): a slow or failed delivery is not sent again, so queue slow # work instead of doing it here, and read the History API when you must not miss one. logger.info( - "identification.scored request_id=%s risk_score=%s band=%s flags=%s", - data.request_id, + "identification.scored identification_id=%s risk_score=%s band=%s flags=%s", + data.identification_id, data.risk_score, data.risk_band, ",".join(data.detection_flags.active()) or "none", diff --git a/generated/shieldlabs_generated/api/history_api_api.py b/generated/shieldlabs_generated/api/history_api_api.py index dbb26a9..c082b4a 100644 --- a/generated/shieldlabs_generated/api/history_api_api.py +++ b/generated/shieldlabs_generated/api/history_api_api.py @@ -43,9 +43,9 @@ def __init__(self, api_client=None) -> None: def search_history( self, search_type: Annotated[StrictStr, Field(description="Identifier to search by. Only these seven values are supported: - `request_id`: one identification (read a verdict); - `device_id`: every identification of one device; - `user_hid`: every identification of one account; - `visitor_id`: every identification of one visitor; - `ip`: every identification from one public IPv4 address; - `session_id`: every identification of one visit; - `cookie_id`: every identification with one browser cookie. The server does not reject other values: it ignores them and returns the latest identifications of the whole domain, so restrict the value on your side.")], - value: Annotated[str, Field(min_length=1, strict=True, description="Value of the identifier, validated on your side before sending: - `request_id`, `device_id`, `visitor_id`, `session_id`, `cookie_id`: a UUID of any version, the nil UUID included, matching `^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`. Send it lowercase. - `ip`: a dotted IPv4 address. IPv6 addresses cannot be searched. - `user_hid`: the exact, case-sensitive User HID as one path segment, encoded the way the server reads it: send the characters `A-Z a-z 0-9 - . _ ~ $ & + , : ; = @` unescaped and percent-encode every other byte of the UTF-8 value as uppercase `%XX`, including `! ' ( ) *`, spaces and `%` itself. The server compares any other encoding literally, so `%40` instead of `@`, or lowercase hex digits, return an empty page instead of the matching rows. Many HTTP clients and generated clients escape `$ & + , : ; = @` in path values: build this path yourself when yours does. A User HID that contains `/` cannot be searched, and most HTTP clients cannot send `.` or `..` because they remove them as dot segments; the pattern rejects these values. Hex-encoded hashes need no escaping at all. The server does not validate the value: a malformed UUID or IPv4 address gets a `500`.")], + value: Annotated[str, Field(min_length=1, strict=True, description="Value of the identifier, validated on your side before sending: - `identification_id`, `device_id`, `visitor_id`, `session_id`, `cookie_id`: a UUID of any version, the nil UUID included, matching `^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`. Send it lowercase. - `ip`: a dotted IPv4 address. IPv6 addresses cannot be searched. - `user_hid`: the exact, case-sensitive User HID as one path segment, encoded the way the server reads it: send the characters `A-Z a-z 0-9 - . _ ~ $ & + , : ; = @` unescaped and percent-encode every other byte of the UTF-8 value as uppercase `%XX`, including `! ' ( ) *`, spaces and `%` itself. The server compares any other encoding literally, so `%40` instead of `@`, or lowercase hex digits, return an empty page instead of the matching rows. Many HTTP clients and generated clients escape `$ & + , : ; = @` in path values: build this path yourself when yours does. A User HID that contains `/` cannot be searched, and most HTTP clients cannot send `.` or `..` because they remove them as dot segments; the pattern rejects these values. Hex-encoded hashes need no escaping at all. The server does not validate the value: a malformed UUID or IPv4 address gets a `500`.")], limit: Annotated[Optional[Annotated[int, Field(le=100, strict=True, ge=1)]], Field(description="Maximum number of identifications to return, from 1 to 100. The server replaces any other value (and a non-numeric one) with 20 instead of clamping it, so validate it on your side.")] = None, - offset: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Number of identifications to skip, for paging. The server treats negative or non-numeric values as 0. Rows are ordered by `created_at` only, so paging while new identifications arrive can repeat or skip rows: deduplicate on `request_id`.")] = None, + offset: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Number of identifications to skip, for paging. The server treats negative or non-numeric values as 0. Rows are ordered by `created_at` only, so paging while new identifications arrive can repeat or skip rows: deduplicate on `identification_id`.")] = None, _request_timeout: Union[ None, Annotated[StrictFloat, Field(gt=0)], @@ -128,9 +128,9 @@ def search_history( def search_history_with_http_info( self, search_type: Annotated[StrictStr, Field(description="Identifier to search by. Only these seven values are supported: - `request_id`: one identification (read a verdict); - `device_id`: every identification of one device; - `user_hid`: every identification of one account; - `visitor_id`: every identification of one visitor; - `ip`: every identification from one public IPv4 address; - `session_id`: every identification of one visit; - `cookie_id`: every identification with one browser cookie. The server does not reject other values: it ignores them and returns the latest identifications of the whole domain, so restrict the value on your side.")], - value: Annotated[str, Field(min_length=1, strict=True, description="Value of the identifier, validated on your side before sending: - `request_id`, `device_id`, `visitor_id`, `session_id`, `cookie_id`: a UUID of any version, the nil UUID included, matching `^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`. Send it lowercase. - `ip`: a dotted IPv4 address. IPv6 addresses cannot be searched. - `user_hid`: the exact, case-sensitive User HID as one path segment, encoded the way the server reads it: send the characters `A-Z a-z 0-9 - . _ ~ $ & + , : ; = @` unescaped and percent-encode every other byte of the UTF-8 value as uppercase `%XX`, including `! ' ( ) *`, spaces and `%` itself. The server compares any other encoding literally, so `%40` instead of `@`, or lowercase hex digits, return an empty page instead of the matching rows. Many HTTP clients and generated clients escape `$ & + , : ; = @` in path values: build this path yourself when yours does. A User HID that contains `/` cannot be searched, and most HTTP clients cannot send `.` or `..` because they remove them as dot segments; the pattern rejects these values. Hex-encoded hashes need no escaping at all. The server does not validate the value: a malformed UUID or IPv4 address gets a `500`.")], + value: Annotated[str, Field(min_length=1, strict=True, description="Value of the identifier, validated on your side before sending: - `identification_id`, `device_id`, `visitor_id`, `session_id`, `cookie_id`: a UUID of any version, the nil UUID included, matching `^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`. Send it lowercase. - `ip`: a dotted IPv4 address. IPv6 addresses cannot be searched. - `user_hid`: the exact, case-sensitive User HID as one path segment, encoded the way the server reads it: send the characters `A-Z a-z 0-9 - . _ ~ $ & + , : ; = @` unescaped and percent-encode every other byte of the UTF-8 value as uppercase `%XX`, including `! ' ( ) *`, spaces and `%` itself. The server compares any other encoding literally, so `%40` instead of `@`, or lowercase hex digits, return an empty page instead of the matching rows. Many HTTP clients and generated clients escape `$ & + , : ; = @` in path values: build this path yourself when yours does. A User HID that contains `/` cannot be searched, and most HTTP clients cannot send `.` or `..` because they remove them as dot segments; the pattern rejects these values. Hex-encoded hashes need no escaping at all. The server does not validate the value: a malformed UUID or IPv4 address gets a `500`.")], limit: Annotated[Optional[Annotated[int, Field(le=100, strict=True, ge=1)]], Field(description="Maximum number of identifications to return, from 1 to 100. The server replaces any other value (and a non-numeric one) with 20 instead of clamping it, so validate it on your side.")] = None, - offset: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Number of identifications to skip, for paging. The server treats negative or non-numeric values as 0. Rows are ordered by `created_at` only, so paging while new identifications arrive can repeat or skip rows: deduplicate on `request_id`.")] = None, + offset: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Number of identifications to skip, for paging. The server treats negative or non-numeric values as 0. Rows are ordered by `created_at` only, so paging while new identifications arrive can repeat or skip rows: deduplicate on `identification_id`.")] = None, _request_timeout: Union[ None, Annotated[StrictFloat, Field(gt=0)], @@ -213,9 +213,9 @@ def search_history_with_http_info( def search_history_without_preload_content( self, search_type: Annotated[StrictStr, Field(description="Identifier to search by. Only these seven values are supported: - `request_id`: one identification (read a verdict); - `device_id`: every identification of one device; - `user_hid`: every identification of one account; - `visitor_id`: every identification of one visitor; - `ip`: every identification from one public IPv4 address; - `session_id`: every identification of one visit; - `cookie_id`: every identification with one browser cookie. The server does not reject other values: it ignores them and returns the latest identifications of the whole domain, so restrict the value on your side.")], - value: Annotated[str, Field(min_length=1, strict=True, description="Value of the identifier, validated on your side before sending: - `request_id`, `device_id`, `visitor_id`, `session_id`, `cookie_id`: a UUID of any version, the nil UUID included, matching `^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`. Send it lowercase. - `ip`: a dotted IPv4 address. IPv6 addresses cannot be searched. - `user_hid`: the exact, case-sensitive User HID as one path segment, encoded the way the server reads it: send the characters `A-Z a-z 0-9 - . _ ~ $ & + , : ; = @` unescaped and percent-encode every other byte of the UTF-8 value as uppercase `%XX`, including `! ' ( ) *`, spaces and `%` itself. The server compares any other encoding literally, so `%40` instead of `@`, or lowercase hex digits, return an empty page instead of the matching rows. Many HTTP clients and generated clients escape `$ & + , : ; = @` in path values: build this path yourself when yours does. A User HID that contains `/` cannot be searched, and most HTTP clients cannot send `.` or `..` because they remove them as dot segments; the pattern rejects these values. Hex-encoded hashes need no escaping at all. The server does not validate the value: a malformed UUID or IPv4 address gets a `500`.")], + value: Annotated[str, Field(min_length=1, strict=True, description="Value of the identifier, validated on your side before sending: - `identification_id`, `device_id`, `visitor_id`, `session_id`, `cookie_id`: a UUID of any version, the nil UUID included, matching `^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`. Send it lowercase. - `ip`: a dotted IPv4 address. IPv6 addresses cannot be searched. - `user_hid`: the exact, case-sensitive User HID as one path segment, encoded the way the server reads it: send the characters `A-Z a-z 0-9 - . _ ~ $ & + , : ; = @` unescaped and percent-encode every other byte of the UTF-8 value as uppercase `%XX`, including `! ' ( ) *`, spaces and `%` itself. The server compares any other encoding literally, so `%40` instead of `@`, or lowercase hex digits, return an empty page instead of the matching rows. Many HTTP clients and generated clients escape `$ & + , : ; = @` in path values: build this path yourself when yours does. A User HID that contains `/` cannot be searched, and most HTTP clients cannot send `.` or `..` because they remove them as dot segments; the pattern rejects these values. Hex-encoded hashes need no escaping at all. The server does not validate the value: a malformed UUID or IPv4 address gets a `500`.")], limit: Annotated[Optional[Annotated[int, Field(le=100, strict=True, ge=1)]], Field(description="Maximum number of identifications to return, from 1 to 100. The server replaces any other value (and a non-numeric one) with 20 instead of clamping it, so validate it on your side.")] = None, - offset: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Number of identifications to skip, for paging. The server treats negative or non-numeric values as 0. Rows are ordered by `created_at` only, so paging while new identifications arrive can repeat or skip rows: deduplicate on `request_id`.")] = None, + offset: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Number of identifications to skip, for paging. The server treats negative or non-numeric values as 0. Rows are ordered by `created_at` only, so paging while new identifications arrive can repeat or skip rows: deduplicate on `identification_id`.")] = None, _request_timeout: Union[ None, Annotated[StrictFloat, Field(gt=0)], diff --git a/generated/shieldlabs_generated/models/history_row.py b/generated/shieldlabs_generated/models/history_row.py index 7312dd4..3797dc2 100644 --- a/generated/shieldlabs_generated/models/history_row.py +++ b/generated/shieldlabs_generated/models/history_row.py @@ -30,7 +30,7 @@ class HistoryRow(BaseModel): """ One identification as stored, in its latest version. It describes the same identification as a webhook `data` object, with different field names: | Webhook `data` | History row | |---|---| | `risk_score` | `score` | | `signals` | `score_details` (JSON-encoded string, zero weights included) | | `detection_flags` | the `is_*` columns and `check_incomplete` (each column names its flag) | | `detection_flags.browser_vpn_proxy` | derive it: `connection_type == \"browser_vpn_proxy\"` | | `domain` | `site_domain` when present, otherwise `domain` | | `public_ip` | `ip` (`0.0.0.0` instead of `\"\"`) and `country` | | `local_ip` | `webrtc_leak_ip` and `webrtc_leak_country` when `webrtc_leak_source` is set and not `none`, otherwise `web_rtc_ip` and `web_rtc_country` | | `traffic_source` | `traffic_channel`, `referrer_domain`, `entry_url`, `click_id_type`, `utm_*` (omitted when empty) | | `observed_at` (when scoring finished) | `created_at` (when the identification was made) | The `ip_mismatch` flag has no column. Rows also carry diagnostic network fields (TCP, MTU and STUN measurements) that are not part of the stable contract: ignore fields you do not know. """ # noqa: E501 - request_id: UUID = Field(description="Identifies one identification. The browser creates it as a UUID v4 and hands it to your page; it is the join key between the browser, the webhook and the History API. The nil UUID appears only on rate-limit marker rows that arrived with a malformed request ID.") + identification_id: UUID = Field(description="Identifies one identification. The browser creates it as a UUID v4 and hands it to your page; it is the join key between the browser, the webhook and the History API. The nil UUID appears only on rate-limit marker rows that arrived with a malformed request ID.") session_id: UUID = Field(description="One visit on one origin (UUID v4 created in the browser), shared by the open tabs of that origin. The next visit after the last tab closes gets a new session ID. The nil UUID appears on rate-limit marker rows.") cookie_id: UUID = Field(description="First-party browser identifier kept by the ShieldLabs agent (UUID v4). A missing or malformed value is stored as the nil UUID.") domain: StrictStr = Field(description="Host the identification came from. Can be a subdomain of your registered domain.") @@ -84,7 +84,7 @@ class HistoryRow(BaseModel): referrer_domain: Optional[StrictStr] = Field(default=None, description="Registrable domain of the referrer without `www.`; the crawler name (for example `GoogleBot`) for search-engine crawlers. Omitted when empty.") click_id_type: Optional[StrictStr] = Field(default=None, description="Ad click identifier type found in the landing URL. Omitted when empty.") additional_properties: Dict[str, Any] = {} - __properties: ClassVar[List[str]] = ["request_id", "session_id", "cookie_id", "domain", "site_domain", "user_hid", "device_id", "visitor_id", "ip", "os", "browser", "device_type", "country", "connection_type", "score", "score_details", "created_at", "ver", "web_rtc_ip", "web_rtc_country", "web_rtc_connection_type", "webrtc_leak_ip", "webrtc_leak_country", "webrtc_leak_connection_type", "webrtc_leak_source", "is_vpn", "is_tor", "is_proxy", "is_datacenter", "is_abuser", "is_privacy_relay", "is_stun_not_checked", "check_incomplete", "is_antidetect", "is_os_mismatch", "is_os_not_detected", "is_timezone_mismatch", "is_js_disabled", "is_browser_automation", "is_incognito", "is_search_bot", "is_suspicious_paid_click", "entry_url", "utm_source", "utm_medium", "utm_campaign", "utm_content", "utm_term", "traffic_channel", "traffic_channel_group", "traffic_reason", "referrer_domain", "click_id_type"] + __properties: ClassVar[List[str]] = ["identification_id", "session_id", "cookie_id", "domain", "site_domain", "user_hid", "device_id", "visitor_id", "ip", "os", "browser", "device_type", "country", "connection_type", "score", "score_details", "created_at", "ver", "web_rtc_ip", "web_rtc_country", "web_rtc_connection_type", "webrtc_leak_ip", "webrtc_leak_country", "webrtc_leak_connection_type", "webrtc_leak_source", "is_vpn", "is_tor", "is_proxy", "is_datacenter", "is_abuser", "is_privacy_relay", "is_stun_not_checked", "check_incomplete", "is_antidetect", "is_os_mismatch", "is_os_not_detected", "is_timezone_mismatch", "is_js_disabled", "is_browser_automation", "is_incognito", "is_search_bot", "is_suspicious_paid_click", "entry_url", "utm_source", "utm_medium", "utm_campaign", "utm_content", "utm_term", "traffic_channel", "traffic_channel_group", "traffic_reason", "referrer_domain", "click_id_type"] @field_validator('created_at') def created_at_validate_regular_expression(cls, value): @@ -154,7 +154,7 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: return cls.model_validate(obj) _obj = cls.model_validate({ - "request_id": obj.get("request_id"), + "identification_id": obj.get("identification_id"), "session_id": obj.get("session_id"), "cookie_id": obj.get("cookie_id"), "domain": obj.get("domain"), diff --git a/generated/shieldlabs_generated/models/identification_scored_data.py b/generated/shieldlabs_generated/models/identification_scored_data.py index bb78dd9..b769f98 100644 --- a/generated/shieldlabs_generated/models/identification_scored_data.py +++ b/generated/shieldlabs_generated/models/identification_scored_data.py @@ -35,7 +35,7 @@ class IdentificationScoredData(BaseModel): """ The scored identification. Every key is always present (no key is ever omitted); only `user_hid` can be `null`. """ # noqa: E501 - request_id: UUID = Field(description="Identifies one identification. The browser creates it as a UUID v4 and hands it to your page; it is the join key between the browser, the webhook and the History API. The nil UUID appears only on rate-limit marker rows that arrived with a malformed request ID.") + identification_id: UUID = Field(description="Identifies one identification. The browser creates it as a UUID v4 and hands it to your page; it is the join key between the browser, the webhook and the History API. The nil UUID appears only on rate-limit marker rows that arrived with a malformed request ID.") visitor_id: UUID = Field(description="Server-side visitor identifier (UUID v5). It is sticky to the device: a new cookie on a known device keeps the existing visitor ID, so clearing cookies usually does not change it. The nil UUID appears on identifications without usable device data, such as rate-limit marker rows.") device_id: UUID = Field(description="Server-side device identifier (UUID v5). It survives cleared cookies and private windows. The nil UUID `00000000-0000-0000-0000-000000000000` means that no usable device signals were collected (for example on rate-limit marker rows): never group identifications by it.") session_id: UUID = Field(description="One visit on one origin (UUID v4 created in the browser), shared by the open tabs of that origin. The next visit after the last tab closes gets a new session ID. The nil UUID appears on rate-limit marker rows.") @@ -53,7 +53,7 @@ class IdentificationScoredData(BaseModel): signals: List[Signal] = Field(description="Weighted risk signals behind `risk_score`, in scoring order. Can be empty. The rate-limit marker carries exactly one entry, `{\"name\":\"rate_limited\",\"weight\":999}`.") detection_flags: DetectionFlags observed_at: datetime = Field(description="When scoring finished and the event was built (not the page view time); identical to the envelope `created_at`. RFC 3339 in UTC with up to 9 fractional digits.") - __properties: ClassVar[List[str]] = ["request_id", "visitor_id", "device_id", "session_id", "cookie_id", "user_hid", "domain", "public_ip", "local_ip", "connection_type", "os", "browser", "device_type", "traffic_source", "risk_score", "signals", "detection_flags", "observed_at"] + __properties: ClassVar[List[str]] = ["identification_id", "visitor_id", "device_id", "session_id", "cookie_id", "user_hid", "domain", "public_ip", "local_ip", "connection_type", "os", "browser", "device_type", "traffic_source", "risk_score", "signals", "detection_flags", "observed_at"] @field_validator('observed_at') def observed_at_validate_regular_expression(cls, value): @@ -140,7 +140,7 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: return cls.model_validate(obj) _obj = cls.model_validate({ - "request_id": obj.get("request_id"), + "identification_id": obj.get("identification_id"), "visitor_id": obj.get("visitor_id"), "device_id": obj.get("device_id"), "session_id": obj.get("session_id"), diff --git a/generated/shieldlabs_generated/models/legacy_snapshot.py b/generated/shieldlabs_generated/models/legacy_snapshot.py index f694a2c..c6be89c 100644 --- a/generated/shieldlabs_generated/models/legacy_snapshot.py +++ b/generated/shieldlabs_generated/models/legacy_snapshot.py @@ -32,7 +32,7 @@ class LegacySnapshot(BaseModel): """ One identification as returned by the deprecated Management API history endpoint (PascalCase keys). Also carries diagnostic network fields that are not part of the stable contract. Use the History API row instead. """ # noqa: E501 - request_id: UUID = Field(description="Identifies one identification. The browser creates it as a UUID v4 and hands it to your page; it is the join key between the browser, the webhook and the History API. The nil UUID appears only on rate-limit marker rows that arrived with a malformed request ID.", alias="RequestID") + identification_id: UUID = Field(description="Identifies one identification. The browser creates it as a UUID v4 and hands it to your page; it is the join key between the browser, the webhook and the History API. The nil UUID appears only on rate-limit marker rows that arrived with a malformed request ID.", alias="IdentificationID") session_id: UUID = Field(description="One visit on one origin (UUID v4 created in the browser), shared by the open tabs of that origin. The next visit after the last tab closes gets a new session ID. The nil UUID appears on rate-limit marker rows.", alias="SessionID") cookie_id: UUID = Field(description="First-party browser identifier kept by the ShieldLabs agent (UUID v4). A missing or malformed value is stored as the nil UUID.", alias="CookieID") device_id: UUID = Field(description="Server-side device identifier (UUID v5). It survives cleared cookies and private windows. The nil UUID `00000000-0000-0000-0000-000000000000` means that no usable device signals were collected (for example on rate-limit marker rows): never group identifications by it.", alias="DeviceID") @@ -51,7 +51,7 @@ class LegacySnapshot(BaseModel): details: List[ScoreDetail] = Field(description="Every entry behind `Score`, informational entries with `Value` 0 included (unlike the History API, this is a parsed array, not a string).", alias="Details") last_request_time: datetime = Field(description="Time of the identification, RFC 3339 with fractional seconds.", alias="LastRequestTime") additional_properties: Dict[str, Any] = {} - __properties: ClassVar[List[str]] = ["RequestID", "SessionID", "CookieID", "DeviceID", "VisitorID", "IP", "ConnectionType", "WebRtcHIP", "WebRtcCountry", "WebRtcConnectionType", "OS", "Browser", "DeviceType", "Country", "UserHID", "Score", "Details", "LastRequestTime"] + __properties: ClassVar[List[str]] = ["IdentificationID", "SessionID", "CookieID", "DeviceID", "VisitorID", "IP", "ConnectionType", "WebRtcHIP", "WebRtcCountry", "WebRtcConnectionType", "OS", "Browser", "DeviceType", "Country", "UserHID", "Score", "Details", "LastRequestTime"] model_config = ConfigDict( validate_by_name=True, @@ -118,7 +118,7 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: return cls.model_validate(obj) _obj = cls.model_validate({ - "RequestID": obj.get("RequestID"), + "IdentificationID": obj.get("IdentificationID"), "SessionID": obj.get("SessionID"), "CookieID": obj.get("CookieID"), "DeviceID": obj.get("DeviceID"), diff --git a/resources/shieldlabs-api.yaml b/resources/shieldlabs-api.yaml index 2838959..a5c763e 100644 --- a/resources/shieldlabs-api.yaml +++ b/resources/shieldlabs-api.yaml @@ -25,7 +25,7 @@ info: History row appears about 1-3 seconds after the browser call and can be refined for up to about 10 seconds as follow-up checks finish. Start the identification when the user begins the protected action (for example when the signup form opens), then either poll the History API by - `request_id` with a short backoff or wait for the webhook. Let one identification authorize one + `identification_id` with a short backoff or wait for the webhook. Let one identification authorize one protected action: reject request IDs you have already used and identifications older than your freshness window. @@ -79,7 +79,7 @@ info: ## Identifiers - - `request_id`: one identification, created in the browser (UUID v4). It joins the browser + - `identification_id`: one identification, created in the browser (UUID v4). It joins the browser call, the webhook and the History row. - `session_id`: one visit on one origin (UUID v4). - `cookie_id`: first-party browser identifier kept by the agent (UUID v4). @@ -134,7 +134,7 @@ info: ShieldLabs sends one signed `POST` for each identification to every enabled endpoint of the domain. A delivery has a 1-second timeout and is not retried today; a later release adds retries that resend identical bytes. Answer 2xx within a second, process the event - asynchronously, make the handler idempotent on `data.request_id`, and use the History API for + asynchronously, make the handler idempotent on `data.identification_id`, and use the History API for guaranteed reads. A History row can be refined after its webhook was sent (its `ver` increases); the webhook is not sent again. See the `identification.scored` and `webhook.ping` entries for the signature algorithm. @@ -160,7 +160,7 @@ servers: description: Management API (every operation also declares its own server) tags: - name: History API - description: 'Read identifications by one identifier on `https://account.shieldlabs.ai` with the Private API Key. The canonical way to read a verdict: by `request_id` right after a protected action, or by `device_id`, `user_hid`, `visitor_id` or `ip` for account-level checks.' + description: 'Read identifications by one identifier on `https://account.shieldlabs.ai` with the Private API Key. The canonical way to read a verdict: by `identification_id` right after a protected action, or by `device_id`, `user_hid`, `visitor_id` or `ip` for account-level checks.' - name: Management API description: Domain profile on `https://api.shieldlabs.ai`, authenticated with the Secret Key and the `X-Shield-Domain` header. Also serves the deprecated history endpoint until 1 January 2027. - name: Health @@ -185,7 +185,7 @@ paths: with the total number of matches. The Private API Key selects the domain; identifications from its subdomains are included (`domain` holds the host, `site_domain` the registered domain). - **Read one verdict.** After a protected action, search by `request_id` with `limit=1`. The row + **Read one verdict.** After a protected action, search by `identification_id` with `limit=1`. The row appears about 1-3 seconds after the browser call and can be refined for up to about 10 seconds as follow-up network checks finish, so start the identification when the user begins the action (for example when the signup form opens), not when the form is submitted. An empty @@ -204,7 +204,7 @@ paths: **Paging.** Page with `offset` while it is below `total`. Rows are ordered by `created_at` only, so paging while new identifications arrive can repeat or skip rows: deduplicate on - `request_id`. + `identification_id`. **Latest state.** A row can be refined after the webhook was sent, for example when late network data re-scores it; its `ver` then increases. The History API always returns the latest @@ -246,7 +246,7 @@ paths: - lang: Shell label: curl source: | - curl "https://account.shieldlabs.ai/api/v1/history/request_id/a5b7c9d1-e3f5-4a7b-9c1d-3e5f7a9b1c3d?limit=1" \ + curl "https://account.shieldlabs.ai/api/v1/history/identification_id/a5b7c9d1-e3f5-4a7b-9c1d-3e5f7a9b1c3d?limit=1" \ -H "Authorization: Bearer $SHIELDLABS_API_KEY" /v1/profile: servers: @@ -485,7 +485,7 @@ webhooks: **Respond fast.** Answer any 2xx status within 1 second and process the event asynchronously; do not redirect. Today each identification is delivered once per endpoint, with no retries. A later release adds retries that resend identical bytes, so make your handler idempotent on - `data.request_id`. + `data.identification_id`. **Latest state.** The event is a snapshot taken when scoring finished. The History row can still be refined afterwards (its `ver` increases) and no second event is sent. Use the History @@ -573,8 +573,8 @@ components: in: path required: true description: |- - Identifier to search by. Only these seven values are supported: - - `request_id`: one identification (read a verdict); + Identifier to search by. These values are supported (`request_id` is a legacy alias of `identification_id`): + - `identification_id`: one identification (read a verdict); - `device_id`: every identification of one device; - `user_hid`: every identification of one account; - `visitor_id`: every identification of one visitor; @@ -587,7 +587,7 @@ components: schema: type: string enum: - - request_id + - identification_id - device_id - user_hid - visitor_id @@ -595,9 +595,9 @@ components: - session_id - cookie_id examples: - requestId: + identificationId: summary: Read one verdict - value: request_id + value: identification_id deviceId: summary: All identifications of one device value: device_id @@ -607,7 +607,7 @@ components: required: true description: |- Value of the identifier, validated on your side before sending: - - `request_id`, `device_id`, `visitor_id`, `session_id`, `cookie_id`: a UUID of any version, + - `identification_id`, `device_id`, `visitor_id`, `session_id`, `cookie_id`: a UUID of any version, the nil UUID included, matching `^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`. Send it lowercase. @@ -628,7 +628,7 @@ components: minLength: 1 pattern: ^(?:[^/.][^/]*|\.[^/.][^/]*|\.\.[^/]+)$ examples: - requestId: + identificationId: summary: A request ID value: a5b7c9d1-e3f5-4a7b-9c1d-3e5f7a9b1c3d ip: @@ -658,7 +658,7 @@ components: name: offset in: query required: false - description: 'Number of identifications to skip, for paging. The server treats negative or non-numeric values as 0. Rows are ordered by `created_at` only, so paging while new identifications arrive can repeat or skip rows: deduplicate on `request_id`.' + description: 'Number of identifications to skip, for paging. The server treats negative or non-numeric values as 0. Rows are ordered by `created_at` only, so paging while new identifications arrive can repeat or skip rows: deduplicate on `identification_id`.' schema: type: integer minimum: 0 @@ -691,7 +691,7 @@ components: schema: type: string enum: - - request_id + - identification_id - device_id - user_hid - visitor_id @@ -699,9 +699,9 @@ components: - session_id - cookie_id examples: - requestId: + identificationId: summary: Search by request ID - value: request_id + value: identification_id DeprecatedHistoryValue: name: value in: path @@ -711,7 +711,7 @@ components: type: string minLength: 1 examples: - requestId: + identificationId: summary: A request ID value: a5b7c9d1-e3f5-4a7b-9c1d-3e5f7a9b1c3d DeprecatedHistoryLimit: @@ -752,7 +752,7 @@ components: summary: Signature of the webhook.ping example value: sha256=ea2685733d254f7028fb031c4214583b0650de01e6c8c93131236024edd9fdd8 schemas: - RequestId: + IdentificationId: type: string format: uuid description: Identifies one identification. The browser creates it as a UUID v4 and hands it to your page; it is the join key between the browser, the webhook and the History API. The nil UUID appears only on rate-limit marker rows that arrived with a malformed request ID. @@ -994,7 +994,7 @@ components: The `ip_mismatch` flag has no column. Rows also carry diagnostic network fields (TCP, MTU and STUN measurements) that are not part of the stable contract: ignore fields you do not know. required: - - request_id + - identification_id - session_id - cookie_id - domain @@ -1035,8 +1035,8 @@ components: - is_incognito - is_search_bot properties: - request_id: - $ref: '#/components/schemas/RequestId' + identification_id: + $ref: '#/components/schemas/IdentificationId' session_id: $ref: '#/components/schemas/SessionId' cookie_id: @@ -1327,7 +1327,7 @@ components: additionalProperties: true description: One identification as returned by the deprecated Management API history endpoint (PascalCase keys). Also carries diagnostic network fields that are not part of the stable contract. Use the History API row instead. required: - - RequestID + - IdentificationID - SessionID - CookieID - DeviceID @@ -1346,8 +1346,8 @@ components: - Details - LastRequestTime properties: - RequestID: - $ref: '#/components/schemas/RequestId' + IdentificationID: + $ref: '#/components/schemas/IdentificationId' SessionID: $ref: '#/components/schemas/SessionId' CookieID: @@ -1657,7 +1657,7 @@ components: type: object description: The scored identification. Every key is always present (no key is ever omitted); only `user_hid` can be `null`. required: - - request_id + - identification_id - visitor_id - device_id - session_id @@ -1676,8 +1676,8 @@ components: - detection_flags - observed_at properties: - request_id: - $ref: '#/components/schemas/RequestId' + identification_id: + $ref: '#/components/schemas/IdentificationId' visitor_id: $ref: '#/components/schemas/VisitorId' device_id: @@ -1764,7 +1764,7 @@ components: description: 'One page of five identifications out of 37 matches: a dangerous paid click through a proxy with an anti-detect browser, a trusted anonymous visit, a VPN visit with a local network leak and a late network correction, a rate-limit marker (999) and a search-engine crawler.' value: data: - - request_id: a5b7c9d1-e3f5-4a7b-9c1d-3e5f7a9b1c3d + - identification_id: a5b7c9d1-e3f5-4a7b-9c1d-3e5f7a9b1c3d session_id: b6c8d0e2-f4a6-4b8c-8d0e-2f4a6b8c0d2e cookie_id: c7d9e1f3-a5b7-4c9d-ae1f-3a5b7c9d1e3f domain: shop.example.com @@ -1822,7 +1822,7 @@ components: traffic_reason: gclid_present click_id_type: gclid is_suspicious_paid_click: true - - request_id: 7c1e2f4a-3b6d-4e8f-9a0b-1c2d3e4f5a6b + - identification_id: 7c1e2f4a-3b6d-4e8f-9a0b-1c2d3e4f5a6b session_id: a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d cookie_id: f0e1d2c3-b4a5-4968-8776-655443322110 domain: example.com @@ -1871,7 +1871,7 @@ components: stun_request_seen: true is_scanner_stun_passed: false stun_flow_status: ok - - request_id: 9e8d7c6b-5a49-4382-9716-05f4e3d2c1b0 + - identification_id: 9e8d7c6b-5a49-4382-9716-05f4e3d2c1b0 session_id: b0c1d2e3-f4a5-4b6c-9d7e-8f9a0b1c2d3e cookie_id: c3d4e5f6-a7b8-4c9d-8e0f-a1b2c3d4e5f6 domain: example.com @@ -1925,7 +1925,7 @@ components: traffic_channel: Referral traffic_channel_group: Referral traffic_reason: external_referrer - - request_id: 1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d + - identification_id: 1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d session_id: 00000000-0000-0000-0000-000000000000 cookie_id: 00000000-0000-0000-0000-000000000000 domain: example.com @@ -1974,7 +1974,7 @@ components: stun_request_seen: false is_scanner_stun_passed: false stun_flow_status: '' - - request_id: 4f5e6d7c-8b9a-4c1d-9e2f-3a4b5c6d7e8f + - identification_id: 4f5e6d7c-8b9a-4c1d-9e2f-3a4b5c6d7e8f session_id: 5a6b7c8d-9e0f-4a1b-8c2d-3e4f5a6b7c8d cookie_id: 6b7c8d9e-0f1a-4b2c-9d3e-4f5a6b7c8d9e domain: example.com @@ -2030,7 +2030,7 @@ components: total: 37 HistoryPageEmpty: summary: Nothing matched - description: No identification matched. When searching by `request_id` right after a protected action, this means "not scored yet" (or an invalid request ID), never "clean". + description: No identification matched. When searching by `identification_id` right after a protected action, this means "not scored yet" (or an invalid request ID), never "clean". value: data: [] total: 0 @@ -2095,7 +2095,7 @@ components: summary: One identification (deprecated shape) description: The PascalCase array returned by the deprecated endpoint, for the same identification as the first History API example row. value: - - RequestID: a5b7c9d1-e3f5-4a7b-9c1d-3e5f7a9b1c3d + - IdentificationID: a5b7c9d1-e3f5-4a7b-9c1d-3e5f7a9b1c3d SessionID: b6c8d0e2-f4a6-4b8c-8d0e-2f4a6b8c0d2e CookieID: c7d9e1f3-a5b7-4c9d-ae1f-3a5b7c9d1e3f DeviceID: d8e0f2a4-b6c8-4d0e-bf2a-4b6c8d0e2f4a @@ -2159,7 +2159,7 @@ components: schema_version: '2026-06-01' created_at: '2026-09-30T12:34:57.482913041Z' data: - request_id: a5b7c9d1-e3f5-4a7b-9c1d-3e5f7a9b1c3d + identification_id: a5b7c9d1-e3f5-4a7b-9c1d-3e5f7a9b1c3d visitor_id: e9f1a3b5-c7d9-4e1f-8a3b-5c7d9e1f3a5b device_id: d8e0f2a4-b6c8-4d0e-bf2a-4b6c8d0e2f4a session_id: b6c8d0e2-f4a6-4b8c-8d0e-2f4a6b8c0d2e @@ -2223,7 +2223,7 @@ components: schema_version: '2026-06-01' created_at: '2026-09-30T13:10:00.5Z' data: - request_id: 1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d + identification_id: 1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d visitor_id: 00000000-0000-0000-0000-000000000000 device_id: 00000000-0000-0000-0000-000000000000 session_id: 00000000-0000-0000-0000-000000000000 @@ -2313,7 +2313,7 @@ components: public_ip: country: BY ip: 203.0.113.10 - request_id: 13f84f05-7c2a-4e9b-9f1d-2a6b8c0e4d11 + identification_id: 13f84f05-7c2a-4e9b-9f1d-2a6b8c0e4d11 risk_score: 30 session_id: 3a2b1c0d-9e8f-4a7b-8c6d-5e4f3a2b1c0d signals: diff --git a/src/shieldlabs/__init__.py b/src/shieldlabs/__init__.py index bf7876f..7c5fccb 100644 --- a/src/shieldlabs/__init__.py +++ b/src/shieldlabs/__init__.py @@ -8,7 +8,7 @@ from shieldlabs import ShieldLabs, evaluate_identification client = ShieldLabs(api_key="sec_your_private_key") - identification = client.identifications.get(request_id) + identification = client.identifications.get(identification_id) verdict = evaluate_identification(identification) """ diff --git a/src/shieldlabs/_client.py b/src/shieldlabs/_client.py index fded10a..2d69ae4 100644 --- a/src/shieldlabs/_client.py +++ b/src/shieldlabs/_client.py @@ -247,7 +247,7 @@ def search( """Read one page of identifications that match ``type`` = ``value``, newest first. Args: - type: ``ip``, ``user_hid``, ``visitor_id``, ``request_id``, ``device_id``, + type: ``ip``, ``user_hid``, ``visitor_id``, ``identification_id``, ``device_id``, ``session_id`` or ``cookie_id``. value: A UUID for the ID types, a dotted IPv4 address for ``ip``, a non-empty string for ``user_hid`` (matched exactly; ``.``, ``..`` and values that contain @@ -270,7 +270,7 @@ def iter( ) -> Iterator[Identification]: """Iterate over every identification that matches, newest first, page by page. - Rows are de-duplicated on ``request_id`` (offset paging can repeat a row while new + Rows are de-duplicated on ``identification_id`` (offset paging can repeat a row while new identifications arrive). Iteration stops at ``total``, at an empty page, or after ``max_items`` identifications. """ @@ -295,9 +295,9 @@ def _iterate( if not page.data: return for identification in page.data: - if identification.request_id in seen: + if identification.identification_id in seen: continue - seen.add(identification.request_id) + seen.add(identification.identification_id) yield identification count += 1 if max_items is not None and count >= max_items: @@ -315,12 +315,12 @@ def __init__(self, client: ShieldLabs) -> None: def get( self, - request_id: Union[str, UUID], + identification_id: Union[str, UUID], wait: bool = True, timeout: float = 10.0, poll_interval: float = 0.25, ) -> Optional[Identification]: - """Return the identification for ``request_id``, waiting for it to be scored. + """Return the identification for ``identification_id``, waiting for it to be scored. Scoring is asynchronous: the History row appears about 1-3 s after the browser call and can be refined for up to about 10 s while follow-up checks finish. This method @@ -346,18 +346,18 @@ def get( that as unverified, never as clean). Raises: - ValidationError: ``request_id`` is not a UUID (nothing is sent). + ValidationError: ``identification_id`` is not a UUID (nothing is sent). BadRequestError, AuthenticationError, NotFoundError: A 400, a 401 or 403, or a 404 answer; polling stops at once. RateLimitError, ServerError, APIConnectionError, APITimeoutError: The last poll failed with this error, or a 429 asked for a pause longer than the time left. """ - rid = validate_uuid(request_id, "request_id") + rid = validate_uuid(identification_id, "identification_id") total_timeout = validate_seconds(timeout, "timeout", allow_zero=True) initial_wait = validate_seconds(poll_interval, "poll_interval", allow_zero=False) client = self._client if not wait: - page = client._search("request_id", rid, 1, 0) + page = client._search("identification_id", rid, 1, 0) return page.data[0] if page.data else None transport = client._transport @@ -366,7 +366,7 @@ def get( error: Optional[ShieldLabsError] = None try: page = client._search( - "request_id", rid, 1, 0, timeout=plan.attempt_timeout(), max_retries=0 + "identification_id", rid, 1, 0, timeout=plan.attempt_timeout(), max_retries=0 ) except _TRANSIENT_ERRORS as exc: error = exc @@ -487,9 +487,9 @@ async def _iterate( if not page.data: return for identification in page.data: - if identification.request_id in seen: + if identification.identification_id in seen: continue - seen.add(identification.request_id) + seen.add(identification.identification_id) yield identification count += 1 if max_items is not None and count >= max_items: @@ -507,18 +507,18 @@ def __init__(self, client: AsyncShieldLabs) -> None: async def get( self, - request_id: Union[str, UUID], + identification_id: Union[str, UUID], wait: bool = True, timeout: float = 10.0, poll_interval: float = 0.25, ) -> Optional[Identification]: """Async ``ShieldLabs.identifications.get``: same waiting rules, errors and result.""" - rid = validate_uuid(request_id, "request_id") + rid = validate_uuid(identification_id, "identification_id") total_timeout = validate_seconds(timeout, "timeout", allow_zero=True) initial_wait = validate_seconds(poll_interval, "poll_interval", allow_zero=False) client = self._client if not wait: - page = await client._search("request_id", rid, 1, 0) + page = await client._search("identification_id", rid, 1, 0) return page.data[0] if page.data else None transport = client._transport @@ -527,7 +527,7 @@ async def get( error: Optional[ShieldLabsError] = None try: page = await client._search( - "request_id", rid, 1, 0, timeout=plan.attempt_timeout(), max_retries=0 + "identification_id", rid, 1, 0, timeout=plan.attempt_timeout(), max_retries=0 ) except _TRANSIENT_ERRORS as exc: error = exc diff --git a/src/shieldlabs/_helpers.py b/src/shieldlabs/_helpers.py index c5e9bc8..b702cc4 100644 --- a/src/shieldlabs/_helpers.py +++ b/src/shieldlabs/_helpers.py @@ -82,7 +82,7 @@ def evaluate_identification( Checks run in this order and the first failure wins: 1. ``missing``: no identification (unverified, never clean). - 2. ``replayed``: ``is_replay(request_id)`` returned ``True``. The SDK stores nothing; keep + 2. ``replayed``: ``is_replay(identification_id)`` returned ``True``. The SDK stores nothing; keep used request IDs in your own store. 3. ``stale``: ``observed_at`` is older than ``max_age`` (a finite number of seconds, 0 or greater, or a timedelta). @@ -104,7 +104,7 @@ def evaluate_identification( if identification is None: return Evaluation(ok=False, reason="missing", band=None) band = risk_band(identification.risk_score) - if is_replay is not None and is_replay(identification.request_id): + if is_replay is not None and is_replay(identification.identification_id): return Evaluation(ok=False, reason="replayed", band=band) current = now if now is not None else datetime.now(timezone.utc) diff --git a/src/shieldlabs/_models.py b/src/shieldlabs/_models.py index 170eee7..9abf521 100644 --- a/src/shieldlabs/_models.py +++ b/src/shieldlabs/_models.py @@ -52,6 +52,12 @@ def _optional_user_hid(value: object) -> Optional[str]: return value if isinstance(value, str) and value != "" else None +def _identification_id(data: Mapping[str, Any]) -> str: + """Prefer identification_id. Fall back to the legacy request_id key.""" + value = as_str(data.get("identification_id")) + return value or as_str(data.get("request_id")) + + class SignalName: """Known risk signal names (``Signal.name``). @@ -198,7 +204,7 @@ class Identification: webhook contract. """ - request_id: str + identification_id: str visitor_id: str device_id: str session_id: str @@ -249,7 +255,7 @@ def from_webhook_data(cls, data: Mapping[str, Any]) -> Identification: if isinstance(item, Mapping) ) return cls( - request_id=as_str(data.get("request_id")), + identification_id=_identification_id(data), visitor_id=as_str(data.get("visitor_id")), device_id=as_str(data.get("device_id")), session_id=as_str(data.get("session_id")), @@ -329,7 +335,7 @@ def from_history_row(cls, row: Mapping[str, Any]) -> Identification: flags[key] = bool(row.get(HISTORY_FLAG_MAP[key], False)) return cls( - request_id=as_str(row.get("request_id")), + identification_id=_identification_id(row), visitor_id=as_str(row.get("visitor_id")), device_id=as_str(row.get("device_id")), session_id=as_str(row.get("session_id")), @@ -372,7 +378,7 @@ def from_dict(cls, data: Mapping[str, Any]) -> Identification: observed_at = data.get("observed_at") source = data.get("source") return cls( - request_id=as_str(data.get("request_id")), + identification_id=_identification_id(data), visitor_id=as_str(data.get("visitor_id")), device_id=as_str(data.get("device_id")), session_id=as_str(data.get("session_id")), @@ -403,7 +409,7 @@ def from_dict(cls, data: Mapping[str, Any]) -> Identification: def to_dict(self) -> dict[str, Any]: """Plain JSON-ready dict (``observed_at`` as RFC 3339 UTC with milliseconds; no ``raw``).""" return { - "request_id": self.request_id, + "identification_id": self.identification_id, "visitor_id": self.visitor_id, "device_id": self.device_id, "session_id": self.session_id, diff --git a/src/shieldlabs/_validation.py b/src/shieldlabs/_validation.py index d53a4a3..acc6759 100644 --- a/src/shieldlabs/_validation.py +++ b/src/shieldlabs/_validation.py @@ -33,7 +33,7 @@ ] LookupType = Literal[ - "ip", "user_hid", "visitor_id", "request_id", "device_id", "session_id", "cookie_id" + "ip", "user_hid", "visitor_id", "identification_id", "request_id", "device_id", "session_id", "cookie_id" ] """The identifier a History API lookup searches by.""" @@ -41,6 +41,7 @@ "ip", "user_hid", "visitor_id", + "identification_id", "request_id", "device_id", "session_id", @@ -50,7 +51,7 @@ DEFAULT_HISTORY_BASE_URL = "https://account.shieldlabs.ai" DEFAULT_MANAGEMENT_BASE_URL = "https://api.shieldlabs.ai" -_UUID_TYPES = frozenset({"visitor_id", "request_id", "device_id", "session_id", "cookie_id"}) +_UUID_TYPES = frozenset({"visitor_id", "identification_id", "request_id", "device_id", "session_id", "cookie_id"}) _UUID_PATTERN = re.compile( r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}" ) @@ -74,7 +75,7 @@ def _text(value: Union[str, UUID], name: str) -> str: return value -def validate_uuid(value: Union[str, UUID], name: str = "request_id") -> str: +def validate_uuid(value: Union[str, UUID], name: str = "identification_id") -> str: """Return the UUID in lowercase, or raise ``ValidationError``. Any version, nil allowed.""" text = _text(value, name) if not _UUID_PATTERN.fullmatch(text): diff --git a/tests/_support.py b/tests/_support.py index dd66bd9..8ca9bfb 100644 --- a/tests/_support.py +++ b/tests/_support.py @@ -20,7 +20,7 @@ HISTORY_HOST = "account.shieldlabs.ai" MANAGEMENT_HOST = "api.shieldlabs.ai" REQUEST_ID = "02f1d973-84db-4156-a7f7-e799e6bf389b" -REQUEST_PATH = f"/api/v1/history/request_id/{REQUEST_ID}" +REQUEST_PATH = f"/api/v1/history/identification_id/{REQUEST_ID}" def load_json(name: str) -> Any: @@ -39,10 +39,10 @@ def history_body(*rows: dict[str, Any], total: Union[int, None] = None) -> dict[ return {"data": list(rows), "total": len(rows) if total is None else total} -def row(request_id: str, **extra: Any) -> dict[str, Any]: +def row(identification_id: str, **extra: Any) -> dict[str, Any]: """A minimal History row for paging tests.""" base: dict[str, Any] = { - "request_id": request_id, + "identification_id": identification_id, "device_id": "ac7c303d-971b-41d1-8e25-cd5b46b46aed", "score": 10, "created_at": "2026-09-30 12:00:00.000", diff --git a/tests/data/history-page.json b/tests/data/history-page.json index 047866b..c67e987 100644 --- a/tests/data/history-page.json +++ b/tests/data/history-page.json @@ -1,7 +1,7 @@ { "data": [ { - "request_id": "02f1d973-84db-4156-a7f7-e799e6bf389b", + "identification_id": "02f1d973-84db-4156-a7f7-e799e6bf389b", "session_id": "bde78778-efd2-4c49-952f-1f11b9c05f35", "cookie_id": "4449bb58-590c-444c-ae1f-d1ddc768dbdd", "domain": "shop.example.com", @@ -61,7 +61,7 @@ "is_suspicious_paid_click": true }, { - "request_id": "7c1e2f4a-3b6d-4e8f-9a0b-1c2d3e4f5a6b", + "identification_id": "7c1e2f4a-3b6d-4e8f-9a0b-1c2d3e4f5a6b", "session_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", "cookie_id": "f0e1d2c3-b4a5-4968-8776-655443322110", "domain": "example.com", @@ -112,7 +112,7 @@ "stun_flow_status": "ok" }, { - "request_id": "9e8d7c6b-5a49-4382-9716-05f4e3d2c1b0", + "identification_id": "9e8d7c6b-5a49-4382-9716-05f4e3d2c1b0", "session_id": "b0c1d2e3-f4a5-4b6c-9d7e-8f9a0b1c2d3e", "cookie_id": "c3d4e5f6-a7b8-4c9d-8e0f-a1b2c3d4e5f6", "domain": "example.com", @@ -168,7 +168,7 @@ "traffic_reason": "external_referrer" }, { - "request_id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", + "identification_id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "session_id": "00000000-0000-0000-0000-000000000000", "cookie_id": "00000000-0000-0000-0000-000000000000", "domain": "example.com", @@ -219,7 +219,7 @@ "stun_flow_status": "" }, { - "request_id": "4f5e6d7c-8b9a-4c1d-9e2f-3a4b5c6d7e8f", + "identification_id": "4f5e6d7c-8b9a-4c1d-9e2f-3a4b5c6d7e8f", "session_id": "5a6b7c8d-9e0f-4a1b-8c2d-3e4f5a6b7c8d", "cookie_id": "6b7c8d9e-0f1a-4b2c-9d3e-4f5a6b7c8d9e", "domain": "example.com", diff --git a/tests/data/normalization-cases.json b/tests/data/normalization-cases.json index 1449e9c..d311d47 100644 --- a/tests/data/normalization-cases.json +++ b/tests/data/normalization-cases.json @@ -5,7 +5,7 @@ "name": "history_02f1d973", "source": "history", "input": { - "request_id": "02f1d973-84db-4156-a7f7-e799e6bf389b", + "identification_id": "02f1d973-84db-4156-a7f7-e799e6bf389b", "session_id": "bde78778-efd2-4c49-952f-1f11b9c05f35", "cookie_id": "4449bb58-590c-444c-ae1f-d1ddc768dbdd", "domain": "shop.example.com", @@ -65,7 +65,7 @@ "is_suspicious_paid_click": true }, "expected": { - "request_id": "02f1d973-84db-4156-a7f7-e799e6bf389b", + "identification_id": "02f1d973-84db-4156-a7f7-e799e6bf389b", "visitor_id": "bde0e249-20d8-4544-838c-ed9a0b6d7a36", "device_id": "ac7c303d-971b-41d1-8e25-cd5b46b46aed", "session_id": "bde78778-efd2-4c49-952f-1f11b9c05f35", @@ -143,7 +143,7 @@ "name": "history_7c1e2f4a", "source": "history", "input": { - "request_id": "7c1e2f4a-3b6d-4e8f-9a0b-1c2d3e4f5a6b", + "identification_id": "7c1e2f4a-3b6d-4e8f-9a0b-1c2d3e4f5a6b", "session_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", "cookie_id": "f0e1d2c3-b4a5-4968-8776-655443322110", "domain": "example.com", @@ -194,7 +194,7 @@ "stun_flow_status": "ok" }, "expected": { - "request_id": "7c1e2f4a-3b6d-4e8f-9a0b-1c2d3e4f5a6b", + "identification_id": "7c1e2f4a-3b6d-4e8f-9a0b-1c2d3e4f5a6b", "visitor_id": "3c4d5e6f-7a8b-5c9d-8e0f-1a2b3c4d5e6f", "device_id": "5d9a1f3e-7b2c-5e4d-8f6a-9b0c1d2e3f4a", "session_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", @@ -262,7 +262,7 @@ "name": "history_9e8d7c6b", "source": "history", "input": { - "request_id": "9e8d7c6b-5a49-4382-9716-05f4e3d2c1b0", + "identification_id": "9e8d7c6b-5a49-4382-9716-05f4e3d2c1b0", "session_id": "b0c1d2e3-f4a5-4b6c-9d7e-8f9a0b1c2d3e", "cookie_id": "c3d4e5f6-a7b8-4c9d-8e0f-a1b2c3d4e5f6", "domain": "example.com", @@ -318,7 +318,7 @@ "traffic_reason": "external_referrer" }, "expected": { - "request_id": "9e8d7c6b-5a49-4382-9716-05f4e3d2c1b0", + "identification_id": "9e8d7c6b-5a49-4382-9716-05f4e3d2c1b0", "visitor_id": "d2e3f4a5-b6c7-5d8e-9f0a-1b2c3d4e5f6a", "device_id": "e1f2a3b4-c5d6-5e7f-8a9b-0c1d2e3f4a5b", "session_id": "b0c1d2e3-f4a5-4b6c-9d7e-8f9a0b1c2d3e", @@ -401,7 +401,7 @@ "name": "history_1a2b3c4d", "source": "history", "input": { - "request_id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", + "identification_id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "session_id": "00000000-0000-0000-0000-000000000000", "cookie_id": "00000000-0000-0000-0000-000000000000", "domain": "example.com", @@ -452,7 +452,7 @@ "stun_flow_status": "" }, "expected": { - "request_id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", + "identification_id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "visitor_id": "00000000-0000-0000-0000-000000000000", "device_id": "00000000-0000-0000-0000-000000000000", "session_id": "00000000-0000-0000-0000-000000000000", @@ -520,7 +520,7 @@ "name": "history_4f5e6d7c", "source": "history", "input": { - "request_id": "4f5e6d7c-8b9a-4c1d-9e2f-3a4b5c6d7e8f", + "identification_id": "4f5e6d7c-8b9a-4c1d-9e2f-3a4b5c6d7e8f", "session_id": "5a6b7c8d-9e0f-4a1b-8c2d-3e4f5a6b7c8d", "cookie_id": "6b7c8d9e-0f1a-4b2c-9d3e-4f5a6b7c8d9e", "domain": "example.com", @@ -575,7 +575,7 @@ "traffic_reason": "ip_crawler_detected" }, "expected": { - "request_id": "4f5e6d7c-8b9a-4c1d-9e2f-3a4b5c6d7e8f", + "identification_id": "4f5e6d7c-8b9a-4c1d-9e2f-3a4b5c6d7e8f", "visitor_id": "8d9e0f1a-2b3c-5d4e-9f5a-6b7c8d9e0f1a", "device_id": "7c8d9e0f-1a2b-5c3d-8e4f-5a6b7c8d9e0f", "session_id": "5a6b7c8d-9e0f-4a1b-8c2d-3e4f5a6b7c8d", @@ -637,7 +637,7 @@ "name": "webhook_scored", "source": "webhook", "input": { - "request_id": "02f1d973-84db-4156-a7f7-e799e6bf389b", + "identification_id": "02f1d973-84db-4156-a7f7-e799e6bf389b", "visitor_id": "bde0e249-20d8-4544-838c-ed9a0b6d7a36", "device_id": "ac7c303d-971b-41d1-8e25-cd5b46b46aed", "session_id": "bde78778-efd2-4c49-952f-1f11b9c05f35", @@ -707,7 +707,7 @@ "observed_at": "2026-09-30T12:34:57.482913041Z" }, "expected": { - "request_id": "02f1d973-84db-4156-a7f7-e799e6bf389b", + "identification_id": "02f1d973-84db-4156-a7f7-e799e6bf389b", "visitor_id": "bde0e249-20d8-4544-838c-ed9a0b6d7a36", "device_id": "ac7c303d-971b-41d1-8e25-cd5b46b46aed", "session_id": "bde78778-efd2-4c49-952f-1f11b9c05f35", @@ -785,7 +785,7 @@ "name": "webhook_rate_limited", "source": "webhook", "input": { - "request_id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", + "identification_id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "visitor_id": "00000000-0000-0000-0000-000000000000", "device_id": "00000000-0000-0000-0000-000000000000", "session_id": "00000000-0000-0000-0000-000000000000", @@ -847,7 +847,7 @@ "observed_at": "2026-09-30T13:10:00.5Z" }, "expected": { - "request_id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", + "identification_id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "visitor_id": "00000000-0000-0000-0000-000000000000", "device_id": "00000000-0000-0000-0000-000000000000", "session_id": "00000000-0000-0000-0000-000000000000", @@ -953,7 +953,7 @@ "country": "BY", "ip": "203.0.113.10" }, - "request_id": "13f84f05-7c2a-4e9b-9f1d-2a6b8c0e4d11", + "identification_id": "13f84f05-7c2a-4e9b-9f1d-2a6b8c0e4d11", "risk_score": 30, "session_id": "3a2b1c0d-9e8f-4a7b-8c6d-5e4f3a2b1c0d", "signals": [ @@ -985,7 +985,7 @@ "visitor_id": "7a6b5c4d-3e2f-5a1b-9c8d-7e6f5a4b3c2d" }, "expected": { - "request_id": "13f84f05-7c2a-4e9b-9f1d-2a6b8c0e4d11", + "identification_id": "13f84f05-7c2a-4e9b-9f1d-2a6b8c0e4d11", "visitor_id": "7a6b5c4d-3e2f-5a1b-9c8d-7e6f5a4b3c2d", "device_id": "6f1e2d3c-4b5a-5968-8776-655443322110", "session_id": "3a2b1c0d-9e8f-4a7b-8c6d-5e4f3a2b1c0d", diff --git a/tests/data/webhook-identification-scored.json b/tests/data/webhook-identification-scored.json index b42704a..fd19039 100644 --- a/tests/data/webhook-identification-scored.json +++ b/tests/data/webhook-identification-scored.json @@ -3,7 +3,7 @@ "schema_version": "2026-10-13", "created_at": "2026-09-30T12:34:57.482913041Z", "data": { - "request_id": "02f1d973-84db-4156-a7f7-e799e6bf389b", + "identification_id": "02f1d973-84db-4156-a7f7-e799e6bf389b", "visitor_id": "bde0e249-20d8-4544-838c-ed9a0b6d7a36", "device_id": "ac7c303d-971b-41d1-8e25-cd5b46b46aed", "session_id": "bde78778-efd2-4c49-952f-1f11b9c05f35", diff --git a/tests/data/webhook-identification-scored.raw.txt b/tests/data/webhook-identification-scored.raw.txt index 77d7650..3fd1706 100644 --- a/tests/data/webhook-identification-scored.raw.txt +++ b/tests/data/webhook-identification-scored.raw.txt @@ -1 +1 @@ -{"event_type":"identification.scored","schema_version":"2026-10-13","created_at":"2026-09-30T12:34:57.482913041Z","data":{"request_id":"02f1d973-84db-4156-a7f7-e799e6bf389b","visitor_id":"bde0e249-20d8-4544-838c-ed9a0b6d7a36","device_id":"ac7c303d-971b-41d1-8e25-cd5b46b46aed","session_id":"bde78778-efd2-4c49-952f-1f11b9c05f35","cookie_id":"4449bb58-590c-444c-ae1f-d1ddc768dbdd","user_hid":"9f86d081884c7d659a2feaa0c55ad015","domain":"example.com","public_ip":{"ip":"203.0.113.24","country":"Netherlands"},"local_ip":{"ip":"198.51.100.23","country":"Germany"},"connection_type":"proxy","os":"Windows","browser":"Chrome","device_type":"desktop","traffic_source":{"channel":"Google Ads","referrer_domain":"google.com","landing_url":"https://shop.example.com/signup?utm_source=google&utm_medium=cpc&gclid=abc123","click_id_type":"gclid","utm_source":"google","utm_medium":"cpc","utm_campaign":"","utm_content":"","utm_term":""},"risk_score":80,"signals":[{"name":"proxy","weight":10},{"name":"datacenter_ip","weight":10},{"name":"antidetect_browser","weight":60}],"detection_flags":{"vpn":false,"proxy":true,"privacy_relay":false,"check_incomplete":false,"stun_not_checked":false,"os_not_detected":false,"timezone_mismatch":false,"javascript_disabled":false,"os_mismatch":false,"ip_mismatch":true,"ws_tcp_latency":false,"anti_detect_browser":true,"device_spoofing":false,"rate_limited":false,"incognito":false,"search_bot":false,"ai_bot":false,"browser_automation":false,"ai_browser":false,"tor":false},"observed_at":"2026-09-30T12:34:57.482913041Z"}} \ No newline at end of file +{"event_type":"identification.scored","schema_version":"2026-10-13","created_at":"2026-09-30T12:34:57.482913041Z","data":{"identification_id":"02f1d973-84db-4156-a7f7-e799e6bf389b","visitor_id":"bde0e249-20d8-4544-838c-ed9a0b6d7a36","device_id":"ac7c303d-971b-41d1-8e25-cd5b46b46aed","session_id":"bde78778-efd2-4c49-952f-1f11b9c05f35","cookie_id":"4449bb58-590c-444c-ae1f-d1ddc768dbdd","user_hid":"9f86d081884c7d659a2feaa0c55ad015","domain":"example.com","public_ip":{"ip":"203.0.113.24","country":"Netherlands"},"local_ip":{"ip":"198.51.100.23","country":"Germany"},"connection_type":"proxy","os":"Windows","browser":"Chrome","device_type":"desktop","traffic_source":{"channel":"Google Ads","referrer_domain":"google.com","landing_url":"https://shop.example.com/signup?utm_source=google&utm_medium=cpc&gclid=abc123","click_id_type":"gclid","utm_source":"google","utm_medium":"cpc","utm_campaign":"","utm_content":"","utm_term":""},"risk_score":80,"signals":[{"name":"proxy","weight":10},{"name":"datacenter_ip","weight":10},{"name":"antidetect_browser","weight":60}],"detection_flags":{"vpn":false,"proxy":true,"privacy_relay":false,"check_incomplete":false,"stun_not_checked":false,"os_not_detected":false,"timezone_mismatch":false,"javascript_disabled":false,"os_mismatch":false,"ip_mismatch":true,"ws_tcp_latency":false,"anti_detect_browser":true,"device_spoofing":false,"rate_limited":false,"incognito":false,"search_bot":false,"ai_bot":false,"browser_automation":false,"ai_browser":false,"tor":false},"observed_at":"2026-09-30T12:34:57.482913041Z"}} \ No newline at end of file diff --git a/tests/data/webhook-rate-limited.json b/tests/data/webhook-rate-limited.json index c72c8de..a6b9410 100644 --- a/tests/data/webhook-rate-limited.json +++ b/tests/data/webhook-rate-limited.json @@ -3,7 +3,7 @@ "schema_version": "2026-10-13", "created_at": "2026-09-30T13:10:00.5Z", "data": { - "request_id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", + "identification_id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "visitor_id": "00000000-0000-0000-0000-000000000000", "device_id": "00000000-0000-0000-0000-000000000000", "session_id": "00000000-0000-0000-0000-000000000000", diff --git a/tests/data/webhook-signature-vectors.json b/tests/data/webhook-signature-vectors.json index 7529cf0..5195366 100644 --- a/tests/data/webhook-signature-vectors.json +++ b/tests/data/webhook-signature-vectors.json @@ -5,27 +5,27 @@ { "name": "valid_lowercase_hex", "secret": "whsec_test_4f1a9c2e7b5d3a8f6e0c1b2d9a7e5f3c", - "body": "{\"event_type\":\"identification.scored\",\"schema_version\":\"2026-06-01\",\"created_at\":\"2026-09-30T10:15:00Z\",\"data\":{\"request_id\":\"3f2b8c1e-9d4a-4e6b-8a7c-2d1e0f9b6a53\",\"risk_score\":35,\"user_hid\":null}}", - "body_base64": "eyJldmVudF90eXBlIjoiaWRlbnRpZmljYXRpb24uc2NvcmVkIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTA6MTU6MDBaIiwiZGF0YSI6eyJyZXF1ZXN0X2lkIjoiM2YyYjhjMWUtOWQ0YS00ZTZiLThhN2MtMmQxZTBmOWI2YTUzIiwicmlza19zY29yZSI6MzUsInVzZXJfaGlkIjpudWxsfX0=", - "signature_header": "sha256=1c2d0251d83eba5ed62c98d3758b805d227022850cdb8ddb1aa3754524345453", + "body": "{\"event_type\":\"identification.scored\",\"schema_version\":\"2026-06-01\",\"created_at\":\"2026-09-30T10:15:00Z\",\"data\":{\"identification_id\":\"3f2b8c1e-9d4a-4e6b-8a7c-2d1e0f9b6a53\",\"risk_score\":35,\"user_hid\":null}}", + "body_base64": "eyJldmVudF90eXBlIjoiaWRlbnRpZmljYXRpb24uc2NvcmVkIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTA6MTU6MDBaIiwiZGF0YSI6eyJpZGVudGlmaWNhdGlvbl9pZCI6IjNmMmI4YzFlLTlkNGEtNGU2Yi04YTdjLTJkMWUwZjliNmE1MyIsInJpc2tfc2NvcmUiOjM1LCJ1c2VyX2hpZCI6bnVsbH19", + "signature_header": "sha256=8129c91dd91ec75ba43cb4d0640c5af3b604564e077532a757ee4a1e77c4f4b0", "valid": true, "note": "Canonical header as sent by ShieldLabs" }, { "name": "valid_uppercase_hex", "secret": "whsec_test_4f1a9c2e7b5d3a8f6e0c1b2d9a7e5f3c", - "body": "{\"event_type\":\"identification.scored\",\"schema_version\":\"2026-06-01\",\"created_at\":\"2026-09-30T10:15:00Z\",\"data\":{\"request_id\":\"3f2b8c1e-9d4a-4e6b-8a7c-2d1e0f9b6a53\",\"risk_score\":35,\"user_hid\":null}}", - "body_base64": "eyJldmVudF90eXBlIjoiaWRlbnRpZmljYXRpb24uc2NvcmVkIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTA6MTU6MDBaIiwiZGF0YSI6eyJyZXF1ZXN0X2lkIjoiM2YyYjhjMWUtOWQ0YS00ZTZiLThhN2MtMmQxZTBmOWI2YTUzIiwicmlza19zY29yZSI6MzUsInVzZXJfaGlkIjpudWxsfX0=", - "signature_header": "sha256=1C2D0251D83EBA5ED62C98D3758B805D227022850CDB8DDB1AA3754524345453", + "body": "{\"event_type\":\"identification.scored\",\"schema_version\":\"2026-06-01\",\"created_at\":\"2026-09-30T10:15:00Z\",\"data\":{\"identification_id\":\"3f2b8c1e-9d4a-4e6b-8a7c-2d1e0f9b6a53\",\"risk_score\":35,\"user_hid\":null}}", + "body_base64": "eyJldmVudF90eXBlIjoiaWRlbnRpZmljYXRpb24uc2NvcmVkIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTA6MTU6MDBaIiwiZGF0YSI6eyJpZGVudGlmaWNhdGlvbl9pZCI6IjNmMmI4YzFlLTlkNGEtNGU2Yi04YTdjLTJkMWUwZjliNmE1MyIsInJpc2tfc2NvcmUiOjM1LCJ1c2VyX2hpZCI6bnVsbH19", + "signature_header": "sha256=8129C91DD91EC75BA43CB4D0640C5AF3B604564E077532A757EE4A1E77C4F4B0", "valid": true, "note": "Hex digest compared case-insensitively" }, { "name": "valid_surrounding_whitespace", "secret": "whsec_test_4f1a9c2e7b5d3a8f6e0c1b2d9a7e5f3c", - "body": "{\"event_type\":\"identification.scored\",\"schema_version\":\"2026-06-01\",\"created_at\":\"2026-09-30T10:15:00Z\",\"data\":{\"request_id\":\"3f2b8c1e-9d4a-4e6b-8a7c-2d1e0f9b6a53\",\"risk_score\":35,\"user_hid\":null}}", - "body_base64": "eyJldmVudF90eXBlIjoiaWRlbnRpZmljYXRpb24uc2NvcmVkIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTA6MTU6MDBaIiwiZGF0YSI6eyJyZXF1ZXN0X2lkIjoiM2YyYjhjMWUtOWQ0YS00ZTZiLThhN2MtMmQxZTBmOWI2YTUzIiwicmlza19zY29yZSI6MzUsInVzZXJfaGlkIjpudWxsfX0=", - "signature_header": " sha256=1c2d0251d83eba5ed62c98d3758b805d227022850cdb8ddb1aa3754524345453 ", + "body": "{\"event_type\":\"identification.scored\",\"schema_version\":\"2026-06-01\",\"created_at\":\"2026-09-30T10:15:00Z\",\"data\":{\"identification_id\":\"3f2b8c1e-9d4a-4e6b-8a7c-2d1e0f9b6a53\",\"risk_score\":35,\"user_hid\":null}}", + "body_base64": "eyJldmVudF90eXBlIjoiaWRlbnRpZmljYXRpb24uc2NvcmVkIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTA6MTU6MDBaIiwiZGF0YSI6eyJpZGVudGlmaWNhdGlvbl9pZCI6IjNmMmI4YzFlLTlkNGEtNGU2Yi04YTdjLTJkMWUwZjliNmE1MyIsInJpc2tfc2NvcmUiOjM1LCJ1c2VyX2hpZCI6bnVsbH19", + "signature_header": " sha256=8129c91dd91ec75ba43cb4d0640c5af3b604564e077532a757ee4a1e77c4f4b0 ", "valid": true, "note": "Leading/trailing whitespace is trimmed from the header value" }, @@ -41,71 +41,71 @@ { "name": "valid_pretty_printed_body", "secret": "whsec_test_4f1a9c2e7b5d3a8f6e0c1b2d9a7e5f3c", - "body": "{\n \"event_type\": \"identification.scored\",\n \"schema_version\": \"2026-06-01\",\n \"created_at\": \"2026-09-30T10:15:00Z\",\n \"data\": {\n \"request_id\": \"3f2b8c1e-9d4a-4e6b-8a7c-2d1e0f9b6a53\",\n \"risk_score\": 35,\n \"user_hid\": null\n }\n}", - "body_base64": "ewogICJldmVudF90eXBlIjogImlkZW50aWZpY2F0aW9uLnNjb3JlZCIsCiAgInNjaGVtYV92ZXJzaW9uIjogIjIwMjYtMDYtMDEiLAogICJjcmVhdGVkX2F0IjogIjIwMjYtMDktMzBUMTA6MTU6MDBaIiwKICAiZGF0YSI6IHsKICAgICJyZXF1ZXN0X2lkIjogIjNmMmI4YzFlLTlkNGEtNGU2Yi04YTdjLTJkMWUwZjliNmE1MyIsCiAgICAicmlza19zY29yZSI6IDM1LAogICAgInVzZXJfaGlkIjogbnVsbAogIH0KfQ==", - "signature_header": "sha256=263308293bcccb571464f60e0f8ef6076f9d23559b1658b6dc6f74ac3fdffd35", + "body": "{\n \"event_type\": \"identification.scored\",\n \"schema_version\": \"2026-06-01\",\n \"created_at\": \"2026-09-30T10:15:00Z\",\n \"data\": {\n \"identification_id\": \"3f2b8c1e-9d4a-4e6b-8a7c-2d1e0f9b6a53\",\n \"risk_score\": 35,\n \"user_hid\": null\n }\n}", + "body_base64": "ewogICJldmVudF90eXBlIjogImlkZW50aWZpY2F0aW9uLnNjb3JlZCIsCiAgInNjaGVtYV92ZXJzaW9uIjogIjIwMjYtMDYtMDEiLAogICJjcmVhdGVkX2F0IjogIjIwMjYtMDktMzBUMTA6MTU6MDBaIiwKICAiZGF0YSI6IHsKICAgICJpZGVudGlmaWNhdGlvbl9pZCI6ICIzZjJiOGMxZS05ZDRhLTRlNmItOGE3Yy0yZDFlMGY5YjZhNTMiLAogICAgInJpc2tfc2NvcmUiOiAzNSwKICAgICJ1c2VyX2hpZCI6IG51bGwKICB9Cn0=", + "signature_header": "sha256=6472e91002b86150f7d11f909347b16cac1eb0ae882aa68b35b1765fa5100dd5", "valid": true, "note": "Whatever bytes arrive are what was signed" }, { "name": "invalid_reserialized_body", "secret": "whsec_test_4f1a9c2e7b5d3a8f6e0c1b2d9a7e5f3c", - "body": "{\n \"event_type\": \"identification.scored\",\n \"schema_version\": \"2026-06-01\",\n \"created_at\": \"2026-09-30T10:15:00Z\",\n \"data\": {\n \"request_id\": \"3f2b8c1e-9d4a-4e6b-8a7c-2d1e0f9b6a53\",\n \"risk_score\": 35,\n \"user_hid\": null\n }\n}", - "body_base64": "ewogICJldmVudF90eXBlIjogImlkZW50aWZpY2F0aW9uLnNjb3JlZCIsCiAgInNjaGVtYV92ZXJzaW9uIjogIjIwMjYtMDYtMDEiLAogICJjcmVhdGVkX2F0IjogIjIwMjYtMDktMzBUMTA6MTU6MDBaIiwKICAiZGF0YSI6IHsKICAgICJyZXF1ZXN0X2lkIjogIjNmMmI4YzFlLTlkNGEtNGU2Yi04YTdjLTJkMWUwZjliNmE1MyIsCiAgICAicmlza19zY29yZSI6IDM1LAogICAgInVzZXJfaGlkIjogbnVsbAogIH0KfQ==", - "signature_header": "sha256=1c2d0251d83eba5ed62c98d3758b805d227022850cdb8ddb1aa3754524345453", + "body": "{\n \"event_type\": \"identification.scored\",\n \"schema_version\": \"2026-06-01\",\n \"created_at\": \"2026-09-30T10:15:00Z\",\n \"data\": {\n \"identification_id\": \"3f2b8c1e-9d4a-4e6b-8a7c-2d1e0f9b6a53\",\n \"risk_score\": 35,\n \"user_hid\": null\n }\n}", + "body_base64": "ewogICJldmVudF90eXBlIjogImlkZW50aWZpY2F0aW9uLnNjb3JlZCIsCiAgInNjaGVtYV92ZXJzaW9uIjogIjIwMjYtMDYtMDEiLAogICJjcmVhdGVkX2F0IjogIjIwMjYtMDktMzBUMTA6MTU6MDBaIiwKICAiZGF0YSI6IHsKICAgICJpZGVudGlmaWNhdGlvbl9pZCI6ICIzZjJiOGMxZS05ZDRhLTRlNmItOGE3Yy0yZDFlMGY5YjZhNTMiLAogICAgInJpc2tfc2NvcmUiOiAzNSwKICAgICJ1c2VyX2hpZCI6IG51bGwKICB9Cn0=", + "signature_header": "sha256=8129c91dd91ec75ba43cb4d0640c5af3b604564e077532a757ee4a1e77c4f4b0", "valid": false, "note": "Signature of the compact body does not match a re-serialized body: always verify raw bytes" }, { "name": "invalid_tampered_body", "secret": "whsec_test_4f1a9c2e7b5d3a8f6e0c1b2d9a7e5f3c", - "body": "{\"event_type\":\"identification.scored\",\"schema_version\":\"2026-06-01\",\"created_at\":\"2026-09-30T10:15:00Z\",\"data\":{\"request_id\":\"3f2b8c1e-9d4a-4e6b-8a7c-2d1e0f9b6a53\",\"risk_score\":5,\"user_hid\":null}}", - "body_base64": "eyJldmVudF90eXBlIjoiaWRlbnRpZmljYXRpb24uc2NvcmVkIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTA6MTU6MDBaIiwiZGF0YSI6eyJyZXF1ZXN0X2lkIjoiM2YyYjhjMWUtOWQ0YS00ZTZiLThhN2MtMmQxZTBmOWI2YTUzIiwicmlza19zY29yZSI6NSwidXNlcl9oaWQiOm51bGx9fQ==", - "signature_header": "sha256=1c2d0251d83eba5ed62c98d3758b805d227022850cdb8ddb1aa3754524345453", + "body": "{\"event_type\":\"identification.scored\",\"schema_version\":\"2026-06-01\",\"created_at\":\"2026-09-30T10:15:00Z\",\"data\":{\"identification_id\":\"3f2b8c1e-9d4a-4e6b-8a7c-2d1e0f9b6a53\",\"risk_score\":5,\"user_hid\":null}}", + "body_base64": "eyJldmVudF90eXBlIjoiaWRlbnRpZmljYXRpb24uc2NvcmVkIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTA6MTU6MDBaIiwiZGF0YSI6eyJpZGVudGlmaWNhdGlvbl9pZCI6IjNmMmI4YzFlLTlkNGEtNGU2Yi04YTdjLTJkMWUwZjliNmE1MyIsInJpc2tfc2NvcmUiOjUsInVzZXJfaGlkIjpudWxsfX0=", + "signature_header": "sha256=8129c91dd91ec75ba43cb4d0640c5af3b604564e077532a757ee4a1e77c4f4b0", "valid": false, "note": "Body changed after signing" }, { "name": "invalid_wrong_secret", "secret": "whsec_test_0000000000000000000000000000000", - "body": "{\"event_type\":\"identification.scored\",\"schema_version\":\"2026-06-01\",\"created_at\":\"2026-09-30T10:15:00Z\",\"data\":{\"request_id\":\"3f2b8c1e-9d4a-4e6b-8a7c-2d1e0f9b6a53\",\"risk_score\":35,\"user_hid\":null}}", - "body_base64": "eyJldmVudF90eXBlIjoiaWRlbnRpZmljYXRpb24uc2NvcmVkIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTA6MTU6MDBaIiwiZGF0YSI6eyJyZXF1ZXN0X2lkIjoiM2YyYjhjMWUtOWQ0YS00ZTZiLThhN2MtMmQxZTBmOWI2YTUzIiwicmlza19zY29yZSI6MzUsInVzZXJfaGlkIjpudWxsfX0=", - "signature_header": "sha256=1c2d0251d83eba5ed62c98d3758b805d227022850cdb8ddb1aa3754524345453", + "body": "{\"event_type\":\"identification.scored\",\"schema_version\":\"2026-06-01\",\"created_at\":\"2026-09-30T10:15:00Z\",\"data\":{\"identification_id\":\"3f2b8c1e-9d4a-4e6b-8a7c-2d1e0f9b6a53\",\"risk_score\":35,\"user_hid\":null}}", + "body_base64": "eyJldmVudF90eXBlIjoiaWRlbnRpZmljYXRpb24uc2NvcmVkIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTA6MTU6MDBaIiwiZGF0YSI6eyJpZGVudGlmaWNhdGlvbl9pZCI6IjNmMmI4YzFlLTlkNGEtNGU2Yi04YTdjLTJkMWUwZjliNmE1MyIsInJpc2tfc2NvcmUiOjM1LCJ1c2VyX2hpZCI6bnVsbH19", + "signature_header": "sha256=8129c91dd91ec75ba43cb4d0640c5af3b604564e077532a757ee4a1e77c4f4b0", "valid": false, "note": "Different endpoint secret" }, { "name": "invalid_missing_prefix", "secret": "whsec_test_4f1a9c2e7b5d3a8f6e0c1b2d9a7e5f3c", - "body": "{\"event_type\":\"identification.scored\",\"schema_version\":\"2026-06-01\",\"created_at\":\"2026-09-30T10:15:00Z\",\"data\":{\"request_id\":\"3f2b8c1e-9d4a-4e6b-8a7c-2d1e0f9b6a53\",\"risk_score\":35,\"user_hid\":null}}", - "body_base64": "eyJldmVudF90eXBlIjoiaWRlbnRpZmljYXRpb24uc2NvcmVkIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTA6MTU6MDBaIiwiZGF0YSI6eyJyZXF1ZXN0X2lkIjoiM2YyYjhjMWUtOWQ0YS00ZTZiLThhN2MtMmQxZTBmOWI2YTUzIiwicmlza19zY29yZSI6MzUsInVzZXJfaGlkIjpudWxsfX0=", - "signature_header": "1c2d0251d83eba5ed62c98d3758b805d227022850cdb8ddb1aa3754524345453", + "body": "{\"event_type\":\"identification.scored\",\"schema_version\":\"2026-06-01\",\"created_at\":\"2026-09-30T10:15:00Z\",\"data\":{\"identification_id\":\"3f2b8c1e-9d4a-4e6b-8a7c-2d1e0f9b6a53\",\"risk_score\":35,\"user_hid\":null}}", + "body_base64": "eyJldmVudF90eXBlIjoiaWRlbnRpZmljYXRpb24uc2NvcmVkIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTA6MTU6MDBaIiwiZGF0YSI6eyJpZGVudGlmaWNhdGlvbl9pZCI6IjNmMmI4YzFlLTlkNGEtNGU2Yi04YTdjLTJkMWUwZjliNmE1MyIsInJpc2tfc2NvcmUiOjM1LCJ1c2VyX2hpZCI6bnVsbH19", + "signature_header": "8129c91dd91ec75ba43cb4d0640c5af3b604564e077532a757ee4a1e77c4f4b0", "valid": false, "note": "The sha256= prefix is required" }, { "name": "invalid_wrong_algorithm_prefix", "secret": "whsec_test_4f1a9c2e7b5d3a8f6e0c1b2d9a7e5f3c", - "body": "{\"event_type\":\"identification.scored\",\"schema_version\":\"2026-06-01\",\"created_at\":\"2026-09-30T10:15:00Z\",\"data\":{\"request_id\":\"3f2b8c1e-9d4a-4e6b-8a7c-2d1e0f9b6a53\",\"risk_score\":35,\"user_hid\":null}}", - "body_base64": "eyJldmVudF90eXBlIjoiaWRlbnRpZmljYXRpb24uc2NvcmVkIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTA6MTU6MDBaIiwiZGF0YSI6eyJyZXF1ZXN0X2lkIjoiM2YyYjhjMWUtOWQ0YS00ZTZiLThhN2MtMmQxZTBmOWI2YTUzIiwicmlza19zY29yZSI6MzUsInVzZXJfaGlkIjpudWxsfX0=", - "signature_header": "sha1=1c2d0251d83eba5ed62c98d3758b805d227022850cdb8ddb1aa3754524345453", + "body": "{\"event_type\":\"identification.scored\",\"schema_version\":\"2026-06-01\",\"created_at\":\"2026-09-30T10:15:00Z\",\"data\":{\"identification_id\":\"3f2b8c1e-9d4a-4e6b-8a7c-2d1e0f9b6a53\",\"risk_score\":35,\"user_hid\":null}}", + "body_base64": "eyJldmVudF90eXBlIjoiaWRlbnRpZmljYXRpb24uc2NvcmVkIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTA6MTU6MDBaIiwiZGF0YSI6eyJpZGVudGlmaWNhdGlvbl9pZCI6IjNmMmI4YzFlLTlkNGEtNGU2Yi04YTdjLTJkMWUwZjliNmE1MyIsInJpc2tfc2NvcmUiOjM1LCJ1c2VyX2hpZCI6bnVsbH19", + "signature_header": "sha1=8129c91dd91ec75ba43cb4d0640c5af3b604564e077532a757ee4a1e77c4f4b0", "valid": false, "note": "Only sha256 is accepted" }, { "name": "invalid_truncated_digest", "secret": "whsec_test_4f1a9c2e7b5d3a8f6e0c1b2d9a7e5f3c", - "body": "{\"event_type\":\"identification.scored\",\"schema_version\":\"2026-06-01\",\"created_at\":\"2026-09-30T10:15:00Z\",\"data\":{\"request_id\":\"3f2b8c1e-9d4a-4e6b-8a7c-2d1e0f9b6a53\",\"risk_score\":35,\"user_hid\":null}}", - "body_base64": "eyJldmVudF90eXBlIjoiaWRlbnRpZmljYXRpb24uc2NvcmVkIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTA6MTU6MDBaIiwiZGF0YSI6eyJyZXF1ZXN0X2lkIjoiM2YyYjhjMWUtOWQ0YS00ZTZiLThhN2MtMmQxZTBmOWI2YTUzIiwicmlza19zY29yZSI6MzUsInVzZXJfaGlkIjpudWxsfX0=", - "signature_header": "sha256=1c2d0251d83eba5ed62c98d3758b805d227022850cdb8ddb1aa37545243454", + "body": "{\"event_type\":\"identification.scored\",\"schema_version\":\"2026-06-01\",\"created_at\":\"2026-09-30T10:15:00Z\",\"data\":{\"identification_id\":\"3f2b8c1e-9d4a-4e6b-8a7c-2d1e0f9b6a53\",\"risk_score\":35,\"user_hid\":null}}", + "body_base64": "eyJldmVudF90eXBlIjoiaWRlbnRpZmljYXRpb24uc2NvcmVkIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTA6MTU6MDBaIiwiZGF0YSI6eyJpZGVudGlmaWNhdGlvbl9pZCI6IjNmMmI4YzFlLTlkNGEtNGU2Yi04YTdjLTJkMWUwZjliNmE1MyIsInJpc2tfc2NvcmUiOjM1LCJ1c2VyX2hpZCI6bnVsbH19", + "signature_header": "sha256=8129c91dd91ec75ba43cb4d0640c5af3b604564e077532a757ee4a1e77c4f4", "valid": false, "note": "Digest must be 64 hex characters" }, { "name": "invalid_non_hex_digest", "secret": "whsec_test_4f1a9c2e7b5d3a8f6e0c1b2d9a7e5f3c", - "body": "{\"event_type\":\"identification.scored\",\"schema_version\":\"2026-06-01\",\"created_at\":\"2026-09-30T10:15:00Z\",\"data\":{\"request_id\":\"3f2b8c1e-9d4a-4e6b-8a7c-2d1e0f9b6a53\",\"risk_score\":35,\"user_hid\":null}}", - "body_base64": "eyJldmVudF90eXBlIjoiaWRlbnRpZmljYXRpb24uc2NvcmVkIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTA6MTU6MDBaIiwiZGF0YSI6eyJyZXF1ZXN0X2lkIjoiM2YyYjhjMWUtOWQ0YS00ZTZiLThhN2MtMmQxZTBmOWI2YTUzIiwicmlza19zY29yZSI6MzUsInVzZXJfaGlkIjpudWxsfX0=", + "body": "{\"event_type\":\"identification.scored\",\"schema_version\":\"2026-06-01\",\"created_at\":\"2026-09-30T10:15:00Z\",\"data\":{\"identification_id\":\"3f2b8c1e-9d4a-4e6b-8a7c-2d1e0f9b6a53\",\"risk_score\":35,\"user_hid\":null}}", + "body_base64": "eyJldmVudF90eXBlIjoiaWRlbnRpZmljYXRpb24uc2NvcmVkIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTA6MTU6MDBaIiwiZGF0YSI6eyJpZGVudGlmaWNhdGlvbl9pZCI6IjNmMmI4YzFlLTlkNGEtNGU2Yi04YTdjLTJkMWUwZjliNmE1MyIsInJpc2tfc2NvcmUiOjM1LCJ1c2VyX2hpZCI6bnVsbH19", "signature_header": "sha256=zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", "valid": false, "note": "Digest must be hexadecimal" @@ -113,8 +113,8 @@ { "name": "invalid_empty_header", "secret": "whsec_test_4f1a9c2e7b5d3a8f6e0c1b2d9a7e5f3c", - "body": "{\"event_type\":\"identification.scored\",\"schema_version\":\"2026-06-01\",\"created_at\":\"2026-09-30T10:15:00Z\",\"data\":{\"request_id\":\"3f2b8c1e-9d4a-4e6b-8a7c-2d1e0f9b6a53\",\"risk_score\":35,\"user_hid\":null}}", - "body_base64": "eyJldmVudF90eXBlIjoiaWRlbnRpZmljYXRpb24uc2NvcmVkIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTA6MTU6MDBaIiwiZGF0YSI6eyJyZXF1ZXN0X2lkIjoiM2YyYjhjMWUtOWQ0YS00ZTZiLThhN2MtMmQxZTBmOWI2YTUzIiwicmlza19zY29yZSI6MzUsInVzZXJfaGlkIjpudWxsfX0=", + "body": "{\"event_type\":\"identification.scored\",\"schema_version\":\"2026-06-01\",\"created_at\":\"2026-09-30T10:15:00Z\",\"data\":{\"identification_id\":\"3f2b8c1e-9d4a-4e6b-8a7c-2d1e0f9b6a53\",\"risk_score\":35,\"user_hid\":null}}", + "body_base64": "eyJldmVudF90eXBlIjoiaWRlbnRpZmljYXRpb24uc2NvcmVkIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTA6MTU6MDBaIiwiZGF0YSI6eyJpZGVudGlmaWNhdGlvbl9pZCI6IjNmMmI4YzFlLTlkNGEtNGU2Yi04YTdjLTJkMWUwZjliNmE1MyIsInJpc2tfc2NvcmUiOjM1LCJ1c2VyX2hpZCI6bnVsbH19", "signature_header": "", "valid": false, "note": "Missing signature header" @@ -122,8 +122,8 @@ { "name": "invalid_prefix_only", "secret": "whsec_test_4f1a9c2e7b5d3a8f6e0c1b2d9a7e5f3c", - "body": "{\"event_type\":\"identification.scored\",\"schema_version\":\"2026-06-01\",\"created_at\":\"2026-09-30T10:15:00Z\",\"data\":{\"request_id\":\"3f2b8c1e-9d4a-4e6b-8a7c-2d1e0f9b6a53\",\"risk_score\":35,\"user_hid\":null}}", - "body_base64": "eyJldmVudF90eXBlIjoiaWRlbnRpZmljYXRpb24uc2NvcmVkIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTA6MTU6MDBaIiwiZGF0YSI6eyJyZXF1ZXN0X2lkIjoiM2YyYjhjMWUtOWQ0YS00ZTZiLThhN2MtMmQxZTBmOWI2YTUzIiwicmlza19zY29yZSI6MzUsInVzZXJfaGlkIjpudWxsfX0=", + "body": "{\"event_type\":\"identification.scored\",\"schema_version\":\"2026-06-01\",\"created_at\":\"2026-09-30T10:15:00Z\",\"data\":{\"identification_id\":\"3f2b8c1e-9d4a-4e6b-8a7c-2d1e0f9b6a53\",\"risk_score\":35,\"user_hid\":null}}", + "body_base64": "eyJldmVudF90eXBlIjoiaWRlbnRpZmljYXRpb24uc2NvcmVkIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTA6MTU6MDBaIiwiZGF0YSI6eyJpZGVudGlmaWNhdGlvbl9pZCI6IjNmMmI4YzFlLTlkNGEtNGU2Yi04YTdjLTJkMWUwZjliNmE1MyIsInJpc2tfc2NvcmUiOjM1LCJ1c2VyX2hpZCI6bnVsbH19", "signature_header": "sha256=", "valid": false, "note": "Prefix without digest" @@ -131,9 +131,9 @@ { "name": "invalid_empty_secret", "secret": "", - "body": "{\"event_type\":\"identification.scored\",\"schema_version\":\"2026-06-01\",\"created_at\":\"2026-09-30T10:15:00Z\",\"data\":{\"request_id\":\"3f2b8c1e-9d4a-4e6b-8a7c-2d1e0f9b6a53\",\"risk_score\":35,\"user_hid\":null}}", - "body_base64": "eyJldmVudF90eXBlIjoiaWRlbnRpZmljYXRpb24uc2NvcmVkIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTA6MTU6MDBaIiwiZGF0YSI6eyJyZXF1ZXN0X2lkIjoiM2YyYjhjMWUtOWQ0YS00ZTZiLThhN2MtMmQxZTBmOWI2YTUzIiwicmlza19zY29yZSI6MzUsInVzZXJfaGlkIjpudWxsfX0=", - "signature_header": "sha256=68ae6c4becfcbc22bb017c96fec281973bc554f96afe7ba875854cd65b3368dc", + "body": "{\"event_type\":\"identification.scored\",\"schema_version\":\"2026-06-01\",\"created_at\":\"2026-09-30T10:15:00Z\",\"data\":{\"identification_id\":\"3f2b8c1e-9d4a-4e6b-8a7c-2d1e0f9b6a53\",\"risk_score\":35,\"user_hid\":null}}", + "body_base64": "eyJldmVudF90eXBlIjoiaWRlbnRpZmljYXRpb24uc2NvcmVkIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTA6MTU6MDBaIiwiZGF0YSI6eyJpZGVudGlmaWNhdGlvbl9pZCI6IjNmMmI4YzFlLTlkNGEtNGU2Yi04YTdjLTJkMWUwZjliNmE1MyIsInJpc2tfc2NvcmUiOjM1LCJ1c2VyX2hpZCI6bnVsbH19", + "signature_header": "sha256=6a346decb68ad735d8421c5c8f36397d2fb34a0f3c0064816ff66cb20b459541", "valid": false, "note": "An empty secret is a configuration error and must never verify" }, @@ -149,20 +149,20 @@ { "name": "valid_scored_with_escaped_ampersand", "secret": "whsec_00112233445566778899aabbccddeeff", - "body": "{\"event_type\":\"identification.scored\",\"schema_version\":\"2026-06-01\",\"created_at\":\"2026-09-30T12:34:57.482913041Z\",\"data\":{\"request_id\":\"02f1d973-84db-4156-a7f7-e799e6bf389b\",\"visitor_id\":\"bde0e249-20d8-4544-838c-ed9a0b6d7a36\",\"device_id\":\"ac7c303d-971b-41d1-8e25-cd5b46b46aed\",\"session_id\":\"bde78778-efd2-4c49-952f-1f11b9c05f35\",\"cookie_id\":\"4449bb58-590c-444c-ae1f-d1ddc768dbdd\",\"user_hid\":\"9f86d081884c7d659a2feaa0c55ad015\",\"domain\":\"example.com\",\"public_ip\":{\"ip\":\"203.0.113.24\",\"country\":\"Netherlands\"},\"local_ip\":{\"ip\":\"198.51.100.23\",\"country\":\"Germany\"},\"connection_type\":\"proxy\",\"os\":\"Windows\",\"browser\":\"Chrome\",\"device_type\":\"desktop\",\"traffic_source\":{\"channel\":\"Google Ads\",\"referrer_domain\":\"google.com\",\"landing_url\":\"https://shop.example.com/signup?utm_source=google\\u0026utm_medium=cpc\\u0026gclid=abc123\",\"click_id_type\":\"gclid\",\"utm_source\":\"google\",\"utm_medium\":\"cpc\",\"utm_campaign\":\"\",\"utm_content\":\"\",\"utm_term\":\"\"},\"risk_score\":80,\"signals\":[{\"name\":\"proxy\",\"weight\":10},{\"name\":\"datacenter_ip\",\"weight\":10},{\"name\":\"antidetect_browser\",\"weight\":60}],\"detection_flags\":{\"vpn\":false,\"privacy_relay\":false,\"browser_vpn_proxy\":false,\"tor\":false,\"proxy\":true,\"datacenter_ip\":true,\"abuser\":false,\"os_mismatch\":false,\"os_not_detected\":false,\"timezone_mismatch\":false,\"anti_detect_browser\":true,\"browser_automation\":false,\"ip_mismatch\":true,\"incognito\":false,\"search_bot\":false,\"suspicious_paid_click\":true,\"javascript_disabled\":false,\"stun_not_checked\":false,\"check_incomplete\":false},\"observed_at\":\"2026-09-30T12:34:57.482913041Z\"}}", - "signature_header": "sha256=c4d44b7873625bdfda98cdb7a02d460f8492ca6a68fad8d147a5f30ad16f92a9", + "body": "{\"event_type\":\"identification.scored\",\"schema_version\":\"2026-06-01\",\"created_at\":\"2026-09-30T12:34:57.482913041Z\",\"data\":{\"identification_id\":\"a5b7c9d1-e3f5-4a7b-9c1d-3e5f7a9b1c3d\",\"visitor_id\":\"e9f1a3b5-c7d9-4e1f-8a3b-5c7d9e1f3a5b\",\"device_id\":\"d8e0f2a4-b6c8-4d0e-bf2a-4b6c8d0e2f4a\",\"session_id\":\"b6c8d0e2-f4a6-4b8c-8d0e-2f4a6b8c0d2e\",\"cookie_id\":\"c7d9e1f3-a5b7-4c9d-ae1f-3a5b7c9d1e3f\",\"user_hid\":\"9f86d081884c7d659a2feaa0c55ad015\",\"domain\":\"example.com\",\"public_ip\":{\"ip\":\"203.0.113.24\",\"country\":\"Netherlands\"},\"local_ip\":{\"ip\":\"198.51.100.23\",\"country\":\"Germany\"},\"connection_type\":\"proxy\",\"os\":\"Windows\",\"browser\":\"Chrome\",\"device_type\":\"desktop\",\"traffic_source\":{\"channel\":\"Google Ads\",\"referrer_domain\":\"google.com\",\"landing_url\":\"https://shop.example.com/signup?utm_source=google\\u0026utm_medium=cpc\\u0026gclid=abc123\",\"click_id_type\":\"gclid\",\"utm_source\":\"google\",\"utm_medium\":\"cpc\",\"utm_campaign\":\"\",\"utm_content\":\"\",\"utm_term\":\"\"},\"risk_score\":80,\"signals\":[{\"name\":\"proxy\",\"weight\":10},{\"name\":\"datacenter_ip\",\"weight\":10},{\"name\":\"antidetect_browser\",\"weight\":60}],\"detection_flags\":{\"vpn\":false,\"privacy_relay\":false,\"browser_vpn_proxy\":false,\"tor\":false,\"proxy\":true,\"datacenter_ip\":true,\"abuser\":false,\"os_mismatch\":false,\"os_not_detected\":false,\"timezone_mismatch\":false,\"anti_detect_browser\":true,\"browser_automation\":false,\"ip_mismatch\":true,\"incognito\":false,\"search_bot\":false,\"suspicious_paid_click\":true,\"javascript_disabled\":false,\"stun_not_checked\":false,\"check_incomplete\":false},\"observed_at\":\"2026-09-30T12:34:57.482913041Z\"}}", + "signature_header": "sha256=ef87f1f65933ed7536236a3b2354c4ff657c6f7e852cb8d5a7a0ba2604951819", "valid": true, "note": "Server bodies escape & as \\u0026; verify the bytes as received", - "body_base64": "eyJldmVudF90eXBlIjoiaWRlbnRpZmljYXRpb24uc2NvcmVkIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTI6MzQ6NTcuNDgyOTEzMDQxWiIsImRhdGEiOnsicmVxdWVzdF9pZCI6IjAyZjFkOTczLTg0ZGItNDE1Ni1hN2Y3LWU3OTllNmJmMzg5YiIsInZpc2l0b3JfaWQiOiJiZGUwZTI0OS0yMGQ4LTQ1NDQtODM4Yy1lZDlhMGI2ZDdhMzYiLCJkZXZpY2VfaWQiOiJhYzdjMzAzZC05NzFiLTQxZDEtOGUyNS1jZDViNDZiNDZhZWQiLCJzZXNzaW9uX2lkIjoiYmRlNzg3NzgtZWZkMi00YzQ5LTk1MmYtMWYxMWI5YzA1ZjM1IiwiY29va2llX2lkIjoiNDQ0OWJiNTgtNTkwYy00NDRjLWFlMWYtZDFkZGM3NjhkYmRkIiwidXNlcl9oaWQiOiI5Zjg2ZDA4MTg4NGM3ZDY1OWEyZmVhYTBjNTVhZDAxNSIsImRvbWFpbiI6ImV4YW1wbGUuY29tIiwicHVibGljX2lwIjp7ImlwIjoiMjAzLjAuMTEzLjI0IiwiY291bnRyeSI6Ik5ldGhlcmxhbmRzIn0sImxvY2FsX2lwIjp7ImlwIjoiMTk4LjUxLjEwMC4yMyIsImNvdW50cnkiOiJHZXJtYW55In0sImNvbm5lY3Rpb25fdHlwZSI6InByb3h5Iiwib3MiOiJXaW5kb3dzIiwiYnJvd3NlciI6IkNocm9tZSIsImRldmljZV90eXBlIjoiZGVza3RvcCIsInRyYWZmaWNfc291cmNlIjp7ImNoYW5uZWwiOiJHb29nbGUgQWRzIiwicmVmZXJyZXJfZG9tYWluIjoiZ29vZ2xlLmNvbSIsImxhbmRpbmdfdXJsIjoiaHR0cHM6Ly9zaG9wLmV4YW1wbGUuY29tL3NpZ251cD91dG1fc291cmNlPWdvb2dsZVx1MDAyNnV0bV9tZWRpdW09Y3BjXHUwMDI2Z2NsaWQ9YWJjMTIzIiwiY2xpY2tfaWRfdHlwZSI6ImdjbGlkIiwidXRtX3NvdXJjZSI6Imdvb2dsZSIsInV0bV9tZWRpdW0iOiJjcGMiLCJ1dG1fY2FtcGFpZ24iOiIiLCJ1dG1fY29udGVudCI6IiIsInV0bV90ZXJtIjoiIn0sInJpc2tfc2NvcmUiOjgwLCJzaWduYWxzIjpbeyJuYW1lIjoicHJveHkiLCJ3ZWlnaHQiOjEwfSx7Im5hbWUiOiJkYXRhY2VudGVyX2lwIiwid2VpZ2h0IjoxMH0seyJuYW1lIjoiYW50aWRldGVjdF9icm93c2VyIiwid2VpZ2h0Ijo2MH1dLCJkZXRlY3Rpb25fZmxhZ3MiOnsidnBuIjpmYWxzZSwicHJpdmFjeV9yZWxheSI6ZmFsc2UsImJyb3dzZXJfdnBuX3Byb3h5IjpmYWxzZSwidG9yIjpmYWxzZSwicHJveHkiOnRydWUsImRhdGFjZW50ZXJfaXAiOnRydWUsImFidXNlciI6ZmFsc2UsIm9zX21pc21hdGNoIjpmYWxzZSwib3Nfbm90X2RldGVjdGVkIjpmYWxzZSwidGltZXpvbmVfbWlzbWF0Y2giOmZhbHNlLCJhbnRpX2RldGVjdF9icm93c2VyIjp0cnVlLCJicm93c2VyX2F1dG9tYXRpb24iOmZhbHNlLCJpcF9taXNtYXRjaCI6dHJ1ZSwiaW5jb2duaXRvIjpmYWxzZSwic2VhcmNoX2JvdCI6ZmFsc2UsInN1c3BpY2lvdXNfcGFpZF9jbGljayI6dHJ1ZSwiamF2YXNjcmlwdF9kaXNhYmxlZCI6ZmFsc2UsInN0dW5fbm90X2NoZWNrZWQiOmZhbHNlLCJjaGVja19pbmNvbXBsZXRlIjpmYWxzZX0sIm9ic2VydmVkX2F0IjoiMjAyNi0wOS0zMFQxMjozNDo1Ny40ODI5MTMwNDFaIn19" + "body_base64": "eyJldmVudF90eXBlIjoiaWRlbnRpZmljYXRpb24uc2NvcmVkIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTI6MzQ6NTcuNDgyOTEzMDQxWiIsImRhdGEiOnsiaWRlbnRpZmljYXRpb25faWQiOiJhNWI3YzlkMS1lM2Y1LTRhN2ItOWMxZC0zZTVmN2E5YjFjM2QiLCJ2aXNpdG9yX2lkIjoiZTlmMWEzYjUtYzdkOS00ZTFmLThhM2ItNWM3ZDllMWYzYTViIiwiZGV2aWNlX2lkIjoiZDhlMGYyYTQtYjZjOC00ZDBlLWJmMmEtNGI2YzhkMGUyZjRhIiwic2Vzc2lvbl9pZCI6ImI2YzhkMGUyLWY0YTYtNGI4Yy04ZDBlLTJmNGE2YjhjMGQyZSIsImNvb2tpZV9pZCI6ImM3ZDllMWYzLWE1YjctNGM5ZC1hZTFmLTNhNWI3YzlkMWUzZiIsInVzZXJfaGlkIjoiOWY4NmQwODE4ODRjN2Q2NTlhMmZlYWEwYzU1YWQwMTUiLCJkb21haW4iOiJleGFtcGxlLmNvbSIsInB1YmxpY19pcCI6eyJpcCI6IjIwMy4wLjExMy4yNCIsImNvdW50cnkiOiJOZXRoZXJsYW5kcyJ9LCJsb2NhbF9pcCI6eyJpcCI6IjE5OC41MS4xMDAuMjMiLCJjb3VudHJ5IjoiR2VybWFueSJ9LCJjb25uZWN0aW9uX3R5cGUiOiJwcm94eSIsIm9zIjoiV2luZG93cyIsImJyb3dzZXIiOiJDaHJvbWUiLCJkZXZpY2VfdHlwZSI6ImRlc2t0b3AiLCJ0cmFmZmljX3NvdXJjZSI6eyJjaGFubmVsIjoiR29vZ2xlIEFkcyIsInJlZmVycmVyX2RvbWFpbiI6Imdvb2dsZS5jb20iLCJsYW5kaW5nX3VybCI6Imh0dHBzOi8vc2hvcC5leGFtcGxlLmNvbS9zaWdudXA/dXRtX3NvdXJjZT1nb29nbGVcdTAwMjZ1dG1fbWVkaXVtPWNwY1x1MDAyNmdjbGlkPWFiYzEyMyIsImNsaWNrX2lkX3R5cGUiOiJnY2xpZCIsInV0bV9zb3VyY2UiOiJnb29nbGUiLCJ1dG1fbWVkaXVtIjoiY3BjIiwidXRtX2NhbXBhaWduIjoiIiwidXRtX2NvbnRlbnQiOiIiLCJ1dG1fdGVybSI6IiJ9LCJyaXNrX3Njb3JlIjo4MCwic2lnbmFscyI6W3sibmFtZSI6InByb3h5Iiwid2VpZ2h0IjoxMH0seyJuYW1lIjoiZGF0YWNlbnRlcl9pcCIsIndlaWdodCI6MTB9LHsibmFtZSI6ImFudGlkZXRlY3RfYnJvd3NlciIsIndlaWdodCI6NjB9XSwiZGV0ZWN0aW9uX2ZsYWdzIjp7InZwbiI6ZmFsc2UsInByaXZhY3lfcmVsYXkiOmZhbHNlLCJicm93c2VyX3Zwbl9wcm94eSI6ZmFsc2UsInRvciI6ZmFsc2UsInByb3h5Ijp0cnVlLCJkYXRhY2VudGVyX2lwIjp0cnVlLCJhYnVzZXIiOmZhbHNlLCJvc19taXNtYXRjaCI6ZmFsc2UsIm9zX25vdF9kZXRlY3RlZCI6ZmFsc2UsInRpbWV6b25lX21pc21hdGNoIjpmYWxzZSwiYW50aV9kZXRlY3RfYnJvd3NlciI6dHJ1ZSwiYnJvd3Nlcl9hdXRvbWF0aW9uIjpmYWxzZSwiaXBfbWlzbWF0Y2giOnRydWUsImluY29nbml0byI6ZmFsc2UsInNlYXJjaF9ib3QiOmZhbHNlLCJzdXNwaWNpb3VzX3BhaWRfY2xpY2siOnRydWUsImphdmFzY3JpcHRfZGlzYWJsZWQiOmZhbHNlLCJzdHVuX25vdF9jaGVja2VkIjpmYWxzZSwiY2hlY2tfaW5jb21wbGV0ZSI6ZmFsc2V9LCJvYnNlcnZlZF9hdCI6IjIwMjYtMDktMzBUMTI6MzQ6NTcuNDgyOTEzMDQxWiJ9fQ==" }, { "name": "invalid_scored_unescaped_reserialization", "secret": "whsec_00112233445566778899aabbccddeeff", - "body": "{\"event_type\":\"identification.scored\",\"schema_version\":\"2026-06-01\",\"created_at\":\"2026-09-30T12:34:57.482913041Z\",\"data\":{\"request_id\":\"02f1d973-84db-4156-a7f7-e799e6bf389b\",\"visitor_id\":\"bde0e249-20d8-4544-838c-ed9a0b6d7a36\",\"device_id\":\"ac7c303d-971b-41d1-8e25-cd5b46b46aed\",\"session_id\":\"bde78778-efd2-4c49-952f-1f11b9c05f35\",\"cookie_id\":\"4449bb58-590c-444c-ae1f-d1ddc768dbdd\",\"user_hid\":\"9f86d081884c7d659a2feaa0c55ad015\",\"domain\":\"example.com\",\"public_ip\":{\"ip\":\"203.0.113.24\",\"country\":\"Netherlands\"},\"local_ip\":{\"ip\":\"198.51.100.23\",\"country\":\"Germany\"},\"connection_type\":\"proxy\",\"os\":\"Windows\",\"browser\":\"Chrome\",\"device_type\":\"desktop\",\"traffic_source\":{\"channel\":\"Google Ads\",\"referrer_domain\":\"google.com\",\"landing_url\":\"https://shop.example.com/signup?utm_source=google&utm_medium=cpc&gclid=abc123\",\"click_id_type\":\"gclid\",\"utm_source\":\"google\",\"utm_medium\":\"cpc\",\"utm_campaign\":\"\",\"utm_content\":\"\",\"utm_term\":\"\"},\"risk_score\":80,\"signals\":[{\"name\":\"proxy\",\"weight\":10},{\"name\":\"datacenter_ip\",\"weight\":10},{\"name\":\"antidetect_browser\",\"weight\":60}],\"detection_flags\":{\"vpn\":false,\"privacy_relay\":false,\"browser_vpn_proxy\":false,\"tor\":false,\"proxy\":true,\"datacenter_ip\":true,\"abuser\":false,\"os_mismatch\":false,\"os_not_detected\":false,\"timezone_mismatch\":false,\"anti_detect_browser\":true,\"browser_automation\":false,\"ip_mismatch\":true,\"incognito\":false,\"search_bot\":false,\"suspicious_paid_click\":true,\"javascript_disabled\":false,\"stun_not_checked\":false,\"check_incomplete\":false},\"observed_at\":\"2026-09-30T12:34:57.482913041Z\"}}", - "signature_header": "sha256=c4d44b7873625bdfda98cdb7a02d460f8492ca6a68fad8d147a5f30ad16f92a9", + "body": "{\"event_type\":\"identification.scored\",\"schema_version\":\"2026-06-01\",\"created_at\":\"2026-09-30T12:34:57.482913041Z\",\"data\":{\"identification_id\":\"a5b7c9d1-e3f5-4a7b-9c1d-3e5f7a9b1c3d\",\"visitor_id\":\"e9f1a3b5-c7d9-4e1f-8a3b-5c7d9e1f3a5b\",\"device_id\":\"d8e0f2a4-b6c8-4d0e-bf2a-4b6c8d0e2f4a\",\"session_id\":\"b6c8d0e2-f4a6-4b8c-8d0e-2f4a6b8c0d2e\",\"cookie_id\":\"c7d9e1f3-a5b7-4c9d-ae1f-3a5b7c9d1e3f\",\"user_hid\":\"9f86d081884c7d659a2feaa0c55ad015\",\"domain\":\"example.com\",\"public_ip\":{\"ip\":\"203.0.113.24\",\"country\":\"Netherlands\"},\"local_ip\":{\"ip\":\"198.51.100.23\",\"country\":\"Germany\"},\"connection_type\":\"proxy\",\"os\":\"Windows\",\"browser\":\"Chrome\",\"device_type\":\"desktop\",\"traffic_source\":{\"channel\":\"Google Ads\",\"referrer_domain\":\"google.com\",\"landing_url\":\"https://shop.example.com/signup?utm_source=google&utm_medium=cpc&gclid=abc123\",\"click_id_type\":\"gclid\",\"utm_source\":\"google\",\"utm_medium\":\"cpc\",\"utm_campaign\":\"\",\"utm_content\":\"\",\"utm_term\":\"\"},\"risk_score\":80,\"signals\":[{\"name\":\"proxy\",\"weight\":10},{\"name\":\"datacenter_ip\",\"weight\":10},{\"name\":\"antidetect_browser\",\"weight\":60}],\"detection_flags\":{\"vpn\":false,\"privacy_relay\":false,\"browser_vpn_proxy\":false,\"tor\":false,\"proxy\":true,\"datacenter_ip\":true,\"abuser\":false,\"os_mismatch\":false,\"os_not_detected\":false,\"timezone_mismatch\":false,\"anti_detect_browser\":true,\"browser_automation\":false,\"ip_mismatch\":true,\"incognito\":false,\"search_bot\":false,\"suspicious_paid_click\":true,\"javascript_disabled\":false,\"stun_not_checked\":false,\"check_incomplete\":false},\"observed_at\":\"2026-09-30T12:34:57.482913041Z\"}}", + "signature_header": "sha256=ef87f1f65933ed7536236a3b2354c4ff657c6f7e852cb8d5a7a0ba2604951819", "valid": false, "note": "Re-serializing (unescaping \\u0026) changes the bytes", - "body_base64": "eyJldmVudF90eXBlIjoiaWRlbnRpZmljYXRpb24uc2NvcmVkIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTI6MzQ6NTcuNDgyOTEzMDQxWiIsImRhdGEiOnsicmVxdWVzdF9pZCI6IjAyZjFkOTczLTg0ZGItNDE1Ni1hN2Y3LWU3OTllNmJmMzg5YiIsInZpc2l0b3JfaWQiOiJiZGUwZTI0OS0yMGQ4LTQ1NDQtODM4Yy1lZDlhMGI2ZDdhMzYiLCJkZXZpY2VfaWQiOiJhYzdjMzAzZC05NzFiLTQxZDEtOGUyNS1jZDViNDZiNDZhZWQiLCJzZXNzaW9uX2lkIjoiYmRlNzg3NzgtZWZkMi00YzQ5LTk1MmYtMWYxMWI5YzA1ZjM1IiwiY29va2llX2lkIjoiNDQ0OWJiNTgtNTkwYy00NDRjLWFlMWYtZDFkZGM3NjhkYmRkIiwidXNlcl9oaWQiOiI5Zjg2ZDA4MTg4NGM3ZDY1OWEyZmVhYTBjNTVhZDAxNSIsImRvbWFpbiI6ImV4YW1wbGUuY29tIiwicHVibGljX2lwIjp7ImlwIjoiMjAzLjAuMTEzLjI0IiwiY291bnRyeSI6Ik5ldGhlcmxhbmRzIn0sImxvY2FsX2lwIjp7ImlwIjoiMTk4LjUxLjEwMC4yMyIsImNvdW50cnkiOiJHZXJtYW55In0sImNvbm5lY3Rpb25fdHlwZSI6InByb3h5Iiwib3MiOiJXaW5kb3dzIiwiYnJvd3NlciI6IkNocm9tZSIsImRldmljZV90eXBlIjoiZGVza3RvcCIsInRyYWZmaWNfc291cmNlIjp7ImNoYW5uZWwiOiJHb29nbGUgQWRzIiwicmVmZXJyZXJfZG9tYWluIjoiZ29vZ2xlLmNvbSIsImxhbmRpbmdfdXJsIjoiaHR0cHM6Ly9zaG9wLmV4YW1wbGUuY29tL3NpZ251cD91dG1fc291cmNlPWdvb2dsZSZ1dG1fbWVkaXVtPWNwYyZnY2xpZD1hYmMxMjMiLCJjbGlja19pZF90eXBlIjoiZ2NsaWQiLCJ1dG1fc291cmNlIjoiZ29vZ2xlIiwidXRtX21lZGl1bSI6ImNwYyIsInV0bV9jYW1wYWlnbiI6IiIsInV0bV9jb250ZW50IjoiIiwidXRtX3Rlcm0iOiIifSwicmlza19zY29yZSI6ODAsInNpZ25hbHMiOlt7Im5hbWUiOiJwcm94eSIsIndlaWdodCI6MTB9LHsibmFtZSI6ImRhdGFjZW50ZXJfaXAiLCJ3ZWlnaHQiOjEwfSx7Im5hbWUiOiJhbnRpZGV0ZWN0X2Jyb3dzZXIiLCJ3ZWlnaHQiOjYwfV0sImRldGVjdGlvbl9mbGFncyI6eyJ2cG4iOmZhbHNlLCJwcml2YWN5X3JlbGF5IjpmYWxzZSwiYnJvd3Nlcl92cG5fcHJveHkiOmZhbHNlLCJ0b3IiOmZhbHNlLCJwcm94eSI6dHJ1ZSwiZGF0YWNlbnRlcl9pcCI6dHJ1ZSwiYWJ1c2VyIjpmYWxzZSwib3NfbWlzbWF0Y2giOmZhbHNlLCJvc19ub3RfZGV0ZWN0ZWQiOmZhbHNlLCJ0aW1lem9uZV9taXNtYXRjaCI6ZmFsc2UsImFudGlfZGV0ZWN0X2Jyb3dzZXIiOnRydWUsImJyb3dzZXJfYXV0b21hdGlvbiI6ZmFsc2UsImlwX21pc21hdGNoIjp0cnVlLCJpbmNvZ25pdG8iOmZhbHNlLCJzZWFyY2hfYm90IjpmYWxzZSwic3VzcGljaW91c19wYWlkX2NsaWNrIjp0cnVlLCJqYXZhc2NyaXB0X2Rpc2FibGVkIjpmYWxzZSwic3R1bl9ub3RfY2hlY2tlZCI6ZmFsc2UsImNoZWNrX2luY29tcGxldGUiOmZhbHNlfSwib2JzZXJ2ZWRfYXQiOiIyMDI2LTA5LTMwVDEyOjM0OjU3LjQ4MjkxMzA0MVoifX0=" + "body_base64": "eyJldmVudF90eXBlIjoiaWRlbnRpZmljYXRpb24uc2NvcmVkIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTI6MzQ6NTcuNDgyOTEzMDQxWiIsImRhdGEiOnsiaWRlbnRpZmljYXRpb25faWQiOiJhNWI3YzlkMS1lM2Y1LTRhN2ItOWMxZC0zZTVmN2E5YjFjM2QiLCJ2aXNpdG9yX2lkIjoiZTlmMWEzYjUtYzdkOS00ZTFmLThhM2ItNWM3ZDllMWYzYTViIiwiZGV2aWNlX2lkIjoiZDhlMGYyYTQtYjZjOC00ZDBlLWJmMmEtNGI2YzhkMGUyZjRhIiwic2Vzc2lvbl9pZCI6ImI2YzhkMGUyLWY0YTYtNGI4Yy04ZDBlLTJmNGE2YjhjMGQyZSIsImNvb2tpZV9pZCI6ImM3ZDllMWYzLWE1YjctNGM5ZC1hZTFmLTNhNWI3YzlkMWUzZiIsInVzZXJfaGlkIjoiOWY4NmQwODE4ODRjN2Q2NTlhMmZlYWEwYzU1YWQwMTUiLCJkb21haW4iOiJleGFtcGxlLmNvbSIsInB1YmxpY19pcCI6eyJpcCI6IjIwMy4wLjExMy4yNCIsImNvdW50cnkiOiJOZXRoZXJsYW5kcyJ9LCJsb2NhbF9pcCI6eyJpcCI6IjE5OC41MS4xMDAuMjMiLCJjb3VudHJ5IjoiR2VybWFueSJ9LCJjb25uZWN0aW9uX3R5cGUiOiJwcm94eSIsIm9zIjoiV2luZG93cyIsImJyb3dzZXIiOiJDaHJvbWUiLCJkZXZpY2VfdHlwZSI6ImRlc2t0b3AiLCJ0cmFmZmljX3NvdXJjZSI6eyJjaGFubmVsIjoiR29vZ2xlIEFkcyIsInJlZmVycmVyX2RvbWFpbiI6Imdvb2dsZS5jb20iLCJsYW5kaW5nX3VybCI6Imh0dHBzOi8vc2hvcC5leGFtcGxlLmNvbS9zaWdudXA/dXRtX3NvdXJjZT1nb29nbGUmdXRtX21lZGl1bT1jcGMmZ2NsaWQ9YWJjMTIzIiwiY2xpY2tfaWRfdHlwZSI6ImdjbGlkIiwidXRtX3NvdXJjZSI6Imdvb2dsZSIsInV0bV9tZWRpdW0iOiJjcGMiLCJ1dG1fY2FtcGFpZ24iOiIiLCJ1dG1fY29udGVudCI6IiIsInV0bV90ZXJtIjoiIn0sInJpc2tfc2NvcmUiOjgwLCJzaWduYWxzIjpbeyJuYW1lIjoicHJveHkiLCJ3ZWlnaHQiOjEwfSx7Im5hbWUiOiJkYXRhY2VudGVyX2lwIiwid2VpZ2h0IjoxMH0seyJuYW1lIjoiYW50aWRldGVjdF9icm93c2VyIiwid2VpZ2h0Ijo2MH1dLCJkZXRlY3Rpb25fZmxhZ3MiOnsidnBuIjpmYWxzZSwicHJpdmFjeV9yZWxheSI6ZmFsc2UsImJyb3dzZXJfdnBuX3Byb3h5IjpmYWxzZSwidG9yIjpmYWxzZSwicHJveHkiOnRydWUsImRhdGFjZW50ZXJfaXAiOnRydWUsImFidXNlciI6ZmFsc2UsIm9zX21pc21hdGNoIjpmYWxzZSwib3Nfbm90X2RldGVjdGVkIjpmYWxzZSwidGltZXpvbmVfbWlzbWF0Y2giOmZhbHNlLCJhbnRpX2RldGVjdF9icm93c2VyIjp0cnVlLCJicm93c2VyX2F1dG9tYXRpb24iOmZhbHNlLCJpcF9taXNtYXRjaCI6dHJ1ZSwiaW5jb2duaXRvIjpmYWxzZSwic2VhcmNoX2JvdCI6ZmFsc2UsInN1c3BpY2lvdXNfcGFpZF9jbGljayI6dHJ1ZSwiamF2YXNjcmlwdF9kaXNhYmxlZCI6ZmFsc2UsInN0dW5fbm90X2NoZWNrZWQiOmZhbHNlLCJjaGVja19pbmNvbXBsZXRlIjpmYWxzZX0sIm9ic2VydmVkX2F0IjoiMjAyNi0wOS0zMFQxMjozNDo1Ny40ODI5MTMwNDFaIn19" }, { "name": "valid_rotation_second_secret_matches", diff --git a/tests/data/webhook-test-delivery.json b/tests/data/webhook-test-delivery.json index 8809d3f..79fbd13 100644 --- a/tests/data/webhook-test-delivery.json +++ b/tests/data/webhook-test-delivery.json @@ -39,7 +39,7 @@ "country": "BY", "ip": "203.0.113.10" }, - "request_id": "13f84f05-7c2a-4e9b-9f1d-2a6b8c0e4d11", + "identification_id": "13f84f05-7c2a-4e9b-9f1d-2a6b8c0e4d11", "risk_score": 30, "session_id": "3a2b1c0d-9e8f-4a7b-8c6d-5e4f3a2b1c0d", "signals": [ diff --git a/tests/test_async.py b/tests/test_async.py index 75f703f..2955e4d 100644 --- a/tests/test_async.py +++ b/tests/test_async.py @@ -59,7 +59,7 @@ async def test_search(client: AsyncShieldLabs, mock: Any) -> None: route = mock.get(host=HISTORY_HOST, path=REQUEST_PATH).respond( 200, json=load_json("history-page.json") ) - page = await client.history.search("request_id", REQUEST_ID, limit=3) + page = await client.history.search("identification_id", REQUEST_ID, limit=3) assert isinstance(page, HistoryPage) assert len(page.data) == 5 assert route.calls.last.request.url.params["limit"] == "3" @@ -71,7 +71,7 @@ async def test_search_validation_sends_nothing(client: AsyncShieldLabs, mock: An with pytest.raises(ValidationError): await client.history.search("user_hid", "") with pytest.raises(ValidationError): - await client.history.search("request_id", REQUEST_ID, limit=101) + await client.history.search("identification_id", REQUEST_ID, limit=101) with pytest.raises(ValidationError): client.history.iter("ip", "2001:db8::1") with pytest.raises(ValidationError): @@ -87,7 +87,7 @@ async def test_iter_dedupes_and_stops(client: AsyncShieldLabs, mock: Any) -> Non ] ) items = [item async for item in client.history.iter("user_hid", "acct-1", page_size=2)] - assert [item.request_id for item in items] == [uuid_for(1), uuid_for(2), uuid_for(3)] + assert [item.identification_id for item in items] == [uuid_for(1), uuid_for(2), uuid_for(3)] assert route.call_count == 2 @@ -130,20 +130,20 @@ async def test_user_hid_escaping_and_unsearchable_values( async def test_retries_and_errors(client: AsyncShieldLabs, mock: Any, fake_time: FakeTime) -> None: route = mock.get(host=HISTORY_HOST) route.side_effect = [httpx.Response(500), httpx.Response(200, json=history_body())] - assert (await client.history.search("request_id", REQUEST_ID)).total == 0 + assert (await client.history.search("identification_id", REQUEST_ID)).total == 0 assert fake_time.sleeps == [0.5] route.side_effect = httpx.ConnectError("refused") with pytest.raises(APIConnectionError): - await client.history.search("request_id", REQUEST_ID) + await client.history.search("identification_id", REQUEST_ID) route.side_effect = httpx.ConnectTimeout("slow") with pytest.raises(APITimeoutError): - await client.history.search("request_id", REQUEST_ID) + await client.history.search("identification_id", REQUEST_ID) route.side_effect = lambda request: httpx.Response(502, text="") with pytest.raises(ServerError): - await client.history.search("request_id", REQUEST_ID) + await client.history.search("identification_id", REQUEST_ID) async def test_context_manager_and_injected_client(mock: Any) -> None: @@ -155,7 +155,7 @@ async def test_context_manager_and_injected_client(mock: Any) -> None: route = mock.get(host=HISTORY_HOST).respond(200, json=history_body()) http_client = httpx.AsyncClient() async with AsyncShieldLabs(api_key=API_KEY, http_client=http_client) as injected: - await injected.history.search("request_id", REQUEST_ID) + await injected.history.search("identification_id", REQUEST_ID) assert not http_client.is_closed assert route.call_count == 1 await http_client.aclose() diff --git a/tests/test_errors.py b/tests/test_errors.py index 6d902b0..37fc1bb 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -51,7 +51,7 @@ def _call(surface: str, fake_time: FakeTime, max_retries: int = 1) -> None: if surface == "history": client = ShieldLabs(api_key=API_KEY, max_retries=max_retries) fake_time.install(client._transport) - client.history.search("request_id", REQUEST_ID) + client.history.search("identification_id", REQUEST_ID) else: management = ShieldLabsManagement( secret_key=SECRET_KEY, domain=DOMAIN, max_retries=max_retries @@ -151,7 +151,7 @@ def test_retries_use_backoff_then_raise(mock: Any, fake_time: FakeTime) -> None: client = ShieldLabs(api_key=API_KEY, max_retries=3) fake_time.install(client._transport) with pytest.raises(ServerError): - client.history.search("request_id", REQUEST_ID) + client.history.search("identification_id", REQUEST_ID) assert route.call_count == 4 assert fake_time.sleeps == [0.5, 1.0, 2.0] @@ -163,7 +163,7 @@ def test_rate_limit_without_retry_after_waits_at_least_one_second( client = ShieldLabs(api_key=API_KEY, max_retries=3) fake_time.install(client._transport) with pytest.raises(RateLimitError): - client.history.search("request_id", REQUEST_ID) + client.history.search("identification_id", REQUEST_ID) assert route.call_count == 4 assert fake_time.sleeps == [1.0, 1.0, 2.0] # backoff 0.5, 1, 2 with a 1 s floor @@ -178,7 +178,7 @@ def test_retry_after_is_honoured_and_capped(mock: Any, fake_time: FakeTime) -> N ) client = ShieldLabs(api_key=API_KEY, max_retries=2) fake_time.install(client._transport) - page = client.history.search("request_id", REQUEST_ID) + page = client.history.search("identification_id", REQUEST_ID) assert page.total == 0 assert route.call_count == 3 assert fake_time.sleeps == [3.0, 10.0] @@ -197,7 +197,7 @@ def test_retry_after_is_followed_as_sent_even_below_one_second( ) client = ShieldLabs(api_key=API_KEY, max_retries=2) fake_time.install(client._transport) - assert client.history.search("request_id", REQUEST_ID).total == 0 + assert client.history.search("identification_id", REQUEST_ID).total == 0 assert route.call_count == 3 assert fake_time.sleeps == [0.0, 0.5] @@ -208,7 +208,7 @@ def test_client_errors_are_never_retried(mock: Any, fake_time: FakeTime, status: client = ShieldLabs(api_key=API_KEY, max_retries=5) fake_time.install(client._transport) with pytest.raises(ApiError): - client.history.search("request_id", REQUEST_ID) + client.history.search("identification_id", REQUEST_ID) assert route.call_count == 1 assert fake_time.sleeps == [] @@ -219,7 +219,7 @@ def test_connection_errors_are_retried(mock: Any, fake_time: FakeTime) -> None: ) client = ShieldLabs(api_key=API_KEY) fake_time.install(client._transport) - assert client.history.search("request_id", REQUEST_ID).total == 0 + assert client.history.search("identification_id", REQUEST_ID).total == 0 assert route.call_count == 2 assert fake_time.sleeps == [0.5] @@ -229,7 +229,7 @@ def test_connection_error_after_retries(mock: Any, fake_time: FakeTime) -> None: client = ShieldLabs(api_key=API_KEY, max_retries=1) fake_time.install(client._transport) with pytest.raises(APIConnectionError, match="refused") as caught: - client.history.search("request_id", REQUEST_ID) + client.history.search("identification_id", REQUEST_ID) assert isinstance(caught.value.__cause__, httpx.ConnectError) assert API_KEY not in str(caught.value) @@ -239,7 +239,7 @@ def test_timeouts_are_retried_then_raised(mock: Any, fake_time: FakeTime) -> Non client = ShieldLabs(api_key=API_KEY, timeout=2.5, max_retries=2) fake_time.install(client._transport) with pytest.raises(APITimeoutError, match=r"2\.5 s"): - client.history.search("request_id", REQUEST_ID) + client.history.search("identification_id", REQUEST_ID) assert route.call_count == 3 assert fake_time.sleeps == [0.5, 1.0] @@ -249,11 +249,11 @@ def test_max_retries_zero(mock: Any, fake_time: FakeTime) -> None: client = ShieldLabs(api_key=API_KEY, max_retries=0) fake_time.install(client._transport) with pytest.raises(ServerError): - client.history.search("request_id", REQUEST_ID) + client.history.search("identification_id", REQUEST_ID) assert route.call_count == 1 def test_timeout_is_passed_to_each_attempt(mock: Any) -> None: route = mock.get(host=HISTORY_HOST).respond(200, json={"data": [], "total": 0}) - ShieldLabs(api_key=API_KEY, timeout=4).history.search("request_id", REQUEST_ID) + ShieldLabs(api_key=API_KEY, timeout=4).history.search("identification_id", REQUEST_ID) assert route.calls.last.request.extensions["timeout"]["read"] == 4.0 diff --git a/tests/test_example_app.py b/tests/test_example_app.py index 0f8cbba..8f8769f 100644 --- a/tests/test_example_app.py +++ b/tests/test_example_app.py @@ -35,7 +35,7 @@ def _now_history_time() -> str: def _row(**changes: Any) -> dict[str, Any]: row = dict(load_json("history-page.json")["data"][1]) # trusted, anonymous - row.update(request_id=REQUEST_ID, created_at=_now_history_time()) + row.update(identification_id=REQUEST_ID, created_at=_now_history_time()) row.update(changes) return row @@ -60,8 +60,8 @@ def app_client(monkeypatch: pytest.MonkeyPatch) -> Iterator[tuple[TestClient, An yield client, router -def _signup(client: TestClient, request_id: str = REQUEST_ID) -> httpx.Response: - return client.post("/signup", json={"email": "user@example.com", "requestId": request_id}) +def _signup(client: TestClient, identification_id: str = REQUEST_ID) -> httpx.Response: + return client.post("/signup", json={"email": "user@example.com", "identificationId": identification_id}) def test_trusted_signup_is_created_once(app_client: tuple[TestClient, Any]) -> None: @@ -101,7 +101,7 @@ def test_missing_identification_is_refused(app_client: tuple[TestClient, Any]) - assert response.json()["reason"] == "missing" -def test_invalid_request_id_and_unavailable_api(app_client: tuple[TestClient, Any]) -> None: +def test_invalid_identification_id_and_unavailable_api(app_client: tuple[TestClient, Any]) -> None: client, router = app_client assert _signup(client, "not-a-uuid").status_code == 400 router.get(host=HISTORY_HOST).respond(401, text='{"error":"invalid api key"}\n') diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 798d929..9a0a1c3 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -41,11 +41,11 @@ def test_clean_identification_is_ok() -> None: assert verdict == Evaluation(ok=True, reason=None, band="trusted", flag=None) -def test_replay_callback_receives_request_id() -> None: +def test_replay_callback_receives_identification_id() -> None: seen: list[str] = [] - def is_replay(request_id: str) -> bool: - seen.append(request_id) + def is_replay(identification_id: str) -> bool: + seen.append(identification_id) return True verdict = evaluate_identification(_identification(), now=NOW, is_replay=is_replay) diff --git a/tests/test_history.py b/tests/test_history.py index b6d965b..62aff10 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -50,11 +50,11 @@ def test_search_sends_expected_request(client: ShieldLabs, mock: Any) -> None: route = mock.get(host=HISTORY_HOST, path=REQUEST_PATH).respond( 200, json=load_json("history-page.json") ) - page = client.history.search("request_id", REQUEST_ID, limit=5, offset=10) + page = client.history.search("identification_id", REQUEST_ID, limit=5, offset=10) assert isinstance(page, HistoryPage) assert page.total == 37 - assert [item.request_id for item in page.data][:2] == [ + assert [item.identification_id for item in page.data][:2] == [ REQUEST_ID, "7c1e2f4a-3b6d-4e8f-9a0b-1c2d3e4f5a6b", ] @@ -182,8 +182,8 @@ def test_ip_lookup(client: ShieldLabs, mock: Any) -> None: ("email", "user@example.com", "type must be one of"), ("auto", "anonymous", "type must be one of"), ("REQUEST_ID", REQUEST_ID, "type must be one of"), - ("request_id", "not-a-uuid", "must be a UUID"), - ("request_id", REQUEST_ID + " ", "must be a UUID"), + ("identification_id", "not-a-uuid", "must be a UUID"), + ("identification_id", REQUEST_ID + " ", "must be a UUID"), ("device_id", "ac7c303d971b41d18e25cd5b46b46aed", "must be a UUID"), ("session_id", "{bde78778-efd2-4c49-952f-1f11b9c05f35}", "must be a UUID"), ("cookie_id", "", "must be a UUID"), @@ -194,7 +194,7 @@ def test_ip_lookup(client: ShieldLabs, mock: Any) -> None: ("ip", "203.0.113.7\n", "dotted IPv4"), ("user_hid", "", "non-empty"), ("user_hid", 12345, "must be a string"), - ("request_id", None, "must be a string"), + ("identification_id", None, "must be a string"), ], ) def test_validation_errors_send_nothing( @@ -222,26 +222,26 @@ def test_limit_and_offset_validation( ) -> None: route = mock.get(host=HISTORY_HOST).respond(200, json=history_body()) with pytest.raises(ValidationError, match=message): - client.history.search("request_id", REQUEST_ID, **kwargs) + client.history.search("identification_id", REQUEST_ID, **kwargs) assert route.call_count == 0 def test_limit_boundaries_are_accepted(client: ShieldLabs, mock: Any) -> None: route = mock.get(host=HISTORY_HOST).respond(200, json=history_body()) - client.history.search("request_id", REQUEST_ID, limit=1) - client.history.search("request_id", REQUEST_ID, limit=100, offset=5000) + client.history.search("identification_id", REQUEST_ID, limit=1) + client.history.search("identification_id", REQUEST_ID, limit=100, offset=5000) assert [c.request.url.params["limit"] for c in route.calls] == ["1", "100"] def test_validation_error_is_also_value_error(client: ShieldLabs) -> None: with pytest.raises(ValueError): - client.history.search("request_id", "nope") + client.history.search("identification_id", "nope") # --- iteration --------------------------------------------------------------------------- -def test_iter_dedupes_on_request_id_and_stops_at_total(client: ShieldLabs, mock: Any) -> None: +def test_iter_dedupes_on_identification_id_and_stops_at_total(client: ShieldLabs, mock: Any) -> None: route = mock.get(host=HISTORY_HOST).mock( side_effect=[ httpx.Response(200, json=history_body(row(uuid_for(1)), row(uuid_for(2)), total=5)), @@ -250,7 +250,7 @@ def test_iter_dedupes_on_request_id_and_stops_at_total(client: ShieldLabs, mock: ] ) items = list(client.history.iter("user_hid", "acct-1", page_size=2)) - assert [item.request_id for item in items] == [uuid_for(i) for i in (1, 2, 3, 4)] + assert [item.identification_id for item in items] == [uuid_for(i) for i in (1, 2, 3, 4)] offsets = [call.request.url.params["offset"] for call in route.calls] limits = {call.request.url.params["limit"] for call in route.calls} assert offsets == ["0", "2", "4"] @@ -279,7 +279,7 @@ def test_iter_respects_max_items(client: ShieldLabs, mock: Any) -> None: ] ) items = list(client.history.iter("ip", "203.0.113.24", page_size=3, max_items=4)) - assert [item.request_id for item in items] == [uuid_for(i) for i in range(4)] + assert [item.identification_id for item in items] == [uuid_for(i) for i in range(4)] assert route.call_count == 2 @@ -333,14 +333,14 @@ def test_api_suffix_is_stripped(mock: Any, base_url: str) -> None: route = mock.get(host=HISTORY_HOST).respond(200, json=history_body()) client = ShieldLabs(api_key=API_KEY, base_url=base_url) assert client.base_url == "https://account.shieldlabs.ai" - client.history.search("request_id", REQUEST_ID) + client.history.search("identification_id", REQUEST_ID) assert route.calls.last.request.url.path == REQUEST_PATH def test_custom_base_url_with_prefix_and_dev_host(mock: Any) -> None: route = mock.get(host="dev.account.shieldlabs.ai").respond(200, json=history_body()) client = ShieldLabs(api_key=API_KEY, base_url="https://dev.account.shieldlabs.ai") - client.history.search("request_id", REQUEST_ID) + client.history.search("identification_id", REQUEST_ID) assert route.call_count == 1 proxied = ShieldLabs(api_key=API_KEY, base_url="http://localhost:8080/shield/api") assert proxied.base_url == "http://localhost:8080/shield" @@ -399,7 +399,7 @@ def test_env_fallbacks(monkeypatch: pytest.MonkeyPatch, mock: Any) -> None: route = mock.get(host="dev.account.shieldlabs.ai").respond(200, json=history_body()) client = ShieldLabs() assert client.base_url == "https://dev.account.shieldlabs.ai" - client.history.search("request_id", REQUEST_ID) + client.history.search("identification_id", REQUEST_ID) assert route.calls.last.request.headers["authorization"] == f"Bearer {API_KEY}" @@ -452,7 +452,7 @@ def test_injected_http_client_is_used_and_left_open(mock: Any) -> None: route = mock.get(host=HISTORY_HOST).respond(200, json=history_body()) http_client = httpx.Client(headers={"X-Trace": "1"}) with ShieldLabs(api_key=API_KEY, http_client=http_client) as client: - client.history.search("request_id", REQUEST_ID) + client.history.search("identification_id", REQUEST_ID) assert not http_client.is_closed assert route.calls.last.request.headers["x-trace"] == "1" assert route.calls.last.request.headers["authorization"] == f"Bearer {API_KEY}" @@ -467,10 +467,10 @@ def test_malformed_success_bodies_raise_api_error(client: ShieldLabs, mock: Any) ] ) with pytest.raises(ApiError, match="not valid JSON") as first: - client.history.search("request_id", REQUEST_ID) + client.history.search("identification_id", REQUEST_ID) assert first.value.status == 200 with pytest.raises(ApiError, match="not a JSON object"): - client.history.search("request_id", REQUEST_ID) + client.history.search("identification_id", REQUEST_ID) def test_errors_survive_deepcopy(client: ShieldLabs, mock: Any) -> None: @@ -478,7 +478,7 @@ def test_errors_survive_deepcopy(client: ShieldLabs, mock: Any) -> None: 401, text='{"error":"invalid api key"}\n', headers={"content-type": "text/plain"} ) with pytest.raises(ApiError) as caught: - client.history.search("request_id", REQUEST_ID) + client.history.search("identification_id", REQUEST_ID) restored = copy.deepcopy(caught.value) assert type(restored) is type(caught.value) assert restored.status == 401 diff --git a/tests/test_normalization.py b/tests/test_normalization.py index f4721a7..4946f5f 100644 --- a/tests/test_normalization.py +++ b/tests/test_normalization.py @@ -123,7 +123,7 @@ def test_identification_band_properties() -> None: def test_history_row_tolerates_missing_and_malformed_fields() -> None: identification = Identification.from_history_row( { - "request_id": 42, + "identification_id": 42, "score": "high", "score_details": "not json", "created_at": "yesterday", @@ -131,7 +131,7 @@ def test_history_row_tolerates_missing_and_malformed_fields() -> None: "ip": " 0.0.0.0 ", } ) - assert identification.request_id == "" + assert identification.identification_id == "" assert identification.risk_score == 0 assert identification.signals == () assert identification.observed_at is None @@ -209,7 +209,7 @@ def test_history_local_ip_uses_leak_source_only_when_set() -> None: def test_webhook_data_tolerates_partial_objects() -> None: identification = Identification.from_webhook_data( { - "request_id": "3f2b8c1e-9d4a-4e6b-8a7c-2d1e0f9b6a53", + "identification_id": "3f2b8c1e-9d4a-4e6b-8a7c-2d1e0f9b6a53", "risk_score": 35, "user_hid": None, "signals": [{"name": "vpn", "weight": 15}, "bad", {"weight": 5}], @@ -237,7 +237,7 @@ def test_from_dict_accepts_datetime_and_signal_descriptions() -> None: moment = datetime(2026, 9, 30, 12, 0, tzinfo=timezone.utc) identification = Identification.from_dict( { - "request_id": "3f2b8c1e-9d4a-4e6b-8a7c-2d1e0f9b6a53", + "identification_id": "3f2b8c1e-9d4a-4e6b-8a7c-2d1e0f9b6a53", "signals": [{"name": "proxy", "weight": 10, "description": "Is proxy"}], "observed_at": moment, "source": "history", @@ -256,7 +256,7 @@ def test_history_page_from_dict() -> None: assert all(item.source == "history" for item in page.data) empty = HistoryPage.from_dict(load_json("history-empty.json")) assert empty == HistoryPage(data=(), total=0) - odd = HistoryPage.from_dict({"data": [{"request_id": "x"}, "junk"], "total": None}) + odd = HistoryPage.from_dict({"data": [{"identification_id": "x"}, "junk"], "total": None}) assert odd.total == 1 assert HistoryPage.from_dict({"data": "nope"}).data == () @@ -264,9 +264,9 @@ def test_history_page_from_dict() -> None: def test_history_page_rows_match_normalization_cases() -> None: page = HistoryPage.from_dict(load_json("history-page.json")) expected = {c["name"]: c["expected"] for c in NORMALIZATION if c["source"] == "history"} - by_request = {v["request_id"]: v for v in expected.values()} + by_request = {v["identification_id"]: v for v in expected.values()} for identification in page.data: - assert identification.to_dict() == by_request[identification.request_id] + assert identification.to_dict() == by_request[identification.identification_id] def test_known_signal_names_are_plain_strings() -> None: diff --git a/tests/test_polling.py b/tests/test_polling.py index 8155c68..a9c4ca6 100644 --- a/tests/test_polling.py +++ b/tests/test_polling.py @@ -168,7 +168,7 @@ async def test_first_poll_is_immediate_and_found( server = serve(mock, fake_time, _found) identification = await get(client, REQUEST_ID) assert isinstance(identification, Identification) - assert identification.request_id == REQUEST_ID + assert identification.identification_id == REQUEST_ID assert server.times == [0.0] assert fake_time.sleeps == [] @@ -413,7 +413,7 @@ async def test_transient_error_then_row_returns_the_row( server = serve(mock, fake_time, answer, answer, _found) identification = await get(client, REQUEST_ID) assert identification is not None - assert identification.request_id == REQUEST_ID + assert identification.identification_id == REQUEST_ID assert server.times == [0.0, 0.25, 0.75] diff --git a/tests/test_readme.py b/tests/test_readme.py index ae737b4..ee1fedd 100644 --- a/tests/test_readme.py +++ b/tests/test_readme.py @@ -67,9 +67,9 @@ def test_quick_start_runs_as_pasted( with respx.mock(assert_all_called=False) as router: route = router.get(host=HISTORY_HOST).respond(200, json={"data": [row], "total": 1}) quick_start = _import_file(script) - assert quick_start.allow_signup(row["request_id"]) is True + assert quick_start.allow_signup(row["identification_id"]) is True # One identification authorizes one action: the same request ID is refused next time. - assert quick_start.allow_signup(row["request_id"]) is False + assert quick_start.allow_signup(row["identification_id"]) is False quick_start.client.close() assert route.call_count == 2 diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py index 61b810a..aed1748 100644 --- a/tests/test_webhooks.py +++ b/tests/test_webhooks.py @@ -148,7 +148,7 @@ def test_unknown_event_type_is_not_an_error() -> None: "event_type": "identification.refined", "schema_version": "2026-06-01", "created_at": "2026-09-30T12:00:00Z", - "data": {"request_id": "02f1d973-84db-4156-a7f7-e799e6bf389b"}, + "data": {"identification_id": "02f1d973-84db-4156-a7f7-e799e6bf389b"}, "extra": True, } body = json.dumps(envelope).encode() From e40b37204bbedf7dd207ed2275ab85c544e6d56c Mon Sep 17 00:00:00 2001 From: EgorMajj <91486022+EgorMajj@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:25:17 +0300 Subject: [PATCH 2/3] fix: align generated types and Python lint after identifier rename --- .../api/history_api_api.py | 30 +++++++++---------- src/shieldlabs/_helpers.py | 3 +- src/shieldlabs/_validation.py | 13 ++++++-- tests/test_example_app.py | 4 ++- tests/test_history.py | 4 ++- 5 files changed, 34 insertions(+), 20 deletions(-) diff --git a/generated/shieldlabs_generated/api/history_api_api.py b/generated/shieldlabs_generated/api/history_api_api.py index c082b4a..71dfff7 100644 --- a/generated/shieldlabs_generated/api/history_api_api.py +++ b/generated/shieldlabs_generated/api/history_api_api.py @@ -42,7 +42,7 @@ def __init__(self, api_client=None) -> None: @validate_call def search_history( self, - search_type: Annotated[StrictStr, Field(description="Identifier to search by. Only these seven values are supported: - `request_id`: one identification (read a verdict); - `device_id`: every identification of one device; - `user_hid`: every identification of one account; - `visitor_id`: every identification of one visitor; - `ip`: every identification from one public IPv4 address; - `session_id`: every identification of one visit; - `cookie_id`: every identification with one browser cookie. The server does not reject other values: it ignores them and returns the latest identifications of the whole domain, so restrict the value on your side.")], + search_type: Annotated[StrictStr, Field(description="Identifier to search by. These values are supported (`request_id` is a legacy alias of `identification_id`): - `identification_id`: one identification (read a verdict); - `device_id`: every identification of one device; - `user_hid`: every identification of one account; - `visitor_id`: every identification of one visitor; - `ip`: every identification from one public IPv4 address; - `session_id`: every identification of one visit; - `cookie_id`: every identification with one browser cookie. The server does not reject other values: it ignores them and returns the latest identifications of the whole domain, so restrict the value on your side.")], value: Annotated[str, Field(min_length=1, strict=True, description="Value of the identifier, validated on your side before sending: - `identification_id`, `device_id`, `visitor_id`, `session_id`, `cookie_id`: a UUID of any version, the nil UUID included, matching `^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`. Send it lowercase. - `ip`: a dotted IPv4 address. IPv6 addresses cannot be searched. - `user_hid`: the exact, case-sensitive User HID as one path segment, encoded the way the server reads it: send the characters `A-Z a-z 0-9 - . _ ~ $ & + , : ; = @` unescaped and percent-encode every other byte of the UTF-8 value as uppercase `%XX`, including `! ' ( ) *`, spaces and `%` itself. The server compares any other encoding literally, so `%40` instead of `@`, or lowercase hex digits, return an empty page instead of the matching rows. Many HTTP clients and generated clients escape `$ & + , : ; = @` in path values: build this path yourself when yours does. A User HID that contains `/` cannot be searched, and most HTTP clients cannot send `.` or `..` because they remove them as dot segments; the pattern rejects these values. Hex-encoded hashes need no escaping at all. The server does not validate the value: a malformed UUID or IPv4 address gets a `500`.")], limit: Annotated[Optional[Annotated[int, Field(le=100, strict=True, ge=1)]], Field(description="Maximum number of identifications to return, from 1 to 100. The server replaces any other value (and a non-numeric one) with 20 instead of clamping it, so validate it on your side.")] = None, offset: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Number of identifications to skip, for paging. The server treats negative or non-numeric values as 0. Rows are ordered by `created_at` only, so paging while new identifications arrive can repeat or skip rows: deduplicate on `identification_id`.")] = None, @@ -61,15 +61,15 @@ def search_history( ) -> HistoryPage: """Search identifications - Returns the identifications of your domain that match one identifier, newest first, together with the total number of matches. The Private API Key selects the domain; identifications from its subdomains are included (`domain` holds the host, `site_domain` the registered domain). **Read one verdict.** After a protected action, search by `request_id` with `limit=1`. The row appears about 1-3 seconds after the browser call and can be refined for up to about 10 seconds as follow-up network checks finish, so start the identification when the user begins the action (for example when the signup form opens), not when the form is submitted. An empty `data` array means \"not scored yet\", never \"clean\". Poll with backoff (first try at once, then wait 250 ms, 500 ms, 1 s, then steps of about 1.5 s) and treat a `429` inside that loop as \"wait longer\". The official server SDKs do this for you. **Account-level checks.** Search by `device_id`, `user_hid`, `visitor_id` or `ip` to see how many accounts share a device, how many devices one account uses, or what else came from one IP address. When you count accounts, skip rows whose `user_hid` is empty or one of the values that do not identify a user: `anonymous`, `fail`, `-1` and `unknown`. **Validate before sending.** The server does not validate the path: an unknown `search_type` returns the latest identifications of the whole domain unfiltered, a malformed UUID or IPv4 value returns `500`, and a `limit` outside 1-100 silently becomes 20. **Paging.** Page with `offset` while it is below `total`. Rows are ordered by `created_at` only, so paging while new identifications arrive can repeat or skip rows: deduplicate on `request_id`. **Latest state.** A row can be refined after the webhook was sent, for example when late network data re-scores it; its `ver` then increases. The History API always returns the latest version, which makes it the guaranteed read path. Reads are free: they do not use your included identifications. + Returns the identifications of your domain that match one identifier, newest first, together with the total number of matches. The Private API Key selects the domain; identifications from its subdomains are included (`domain` holds the host, `site_domain` the registered domain). **Read one verdict.** After a protected action, search by `identification_id` with `limit=1`. The row appears about 1-3 seconds after the browser call and can be refined for up to about 10 seconds as follow-up network checks finish, so start the identification when the user begins the action (for example when the signup form opens), not when the form is submitted. An empty `data` array means \"not scored yet\", never \"clean\". Poll with backoff (first try at once, then wait 250 ms, 500 ms, 1 s, then steps of about 1.5 s) and treat a `429` inside that loop as \"wait longer\". The official server SDKs do this for you. **Account-level checks.** Search by `device_id`, `user_hid`, `visitor_id` or `ip` to see how many accounts share a device, how many devices one account uses, or what else came from one IP address. When you count accounts, skip rows whose `user_hid` is empty or one of the values that do not identify a user: `anonymous`, `fail`, `-1` and `unknown`. **Validate before sending.** The server does not validate the path: an unknown `search_type` returns the latest identifications of the whole domain unfiltered, a malformed UUID or IPv4 value returns `500`, and a `limit` outside 1-100 silently becomes 20. **Paging.** Page with `offset` while it is below `total`. Rows are ordered by `created_at` only, so paging while new identifications arrive can repeat or skip rows: deduplicate on `identification_id`. **Latest state.** A row can be refined after the webhook was sent, for example when late network data re-scores it; its `ver` then increases. The History API always returns the latest version, which makes it the guaranteed read path. Reads are free: they do not use your included identifications. - :param search_type: Identifier to search by. Only these seven values are supported: - `request_id`: one identification (read a verdict); - `device_id`: every identification of one device; - `user_hid`: every identification of one account; - `visitor_id`: every identification of one visitor; - `ip`: every identification from one public IPv4 address; - `session_id`: every identification of one visit; - `cookie_id`: every identification with one browser cookie. The server does not reject other values: it ignores them and returns the latest identifications of the whole domain, so restrict the value on your side. (required) + :param search_type: Identifier to search by. These values are supported (`request_id` is a legacy alias of `identification_id`): - `identification_id`: one identification (read a verdict); - `device_id`: every identification of one device; - `user_hid`: every identification of one account; - `visitor_id`: every identification of one visitor; - `ip`: every identification from one public IPv4 address; - `session_id`: every identification of one visit; - `cookie_id`: every identification with one browser cookie. The server does not reject other values: it ignores them and returns the latest identifications of the whole domain, so restrict the value on your side. (required) :type search_type: str - :param value: Value of the identifier, validated on your side before sending: - `request_id`, `device_id`, `visitor_id`, `session_id`, `cookie_id`: a UUID of any version, the nil UUID included, matching `^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`. Send it lowercase. - `ip`: a dotted IPv4 address. IPv6 addresses cannot be searched. - `user_hid`: the exact, case-sensitive User HID as one path segment, encoded the way the server reads it: send the characters `A-Z a-z 0-9 - . _ ~ $ & + , : ; = @` unescaped and percent-encode every other byte of the UTF-8 value as uppercase `%XX`, including `! ' ( ) *`, spaces and `%` itself. The server compares any other encoding literally, so `%40` instead of `@`, or lowercase hex digits, return an empty page instead of the matching rows. Many HTTP clients and generated clients escape `$ & + , : ; = @` in path values: build this path yourself when yours does. A User HID that contains `/` cannot be searched, and most HTTP clients cannot send `.` or `..` because they remove them as dot segments; the pattern rejects these values. Hex-encoded hashes need no escaping at all. The server does not validate the value: a malformed UUID or IPv4 address gets a `500`. (required) + :param value: Value of the identifier, validated on your side before sending: - `identification_id`, `device_id`, `visitor_id`, `session_id`, `cookie_id`: a UUID of any version, the nil UUID included, matching `^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`. Send it lowercase. - `ip`: a dotted IPv4 address. IPv6 addresses cannot be searched. - `user_hid`: the exact, case-sensitive User HID as one path segment, encoded the way the server reads it: send the characters `A-Z a-z 0-9 - . _ ~ $ & + , : ; = @` unescaped and percent-encode every other byte of the UTF-8 value as uppercase `%XX`, including `! ' ( ) *`, spaces and `%` itself. The server compares any other encoding literally, so `%40` instead of `@`, or lowercase hex digits, return an empty page instead of the matching rows. Many HTTP clients and generated clients escape `$ & + , : ; = @` in path values: build this path yourself when yours does. A User HID that contains `/` cannot be searched, and most HTTP clients cannot send `.` or `..` because they remove them as dot segments; the pattern rejects these values. Hex-encoded hashes need no escaping at all. The server does not validate the value: a malformed UUID or IPv4 address gets a `500`. (required) :type value: str :param limit: Maximum number of identifications to return, from 1 to 100. The server replaces any other value (and a non-numeric one) with 20 instead of clamping it, so validate it on your side. :type limit: int - :param offset: Number of identifications to skip, for paging. The server treats negative or non-numeric values as 0. Rows are ordered by `created_at` only, so paging while new identifications arrive can repeat or skip rows: deduplicate on `request_id`. + :param offset: Number of identifications to skip, for paging. The server treats negative or non-numeric values as 0. Rows are ordered by `created_at` only, so paging while new identifications arrive can repeat or skip rows: deduplicate on `identification_id`. :type offset: int :param _request_timeout: timeout setting for this request. If one number provided, it will be total request @@ -127,7 +127,7 @@ def search_history( @validate_call def search_history_with_http_info( self, - search_type: Annotated[StrictStr, Field(description="Identifier to search by. Only these seven values are supported: - `request_id`: one identification (read a verdict); - `device_id`: every identification of one device; - `user_hid`: every identification of one account; - `visitor_id`: every identification of one visitor; - `ip`: every identification from one public IPv4 address; - `session_id`: every identification of one visit; - `cookie_id`: every identification with one browser cookie. The server does not reject other values: it ignores them and returns the latest identifications of the whole domain, so restrict the value on your side.")], + search_type: Annotated[StrictStr, Field(description="Identifier to search by. These values are supported (`request_id` is a legacy alias of `identification_id`): - `identification_id`: one identification (read a verdict); - `device_id`: every identification of one device; - `user_hid`: every identification of one account; - `visitor_id`: every identification of one visitor; - `ip`: every identification from one public IPv4 address; - `session_id`: every identification of one visit; - `cookie_id`: every identification with one browser cookie. The server does not reject other values: it ignores them and returns the latest identifications of the whole domain, so restrict the value on your side.")], value: Annotated[str, Field(min_length=1, strict=True, description="Value of the identifier, validated on your side before sending: - `identification_id`, `device_id`, `visitor_id`, `session_id`, `cookie_id`: a UUID of any version, the nil UUID included, matching `^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`. Send it lowercase. - `ip`: a dotted IPv4 address. IPv6 addresses cannot be searched. - `user_hid`: the exact, case-sensitive User HID as one path segment, encoded the way the server reads it: send the characters `A-Z a-z 0-9 - . _ ~ $ & + , : ; = @` unescaped and percent-encode every other byte of the UTF-8 value as uppercase `%XX`, including `! ' ( ) *`, spaces and `%` itself. The server compares any other encoding literally, so `%40` instead of `@`, or lowercase hex digits, return an empty page instead of the matching rows. Many HTTP clients and generated clients escape `$ & + , : ; = @` in path values: build this path yourself when yours does. A User HID that contains `/` cannot be searched, and most HTTP clients cannot send `.` or `..` because they remove them as dot segments; the pattern rejects these values. Hex-encoded hashes need no escaping at all. The server does not validate the value: a malformed UUID or IPv4 address gets a `500`.")], limit: Annotated[Optional[Annotated[int, Field(le=100, strict=True, ge=1)]], Field(description="Maximum number of identifications to return, from 1 to 100. The server replaces any other value (and a non-numeric one) with 20 instead of clamping it, so validate it on your side.")] = None, offset: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Number of identifications to skip, for paging. The server treats negative or non-numeric values as 0. Rows are ordered by `created_at` only, so paging while new identifications arrive can repeat or skip rows: deduplicate on `identification_id`.")] = None, @@ -146,15 +146,15 @@ def search_history_with_http_info( ) -> ApiResponse[HistoryPage]: """Search identifications - Returns the identifications of your domain that match one identifier, newest first, together with the total number of matches. The Private API Key selects the domain; identifications from its subdomains are included (`domain` holds the host, `site_domain` the registered domain). **Read one verdict.** After a protected action, search by `request_id` with `limit=1`. The row appears about 1-3 seconds after the browser call and can be refined for up to about 10 seconds as follow-up network checks finish, so start the identification when the user begins the action (for example when the signup form opens), not when the form is submitted. An empty `data` array means \"not scored yet\", never \"clean\". Poll with backoff (first try at once, then wait 250 ms, 500 ms, 1 s, then steps of about 1.5 s) and treat a `429` inside that loop as \"wait longer\". The official server SDKs do this for you. **Account-level checks.** Search by `device_id`, `user_hid`, `visitor_id` or `ip` to see how many accounts share a device, how many devices one account uses, or what else came from one IP address. When you count accounts, skip rows whose `user_hid` is empty or one of the values that do not identify a user: `anonymous`, `fail`, `-1` and `unknown`. **Validate before sending.** The server does not validate the path: an unknown `search_type` returns the latest identifications of the whole domain unfiltered, a malformed UUID or IPv4 value returns `500`, and a `limit` outside 1-100 silently becomes 20. **Paging.** Page with `offset` while it is below `total`. Rows are ordered by `created_at` only, so paging while new identifications arrive can repeat or skip rows: deduplicate on `request_id`. **Latest state.** A row can be refined after the webhook was sent, for example when late network data re-scores it; its `ver` then increases. The History API always returns the latest version, which makes it the guaranteed read path. Reads are free: they do not use your included identifications. + Returns the identifications of your domain that match one identifier, newest first, together with the total number of matches. The Private API Key selects the domain; identifications from its subdomains are included (`domain` holds the host, `site_domain` the registered domain). **Read one verdict.** After a protected action, search by `identification_id` with `limit=1`. The row appears about 1-3 seconds after the browser call and can be refined for up to about 10 seconds as follow-up network checks finish, so start the identification when the user begins the action (for example when the signup form opens), not when the form is submitted. An empty `data` array means \"not scored yet\", never \"clean\". Poll with backoff (first try at once, then wait 250 ms, 500 ms, 1 s, then steps of about 1.5 s) and treat a `429` inside that loop as \"wait longer\". The official server SDKs do this for you. **Account-level checks.** Search by `device_id`, `user_hid`, `visitor_id` or `ip` to see how many accounts share a device, how many devices one account uses, or what else came from one IP address. When you count accounts, skip rows whose `user_hid` is empty or one of the values that do not identify a user: `anonymous`, `fail`, `-1` and `unknown`. **Validate before sending.** The server does not validate the path: an unknown `search_type` returns the latest identifications of the whole domain unfiltered, a malformed UUID or IPv4 value returns `500`, and a `limit` outside 1-100 silently becomes 20. **Paging.** Page with `offset` while it is below `total`. Rows are ordered by `created_at` only, so paging while new identifications arrive can repeat or skip rows: deduplicate on `identification_id`. **Latest state.** A row can be refined after the webhook was sent, for example when late network data re-scores it; its `ver` then increases. The History API always returns the latest version, which makes it the guaranteed read path. Reads are free: they do not use your included identifications. - :param search_type: Identifier to search by. Only these seven values are supported: - `request_id`: one identification (read a verdict); - `device_id`: every identification of one device; - `user_hid`: every identification of one account; - `visitor_id`: every identification of one visitor; - `ip`: every identification from one public IPv4 address; - `session_id`: every identification of one visit; - `cookie_id`: every identification with one browser cookie. The server does not reject other values: it ignores them and returns the latest identifications of the whole domain, so restrict the value on your side. (required) + :param search_type: Identifier to search by. These values are supported (`request_id` is a legacy alias of `identification_id`): - `identification_id`: one identification (read a verdict); - `device_id`: every identification of one device; - `user_hid`: every identification of one account; - `visitor_id`: every identification of one visitor; - `ip`: every identification from one public IPv4 address; - `session_id`: every identification of one visit; - `cookie_id`: every identification with one browser cookie. The server does not reject other values: it ignores them and returns the latest identifications of the whole domain, so restrict the value on your side. (required) :type search_type: str - :param value: Value of the identifier, validated on your side before sending: - `request_id`, `device_id`, `visitor_id`, `session_id`, `cookie_id`: a UUID of any version, the nil UUID included, matching `^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`. Send it lowercase. - `ip`: a dotted IPv4 address. IPv6 addresses cannot be searched. - `user_hid`: the exact, case-sensitive User HID as one path segment, encoded the way the server reads it: send the characters `A-Z a-z 0-9 - . _ ~ $ & + , : ; = @` unescaped and percent-encode every other byte of the UTF-8 value as uppercase `%XX`, including `! ' ( ) *`, spaces and `%` itself. The server compares any other encoding literally, so `%40` instead of `@`, or lowercase hex digits, return an empty page instead of the matching rows. Many HTTP clients and generated clients escape `$ & + , : ; = @` in path values: build this path yourself when yours does. A User HID that contains `/` cannot be searched, and most HTTP clients cannot send `.` or `..` because they remove them as dot segments; the pattern rejects these values. Hex-encoded hashes need no escaping at all. The server does not validate the value: a malformed UUID or IPv4 address gets a `500`. (required) + :param value: Value of the identifier, validated on your side before sending: - `identification_id`, `device_id`, `visitor_id`, `session_id`, `cookie_id`: a UUID of any version, the nil UUID included, matching `^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`. Send it lowercase. - `ip`: a dotted IPv4 address. IPv6 addresses cannot be searched. - `user_hid`: the exact, case-sensitive User HID as one path segment, encoded the way the server reads it: send the characters `A-Z a-z 0-9 - . _ ~ $ & + , : ; = @` unescaped and percent-encode every other byte of the UTF-8 value as uppercase `%XX`, including `! ' ( ) *`, spaces and `%` itself. The server compares any other encoding literally, so `%40` instead of `@`, or lowercase hex digits, return an empty page instead of the matching rows. Many HTTP clients and generated clients escape `$ & + , : ; = @` in path values: build this path yourself when yours does. A User HID that contains `/` cannot be searched, and most HTTP clients cannot send `.` or `..` because they remove them as dot segments; the pattern rejects these values. Hex-encoded hashes need no escaping at all. The server does not validate the value: a malformed UUID or IPv4 address gets a `500`. (required) :type value: str :param limit: Maximum number of identifications to return, from 1 to 100. The server replaces any other value (and a non-numeric one) with 20 instead of clamping it, so validate it on your side. :type limit: int - :param offset: Number of identifications to skip, for paging. The server treats negative or non-numeric values as 0. Rows are ordered by `created_at` only, so paging while new identifications arrive can repeat or skip rows: deduplicate on `request_id`. + :param offset: Number of identifications to skip, for paging. The server treats negative or non-numeric values as 0. Rows are ordered by `created_at` only, so paging while new identifications arrive can repeat or skip rows: deduplicate on `identification_id`. :type offset: int :param _request_timeout: timeout setting for this request. If one number provided, it will be total request @@ -212,7 +212,7 @@ def search_history_with_http_info( @validate_call def search_history_without_preload_content( self, - search_type: Annotated[StrictStr, Field(description="Identifier to search by. Only these seven values are supported: - `request_id`: one identification (read a verdict); - `device_id`: every identification of one device; - `user_hid`: every identification of one account; - `visitor_id`: every identification of one visitor; - `ip`: every identification from one public IPv4 address; - `session_id`: every identification of one visit; - `cookie_id`: every identification with one browser cookie. The server does not reject other values: it ignores them and returns the latest identifications of the whole domain, so restrict the value on your side.")], + search_type: Annotated[StrictStr, Field(description="Identifier to search by. These values are supported (`request_id` is a legacy alias of `identification_id`): - `identification_id`: one identification (read a verdict); - `device_id`: every identification of one device; - `user_hid`: every identification of one account; - `visitor_id`: every identification of one visitor; - `ip`: every identification from one public IPv4 address; - `session_id`: every identification of one visit; - `cookie_id`: every identification with one browser cookie. The server does not reject other values: it ignores them and returns the latest identifications of the whole domain, so restrict the value on your side.")], value: Annotated[str, Field(min_length=1, strict=True, description="Value of the identifier, validated on your side before sending: - `identification_id`, `device_id`, `visitor_id`, `session_id`, `cookie_id`: a UUID of any version, the nil UUID included, matching `^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`. Send it lowercase. - `ip`: a dotted IPv4 address. IPv6 addresses cannot be searched. - `user_hid`: the exact, case-sensitive User HID as one path segment, encoded the way the server reads it: send the characters `A-Z a-z 0-9 - . _ ~ $ & + , : ; = @` unescaped and percent-encode every other byte of the UTF-8 value as uppercase `%XX`, including `! ' ( ) *`, spaces and `%` itself. The server compares any other encoding literally, so `%40` instead of `@`, or lowercase hex digits, return an empty page instead of the matching rows. Many HTTP clients and generated clients escape `$ & + , : ; = @` in path values: build this path yourself when yours does. A User HID that contains `/` cannot be searched, and most HTTP clients cannot send `.` or `..` because they remove them as dot segments; the pattern rejects these values. Hex-encoded hashes need no escaping at all. The server does not validate the value: a malformed UUID or IPv4 address gets a `500`.")], limit: Annotated[Optional[Annotated[int, Field(le=100, strict=True, ge=1)]], Field(description="Maximum number of identifications to return, from 1 to 100. The server replaces any other value (and a non-numeric one) with 20 instead of clamping it, so validate it on your side.")] = None, offset: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Number of identifications to skip, for paging. The server treats negative or non-numeric values as 0. Rows are ordered by `created_at` only, so paging while new identifications arrive can repeat or skip rows: deduplicate on `identification_id`.")] = None, @@ -231,15 +231,15 @@ def search_history_without_preload_content( ) -> RESTResponseType: """Search identifications - Returns the identifications of your domain that match one identifier, newest first, together with the total number of matches. The Private API Key selects the domain; identifications from its subdomains are included (`domain` holds the host, `site_domain` the registered domain). **Read one verdict.** After a protected action, search by `request_id` with `limit=1`. The row appears about 1-3 seconds after the browser call and can be refined for up to about 10 seconds as follow-up network checks finish, so start the identification when the user begins the action (for example when the signup form opens), not when the form is submitted. An empty `data` array means \"not scored yet\", never \"clean\". Poll with backoff (first try at once, then wait 250 ms, 500 ms, 1 s, then steps of about 1.5 s) and treat a `429` inside that loop as \"wait longer\". The official server SDKs do this for you. **Account-level checks.** Search by `device_id`, `user_hid`, `visitor_id` or `ip` to see how many accounts share a device, how many devices one account uses, or what else came from one IP address. When you count accounts, skip rows whose `user_hid` is empty or one of the values that do not identify a user: `anonymous`, `fail`, `-1` and `unknown`. **Validate before sending.** The server does not validate the path: an unknown `search_type` returns the latest identifications of the whole domain unfiltered, a malformed UUID or IPv4 value returns `500`, and a `limit` outside 1-100 silently becomes 20. **Paging.** Page with `offset` while it is below `total`. Rows are ordered by `created_at` only, so paging while new identifications arrive can repeat or skip rows: deduplicate on `request_id`. **Latest state.** A row can be refined after the webhook was sent, for example when late network data re-scores it; its `ver` then increases. The History API always returns the latest version, which makes it the guaranteed read path. Reads are free: they do not use your included identifications. + Returns the identifications of your domain that match one identifier, newest first, together with the total number of matches. The Private API Key selects the domain; identifications from its subdomains are included (`domain` holds the host, `site_domain` the registered domain). **Read one verdict.** After a protected action, search by `identification_id` with `limit=1`. The row appears about 1-3 seconds after the browser call and can be refined for up to about 10 seconds as follow-up network checks finish, so start the identification when the user begins the action (for example when the signup form opens), not when the form is submitted. An empty `data` array means \"not scored yet\", never \"clean\". Poll with backoff (first try at once, then wait 250 ms, 500 ms, 1 s, then steps of about 1.5 s) and treat a `429` inside that loop as \"wait longer\". The official server SDKs do this for you. **Account-level checks.** Search by `device_id`, `user_hid`, `visitor_id` or `ip` to see how many accounts share a device, how many devices one account uses, or what else came from one IP address. When you count accounts, skip rows whose `user_hid` is empty or one of the values that do not identify a user: `anonymous`, `fail`, `-1` and `unknown`. **Validate before sending.** The server does not validate the path: an unknown `search_type` returns the latest identifications of the whole domain unfiltered, a malformed UUID or IPv4 value returns `500`, and a `limit` outside 1-100 silently becomes 20. **Paging.** Page with `offset` while it is below `total`. Rows are ordered by `created_at` only, so paging while new identifications arrive can repeat or skip rows: deduplicate on `identification_id`. **Latest state.** A row can be refined after the webhook was sent, for example when late network data re-scores it; its `ver` then increases. The History API always returns the latest version, which makes it the guaranteed read path. Reads are free: they do not use your included identifications. - :param search_type: Identifier to search by. Only these seven values are supported: - `request_id`: one identification (read a verdict); - `device_id`: every identification of one device; - `user_hid`: every identification of one account; - `visitor_id`: every identification of one visitor; - `ip`: every identification from one public IPv4 address; - `session_id`: every identification of one visit; - `cookie_id`: every identification with one browser cookie. The server does not reject other values: it ignores them and returns the latest identifications of the whole domain, so restrict the value on your side. (required) + :param search_type: Identifier to search by. These values are supported (`request_id` is a legacy alias of `identification_id`): - `identification_id`: one identification (read a verdict); - `device_id`: every identification of one device; - `user_hid`: every identification of one account; - `visitor_id`: every identification of one visitor; - `ip`: every identification from one public IPv4 address; - `session_id`: every identification of one visit; - `cookie_id`: every identification with one browser cookie. The server does not reject other values: it ignores them and returns the latest identifications of the whole domain, so restrict the value on your side. (required) :type search_type: str - :param value: Value of the identifier, validated on your side before sending: - `request_id`, `device_id`, `visitor_id`, `session_id`, `cookie_id`: a UUID of any version, the nil UUID included, matching `^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`. Send it lowercase. - `ip`: a dotted IPv4 address. IPv6 addresses cannot be searched. - `user_hid`: the exact, case-sensitive User HID as one path segment, encoded the way the server reads it: send the characters `A-Z a-z 0-9 - . _ ~ $ & + , : ; = @` unescaped and percent-encode every other byte of the UTF-8 value as uppercase `%XX`, including `! ' ( ) *`, spaces and `%` itself. The server compares any other encoding literally, so `%40` instead of `@`, or lowercase hex digits, return an empty page instead of the matching rows. Many HTTP clients and generated clients escape `$ & + , : ; = @` in path values: build this path yourself when yours does. A User HID that contains `/` cannot be searched, and most HTTP clients cannot send `.` or `..` because they remove them as dot segments; the pattern rejects these values. Hex-encoded hashes need no escaping at all. The server does not validate the value: a malformed UUID or IPv4 address gets a `500`. (required) + :param value: Value of the identifier, validated on your side before sending: - `identification_id`, `device_id`, `visitor_id`, `session_id`, `cookie_id`: a UUID of any version, the nil UUID included, matching `^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`. Send it lowercase. - `ip`: a dotted IPv4 address. IPv6 addresses cannot be searched. - `user_hid`: the exact, case-sensitive User HID as one path segment, encoded the way the server reads it: send the characters `A-Z a-z 0-9 - . _ ~ $ & + , : ; = @` unescaped and percent-encode every other byte of the UTF-8 value as uppercase `%XX`, including `! ' ( ) *`, spaces and `%` itself. The server compares any other encoding literally, so `%40` instead of `@`, or lowercase hex digits, return an empty page instead of the matching rows. Many HTTP clients and generated clients escape `$ & + , : ; = @` in path values: build this path yourself when yours does. A User HID that contains `/` cannot be searched, and most HTTP clients cannot send `.` or `..` because they remove them as dot segments; the pattern rejects these values. Hex-encoded hashes need no escaping at all. The server does not validate the value: a malformed UUID or IPv4 address gets a `500`. (required) :type value: str :param limit: Maximum number of identifications to return, from 1 to 100. The server replaces any other value (and a non-numeric one) with 20 instead of clamping it, so validate it on your side. :type limit: int - :param offset: Number of identifications to skip, for paging. The server treats negative or non-numeric values as 0. Rows are ordered by `created_at` only, so paging while new identifications arrive can repeat or skip rows: deduplicate on `request_id`. + :param offset: Number of identifications to skip, for paging. The server treats negative or non-numeric values as 0. Rows are ordered by `created_at` only, so paging while new identifications arrive can repeat or skip rows: deduplicate on `identification_id`. :type offset: int :param _request_timeout: timeout setting for this request. If one number provided, it will be total request diff --git a/src/shieldlabs/_helpers.py b/src/shieldlabs/_helpers.py index b702cc4..7d4fbc8 100644 --- a/src/shieldlabs/_helpers.py +++ b/src/shieldlabs/_helpers.py @@ -82,7 +82,8 @@ def evaluate_identification( Checks run in this order and the first failure wins: 1. ``missing``: no identification (unverified, never clean). - 2. ``replayed``: ``is_replay(identification_id)`` returned ``True``. The SDK stores nothing; keep + 2. ``replayed``: ``is_replay(identification_id)`` returned ``True``. + The SDK stores nothing; keep used request IDs in your own store. 3. ``stale``: ``observed_at`` is older than ``max_age`` (a finite number of seconds, 0 or greater, or a timedelta). diff --git a/src/shieldlabs/_validation.py b/src/shieldlabs/_validation.py index acc6759..b3ec641 100644 --- a/src/shieldlabs/_validation.py +++ b/src/shieldlabs/_validation.py @@ -33,7 +33,14 @@ ] LookupType = Literal[ - "ip", "user_hid", "visitor_id", "identification_id", "request_id", "device_id", "session_id", "cookie_id" + "ip", + "user_hid", + "visitor_id", + "identification_id", + "request_id", + "device_id", + "session_id", + "cookie_id", ] """The identifier a History API lookup searches by.""" @@ -51,7 +58,9 @@ DEFAULT_HISTORY_BASE_URL = "https://account.shieldlabs.ai" DEFAULT_MANAGEMENT_BASE_URL = "https://api.shieldlabs.ai" -_UUID_TYPES = frozenset({"visitor_id", "identification_id", "request_id", "device_id", "session_id", "cookie_id"}) +_UUID_TYPES = frozenset( + {"visitor_id", "identification_id", "request_id", "device_id", "session_id", "cookie_id"} +) _UUID_PATTERN = re.compile( r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}" ) diff --git a/tests/test_example_app.py b/tests/test_example_app.py index 8f8769f..2f2489b 100644 --- a/tests/test_example_app.py +++ b/tests/test_example_app.py @@ -61,7 +61,9 @@ def app_client(monkeypatch: pytest.MonkeyPatch) -> Iterator[tuple[TestClient, An def _signup(client: TestClient, identification_id: str = REQUEST_ID) -> httpx.Response: - return client.post("/signup", json={"email": "user@example.com", "identificationId": identification_id}) + return client.post( + "/signup", json={"email": "user@example.com", "identificationId": identification_id} + ) def test_trusted_signup_is_created_once(app_client: tuple[TestClient, Any]) -> None: diff --git a/tests/test_history.py b/tests/test_history.py index 62aff10..fc279b6 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -241,7 +241,9 @@ def test_validation_error_is_also_value_error(client: ShieldLabs) -> None: # --- iteration --------------------------------------------------------------------------- -def test_iter_dedupes_on_identification_id_and_stops_at_total(client: ShieldLabs, mock: Any) -> None: +def test_iter_dedupes_on_identification_id_and_stops_at_total( + client: ShieldLabs, mock: Any +) -> None: route = mock.get(host=HISTORY_HOST).mock( side_effect=[ httpx.Response(200, json=history_body(row(uuid_for(1)), row(uuid_for(2)), total=5)), From 6c6f459ba5839f03f88169cf328a01f6908e2f10 Mon Sep 17 00:00:00 2001 From: EgorMajj <91486022+EgorMajj@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:27:46 +0300 Subject: [PATCH 3/3] style: apply Python formatter after identifier rename --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 3bbe7bc..6d86e6f 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,9 @@ def allow_signup(identification_id: str) -> bool: identification = client.identifications.get(identification_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_identification_ids) + verdict = evaluate_identification( + identification, is_replay=lambda rid: rid in used_identification_ids + ) if identification is not None: used_identification_ids.add(identification.identification_id) return verdict.ok