diff --git a/.gitattributes b/.gitattributes
index ef70083..8869bf7 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -14,3 +14,6 @@
/phpstan.neon.dist export-ignore
/phpunit.xml.dist export-ignore
/CONTRIBUTING.md export-ignore
+/contract-sync.json export-ignore
+/.shieldlabs-contract.lock export-ignore
+/scripts export-ignore
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 2837a78..4527ca3 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -62,3 +62,13 @@ jobs:
run: vendor/bin/php-cs-fixer check --diff
- name: Coverage
run: vendor/bin/phpunit --coverage-text --only-summary-for-coverage-text
+
+ contract:
+ name: Shared contract files match the lock
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.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..1216288
--- /dev/null
+++ b/.github/workflows/contract-sync.yml
@@ -0,0 +1,179 @@
+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@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.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: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2
+ if: steps.sync.outputs.changed == 'true'
+ with:
+ php-version: '8.4'
+ extensions: curl, json, mbstring
+ coverage: none
+ tools: composer:v2
+
+ - name: Run the full test suite
+ id: tests
+ if: steps.sync.outputs.changed == 'true'
+ continue-on-error: true
+ shell: bash
+ run: |
+ {
+ composer validate --strict
+ composer update --no-interaction --no-progress --prefer-dist
+ vendor/bin/phpunit
+ vendor/bin/phpstan analyse --no-progress
+ vendor/bin/php-cs-fixer check --diff
+ } 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 7d33d46..c50d15a 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -25,7 +25,10 @@ docker run --rm -v "$PWD":/app -w /app composer:2 sh -c "composer install && com
in `tests/Support/`; integration tests start `php -S` servers.
- `tests/data/` holds the shared test fixtures that every ShieldLabs server SDK passes. Do
not edit them in this repository: when the API changes, they change in every SDK
- together.
+ together. They are synced from `contract/` in shieldlabs-openapi: `contract-sync.json`
+ maps each file, `.shieldlabs-contract.lock` records the release, CI runs
+ `python3 scripts/sync_contract.py --check`, and the `contract-sync.yml` workflow opens a
+ pull request when a new release changes them.
- Keep the public API small and typed. PHPStan runs at the max level and php-cs-fixer
applies the PER coding style (`composer cs:fix`).
- The SDK must not log keys, secrets or request bodies, and must not make network calls
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/Integration/CurlClientTest.php b/tests/Integration/CurlClientTest.php
index d494135..e042de0 100644
--- a/tests/Integration/CurlClientTest.php
+++ b/tests/Integration/CurlClientTest.php
@@ -178,11 +178,11 @@ public function testTheSdkWorksOutOfTheBoxOverRealHttp(): void
\assert(self::$server !== null);
$client = new ShieldLabs(['api_key' => Clients::API_KEY, 'base_url' => self::$server->url . '/api/']);
- $page = $client->history->search(LookupType::DeviceId, 'ac7c303d-971b-41d1-8e25-cd5b46b46aed');
+ $page = $client->history->search(LookupType::DeviceId, 'd8e0f2a4-b6c8-4d0e-bf2a-4b6c8d0e2f4a');
self::assertSame(37, $page->total);
self::assertCount(5, $page->data);
- self::assertSame('02f1d973-84db-4156-a7f7-e799e6bf389b', $page->data[0]->request_id);
+ self::assertSame('a5b7c9d1-e3f5-4a7b-9c1d-3e5f7a9b1c3d', $page->data[0]->request_id);
}
public function testTheSdkMapsARealAuthenticationError(): void
diff --git a/tests/Integration/ExampleServerTest.php b/tests/Integration/ExampleServerTest.php
index ed8e609..d751930 100644
--- a/tests/Integration/ExampleServerTest.php
+++ b/tests/Integration/ExampleServerTest.php
@@ -136,8 +136,8 @@ public function testAcceptsSignedWebhooksOnceAndLogsThem(): void
self::assertSame([200, ['received' => true]], \array_slice(self::post('/webhooks/shieldlabs', $body, $headers), 0, 2));
$log = self::log();
- self::assertStringContainsString('identification.scored request_id=02f1d973-84db-4156-a7f7-e799e6bf389b risk_score=80 band=dangerous', $log);
- self::assertStringContainsString('Duplicate delivery ignored for request_id=02f1d973-84db-4156-a7f7-e799e6bf389b', $log);
+ self::assertStringContainsString('identification.scored request_id=a5b7c9d1-e3f5-4a7b-9c1d-3e5f7a9b1c3d risk_score=80 band=dangerous', $log);
+ self::assertStringContainsString('Duplicate delivery ignored for request_id=a5b7c9d1-e3f5-4a7b-9c1d-3e5f7a9b1c3d', $log);
}
public function testAcknowledgesTheVerifyPing(): void
diff --git a/tests/Unit/ErrorResponsesTest.php b/tests/Unit/ErrorResponsesTest.php
index b04a1bc..63446eb 100644
--- a/tests/Unit/ErrorResponsesTest.php
+++ b/tests/Unit/ErrorResponsesTest.php
@@ -76,7 +76,7 @@ public function testParsesJsonSentAsPlainText(): void
$http = (new MockHttpClient())->always(MockHttpClient::text(401, "{\"error\":\"invalid api key\"}\n", 'text/plain; charset=utf-8'));
try {
- Clients::history($http)->history->search(LookupType::DeviceId, 'ac7c303d-971b-41d1-8e25-cd5b46b46aed');
+ Clients::history($http)->history->search(LookupType::DeviceId, 'd8e0f2a4-b6c8-4d0e-bf2a-4b6c8d0e2f4a');
self::fail('Expected an exception');
} catch (ApiException $exception) {
self::assertSame('invalid api key', $exception->getErrorMessage());
diff --git a/tests/Unit/HistoryIterateTest.php b/tests/Unit/HistoryIterateTest.php
index 3cb20c9..692ea65 100644
--- a/tests/Unit/HistoryIterateTest.php
+++ b/tests/Unit/HistoryIterateTest.php
@@ -23,7 +23,7 @@ private static function page(array $requestIds, int $total): ResponseInterface
{
$rows = array_map(static fn(string $id): array => [
'request_id' => $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',
], $requestIds);
@@ -71,7 +71,7 @@ public function testWalksPagesUntilTheTotal(): void
self::page([self::id(5)], 5),
);
- $ids = self::ids(Clients::history($http)->history->iterate(LookupType::DeviceId, 'ac7c303d-971b-41d1-8e25-cd5b46b46aed', ['page_size' => 2]));
+ $ids = self::ids(Clients::history($http)->history->iterate(LookupType::DeviceId, 'd8e0f2a4-b6c8-4d0e-bf2a-4b6c8d0e2f4a', ['page_size' => 2]));
self::assertSame([self::id(1), self::id(2), self::id(3), self::id(4), self::id(5)], $ids);
self::assertSame([['2', '0'], ['2', '2'], ['2', '4']], self::paging($http));
diff --git a/tests/Unit/HistorySearchTest.php b/tests/Unit/HistorySearchTest.php
index 1d126ab..08d2c95 100644
--- a/tests/Unit/HistorySearchTest.php
+++ b/tests/Unit/HistorySearchTest.php
@@ -21,13 +21,13 @@ public function testSendsTheDocumentedRequest(): void
{
$http = (new MockHttpClient())->always(MockHttpClient::json(200, Fixtures::json('history-page.json')));
- $page = Clients::history($http)->history->search(LookupType::DeviceId, 'AC7C303D-971B-41D1-8E25-CD5B46B46AED', ['limit' => 50]);
+ $page = Clients::history($http)->history->search(LookupType::DeviceId, 'D8E0F2A4-B6C8-4D0E-BF2A-4B6C8D0E2F4A', ['limit' => 50]);
self::assertSame(37, $page->total);
$request = $http->lastRequest();
self::assertSame('GET', $request->getMethod());
self::assertSame(
- 'https://account.shieldlabs.ai/api/v1/history/device_id/ac7c303d-971b-41d1-8e25-cd5b46b46aed?limit=50&offset=0',
+ 'https://account.shieldlabs.ai/api/v1/history/device_id/d8e0f2a4-b6c8-4d0e-bf2a-4b6c8d0e2f4a?limit=50&offset=0',
(string) $request->getUri(),
);
self::assertSame('Bearer ' . Clients::API_KEY, $request->getHeaderLine('Authorization'));
@@ -134,8 +134,8 @@ public function testAcceptsTheNilUuidAndAnyVersion(): void
$client = Clients::history($http);
$client->history->search(LookupType::VisitorId, '00000000-0000-0000-0000-000000000000');
- $client->history->search(LookupType::SessionId, 'bde0e249-20d8-5544-838c-ed9a0b6d7a36');
- $client->history->search(LookupType::CookieId, '4449bb58-590c-144c-ae1f-d1ddc768dbdd');
+ $client->history->search(LookupType::SessionId, 'e9f1a3b5-c7d9-5e1f-8a3b-5c7d9e1f3a5b');
+ $client->history->search(LookupType::CookieId, 'c7d9e1f3-a5b7-1c9d-ae1f-3a5b7c9d1e3f');
self::assertCount(3, $http->requests);
}
@@ -147,11 +147,11 @@ public static function invalidLookups(): iterable
{
yield 'unknown type' => ['auto', '203.0.113.24', [], 'Unknown lookup type "auto"'];
yield 'empty type' => ['', '203.0.113.24', [], 'Unknown lookup type'];
- yield 'uppercase type' => ['DEVICE_ID', 'ac7c303d-971b-41d1-8e25-cd5b46b46aed', [], 'Unknown lookup type'];
+ yield 'uppercase type' => ['DEVICE_ID', 'd8e0f2a4-b6c8-4d0e-bf2a-4b6c8d0e2f4a', [], 'Unknown lookup type'];
yield 'bad UUID' => [LookupType::DeviceId, 'abc', [], 'must be a UUID'];
- yield 'UUID without dashes' => [LookupType::RequestId, 'ac7c303d971b41d18e25cd5b46b46aed', [], 'must be a UUID'];
- yield 'UUID in braces' => [LookupType::VisitorId, '{ac7c303d-971b-41d1-8e25-cd5b46b46aed}', [], 'must be a UUID'];
- yield 'UUID with newline' => [LookupType::RequestId, "ac7c303d-971b-41d1-8e25-cd5b46b46aed\n", [], 'must be a UUID'];
+ yield 'UUID without dashes' => [LookupType::RequestId, 'd8e0f2a4b6c84d0ebf2a4b6c8d0e2f4a', [], 'must be a UUID'];
+ yield 'UUID in braces' => [LookupType::VisitorId, '{d8e0f2a4-b6c8-4d0e-bf2a-4b6c8d0e2f4a}', [], 'must be a UUID'];
+ yield 'UUID with newline' => [LookupType::RequestId, "d8e0f2a4-b6c8-4d0e-bf2a-4b6c8d0e2f4a\n", [], 'must be a UUID'];
yield 'IPv6' => [LookupType::Ip, '2001:db8::1', [], 'IPv6'];
yield 'IPv4 out of range' => [LookupType::Ip, '203.0.113.256', [], 'IPv4'];
yield 'IPv4 with spaces' => [LookupType::Ip, ' 203.0.113.5', [], 'IPv4'];
@@ -364,7 +364,7 @@ public function testIteratesAPage(): void
}
self::assertCount(5, $ids);
- self::assertSame('02f1d973-84db-4156-a7f7-e799e6bf389b', $ids[0]);
+ self::assertSame('a5b7c9d1-e3f5-4a7b-9c1d-3e5f7a9b1c3d', $ids[0]);
}
public function testVersionConstantMatchesTheChangelog(): void
diff --git a/tests/Unit/ModelTest.php b/tests/Unit/ModelTest.php
index 5beb0f1..32e26a3 100644
--- a/tests/Unit/ModelTest.php
+++ b/tests/Unit/ModelTest.php
@@ -29,7 +29,7 @@ public function testExposesSnakeCaseProperties(): void
{
$identification = self::first();
- self::assertSame('02f1d973-84db-4156-a7f7-e799e6bf389b', $identification->request_id);
+ self::assertSame('a5b7c9d1-e3f5-4a7b-9c1d-3e5f7a9b1c3d', $identification->request_id);
self::assertSame('9f86d081884c7d659a2feaa0c55ad015', $identification->user_hid);
self::assertSame('example.com', $identification->domain);
self::assertSame('Netherlands', $identification->public_ip->country);
diff --git a/tests/Unit/RiskEvaluateTest.php b/tests/Unit/RiskEvaluateTest.php
index 529fc72..a66c807 100644
--- a/tests/Unit/RiskEvaluateTest.php
+++ b/tests/Unit/RiskEvaluateTest.php
@@ -22,16 +22,16 @@ final class RiskEvaluateTest extends TestCase
private static function identification(
int $score = 10,
- string $deviceId = 'ac7c303d-971b-41d1-8e25-cd5b46b46aed',
+ string $deviceId = 'd8e0f2a4-b6c8-4d0e-bf2a-4b6c8d0e2f4a',
?DetectionFlags $flags = null,
?string $observedAt = self::OBSERVED,
): Identification {
return new Identification(
request_id: '3b241101-e2bb-4255-8caf-4136c566a962',
- visitor_id: 'bde0e249-20d8-4544-838c-ed9a0b6d7a36',
+ visitor_id: 'e9f1a3b5-c7d9-4e1f-8a3b-5c7d9e1f3a5b',
device_id: $deviceId,
- session_id: 'bde78778-efd2-4c49-952f-1f11b9c05f35',
- cookie_id: '4449bb58-590c-444c-ae1f-d1ddc768dbdd',
+ session_id: 'b6c8d0e2-f4a6-4b8c-8d0e-2f4a6b8c0d2e',
+ cookie_id: 'c7d9e1f3-a5b7-4c9d-ae1f-3a5b7c9d1e3f',
user_hid: '9f86d081884c7d659a2feaa0c55ad015',
domain: 'example.com',
public_ip: new IpInfo('203.0.113.24', 'Netherlands'),
diff --git a/tests/data/README.md b/tests/data/README.md
index 0cc55f3..729cd26 100644
--- a/tests/data/README.md
+++ b/tests/data/README.md
@@ -5,3 +5,5 @@ deliveries, signature vectors, Management API profiles, error responses and the
expected normalized identifications. Every ShieldLabs server SDK runs the same
files, so do not edit them by hand. The `.raw.txt` files hold exact request bodies:
keep their bytes unchanged (no added newline).
+
+They come from `contract/` in [shieldlabs-openapi](https://github.com/ShieldLabs-ai/shieldlabs-openapi/tree/main/contract): `contract-sync.json` maps each file, `.shieldlabs-contract.lock` records the release they come from, CI runs `python3 scripts/sync_contract.py --check`, and the `contract-sync.yml` workflow opens a pull request when a new release changes them.
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",
]