From b3071770a84bfb9e935eb1fe127e77ff7417590b Mon Sep 17 00:00:00 2001
From: EgorMajj <91486022+EgorMajj@users.noreply.github.com>
Date: Thu, 1 Oct 2026 17:38:43 +0300
Subject: [PATCH] chore: pull the shared API contract from shieldlabs-openapi
Adds contract-sync.json, .shieldlabs-contract.lock and scripts/sync_contract.py
so CI can --check the committed fixtures offline. The contract-sync workflow
takes a new shieldlabs-openapi release (daily, by hand, or repository_dispatch
contract-release), runs the test suite and opens a pull request.
The fixtures themselves are now those of contract v1.0.1.
---
.github/workflows/ci.yml | 10 +
.github/workflows/contract-sync.yml | 180 ++++++++
.shieldlabs-contract.lock | 21 +
CONTRIBUTING.md | 5 +
contract-sync.json | 20 +
scripts/sync_contract.py | 388 ++++++++++++++++++
tests/_support.py | 4 +-
tests/data/history-page.json | 10 +-
tests/data/normalization-cases.json | 42 +-
tests/data/webhook-identification-scored.json | 10 +-
.../webhook-identification-scored.raw.txt | 2 +-
tests/data/webhook-signature-vectors.json | 12 +-
tests/test_history.py | 10 +-
tests/test_webhooks.py | 2 +-
14 files changed, 670 insertions(+), 46 deletions(-)
create mode 100644 .github/workflows/contract-sync.yml
create mode 100644 .shieldlabs-contract.lock
create mode 100644 contract-sync.json
create mode 100644 scripts/sync_contract.py
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 9976f0f..5707ee0 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -70,3 +70,13 @@ jobs:
python -m pip install build==1.6.1 twine==7.0.0
python -m build
twine check --strict dist/*
+
+ contract:
+ name: Shared contract files match the lock
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
+ with:
+ persist-credentials: false
+ - name: Contract files match .shieldlabs-contract.lock
+ run: python3 scripts/sync_contract.py --check
diff --git a/.github/workflows/contract-sync.yml b/.github/workflows/contract-sync.yml
new file mode 100644
index 0000000..c3ad913
--- /dev/null
+++ b/.github/workflows/contract-sync.yml
@@ -0,0 +1,180 @@
+name: Contract sync
+
+# Takes the shared API contract (test fixtures, see contract-sync.json) from the newest
+# shieldlabs-openapi release, or from the release given by hand or by a contract-release
+# dispatch. When files change, it runs the full test suite and opens a pull request with the
+# result. Pull requests opened with GITHUB_TOKEN start no other workflow, so the tests run here.
+
+on:
+ schedule:
+ - cron: '17 6 * * *'
+ workflow_dispatch:
+ inputs:
+ ref:
+ description: "Contract release: 'latest' or a tag such as v1.0.1"
+ required: false
+ default: latest
+ type: string
+ repository_dispatch:
+ types: [contract-release]
+
+permissions:
+ contents: read
+
+concurrency:
+ group: contract-sync-${{ github.repository }}
+ cancel-in-progress: false
+
+jobs:
+ sync:
+ name: Sync the shared contract
+ runs-on: ubuntu-latest
+ timeout-minutes: 45
+ permissions:
+ contents: write
+ pull-requests: write
+ steps:
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
+ with:
+ persist-credentials: false
+
+ - name: Sync the contract files
+ id: sync
+ env:
+ INPUT_REF: ${{ inputs.ref }}
+ DISPATCH_REF: ${{ github.event.client_payload.ref }}
+ # Only raises the GitHub API rate limit when resolving 'latest'.
+ GITHUB_TOKEN: ${{ github.token }}
+ run: |
+ ref="${DISPATCH_REF:-${INPUT_REF:-latest}}"
+ if ! [[ "$ref" =~ ^(latest|v[0-9]+\.[0-9]+\.[0-9]+)$ ]]; then
+ echo "::error::Invalid contract ref: use latest or a tag such as v1.0.1."
+ exit 1
+ fi
+ python3 scripts/sync_contract.py --ref "$ref"
+ resolved="$(python3 -c 'import json; print(json.load(open(".shieldlabs-contract.lock"))["ref"])')"
+ if ! [[ "$resolved" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+ echo "::error::Unexpected ref in .shieldlabs-contract.lock."
+ exit 1
+ fi
+ mapfile -t files < <(python3 -c 'import json; print("\n".join(json.load(open("contract-sync.json"))["files"].values()))')
+ if [ -n "$(git status --porcelain -- "${files[@]}")" ]; then
+ echo "changed=true" >> "$GITHUB_OUTPUT"
+ else
+ # Same bytes under a newer tag: keep the lock as it is, no pull request.
+ git checkout -- .shieldlabs-contract.lock
+ echo "Contract files are already those of $resolved."
+ echo "changed=false" >> "$GITHUB_OUTPUT"
+ fi
+ echo "ref=$resolved" >> "$GITHUB_OUTPUT"
+
+ - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
+ if: steps.sync.outputs.changed == 'true'
+ with:
+ python-version: '3.12'
+
+ - name: Run the full test suite
+ id: tests
+ if: steps.sync.outputs.changed == 'true'
+ continue-on-error: true
+ shell: bash
+ run: |
+ {
+ python -m pip install --upgrade pip
+ python -m pip install -e ".[dev]" -r examples/requirements.txt
+ ruff check .
+ ruff format --check .
+ mypy --strict src
+ pytest -q --cov=shieldlabs --cov-report=term-missing
+ python -m pip install build==1.6.1 twine==7.0.0
+ python -m build
+ twine check --strict dist/*
+ } 2>&1 | tee "$RUNNER_TEMP/contract-tests.log"
+
+ - name: Push the branch and open or update the pull request
+ if: steps.sync.outputs.changed == 'true'
+ env:
+ GH_TOKEN: ${{ github.token }}
+ CONTRACT_REF: ${{ steps.sync.outputs.ref }}
+ TESTS: ${{ steps.tests.outcome }}
+ run: |
+ set -euo pipefail
+ if ! [[ "$CONTRACT_REF" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+ echo "::error::Unexpected contract ref."
+ exit 1
+ fi
+ export GH_REPO="$GITHUB_REPOSITORY"
+ base="$GITHUB_REF_NAME"
+ branch="contract-sync/$CONTRACT_REF"
+ label="contract-change-needs-code"
+ version="$(python3 -c 'import json; print(json.load(open(".shieldlabs-contract.lock"))["contract_version"])')"
+ mapfile -t files < <(python3 -c 'import json; print("\n".join(json.load(open("contract-sync.json"))["files"].values()))')
+
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ git switch -c "$branch"
+ git add -- "${files[@]}" .shieldlabs-contract.lock
+ changes="$(git diff --cached --name-status)"
+ git commit -q -m "chore: sync the shared contract $CONTRACT_REF"
+
+ remote_sha="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/$branch" --jq .object.sha 2>/dev/null || true)"
+ remote_tree=""
+ if [[ "$remote_sha" =~ ^[0-9a-f]{40}$ ]]; then
+ remote_tree="$(gh api "repos/$GITHUB_REPOSITORY/git/commits/$remote_sha" --jq .tree.sha)"
+ fi
+ if [ "$remote_tree" = "$(git rev-parse 'HEAD^{tree}')" ]; then
+ echo "$branch already holds these changes."
+ else
+ git push --force "https://x-access-token:$GH_TOKEN@${GITHUB_SERVER_URL#https://}/$GITHUB_REPOSITORY.git" "HEAD:refs/heads/$branch"
+ fi
+
+ body="$RUNNER_TEMP/contract-pr.md"
+ {
+ echo "Syncs the shared API contract to [$CONTRACT_REF](https://github.com/ShieldLabs-ai/shieldlabs-openapi/releases/tag/$CONTRACT_REF) of shieldlabs-openapi (contract_version $version)."
+ echo
+ echo "Changed files:"
+ echo
+ echo '```'
+ echo "$changes"
+ echo '```'
+ echo
+ if [ "$TESTS" = "success" ]; then
+ echo "Tests: the full test suite passes with these files."
+ else
+ echo "Tests: **the full test suite fails with these files** ($TESTS). The SDK must change before it can take this contract; push the fixes to this branch."
+ echo
+ echo "Last lines of the test log
"
+ echo
+ echo '```'
+ tail -n 80 "$RUNNER_TEMP/contract-tests.log" 2>/dev/null | cut -c1-300 || true
+ echo '```'
+ echo
+ echo " "
+ fi
+ echo
+ echo "Run: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"
+ echo
+ echo "Pull requests opened by this workflow start no other workflow. Close and reopen this pull request (or push to its branch) to run the usual checks."
+ } > "$body"
+
+ number="$(gh pr list --head "$branch" --base "$base" --state open --json number --jq '.[0].number // empty')"
+ if [ -n "$number" ]; then
+ gh pr edit "$number" --body-file "$body"
+ elif ! gh pr create --base "$base" --head "$branch" --title "chore: sync the shared contract $CONTRACT_REF" --body-file "$body"; then
+ echo "::error::Could not open the pull request. Enable 'Allow GitHub Actions to create and approve pull requests' for this repository, or open it by hand: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/compare/$base...$branch"
+ exit 1
+ fi
+
+ if [ "$TESTS" = "success" ]; then
+ gh pr edit "$branch" --remove-label "$label" >/dev/null 2>&1 || true
+ else
+ gh label create "$label" --force --color D93F0B --description "A contract sync whose tests fail: the SDK must change first" \
+ || echo "::warning::Could not create the $label label."
+ gh pr edit "$branch" --add-label "$label"
+ fi
+
+ - name: Fail when the new contract breaks the tests
+ if: steps.tests.outcome == 'failure'
+ run: |
+ echo "::error::The test suite fails with the new contract files. See the pull request labeled contract-change-needs-code."
+ exit 1
diff --git a/.shieldlabs-contract.lock b/.shieldlabs-contract.lock
new file mode 100644
index 0000000..a4cda52
--- /dev/null
+++ b/.shieldlabs-contract.lock
@@ -0,0 +1,21 @@
+{
+ "ref": "v1.0.1",
+ "contract_version": "1.0.1",
+ "files": {
+ "error-responses.json": "2be02da69063dcbbf40643559ac6eb6250ae22f5b41562f79810e97e9e3915e0",
+ "history-empty.json": "5616a1180d8234908daca7a08a05afa49a2d0ce06426cc97ebaf7971ba05b339",
+ "history-page.json": "50abc4a9622f800154043b2c67c0479f7e445d01ea46e882e973b217f32f7c9e",
+ "management-profile-expected.json": "5069ddc195bd0a11f5f53c8c964a6096bb54c116c0797fd3a6dd5d26149bc6f9",
+ "management-profile.json": "a02126112d37f0e5857f71d51cbfa201e876217001433af4f41ff27c31e488e6",
+ "normalization-cases.json": "66f1dae0293c26bf55370e713dc54a801b81d433e892022eede22ab592c5bcbe",
+ "risk-band-cases.json": "c9b168f5df1738faccffd69cea23bfdc8d299329ac2a129206ddf7cf9891c20c",
+ "signal-slug-cases.json": "8a79760a89503d48eb11c1e5e5d9b7dbe6756734092fc5a0a93f110f46228365",
+ "webhook-identification-scored.json": "be64dd698516acd46c773c62000bacf5e152ff54b7845c3b1819a563b948ffb2",
+ "webhook-identification-scored.raw.txt": "4cfa0fd3fe0fcfed1de1172d2b7d94c17246c0d6f27c2125909f03d05c71b029",
+ "webhook-ping.json": "90569a442e10c0af00393fcc455e5347d88b1e60624749056815b09ca267a471",
+ "webhook-ping.raw.txt": "5d0fde10a301d142970dc184d2a61cbb03ee58394481abb2e316065a390d4f77",
+ "webhook-rate-limited.json": "4985e08b2b306187d4ad3a78dc9e9d6ecf3bfcbd5fb781921bc02bb9f8a90ccd",
+ "webhook-signature-vectors.json": "33daee0c2823bb9b86b63a688b3065e9cfde3b8ecec14f9a988dfbdd5552fe89",
+ "webhook-test-delivery.json": "7678d32cf757ec604adb35b5b1dbe2a226530cec25266756ed96268f81c6d16d"
+ }
+}
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index c51a850..8e5bd51 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -38,6 +38,11 @@ They are identical in every SDK and compared byte for byte (the `.raw.txt` files
webhook bodies without a trailing newline), so do not edit them in a pull request. If a fixture
looks wrong, open an issue.
+They come from `contract/` in shieldlabs-openapi. `contract-sync.json` maps each file,
+`.shieldlabs-contract.lock` records the release they come from, and CI runs
+`python3 scripts/sync_contract.py --check`. The `contract-sync.yml` workflow checks for a new
+release every day and opens a pull request, with the test result, when it changes the files.
+
## Writing style
Docs, docstrings and comments use plain technical English and the terms used in the README.
diff --git a/contract-sync.json b/contract-sync.json
new file mode 100644
index 0000000..db5e87c
--- /dev/null
+++ b/contract-sync.json
@@ -0,0 +1,20 @@
+{
+ "source": "shieldlabs-openapi",
+ "files": {
+ "error-responses.json": "tests/data/error-responses.json",
+ "history-empty.json": "tests/data/history-empty.json",
+ "history-page.json": "tests/data/history-page.json",
+ "management-profile-expected.json": "tests/data/management-profile-expected.json",
+ "management-profile.json": "tests/data/management-profile.json",
+ "normalization-cases.json": "tests/data/normalization-cases.json",
+ "risk-band-cases.json": "tests/data/risk-band-cases.json",
+ "signal-slug-cases.json": "tests/data/signal-slug-cases.json",
+ "webhook-identification-scored.json": "tests/data/webhook-identification-scored.json",
+ "webhook-identification-scored.raw.txt": "tests/data/webhook-identification-scored.raw.txt",
+ "webhook-ping.json": "tests/data/webhook-ping.json",
+ "webhook-ping.raw.txt": "tests/data/webhook-ping.raw.txt",
+ "webhook-rate-limited.json": "tests/data/webhook-rate-limited.json",
+ "webhook-signature-vectors.json": "tests/data/webhook-signature-vectors.json",
+ "webhook-test-delivery.json": "tests/data/webhook-test-delivery.json"
+ }
+}
diff --git a/scripts/sync_contract.py b/scripts/sync_contract.py
new file mode 100644
index 0000000..bf25d3a
--- /dev/null
+++ b/scripts/sync_contract.py
@@ -0,0 +1,388 @@
+#!/usr/bin/env python3
+"""Sync the shared ShieldLabs API contract files into an SDK repository.
+
+The contract lives in the public shieldlabs-openapi repository under contract/: shared test
+fixtures plus contract/manifest.json, which lists the SHA-256 of every file. An SDK repository
+says where each file goes in contract-sync.json:
+
+ {"source": "shieldlabs-openapi",
+ "files": {"normalization-cases.json": "tests/data/normalization-cases.json"}}
+
+and records what it synced in .shieldlabs-contract.lock:
+
+ {"ref": "v1.0.1", "contract_version": "1.0.1",
+ "files": {"normalization-cases.json": ""}}
+
+Usage, from the repository root (Python 3.9 or later, standard library only):
+
+ python3 scripts/sync_contract.py sync the newest vX.Y.Z tag
+ python3 scripts/sync_contract.py --ref v1.0.1 sync one release
+ python3 scripts/sync_contract.py --check offline: committed files match the lock
+
+The canonical copy of this script is scripts/sync_contract.py in shieldlabs-openapi. SDK
+repositories keep an identical copy, so --check works offline.
+"""
+
+import argparse
+import hashlib
+import json
+import os
+import re
+import sys
+import tempfile
+import time
+import urllib.error
+import urllib.request
+from pathlib import Path, PurePosixPath
+from typing import Callable, Optional, TextIO, Union
+
+DEFAULT_OWNER = "ShieldLabs-ai"
+CONFIG_FILE = "contract-sync.json"
+LOCK_FILE = ".shieldlabs-contract.lock"
+MANIFEST_FILE = "manifest.json"
+CONTRACT_DIR = "contract"
+RAW_BASE = "https://raw.githubusercontent.com"
+API_BASE = "https://api.github.com"
+USER_AGENT = "shieldlabs-contract-sync"
+
+REF_PATTERN = re.compile(r"latest|v[0-9]+\.[0-9]+\.[0-9]+")
+TAG_PATTERN = re.compile(r"v([0-9]+)\.([0-9]+)\.([0-9]+)")
+NAME_PATTERN = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*")
+SOURCE_PATTERN = re.compile(r"(?:[A-Za-z0-9_.-]+/)?[A-Za-z0-9_.-]+")
+SHA256_PATTERN = re.compile(r"[0-9a-f]{64}")
+NEXT_LINK = re.compile(r'<([^>]+)>;\s*rel="next"')
+
+# (url, headers) -> (body, URL of the next page from the Link header, if any)
+Fetch = Callable[[str, dict[str, str]], tuple[bytes, Optional[str]]]
+
+
+class ContractError(Exception):
+ """A problem to fix: bad input, a missing file, a hash mismatch or drift."""
+
+
+class NotFoundError(ContractError):
+ """The requested file does not exist."""
+
+
+def sha256_bytes(data: bytes) -> str:
+ return hashlib.sha256(data).hexdigest()
+
+
+def validate_ref(ref: str) -> str:
+ if not REF_PATTERN.fullmatch(ref):
+ raise ContractError(f"invalid ref {ref!r}: use 'latest' or a tag such as v1.2.3")
+ return ref
+
+
+def validate_name(name: object) -> str:
+ if not isinstance(name, str) or not NAME_PATTERN.fullmatch(name) or name == MANIFEST_FILE:
+ raise ContractError(f"invalid contract file name {name!r}")
+ return name
+
+
+def validate_digests(files: dict, where: str) -> dict[str, str]:
+ for name, digest in files.items():
+ validate_name(name)
+ if not isinstance(digest, str) or not SHA256_PATTERN.fullmatch(digest):
+ raise ContractError(f"{where}: invalid SHA-256 for {name}")
+ return files
+
+
+def destination(root: Path, value: object) -> Path:
+ if not isinstance(value, str) or not value or "\\" in value:
+ raise ContractError(f"invalid destination path {value!r}")
+ rel = PurePosixPath(value)
+ if rel.is_absolute() or ".." in rel.parts or not rel.parts:
+ raise ContractError(f"destination must be a relative path inside the repository: {value!r}")
+ return root.joinpath(*rel.parts)
+
+
+def read_json(path: Path, what: str) -> object:
+ try:
+ return json.loads(path.read_text(encoding="utf-8"))
+ except FileNotFoundError:
+ raise ContractError(f"{what} not found: {path}") from None
+ except (ValueError, UnicodeDecodeError) as exc:
+ raise ContractError(f"{what} is not valid JSON: {path}: {exc}") from None
+
+
+class Config:
+ """contract-sync.json: the source repository and where each contract file goes."""
+
+ def __init__(self, root: Path, owner: str, repo: str, files: dict[str, Path]) -> None:
+ self.root = root
+ self.owner = owner
+ self.repo = repo
+ self.files = files
+
+ @classmethod
+ def load(cls, root: Path, path: Path) -> "Config":
+ data = read_json(path, CONFIG_FILE)
+ if not isinstance(data, dict):
+ raise ContractError(f"{path}: expected a JSON object")
+ source = data.get("source")
+ if not isinstance(source, str) or not SOURCE_PATTERN.fullmatch(source):
+ raise ContractError(f"{path}: 'source' must be a repository such as shieldlabs-openapi")
+ owner, _, repo = source.rpartition("/")
+ files = data.get("files")
+ if not isinstance(files, dict) or not files:
+ raise ContractError(f"{path}: 'files' must map contract file names to paths")
+ mapped: dict[str, Path] = {}
+ for name, value in files.items():
+ target = destination(root, value)
+ if target in mapped.values():
+ raise ContractError(f"{path}: two contract files map to {value}")
+ mapped[validate_name(name)] = target
+ return cls(root, owner or DEFAULT_OWNER, repo, mapped)
+
+ def display(self, path: Path) -> str:
+ return path.relative_to(self.root).as_posix()
+
+
+def parse_manifest(data: bytes) -> tuple[str, dict[str, str]]:
+ try:
+ manifest = json.loads(data.decode("utf-8"))
+ except (ValueError, UnicodeDecodeError) as exc:
+ raise ContractError(f"{MANIFEST_FILE} is not valid JSON: {exc}") from None
+ if not isinstance(manifest, dict):
+ raise ContractError(f"{MANIFEST_FILE}: expected a JSON object")
+ version, files = manifest.get("contract_version"), manifest.get("files")
+ if not isinstance(version, str) or not version or not isinstance(files, dict):
+ raise ContractError(f"{MANIFEST_FILE} needs 'contract_version' and 'files'")
+ return version, validate_digests(files, MANIFEST_FILE)
+
+
+def load_lock(path: Path) -> tuple[str, str, dict[str, str]]:
+ data = read_json(path, LOCK_FILE)
+ if not isinstance(data, dict):
+ raise ContractError(f"{path}: expected a JSON object")
+ ref, version, files = data.get("ref"), data.get("contract_version"), data.get("files")
+ if not isinstance(ref, str) or not TAG_PATTERN.fullmatch(ref):
+ raise ContractError(f"{path}: 'ref' must be a tag such as v1.2.3")
+ if not isinstance(version, str) or not isinstance(files, dict):
+ raise ContractError(f"{path}: 'contract_version' and 'files' are required")
+ return ref, version, validate_digests(files, str(path))
+
+
+def render_lock(ref: str, version: str, files: dict[str, str]) -> str:
+ lock = {"ref": ref, "contract_version": version, "files": dict(sorted(files.items()))}
+ return json.dumps(lock, indent=2) + "\n"
+
+
+def http_fetch(url: str, headers: dict[str, str]) -> tuple[bytes, Optional[str]]:
+ """GET with a timeout; network errors and 5xx answers are retried twice."""
+ request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, **headers})
+ for attempt in range(3):
+ if attempt:
+ time.sleep(2**attempt)
+ try:
+ with urllib.request.urlopen(request, timeout=30) as response:
+ match = NEXT_LINK.search(response.headers.get("Link") or "")
+ return response.read(), match.group(1) if match else None
+ except urllib.error.HTTPError as exc:
+ if exc.code == 404:
+ raise NotFoundError(f"not found: {url}") from None
+ if exc.code < 500 or attempt == 2:
+ raise ContractError(f"HTTP {exc.code} for {url}") from None
+ except (urllib.error.URLError, OSError) as exc:
+ if attempt == 2:
+ raise ContractError(f"cannot fetch {url}: {exc}") from None
+ raise ContractError(f"cannot fetch {url}")
+
+
+def newest_tag(names: list[str]) -> Optional[str]:
+ versions = []
+ for name in names:
+ match = TAG_PATTERN.fullmatch(name)
+ if match:
+ versions.append((tuple(int(part) for part in match.groups()), name))
+ return max(versions)[1] if versions else None
+
+
+def resolve_latest(owner: str, repo: str, fetch: Fetch = http_fetch) -> str:
+ """The newest vX.Y.Z tag of owner/repo from the public GitHub API. No token is needed;
+ GITHUB_TOKEN, when set, only raises the API rate limit."""
+ headers = {"Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28"}
+ token = os.environ.get("GITHUB_TOKEN", "").strip()
+ if token:
+ headers["Authorization"] = f"Bearer {token}"
+ url: Optional[str] = f"{API_BASE}/repos/{owner}/{repo}/tags?per_page=100"
+ names: list[str] = []
+ pages = 0
+ while url and pages < 20:
+ body, url = fetch(url, headers)
+ pages += 1
+ try:
+ page = json.loads(body.decode("utf-8"))
+ except (ValueError, UnicodeDecodeError) as exc:
+ raise ContractError(f"unexpected answer from the GitHub API: {exc}") from None
+ if not isinstance(page, list):
+ raise ContractError("unexpected answer from the GitHub API: expected a list of tags")
+ names.extend(
+ t["name"] for t in page if isinstance(t, dict) and isinstance(t.get("name"), str)
+ )
+ tag = newest_tag(names)
+ if tag is None:
+ raise ContractError(f"{owner}/{repo} has no vX.Y.Z tag yet")
+ return tag
+
+
+class RemoteSource:
+ """Contract files of one tag, downloaded from raw.githubusercontent.com."""
+
+ def __init__(self, owner: str, repo: str, ref: str, fetch: Fetch = http_fetch) -> None:
+ self.base = f"{RAW_BASE}/{owner}/{repo}/{ref}/{CONTRACT_DIR}"
+ self.ref = ref
+ self.fetch = fetch
+
+ def read(self, name: str) -> bytes:
+ try:
+ return self.fetch(f"{self.base}/{name}", {})[0]
+ except NotFoundError:
+ if name == MANIFEST_FILE:
+ raise ContractError(
+ f"{self.ref} has no {CONTRACT_DIR}/{MANIFEST_FILE}: "
+ "that release predates the contract"
+ ) from None
+ raise
+
+
+class LocalSource:
+ """Contract files from a local directory, such as contract/ in a checkout."""
+
+ def __init__(self, directory: Path) -> None:
+ self.directory = directory
+
+ def read(self, name: str) -> bytes:
+ try:
+ return (self.directory / name).read_bytes()
+ except FileNotFoundError:
+ raise NotFoundError(f"not found: {self.directory / name}") from None
+
+
+Source = Union[RemoteSource, LocalSource]
+
+
+def write_atomic(path: Path, data: bytes) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=f".{path.name}.")
+ try:
+ with os.fdopen(fd, "wb") as handle:
+ handle.write(data)
+ os.replace(tmp, path)
+ except BaseException:
+ Path(tmp).unlink(missing_ok=True)
+ raise
+
+
+def sync(config: Config, source: Source, ref: str, lock: Path, out: TextIO) -> list[str]:
+ """Downloads and verifies every mapped file, then writes the files and the lock.
+
+ Nothing is written unless every file matches the manifest. Returns the changed paths."""
+ version, manifest = parse_manifest(source.read(MANIFEST_FILE))
+ missing = sorted(set(config.files) - set(manifest))
+ if missing:
+ raise ContractError(f"contract {ref} does not contain: {', '.join(missing)}")
+ payloads: dict[str, bytes] = {}
+ for name in sorted(config.files):
+ data = source.read(name)
+ digest = sha256_bytes(data)
+ if digest != manifest[name]:
+ raise ContractError(
+ f"{name} at {ref} does not match {MANIFEST_FILE} "
+ f"(expected {manifest[name][:12]}, got {digest[:12]}); nothing was written"
+ )
+ payloads[name] = data
+ lock_bytes = render_lock(ref, version, {n: manifest[n] for n in payloads}).encode("utf-8")
+ changed = []
+ for path, data in [*((config.files[n], d) for n, d in payloads.items()), (lock, lock_bytes)]:
+ if not path.is_file() or path.read_bytes() != data:
+ write_atomic(path, data)
+ changed.append(config.display(path))
+ print(f"Contract {ref} (contract_version {version}): {len(payloads)} files", file=out)
+ for path in changed:
+ print(f" updated {path}", file=out)
+ if not changed:
+ print(" already up to date", file=out)
+ return changed
+
+
+def check(config: Config, lock: Path, out: TextIO) -> list[str]:
+ """Offline: compares the committed files with the lock. Returns one message per problem."""
+ ref, version, locked = load_lock(lock)
+ mapped, listed = set(config.files), set(locked)
+ problems = [f"{n}: in {CONFIG_FILE} but not in the lock" for n in sorted(mapped - listed)]
+ problems += [f"{n}: in the lock but not in {CONFIG_FILE}" for n in sorted(listed - mapped)]
+ for name in sorted(mapped & listed):
+ path = config.files[name]
+ if not path.is_file():
+ problems.append(f"{config.display(path)}: missing")
+ continue
+ digest = sha256_bytes(path.read_bytes())
+ if digest != locked[name]:
+ problems.append(
+ f"{config.display(path)}: SHA-256 {digest[:12]} differs from the lock "
+ f"({locked[name][:12]}, contract {ref})"
+ )
+ if not problems:
+ print(
+ f"Contract files match the lock: {ref}, "
+ f"contract_version {version}, {len(locked)} files",
+ file=out,
+ )
+ return problems
+
+
+def main(argv: Optional[list[str]] = None) -> int:
+ parser = argparse.ArgumentParser(
+ description="Sync the shared ShieldLabs API contract files into this repository."
+ )
+ parser.add_argument(
+ "--ref", default="latest", help="'latest' (default) or a tag such as v1.2.3"
+ )
+ parser.add_argument(
+ "--check", action="store_true", help="offline: compare the files with the lock"
+ )
+ parser.add_argument("--root", default=".", help="repository root (default: current directory)")
+ parser.add_argument("--config", default=CONFIG_FILE, help="mapping file, relative to --root")
+ parser.add_argument("--lock", default=LOCK_FILE, help="lock file, relative to --root")
+ parser.add_argument(
+ "--source-dir", help="read the contract from a local directory (needs a tag in --ref)"
+ )
+ args = parser.parse_args(argv)
+ root = Path(args.root).resolve()
+ try:
+ config = Config.load(root, root / args.config)
+ lock = root / args.lock
+ if args.check:
+ problems = check(config, lock, sys.stdout)
+ if problems:
+ print(f"Contract drift: these files do not match {args.lock}:", file=sys.stderr)
+ for problem in problems:
+ print(f" {problem}", file=sys.stderr)
+ print(
+ "Contract files are not edited by hand. Change them in shieldlabs-openapi, or "
+ "restore them with: python3 scripts/sync_contract.py --ref [",
+ file=sys.stderr,
+ )
+ return 1
+ return 0
+ ref = validate_ref(args.ref)
+ source: Source
+ if args.source_dir:
+ if ref == "latest":
+ raise ContractError("--source-dir needs a tag in --ref, such as v1.2.3")
+ source = LocalSource(Path(args.source_dir))
+ else:
+ if ref == "latest":
+ ref = resolve_latest(config.owner, config.repo)
+ source = RemoteSource(config.owner, config.repo, ref)
+ sync(config, source, ref, lock, sys.stdout)
+ return 0
+ except ContractError as exc:
+ print(f"contract-sync: error: {exc}", file=sys.stderr)
+ return 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/tests/_support.py b/tests/_support.py
index dd66bd9..bf224a4 100644
--- a/tests/_support.py
+++ b/tests/_support.py
@@ -19,7 +19,7 @@
DOMAIN = "example.com"
HISTORY_HOST = "account.shieldlabs.ai"
MANAGEMENT_HOST = "api.shieldlabs.ai"
-REQUEST_ID = "02f1d973-84db-4156-a7f7-e799e6bf389b"
+REQUEST_ID = "a5b7c9d1-e3f5-4a7b-9c1d-3e5f7a9b1c3d"
REQUEST_PATH = f"/api/v1/history/request_id/{REQUEST_ID}"
@@ -43,7 +43,7 @@ def row(request_id: str, **extra: Any) -> dict[str, Any]:
"""A minimal History row for paging tests."""
base: dict[str, Any] = {
"request_id": request_id,
- "device_id": "ac7c303d-971b-41d1-8e25-cd5b46b46aed",
+ "device_id": "d8e0f2a4-b6c8-4d0e-bf2a-4b6c8d0e2f4a",
"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..6b8c00c 100644
--- a/tests/data/history-page.json
+++ b/tests/data/history-page.json
@@ -1,14 +1,14 @@
{
"data": [
{
- "request_id": "02f1d973-84db-4156-a7f7-e799e6bf389b",
- "session_id": "bde78778-efd2-4c49-952f-1f11b9c05f35",
- "cookie_id": "4449bb58-590c-444c-ae1f-d1ddc768dbdd",
+ "request_id": "a5b7c9d1-e3f5-4a7b-9c1d-3e5f7a9b1c3d",
+ "session_id": "b6c8d0e2-f4a6-4b8c-8d0e-2f4a6b8c0d2e",
+ "cookie_id": "c7d9e1f3-a5b7-4c9d-ae1f-3a5b7c9d1e3f",
"domain": "shop.example.com",
"site_domain": "example.com",
"user_hid": "9f86d081884c7d659a2feaa0c55ad015",
- "device_id": "ac7c303d-971b-41d1-8e25-cd5b46b46aed",
- "visitor_id": "bde0e249-20d8-4544-838c-ed9a0b6d7a36",
+ "device_id": "d8e0f2a4-b6c8-4d0e-bf2a-4b6c8d0e2f4a",
+ "visitor_id": "e9f1a3b5-c7d9-4e1f-8a3b-5c7d9e1f3a5b",
"ip": "203.0.113.24",
"os": "Windows",
"browser": "Chrome",
diff --git a/tests/data/normalization-cases.json b/tests/data/normalization-cases.json
index 0ccaff3..0ffe1ac 100644
--- a/tests/data/normalization-cases.json
+++ b/tests/data/normalization-cases.json
@@ -2,17 +2,17 @@
"description": "History API rows and webhook data objects with the Identification every SDK must produce. Compare observed_at at millisecond precision (truncate, never round). Keys not listed in expected (for example raw) are free.",
"cases": [
{
- "name": "history_02f1d973",
+ "name": "history_a5b7c9d1",
"source": "history",
"input": {
- "request_id": "02f1d973-84db-4156-a7f7-e799e6bf389b",
- "session_id": "bde78778-efd2-4c49-952f-1f11b9c05f35",
- "cookie_id": "4449bb58-590c-444c-ae1f-d1ddc768dbdd",
+ "request_id": "a5b7c9d1-e3f5-4a7b-9c1d-3e5f7a9b1c3d",
+ "session_id": "b6c8d0e2-f4a6-4b8c-8d0e-2f4a6b8c0d2e",
+ "cookie_id": "c7d9e1f3-a5b7-4c9d-ae1f-3a5b7c9d1e3f",
"domain": "shop.example.com",
"site_domain": "example.com",
"user_hid": "9f86d081884c7d659a2feaa0c55ad015",
- "device_id": "ac7c303d-971b-41d1-8e25-cd5b46b46aed",
- "visitor_id": "bde0e249-20d8-4544-838c-ed9a0b6d7a36",
+ "device_id": "d8e0f2a4-b6c8-4d0e-bf2a-4b6c8d0e2f4a",
+ "visitor_id": "e9f1a3b5-c7d9-4e1f-8a3b-5c7d9e1f3a5b",
"ip": "203.0.113.24",
"os": "Windows",
"browser": "Chrome",
@@ -65,11 +65,11 @@
"is_suspicious_paid_click": true
},
"expected": {
- "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",
+ "request_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": {
@@ -632,11 +632,11 @@
"name": "webhook_scored",
"source": "webhook",
"input": {
- "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",
+ "request_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": {
@@ -701,11 +701,11 @@
"observed_at": "2026-09-30T12:34:57.482913041Z"
},
"expected": {
- "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",
+ "request_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": {
diff --git a/tests/data/webhook-identification-scored.json b/tests/data/webhook-identification-scored.json
index f2de2d2..7978a58 100644
--- a/tests/data/webhook-identification-scored.json
+++ b/tests/data/webhook-identification-scored.json
@@ -3,11 +3,11 @@
"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",
+ "request_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": {
diff --git a/tests/data/webhook-identification-scored.raw.txt b/tests/data/webhook-identification-scored.raw.txt
index a4da089..4657d9f 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-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"}}
\ No newline at end of file
+{"event_type":"identification.scored","schema_version":"2026-06-01","created_at":"2026-09-30T12:34:57.482913041Z","data":{"request_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"}}
\ No newline at end of file
diff --git a/tests/data/webhook-signature-vectors.json b/tests/data/webhook-signature-vectors.json
index 7529cf0..2085113 100644
--- a/tests/data/webhook-signature-vectors.json
+++ b/tests/data/webhook-signature-vectors.json
@@ -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\":{\"request_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=397ff9bd26888e9e86addc2d920a8c5b2037251a3a1181f3b4810ca6c5f78062",
"valid": true,
"note": "Server bodies escape & as \\u0026; verify the bytes as received",
- "body_base64": "eyJldmVudF90eXBlIjoiaWRlbnRpZmljYXRpb24uc2NvcmVkIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTI6MzQ6NTcuNDgyOTEzMDQxWiIsImRhdGEiOnsicmVxdWVzdF9pZCI6IjAyZjFkOTczLTg0ZGItNDE1Ni1hN2Y3LWU3OTllNmJmMzg5YiIsInZpc2l0b3JfaWQiOiJiZGUwZTI0OS0yMGQ4LTQ1NDQtODM4Yy1lZDlhMGI2ZDdhMzYiLCJkZXZpY2VfaWQiOiJhYzdjMzAzZC05NzFiLTQxZDEtOGUyNS1jZDViNDZiNDZhZWQiLCJzZXNzaW9uX2lkIjoiYmRlNzg3NzgtZWZkMi00YzQ5LTk1MmYtMWYxMWI5YzA1ZjM1IiwiY29va2llX2lkIjoiNDQ0OWJiNTgtNTkwYy00NDRjLWFlMWYtZDFkZGM3NjhkYmRkIiwidXNlcl9oaWQiOiI5Zjg2ZDA4MTg4NGM3ZDY1OWEyZmVhYTBjNTVhZDAxNSIsImRvbWFpbiI6ImV4YW1wbGUuY29tIiwicHVibGljX2lwIjp7ImlwIjoiMjAzLjAuMTEzLjI0IiwiY291bnRyeSI6Ik5ldGhlcmxhbmRzIn0sImxvY2FsX2lwIjp7ImlwIjoiMTk4LjUxLjEwMC4yMyIsImNvdW50cnkiOiJHZXJtYW55In0sImNvbm5lY3Rpb25fdHlwZSI6InByb3h5Iiwib3MiOiJXaW5kb3dzIiwiYnJvd3NlciI6IkNocm9tZSIsImRldmljZV90eXBlIjoiZGVza3RvcCIsInRyYWZmaWNfc291cmNlIjp7ImNoYW5uZWwiOiJHb29nbGUgQWRzIiwicmVmZXJyZXJfZG9tYWluIjoiZ29vZ2xlLmNvbSIsImxhbmRpbmdfdXJsIjoiaHR0cHM6Ly9zaG9wLmV4YW1wbGUuY29tL3NpZ251cD91dG1fc291cmNlPWdvb2dsZVx1MDAyNnV0bV9tZWRpdW09Y3BjXHUwMDI2Z2NsaWQ9YWJjMTIzIiwiY2xpY2tfaWRfdHlwZSI6ImdjbGlkIiwidXRtX3NvdXJjZSI6Imdvb2dsZSIsInV0bV9tZWRpdW0iOiJjcGMiLCJ1dG1fY2FtcGFpZ24iOiIiLCJ1dG1fY29udGVudCI6IiIsInV0bV90ZXJtIjoiIn0sInJpc2tfc2NvcmUiOjgwLCJzaWduYWxzIjpbeyJuYW1lIjoicHJveHkiLCJ3ZWlnaHQiOjEwfSx7Im5hbWUiOiJkYXRhY2VudGVyX2lwIiwid2VpZ2h0IjoxMH0seyJuYW1lIjoiYW50aWRldGVjdF9icm93c2VyIiwid2VpZ2h0Ijo2MH1dLCJkZXRlY3Rpb25fZmxhZ3MiOnsidnBuIjpmYWxzZSwicHJpdmFjeV9yZWxheSI6ZmFsc2UsImJyb3dzZXJfdnBuX3Byb3h5IjpmYWxzZSwidG9yIjpmYWxzZSwicHJveHkiOnRydWUsImRhdGFjZW50ZXJfaXAiOnRydWUsImFidXNlciI6ZmFsc2UsIm9zX21pc21hdGNoIjpmYWxzZSwib3Nfbm90X2RldGVjdGVkIjpmYWxzZSwidGltZXpvbmVfbWlzbWF0Y2giOmZhbHNlLCJhbnRpX2RldGVjdF9icm93c2VyIjp0cnVlLCJicm93c2VyX2F1dG9tYXRpb24iOmZhbHNlLCJpcF9taXNtYXRjaCI6dHJ1ZSwiaW5jb2duaXRvIjpmYWxzZSwic2VhcmNoX2JvdCI6ZmFsc2UsInN1c3BpY2lvdXNfcGFpZF9jbGljayI6dHJ1ZSwiamF2YXNjcmlwdF9kaXNhYmxlZCI6ZmFsc2UsInN0dW5fbm90X2NoZWNrZWQiOmZhbHNlLCJjaGVja19pbmNvbXBsZXRlIjpmYWxzZX0sIm9ic2VydmVkX2F0IjoiMjAyNi0wOS0zMFQxMjozNDo1Ny40ODI5MTMwNDFaIn19"
+ "body_base64": "eyJldmVudF90eXBlIjoiaWRlbnRpZmljYXRpb24uc2NvcmVkIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTI6MzQ6NTcuNDgyOTEzMDQxWiIsImRhdGEiOnsicmVxdWVzdF9pZCI6ImE1YjdjOWQxLWUzZjUtNGE3Yi05YzFkLTNlNWY3YTliMWMzZCIsInZpc2l0b3JfaWQiOiJlOWYxYTNiNS1jN2Q5LTRlMWYtOGEzYi01YzdkOWUxZjNhNWIiLCJkZXZpY2VfaWQiOiJkOGUwZjJhNC1iNmM4LTRkMGUtYmYyYS00YjZjOGQwZTJmNGEiLCJzZXNzaW9uX2lkIjoiYjZjOGQwZTItZjRhNi00YjhjLThkMGUtMmY0YTZiOGMwZDJlIiwiY29va2llX2lkIjoiYzdkOWUxZjMtYTViNy00YzlkLWFlMWYtM2E1YjdjOWQxZTNmIiwidXNlcl9oaWQiOiI5Zjg2ZDA4MTg4NGM3ZDY1OWEyZmVhYTBjNTVhZDAxNSIsImRvbWFpbiI6ImV4YW1wbGUuY29tIiwicHVibGljX2lwIjp7ImlwIjoiMjAzLjAuMTEzLjI0IiwiY291bnRyeSI6Ik5ldGhlcmxhbmRzIn0sImxvY2FsX2lwIjp7ImlwIjoiMTk4LjUxLjEwMC4yMyIsImNvdW50cnkiOiJHZXJtYW55In0sImNvbm5lY3Rpb25fdHlwZSI6InByb3h5Iiwib3MiOiJXaW5kb3dzIiwiYnJvd3NlciI6IkNocm9tZSIsImRldmljZV90eXBlIjoiZGVza3RvcCIsInRyYWZmaWNfc291cmNlIjp7ImNoYW5uZWwiOiJHb29nbGUgQWRzIiwicmVmZXJyZXJfZG9tYWluIjoiZ29vZ2xlLmNvbSIsImxhbmRpbmdfdXJsIjoiaHR0cHM6Ly9zaG9wLmV4YW1wbGUuY29tL3NpZ251cD91dG1fc291cmNlPWdvb2dsZVx1MDAyNnV0bV9tZWRpdW09Y3BjXHUwMDI2Z2NsaWQ9YWJjMTIzIiwiY2xpY2tfaWRfdHlwZSI6ImdjbGlkIiwidXRtX3NvdXJjZSI6Imdvb2dsZSIsInV0bV9tZWRpdW0iOiJjcGMiLCJ1dG1fY2FtcGFpZ24iOiIiLCJ1dG1fY29udGVudCI6IiIsInV0bV90ZXJtIjoiIn0sInJpc2tfc2NvcmUiOjgwLCJzaWduYWxzIjpbeyJuYW1lIjoicHJveHkiLCJ3ZWlnaHQiOjEwfSx7Im5hbWUiOiJkYXRhY2VudGVyX2lwIiwid2VpZ2h0IjoxMH0seyJuYW1lIjoiYW50aWRldGVjdF9icm93c2VyIiwid2VpZ2h0Ijo2MH1dLCJkZXRlY3Rpb25fZmxhZ3MiOnsidnBuIjpmYWxzZSwicHJpdmFjeV9yZWxheSI6ZmFsc2UsImJyb3dzZXJfdnBuX3Byb3h5IjpmYWxzZSwidG9yIjpmYWxzZSwicHJveHkiOnRydWUsImRhdGFjZW50ZXJfaXAiOnRydWUsImFidXNlciI6ZmFsc2UsIm9zX21pc21hdGNoIjpmYWxzZSwib3Nfbm90X2RldGVjdGVkIjpmYWxzZSwidGltZXpvbmVfbWlzbWF0Y2giOmZhbHNlLCJhbnRpX2RldGVjdF9icm93c2VyIjp0cnVlLCJicm93c2VyX2F1dG9tYXRpb24iOmZhbHNlLCJpcF9taXNtYXRjaCI6dHJ1ZSwiaW5jb2duaXRvIjpmYWxzZSwic2VhcmNoX2JvdCI6ZmFsc2UsInN1c3BpY2lvdXNfcGFpZF9jbGljayI6dHJ1ZSwiamF2YXNjcmlwdF9kaXNhYmxlZCI6ZmFsc2UsInN0dW5fbm90X2NoZWNrZWQiOmZhbHNlLCJjaGVja19pbmNvbXBsZXRlIjpmYWxzZX0sIm9ic2VydmVkX2F0IjoiMjAyNi0wOS0zMFQxMjozNDo1Ny40ODI5MTMwNDFaIn19"
},
{
"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\":{\"request_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=397ff9bd26888e9e86addc2d920a8c5b2037251a3a1181f3b4810ca6c5f78062",
"valid": false,
"note": "Re-serializing (unescaping \\u0026) changes the bytes",
- "body_base64": "eyJldmVudF90eXBlIjoiaWRlbnRpZmljYXRpb24uc2NvcmVkIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTI6MzQ6NTcuNDgyOTEzMDQxWiIsImRhdGEiOnsicmVxdWVzdF9pZCI6IjAyZjFkOTczLTg0ZGItNDE1Ni1hN2Y3LWU3OTllNmJmMzg5YiIsInZpc2l0b3JfaWQiOiJiZGUwZTI0OS0yMGQ4LTQ1NDQtODM4Yy1lZDlhMGI2ZDdhMzYiLCJkZXZpY2VfaWQiOiJhYzdjMzAzZC05NzFiLTQxZDEtOGUyNS1jZDViNDZiNDZhZWQiLCJzZXNzaW9uX2lkIjoiYmRlNzg3NzgtZWZkMi00YzQ5LTk1MmYtMWYxMWI5YzA1ZjM1IiwiY29va2llX2lkIjoiNDQ0OWJiNTgtNTkwYy00NDRjLWFlMWYtZDFkZGM3NjhkYmRkIiwidXNlcl9oaWQiOiI5Zjg2ZDA4MTg4NGM3ZDY1OWEyZmVhYTBjNTVhZDAxNSIsImRvbWFpbiI6ImV4YW1wbGUuY29tIiwicHVibGljX2lwIjp7ImlwIjoiMjAzLjAuMTEzLjI0IiwiY291bnRyeSI6Ik5ldGhlcmxhbmRzIn0sImxvY2FsX2lwIjp7ImlwIjoiMTk4LjUxLjEwMC4yMyIsImNvdW50cnkiOiJHZXJtYW55In0sImNvbm5lY3Rpb25fdHlwZSI6InByb3h5Iiwib3MiOiJXaW5kb3dzIiwiYnJvd3NlciI6IkNocm9tZSIsImRldmljZV90eXBlIjoiZGVza3RvcCIsInRyYWZmaWNfc291cmNlIjp7ImNoYW5uZWwiOiJHb29nbGUgQWRzIiwicmVmZXJyZXJfZG9tYWluIjoiZ29vZ2xlLmNvbSIsImxhbmRpbmdfdXJsIjoiaHR0cHM6Ly9zaG9wLmV4YW1wbGUuY29tL3NpZ251cD91dG1fc291cmNlPWdvb2dsZSZ1dG1fbWVkaXVtPWNwYyZnY2xpZD1hYmMxMjMiLCJjbGlja19pZF90eXBlIjoiZ2NsaWQiLCJ1dG1fc291cmNlIjoiZ29vZ2xlIiwidXRtX21lZGl1bSI6ImNwYyIsInV0bV9jYW1wYWlnbiI6IiIsInV0bV9jb250ZW50IjoiIiwidXRtX3Rlcm0iOiIifSwicmlza19zY29yZSI6ODAsInNpZ25hbHMiOlt7Im5hbWUiOiJwcm94eSIsIndlaWdodCI6MTB9LHsibmFtZSI6ImRhdGFjZW50ZXJfaXAiLCJ3ZWlnaHQiOjEwfSx7Im5hbWUiOiJhbnRpZGV0ZWN0X2Jyb3dzZXIiLCJ3ZWlnaHQiOjYwfV0sImRldGVjdGlvbl9mbGFncyI6eyJ2cG4iOmZhbHNlLCJwcml2YWN5X3JlbGF5IjpmYWxzZSwiYnJvd3Nlcl92cG5fcHJveHkiOmZhbHNlLCJ0b3IiOmZhbHNlLCJwcm94eSI6dHJ1ZSwiZGF0YWNlbnRlcl9pcCI6dHJ1ZSwiYWJ1c2VyIjpmYWxzZSwib3NfbWlzbWF0Y2giOmZhbHNlLCJvc19ub3RfZGV0ZWN0ZWQiOmZhbHNlLCJ0aW1lem9uZV9taXNtYXRjaCI6ZmFsc2UsImFudGlfZGV0ZWN0X2Jyb3dzZXIiOnRydWUsImJyb3dzZXJfYXV0b21hdGlvbiI6ZmFsc2UsImlwX21pc21hdGNoIjp0cnVlLCJpbmNvZ25pdG8iOmZhbHNlLCJzZWFyY2hfYm90IjpmYWxzZSwic3VzcGljaW91c19wYWlkX2NsaWNrIjp0cnVlLCJqYXZhc2NyaXB0X2Rpc2FibGVkIjpmYWxzZSwic3R1bl9ub3RfY2hlY2tlZCI6ZmFsc2UsImNoZWNrX2luY29tcGxldGUiOmZhbHNlfSwib2JzZXJ2ZWRfYXQiOiIyMDI2LTA5LTMwVDEyOjM0OjU3LjQ4MjkxMzA0MVoifX0="
+ "body_base64": "eyJldmVudF90eXBlIjoiaWRlbnRpZmljYXRpb24uc2NvcmVkIiwic2NoZW1hX3ZlcnNpb24iOiIyMDI2LTA2LTAxIiwiY3JlYXRlZF9hdCI6IjIwMjYtMDktMzBUMTI6MzQ6NTcuNDgyOTEzMDQxWiIsImRhdGEiOnsicmVxdWVzdF9pZCI6ImE1YjdjOWQxLWUzZjUtNGE3Yi05YzFkLTNlNWY3YTliMWMzZCIsInZpc2l0b3JfaWQiOiJlOWYxYTNiNS1jN2Q5LTRlMWYtOGEzYi01YzdkOWUxZjNhNWIiLCJkZXZpY2VfaWQiOiJkOGUwZjJhNC1iNmM4LTRkMGUtYmYyYS00YjZjOGQwZTJmNGEiLCJzZXNzaW9uX2lkIjoiYjZjOGQwZTItZjRhNi00YjhjLThkMGUtMmY0YTZiOGMwZDJlIiwiY29va2llX2lkIjoiYzdkOWUxZjMtYTViNy00YzlkLWFlMWYtM2E1YjdjOWQxZTNmIiwidXNlcl9oaWQiOiI5Zjg2ZDA4MTg4NGM3ZDY1OWEyZmVhYTBjNTVhZDAxNSIsImRvbWFpbiI6ImV4YW1wbGUuY29tIiwicHVibGljX2lwIjp7ImlwIjoiMjAzLjAuMTEzLjI0IiwiY291bnRyeSI6Ik5ldGhlcmxhbmRzIn0sImxvY2FsX2lwIjp7ImlwIjoiMTk4LjUxLjEwMC4yMyIsImNvdW50cnkiOiJHZXJtYW55In0sImNvbm5lY3Rpb25fdHlwZSI6InByb3h5Iiwib3MiOiJXaW5kb3dzIiwiYnJvd3NlciI6IkNocm9tZSIsImRldmljZV90eXBlIjoiZGVza3RvcCIsInRyYWZmaWNfc291cmNlIjp7ImNoYW5uZWwiOiJHb29nbGUgQWRzIiwicmVmZXJyZXJfZG9tYWluIjoiZ29vZ2xlLmNvbSIsImxhbmRpbmdfdXJsIjoiaHR0cHM6Ly9zaG9wLmV4YW1wbGUuY29tL3NpZ251cD91dG1fc291cmNlPWdvb2dsZSZ1dG1fbWVkaXVtPWNwYyZnY2xpZD1hYmMxMjMiLCJjbGlja19pZF90eXBlIjoiZ2NsaWQiLCJ1dG1fc291cmNlIjoiZ29vZ2xlIiwidXRtX21lZGl1bSI6ImNwYyIsInV0bV9jYW1wYWlnbiI6IiIsInV0bV9jb250ZW50IjoiIiwidXRtX3Rlcm0iOiIifSwicmlza19zY29yZSI6ODAsInNpZ25hbHMiOlt7Im5hbWUiOiJwcm94eSIsIndlaWdodCI6MTB9LHsibmFtZSI6ImRhdGFjZW50ZXJfaXAiLCJ3ZWlnaHQiOjEwfSx7Im5hbWUiOiJhbnRpZGV0ZWN0X2Jyb3dzZXIiLCJ3ZWlnaHQiOjYwfV0sImRldGVjdGlvbl9mbGFncyI6eyJ2cG4iOmZhbHNlLCJwcml2YWN5X3JlbGF5IjpmYWxzZSwiYnJvd3Nlcl92cG5fcHJveHkiOmZhbHNlLCJ0b3IiOmZhbHNlLCJwcm94eSI6dHJ1ZSwiZGF0YWNlbnRlcl9pcCI6dHJ1ZSwiYWJ1c2VyIjpmYWxzZSwib3NfbWlzbWF0Y2giOmZhbHNlLCJvc19ub3RfZGV0ZWN0ZWQiOmZhbHNlLCJ0aW1lem9uZV9taXNtYXRjaCI6ZmFsc2UsImFudGlfZGV0ZWN0X2Jyb3dzZXIiOnRydWUsImJyb3dzZXJfYXV0b21hdGlvbiI6ZmFsc2UsImlwX21pc21hdGNoIjp0cnVlLCJpbmNvZ25pdG8iOmZhbHNlLCJzZWFyY2hfYm90IjpmYWxzZSwic3VzcGljaW91c19wYWlkX2NsaWNrIjp0cnVlLCJqYXZhc2NyaXB0X2Rpc2FibGVkIjpmYWxzZSwic3R1bl9ub3RfY2hlY2tlZCI6ZmFsc2UsImNoZWNrX2luY29tcGxldGUiOmZhbHNlfSwib2JzZXJ2ZWRfYXQiOiIyMDI2LTA5LTMwVDEyOjM0OjU3LjQ4MjkxMzA0MVoifX0="
},
{
"name": "valid_rotation_second_secret_matches",
diff --git a/tests/test_history.py b/tests/test_history.py
index b6d965b..3e362d9 100644
--- a/tests/test_history.py
+++ b/tests/test_history.py
@@ -32,7 +32,7 @@
)
from shieldlabs._http import USER_AGENT
-DEVICE_ID = "AC7C303D-971B-41D1-8E25-CD5B46B46AED"
+DEVICE_ID = "D8E0F2A4-B6C8-4D0E-BF2A-4B6C8D0E2F4A"
@pytest.fixture
@@ -78,11 +78,11 @@ def test_search_defaults_and_empty_page(client: ShieldLabs, mock: Any) -> None:
def test_uuid_values_are_sent_lowercase(client: ShieldLabs, mock: Any) -> None:
route = mock.get(host=HISTORY_HOST).respond(200, json=history_body())
client.history.search("device_id", DEVICE_ID)
- client.history.search("visitor_id", UUID("bde0e249-20d8-4544-838c-ed9a0b6d7a36"))
+ client.history.search("visitor_id", UUID("e9f1a3b5-c7d9-4e1f-8a3b-5c7d9e1f3a5b"))
paths = [call.request.url.path for call in route.calls]
assert paths == [
f"/api/v1/history/device_id/{DEVICE_ID.lower()}",
- "/api/v1/history/visitor_id/bde0e249-20d8-4544-838c-ed9a0b6d7a36",
+ "/api/v1/history/visitor_id/e9f1a3b5-c7d9-4e1f-8a3b-5c7d9e1f3a5b",
]
@@ -184,8 +184,8 @@ def test_ip_lookup(client: ShieldLabs, mock: Any) -> None:
("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"),
- ("device_id", "ac7c303d971b41d18e25cd5b46b46aed", "must be a UUID"),
- ("session_id", "{bde78778-efd2-4c49-952f-1f11b9c05f35}", "must be a UUID"),
+ ("device_id", "d8e0f2a4b6c84d0ebf2a4b6c8d0e2f4a", "must be a UUID"),
+ ("session_id", "{b6c8d0e2-f4a6-4b8c-8d0e-2f4a6b8c0d2e}", "must be a UUID"),
("cookie_id", "", "must be a UUID"),
("ip", "2001:db8::1", "IPv6"),
("ip", "203.0.113", "dotted IPv4"),
diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py
index 1406e85..1c22d25 100644
--- a/tests/test_webhooks.py
+++ b/tests/test_webhooks.py
@@ -150,7 +150,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": {"request_id": "a5b7c9d1-e3f5-4a7b-9c1d-3e5f7a9b1c3d"},
"extra": True,
}
body = json.dumps(envelope).encode()
]