diff --git a/dist/schemas/identification-scored-event.schema.json b/dist/schemas/identification-scored-event.schema.json index e29d47d..62a23f7 100644 --- a/dist/schemas/identification-scored-event.schema.json +++ b/dist/schemas/identification-scored-event.schema.json @@ -21,12 +21,66 @@ }, "created_at": { "$ref": "#/$defs/Rfc3339Timestamp", - "description": "When the event was built. Equal to `data.observed_at`." + "description": "When the event envelope was created, distinct from data.observed_at (scan time)." }, "data": { "$ref": "#/$defs/IdentificationScoredData" + }, + "event_id": { + "type": "string", + "minLength": 1, + "description": "Logical site/request/final-result-version/type identity. Stable across retries and endpoints." + }, + "site_id": { + "type": "integer", + "minimum": 1, + "description": "Site scope when available; legacy domain-only accounts omit it." } }, + "allOf": [ + { + "if": { + "properties": { + "schema_version": { + "const": "2026-10-06" + } + }, + "required": [ + "schema_version" + ] + }, + "then": { + "required": [ + "event_id" + ], + "properties": { + "data": { + "required": [ + "result_version", + "scoring_version", + "risk_events", + "hre" + ], + "type": "object", + "additionalProperties": true, + "properties": { + "result_version": {}, + "scoring_version": {}, + "risk_events": {}, + "hre": {} + } + }, + "event_id": { + "type": "string", + "minLength": 1, + "description": "Logical site/request/final-result-version/type identity. Stable across retries and endpoints." + } + }, + "type": "object", + "additionalProperties": true + } + } + ], "$defs": { "Browser": { "type": "string", @@ -70,7 +124,7 @@ }, "DetectionFlags": { "type": "object", - "description": "Stable yes/no verdicts for the identification. Always all 19 keys. Branch on these flags and on\nthe Risk Score; signal names are for display and logging.\n\nWhen `search_bot` is `true`, `incognito`, `check_incomplete`, `ip_mismatch` and\n`javascript_disabled` are always `false`.", + "description": "Stable yes/no verdicts for the identification. Legacy 19 keys are always present; the four extension flags are present in schema 2026-10-06. Branch on these flags and on\nthe Risk Score; signal names are for display and logging.\n\nWhen `search_bot` is `true`, `incognito`, `check_incomplete`, `ip_mismatch` and\n`javascript_disabled` are always `false`.", "required": [ "vpn", "privacy_relay", @@ -168,6 +222,18 @@ "check_incomplete": { "type": "boolean", "description": "Part of the browser checks timed out, so the verdict rests on partial data. Informational." + }, + "os_mismatch2": { + "type": "boolean" + }, + "device_spoofing": { + "type": "boolean" + }, + "latency_test": { + "type": "boolean" + }, + "banned_ip": { + "type": "boolean" } } }, @@ -187,9 +253,104 @@ "desktop" ] }, + "Fingerprint": { + "type": "object", + "required": [ + "outcome", + "rules_version" + ], + "properties": { + "outcome": { + "type": "string" + }, + "record_id": { + "type": "string" + }, + "hardware_id": { + "type": "string", + "description": "FP21 hardware identity, distinct from device_id." + }, + "rules_version": { + "type": "string" + }, + "sharing": { + "type": "object" + }, + "takeover": { + "type": "object" + }, + "travel": { + "type": "object" + } + }, + "description": "FP21 for tracked users. Legacy sharing/takeover/travel mirrors are retained; prefer data.hre. Absent on anonymous checks." + }, + "HRE": { + "type": "object", + "required": [ + "account_sharing", + "account_takeover", + "impossible_travel" + ], + "properties": { + "rules_version": { + "type": "string" + }, + "account_sharing": { + "$ref": "#/$defs/HREResult" + }, + "account_takeover": { + "$ref": "#/$defs/HREResult" + }, + "impossible_travel": { + "$ref": "#/$defs/HREResult" + } + }, + "description": "Three completed on-demand handler results. Technical errors block emission; no_history/skipped are explicit not_evaluated results. Anonymous checks are not_applicable. Multiaccount is separate." + }, + "HREResult": { + "type": "object", + "required": [ + "status", + "level", + "reason" + ], + "properties": { + "status": { + "type": "string", + "examples": [ + "evaluated", + "not_evaluated", + "not_applicable" + ] + }, + "level": { + "type": [ + "string", + "null" + ], + "examples": [ + "medium", + "high", + null + ] + }, + "reason": { + "type": "string" + }, + "devices": { + "type": "integer", + "minimum": 0 + }, + "min_devices": { + "type": "integer", + "minimum": 1 + } + } + }, "IdentificationScoredData": { "type": "object", - "description": "The scored identification. Every key is always present (no key is ever omitted); only `user_hid` can be `null`.", + "description": "Final identification. risk_score is this scan only; no all-time entity risk. New extension fields are required by version 2026-10-06; legacy bodies remain accepted.", "required": [ "request_id", "visitor_id", @@ -274,7 +435,27 @@ }, "observed_at": { "$ref": "#/$defs/Rfc3339Timestamp", - "description": "When scoring finished and the event was built (not the page view time); identical to the envelope `created_at`. RFC 3339 in UTC with up to 9 fractional digits." + "description": "Original snapshot scan clock, distinct from envelope created_at. RFC 3339 in UTC with up to 9 fractional digits." + }, + "result_version": { + "type": "string", + "minLength": 1 + }, + "scoring_version": { + "type": "string", + "description": "Core build source revision; core:unversioned on local builds." + }, + "risk_events": { + "type": "array", + "items": { + "$ref": "#/$defs/RiskEvent" + } + }, + "hre": { + "$ref": "#/$defs/HRE" + }, + "fingerprint": { + "$ref": "#/$defs/Fingerprint" } } }, @@ -330,6 +511,40 @@ "2026-09-30T12:34:56Z" ] }, + "RiskEvent": { + "type": "object", + "required": [ + "code", + "detected", + "weight", + "contribution", + "status" + ], + "properties": { + "code": { + "type": "string" + }, + "detected": { + "type": "boolean", + "description": "Final scoring flag. false does not assert that every underlying probe completed." + }, + "weight": { + "type": "integer", + "description": "Catalogue weight, not an additive score. Banned IP 999 is a marker." + }, + "contribution": { + "type": "integer", + "description": "Matching score details, may contain corrections. Never recompute risk_score by summing." + }, + "status": { + "type": "string", + "description": "The final scoring flag has been evaluated. Probe incompleteness is reported by dedicated risk events.", + "examples": [ + "evaluated" + ] + } + } + }, "RiskScore": { "type": "integer", "minimum": 0, @@ -344,8 +559,9 @@ "SchemaVersion": { "type": "string", "minLength": 1, - "description": "Version of the webhook payload contract. Every event sent today carries `2026-06-01`. Accept other values, so that a future version does not break your handler.", + "description": "Webhook contract version. Current release 2026-10-06; parsers also accept legacy 2026-06-01.", "examples": [ + "2026-10-06", "2026-06-01" ] }, diff --git a/dist/schemas/identification.schema.json b/dist/schemas/identification.schema.json index 52abfcf..31be186 100644 --- a/dist/schemas/identification.schema.json +++ b/dist/schemas/identification.schema.json @@ -189,7 +189,7 @@ }, "DetectionFlags": { "type": "object", - "description": "Stable yes/no verdicts for the identification. Always all 19 keys. Branch on these flags and on\nthe Risk Score; signal names are for display and logging.\n\nWhen `search_bot` is `true`, `incognito`, `check_incomplete`, `ip_mismatch` and\n`javascript_disabled` are always `false`.", + "description": "Stable yes/no verdicts for the identification. Legacy 19 keys are always present; the four extension flags are present in schema 2026-10-06. Branch on these flags and on\nthe Risk Score; signal names are for display and logging.\n\nWhen `search_bot` is `true`, `incognito`, `check_incomplete`, `ip_mismatch` and\n`javascript_disabled` are always `false`.", "required": [ "vpn", "privacy_relay", @@ -287,6 +287,18 @@ "check_incomplete": { "type": "boolean", "description": "Part of the browser checks timed out, so the verdict rests on partial data. Informational." + }, + "os_mismatch2": { + "type": "boolean" + }, + "device_spoofing": { + "type": "boolean" + }, + "latency_test": { + "type": "boolean" + }, + "banned_ip": { + "type": "boolean" } } }, diff --git a/dist/schemas/webhook-event.schema.json b/dist/schemas/webhook-event.schema.json index e26328d..cde30fc 100644 --- a/dist/schemas/webhook-event.schema.json +++ b/dist/schemas/webhook-event.schema.json @@ -100,7 +100,7 @@ }, "DetectionFlags": { "type": "object", - "description": "Stable yes/no verdicts for the identification. Always all 19 keys. Branch on these flags and on\nthe Risk Score; signal names are for display and logging.\n\nWhen `search_bot` is `true`, `incognito`, `check_incomplete`, `ip_mismatch` and\n`javascript_disabled` are always `false`.", + "description": "Stable yes/no verdicts for the identification. Legacy 19 keys are always present; the four extension flags are present in schema 2026-10-06. Branch on these flags and on\nthe Risk Score; signal names are for display and logging.\n\nWhen `search_bot` is `true`, `incognito`, `check_incomplete`, `ip_mismatch` and\n`javascript_disabled` are always `false`.", "required": [ "vpn", "privacy_relay", @@ -198,6 +198,18 @@ "check_incomplete": { "type": "boolean", "description": "Part of the browser checks timed out, so the verdict rests on partial data. Informational." + }, + "os_mismatch2": { + "type": "boolean" + }, + "device_spoofing": { + "type": "boolean" + }, + "latency_test": { + "type": "boolean" + }, + "banned_ip": { + "type": "boolean" } } }, @@ -217,9 +229,104 @@ "desktop" ] }, + "Fingerprint": { + "type": "object", + "required": [ + "outcome", + "rules_version" + ], + "properties": { + "outcome": { + "type": "string" + }, + "record_id": { + "type": "string" + }, + "hardware_id": { + "type": "string", + "description": "FP21 hardware identity, distinct from device_id." + }, + "rules_version": { + "type": "string" + }, + "sharing": { + "type": "object" + }, + "takeover": { + "type": "object" + }, + "travel": { + "type": "object" + } + }, + "description": "FP21 for tracked users. Legacy sharing/takeover/travel mirrors are retained; prefer data.hre. Absent on anonymous checks." + }, + "HRE": { + "type": "object", + "required": [ + "account_sharing", + "account_takeover", + "impossible_travel" + ], + "properties": { + "rules_version": { + "type": "string" + }, + "account_sharing": { + "$ref": "#/$defs/HREResult" + }, + "account_takeover": { + "$ref": "#/$defs/HREResult" + }, + "impossible_travel": { + "$ref": "#/$defs/HREResult" + } + }, + "description": "Three completed on-demand handler results. Technical errors block emission; no_history/skipped are explicit not_evaluated results. Anonymous checks are not_applicable. Multiaccount is separate." + }, + "HREResult": { + "type": "object", + "required": [ + "status", + "level", + "reason" + ], + "properties": { + "status": { + "type": "string", + "examples": [ + "evaluated", + "not_evaluated", + "not_applicable" + ] + }, + "level": { + "type": [ + "string", + "null" + ], + "examples": [ + "medium", + "high", + null + ] + }, + "reason": { + "type": "string" + }, + "devices": { + "type": "integer", + "minimum": 0 + }, + "min_devices": { + "type": "integer", + "minimum": 1 + } + } + }, "IdentificationScoredData": { "type": "object", - "description": "The scored identification. Every key is always present (no key is ever omitted); only `user_hid` can be `null`.", + "description": "Final identification. risk_score is this scan only; no all-time entity risk. New extension fields are required by version 2026-10-06; legacy bodies remain accepted.", "required": [ "request_id", "visitor_id", @@ -304,7 +411,27 @@ }, "observed_at": { "$ref": "#/$defs/Rfc3339Timestamp", - "description": "When scoring finished and the event was built (not the page view time); identical to the envelope `created_at`. RFC 3339 in UTC with up to 9 fractional digits." + "description": "Original snapshot scan clock, distinct from envelope created_at. RFC 3339 in UTC with up to 9 fractional digits." + }, + "result_version": { + "type": "string", + "minLength": 1 + }, + "scoring_version": { + "type": "string", + "description": "Core build source revision; core:unversioned on local builds." + }, + "risk_events": { + "type": "array", + "items": { + "$ref": "#/$defs/RiskEvent" + } + }, + "hre": { + "$ref": "#/$defs/HRE" + }, + "fingerprint": { + "$ref": "#/$defs/Fingerprint" } } }, @@ -328,12 +455,66 @@ }, "created_at": { "$ref": "#/$defs/Rfc3339Timestamp", - "description": "When the event was built. Equal to `data.observed_at`." + "description": "When the event envelope was created, distinct from data.observed_at (scan time)." }, "data": { "$ref": "#/$defs/IdentificationScoredData" + }, + "event_id": { + "type": "string", + "minLength": 1, + "description": "Logical site/request/final-result-version/type identity. Stable across retries and endpoints." + }, + "site_id": { + "type": "integer", + "minimum": 1, + "description": "Site scope when available; legacy domain-only accounts omit it." } - } + }, + "allOf": [ + { + "if": { + "properties": { + "schema_version": { + "const": "2026-10-06" + } + }, + "required": [ + "schema_version" + ] + }, + "then": { + "required": [ + "event_id" + ], + "properties": { + "data": { + "required": [ + "result_version", + "scoring_version", + "risk_events", + "hre" + ], + "type": "object", + "additionalProperties": true, + "properties": { + "result_version": {}, + "scoring_version": {}, + "risk_events": {}, + "hre": {} + } + }, + "event_id": { + "type": "string", + "minLength": 1, + "description": "Logical site/request/final-result-version/type identity. Stable across retries and endpoints." + } + }, + "type": "object", + "additionalProperties": true + } + } + ] }, "IpInfo": { "type": "object", @@ -387,6 +568,40 @@ "2026-09-30T12:34:56Z" ] }, + "RiskEvent": { + "type": "object", + "required": [ + "code", + "detected", + "weight", + "contribution", + "status" + ], + "properties": { + "code": { + "type": "string" + }, + "detected": { + "type": "boolean", + "description": "Final scoring flag. false does not assert that every underlying probe completed." + }, + "weight": { + "type": "integer", + "description": "Catalogue weight, not an additive score. Banned IP 999 is a marker." + }, + "contribution": { + "type": "integer", + "description": "Matching score details, may contain corrections. Never recompute risk_score by summing." + }, + "status": { + "type": "string", + "description": "The final scoring flag has been evaluated. Probe incompleteness is reported by dedicated risk events.", + "examples": [ + "evaluated" + ] + } + } + }, "RiskScore": { "type": "integer", "minimum": 0, @@ -401,8 +616,9 @@ "SchemaVersion": { "type": "string", "minLength": 1, - "description": "Version of the webhook payload contract. Every event sent today carries `2026-06-01`. Accept other values, so that a future version does not break your handler.", + "description": "Webhook contract version. Current release 2026-10-06; parsers also accept legacy 2026-06-01.", "examples": [ + "2026-10-06", "2026-06-01" ] }, @@ -561,6 +777,10 @@ "created_at": { "$ref": "#/$defs/Rfc3339Timestamp", "description": "When the ping was sent, with second precision." + }, + "event_id": { + "type": "string", + "minLength": 1 } } } diff --git a/dist/schemas/webhook-ping-event.schema.json b/dist/schemas/webhook-ping-event.schema.json index 6a64cea..3f9f1e5 100644 --- a/dist/schemas/webhook-ping-event.schema.json +++ b/dist/schemas/webhook-ping-event.schema.json @@ -21,6 +21,10 @@ "created_at": { "$ref": "#/$defs/Rfc3339Timestamp", "description": "When the ping was sent, with second precision." + }, + "event_id": { + "type": "string", + "minLength": 1 } }, "$defs": { @@ -37,8 +41,9 @@ "SchemaVersion": { "type": "string", "minLength": 1, - "description": "Version of the webhook payload contract. Every event sent today carries `2026-06-01`. Accept other values, so that a future version does not break your handler.", + "description": "Webhook contract version. Current release 2026-10-06; parsers also accept legacy 2026-06-01.", "examples": [ + "2026-10-06", "2026-06-01" ] } diff --git a/dist/shieldlabs-api.json b/dist/shieldlabs-api.json index d2cab83..176bab3 100644 --- a/dist/shieldlabs-api.json +++ b/dist/shieldlabs-api.json @@ -439,11 +439,14 @@ "Webhooks" ], "summary": "Identification scored", - "description": "Sent to every enabled webhook endpoint of your domain once for each identification, when its\nscoring is final: usually about 300 ms after the browser check, and at most about 10 seconds\nlater when follow-up network checks run.\n\n**Verify, then parse.** Compute HMAC-SHA256 over the raw request body and compare it with\n`X-Shield-Signature` before you parse the JSON:\n- key: the endpoint's signing secret as UTF-8 bytes, including the `whsec_` prefix (not hex-\n or base64-decoded, not stripped);\n- message: the exact bytes received; re-serializing parsed JSON changes them (for example, `&`\n arrives escaped as `\\u0026`);\n- expected header: `sha256=` followed by the lowercase hex digest, compared in constant time.\n\nThere is no timestamp, delivery ID or event-type header. Rotating a secret replaces it at\nonce, so accept both the old and the new secret until your deployment has switched.\n\n**Respond fast.** Answer any 2xx status within 1 second and process the event asynchronously;\ndo not redirect. Today each identification is delivered once per endpoint, with no retries. A\nlater release adds retries that resend identical bytes, so make your handler idempotent on\n`data.request_id`.\n\n**Latest state.** The event is a snapshot taken when scoring finished. The History row can\nstill be refined afterwards (its `ver` increases) and no second event is sent. Use the History\nAPI for guaranteed reads and for the latest state.\n\n**Test deliveries.** The Test button in the analytics dashboard sends a fixed sample with keys\nsorted alphabetically, second-precision timestamps and two-letter country values. Its\n`detection_flags` lack `browser_automation` and `search_bot`: parse missing flags as `false`.\n\nDeliveries are free and do not use your included identifications.", + "description": "Sent after final snapshot scoring and mandatory fp21 + three HRE handlers. Risk score is the identification score only. AI bots and AI browser are not implemented and not emitted.\n\nVerify HMAC-SHA256 over exact raw bytes with the endpoint secret. Body event_id is signed; X-Shield-Event-Id is its convenience mirror. Persist and deduplicate by event_id before returning 2xx within the 1-second send timeout. Legacy bodies without event_id can use data.request_id. Retries preserve body and ID. Network/timeouts/429/5xx are retried with backoff up to 8 failed sends or 15 minutes of retry age; exhaustion/permanent 4xx goes to DLQ. Delivery is at-least-once attempts within that window, not guaranteed eternal delivery or exactly-once. Endpoints are independent. Redirects are not followed.\n\nUse History for recovery/latest state; later snapshot corrections do not automatically emit another identification.scored event.", "security": [], "parameters": [ { "$ref": "#/components/parameters/ShieldSignature" + }, + { + "$ref": "#/components/parameters/ShieldEventId" } ], "requestBody": { @@ -463,6 +466,252 @@ }, "testDelivery": { "$ref": "#/components/examples/IdentificationScoredTestDelivery" + }, + "current": { + "summary": "Current contract with fp21 and HRE", + "value": { + "event_id": "ce256c0f66536957adc5f719250f5b10ce331bd1", + "site_id": 7, + "event_type": "identification.scored", + "schema_version": "2026-10-06", + "created_at": "2026-10-06T00:00:02Z", + "data": { + "result_version": "2026-10-06T00:00:01Z", + "scoring_version": "core:fixture", + "risk_events": [ + { + "code": "vpn", + "detected": false, + "weight": 30, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "proxy", + "detected": false, + "weight": 30, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "privacy_relay", + "detected": false, + "weight": 30, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "check_incomplete", + "detected": false, + "weight": 0, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "network_check_incomplete", + "detected": false, + "weight": 30, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "os_not_detected", + "detected": false, + "weight": 30, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "timezone_mismatch", + "detected": false, + "weight": 10, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "javascript_disabled", + "detected": false, + "weight": 90, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "os_mismatch", + "detected": false, + "weight": 60, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "os_mismatch2", + "detected": false, + "weight": 60, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "ip_mismatch", + "detected": false, + "weight": 45, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "latency_test", + "detected": false, + "weight": 60, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "anti_detect_browser", + "detected": false, + "weight": 60, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "device_spoofing", + "detected": false, + "weight": 90, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "banned_ip", + "detected": false, + "weight": 999, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "incognito", + "detected": true, + "weight": 0, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "search_bot", + "detected": false, + "weight": 0, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "browser_automation", + "detected": false, + "weight": 60, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "tor", + "detected": false, + "weight": 99, + "contribution": 0, + "status": "evaluated" + } + ], + "hre": { + "rules_version": "fp21-fixture", + "account_sharing": { + "status": "evaluated", + "level": "high", + "reason": "", + "devices": 4, + "min_devices": 4 + }, + "account_takeover": { + "status": "evaluated", + "level": "medium", + "reason": "fixture" + }, + "impossible_travel": { + "status": "evaluated", + "level": null, + "reason": "below_distance" + } + }, + "request_id": "13f84f05-7c2a-4e9b-9f1d-2a6b8c0e4d11", + "visitor_id": "161dfbad-8e7f-4a6b-9c5d-0e1f2a3b4c5d", + "device_id": "5eb7fd5c-2a1b-4c3d-9e8f-7a6b5c4d3e2f", + "session_id": "7a1b2c3d-4e5f-4789-abcd-ef0123456789", + "cookie_id": "3f2e1d0c-9b8a-4654-b210-fedcba987654", + "user_hid": "test-user", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.1", + "country": "" + }, + "local_ip": { + "ip": "", + "country": "" + }, + "connection_type": "direct", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "", + "referrer_domain": "", + "landing_url": "", + "click_id_type": "", + "utm_source": "", + "utm_medium": "", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 0, + "signals": [], + "detection_flags": { + "os_mismatch2": false, + "device_spoofing": false, + "latency_test": false, + "banned_ip": false, + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": false, + "datacenter_ip": false, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": false, + "browser_automation": false, + "ip_mismatch": false, + "incognito": true, + "search_bot": false, + "suspicious_paid_click": false, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-10-06T00:00:00Z", + "fingerprint": { + "outcome": "new_record", + "record_id": "sample-record", + "hardware_id": "sample-hardware", + "rules_version": "fp21-fixture", + "sharing": { + "level": "high", + "devices": 4, + "min_devices": 4 + }, + "takeover": { + "level": "medium", + "reason": "fixture" + }, + "travel": { + "level": "", + "reason": "below_distance" + } + } + } + } } } } @@ -473,7 +722,7 @@ "description": "Delivery accepted. The response body is ignored." }, "4XX": { - "description": "Delivery rejected, for example with `401` when the signature does not verify. Any status other than 2xx, and a timeout after 1 second, counts as a failed delivery; failed deliveries are not retried today." + "description": "Permanent 4xx are terminal; 429 is retried. 2xx acknowledges durable acceptance." } } } @@ -490,6 +739,9 @@ "parameters": [ { "$ref": "#/components/parameters/ShieldSignature" + }, + { + "$ref": "#/components/parameters/ShieldEventId" } ], "requestBody": { @@ -724,6 +976,21 @@ "value": "sha256=ea2685733d254f7028fb031c4214583b0650de01e6c8c93131236024edd9fdd8" } } + }, + "ShieldEventId": { + "name": "X-Shield-Event-Id", + "in": "header", + "required": false, + "description": "Convenience mirror of signed body event_id. Trust the body after HMAC verification. Absent on older bodies. Retries reuse the logical ID across attempts; it is not a separate delivery-attempt identity.", + "schema": { + "type": "string" + }, + "examples": { + "event": { + "summary": "Logical event identity", + "value": "79e23184cd0b5aae43125e3b14910f13b107a54b" + } + } } }, "schemas": { @@ -1526,8 +1793,9 @@ "SchemaVersion": { "type": "string", "minLength": 1, - "description": "Version of the webhook payload contract. Every event sent today carries `2026-06-01`. Accept other values, so that a future version does not break your handler.", + "description": "Webhook contract version. Current release 2026-10-06; parsers also accept legacy 2026-06-01.", "examples": [ + "2026-10-06", "2026-06-01" ] }, @@ -1679,7 +1947,7 @@ }, "DetectionFlags": { "type": "object", - "description": "Stable yes/no verdicts for the identification. Always all 19 keys. Branch on these flags and on\nthe Risk Score; signal names are for display and logging.\n\nWhen `search_bot` is `true`, `incognito`, `check_incomplete`, `ip_mismatch` and\n`javascript_disabled` are always `false`.", + "description": "Stable yes/no verdicts for the identification. Legacy 19 keys are always present; the four extension flags are present in schema 2026-10-06. Branch on these flags and on\nthe Risk Score; signal names are for display and logging.\n\nWhen `search_bot` is `true`, `incognito`, `check_incomplete`, `ip_mismatch` and\n`javascript_disabled` are always `false`.", "required": [ "vpn", "privacy_relay", @@ -1777,12 +2045,153 @@ "check_incomplete": { "type": "boolean", "description": "Part of the browser checks timed out, so the verdict rests on partial data. Informational." + }, + "os_mismatch2": { + "type": "boolean" + }, + "device_spoofing": { + "type": "boolean" + }, + "latency_test": { + "type": "boolean" + }, + "banned_ip": { + "type": "boolean" + } + } + }, + "RiskEvent": { + "type": "object", + "required": [ + "code", + "detected", + "weight", + "contribution", + "status" + ], + "properties": { + "code": { + "type": "string" + }, + "detected": { + "type": "boolean", + "description": "Final scoring flag. false does not assert that every underlying probe completed." + }, + "weight": { + "type": "integer", + "description": "Catalogue weight, not an additive score. Banned IP 999 is a marker." + }, + "contribution": { + "type": "integer", + "description": "Matching score details, may contain corrections. Never recompute risk_score by summing." + }, + "status": { + "type": "string", + "description": "The final scoring flag has been evaluated. Probe incompleteness is reported by dedicated risk events.", + "examples": [ + "evaluated" + ] + } + } + }, + "HREResult": { + "type": "object", + "required": [ + "status", + "level", + "reason" + ], + "properties": { + "status": { + "type": "string", + "examples": [ + "evaluated", + "not_evaluated", + "not_applicable" + ] + }, + "level": { + "type": [ + "string", + "null" + ], + "examples": [ + "medium", + "high", + null + ] + }, + "reason": { + "type": "string" + }, + "devices": { + "type": "integer", + "minimum": 0 + }, + "min_devices": { + "type": "integer", + "minimum": 1 } } }, + "HRE": { + "type": "object", + "required": [ + "account_sharing", + "account_takeover", + "impossible_travel" + ], + "properties": { + "rules_version": { + "type": "string" + }, + "account_sharing": { + "$ref": "#/components/schemas/HREResult" + }, + "account_takeover": { + "$ref": "#/components/schemas/HREResult" + }, + "impossible_travel": { + "$ref": "#/components/schemas/HREResult" + } + }, + "description": "Three completed on-demand handler results. Technical errors block emission; no_history/skipped are explicit not_evaluated results. Anonymous checks are not_applicable. Multiaccount is separate." + }, + "Fingerprint": { + "type": "object", + "required": [ + "outcome", + "rules_version" + ], + "properties": { + "outcome": { + "type": "string" + }, + "record_id": { + "type": "string" + }, + "hardware_id": { + "type": "string", + "description": "FP21 hardware identity, distinct from device_id." + }, + "rules_version": { + "type": "string" + }, + "sharing": { + "type": "object" + }, + "takeover": { + "type": "object" + }, + "travel": { + "type": "object" + } + }, + "description": "FP21 for tracked users. Legacy sharing/takeover/travel mirrors are retained; prefer data.hre. Absent on anonymous checks." + }, "IdentificationScoredData": { "type": "object", - "description": "The scored identification. Every key is always present (no key is ever omitted); only `user_hid` can be `null`.", + "description": "Final identification. risk_score is this scan only; no all-time entity risk. New extension fields are required by version 2026-10-06; legacy bodies remain accepted.", "required": [ "request_id", "visitor_id", @@ -1867,7 +2276,27 @@ }, "observed_at": { "$ref": "#/components/schemas/Rfc3339Timestamp", - "description": "When scoring finished and the event was built (not the page view time); identical to the envelope `created_at`. RFC 3339 in UTC with up to 9 fractional digits." + "description": "Original snapshot scan clock, distinct from envelope created_at. RFC 3339 in UTC with up to 9 fractional digits." + }, + "result_version": { + "type": "string", + "minLength": 1 + }, + "scoring_version": { + "type": "string", + "description": "Core build source revision; core:unversioned on local builds." + }, + "risk_events": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RiskEvent" + } + }, + "hre": { + "$ref": "#/components/schemas/HRE" + }, + "fingerprint": { + "$ref": "#/components/schemas/Fingerprint" } } }, @@ -1891,12 +2320,66 @@ }, "created_at": { "$ref": "#/components/schemas/Rfc3339Timestamp", - "description": "When the event was built. Equal to `data.observed_at`." + "description": "When the event envelope was created, distinct from data.observed_at (scan time)." }, "data": { "$ref": "#/components/schemas/IdentificationScoredData" + }, + "event_id": { + "type": "string", + "minLength": 1, + "description": "Logical site/request/final-result-version/type identity. Stable across retries and endpoints." + }, + "site_id": { + "type": "integer", + "minimum": 1, + "description": "Site scope when available; legacy domain-only accounts omit it." } - } + }, + "allOf": [ + { + "if": { + "properties": { + "schema_version": { + "const": "2026-10-06" + } + }, + "required": [ + "schema_version" + ] + }, + "then": { + "required": [ + "event_id" + ], + "properties": { + "data": { + "required": [ + "result_version", + "scoring_version", + "risk_events", + "hre" + ], + "type": "object", + "additionalProperties": true, + "properties": { + "result_version": {}, + "scoring_version": {}, + "risk_events": {}, + "hre": {} + } + }, + "event_id": { + "type": "string", + "minLength": 1, + "description": "Logical site/request/final-result-version/type identity. Stable across retries and endpoints." + } + }, + "type": "object", + "additionalProperties": true + } + } + ] }, "WebhookPingEvent": { "type": "object", @@ -1918,6 +2401,10 @@ "created_at": { "$ref": "#/components/schemas/Rfc3339Timestamp", "description": "When the ping was sent, with second precision." + }, + "event_id": { + "type": "string", + "minLength": 1 } } } diff --git a/dist/shieldlabs-api.yaml b/dist/shieldlabs-api.yaml index 2838959..b26b53a 100644 --- a/dist/shieldlabs-api.yaml +++ b/dist/shieldlabs-api.yaml @@ -467,38 +467,15 @@ webhooks: - Webhooks summary: Identification scored description: |- - Sent to every enabled webhook endpoint of your domain once for each identification, when its - scoring is final: usually about 300 ms after the browser check, and at most about 10 seconds - later when follow-up network checks run. + Sent after final snapshot scoring and mandatory fp21 + three HRE handlers. Risk score is the identification score only. AI bots and AI browser are not implemented and not emitted. - **Verify, then parse.** Compute HMAC-SHA256 over the raw request body and compare it with - `X-Shield-Signature` before you parse the JSON: - - key: the endpoint's signing secret as UTF-8 bytes, including the `whsec_` prefix (not hex- - or base64-decoded, not stripped); - - message: the exact bytes received; re-serializing parsed JSON changes them (for example, `&` - arrives escaped as `\u0026`); - - expected header: `sha256=` followed by the lowercase hex digest, compared in constant time. + Verify HMAC-SHA256 over exact raw bytes with the endpoint secret. Body event_id is signed; X-Shield-Event-Id is its convenience mirror. Persist and deduplicate by event_id before returning 2xx within the 1-second send timeout. Legacy bodies without event_id can use data.request_id. Retries preserve body and ID. Network/timeouts/429/5xx are retried with backoff up to 8 failed sends or 15 minutes of retry age; exhaustion/permanent 4xx goes to DLQ. Delivery is at-least-once attempts within that window, not guaranteed eternal delivery or exactly-once. Endpoints are independent. Redirects are not followed. - There is no timestamp, delivery ID or event-type header. Rotating a secret replaces it at - once, so accept both the old and the new secret until your deployment has switched. - - **Respond fast.** Answer any 2xx status within 1 second and process the event asynchronously; - do not redirect. Today each identification is delivered once per endpoint, with no retries. A - later release adds retries that resend identical bytes, so make your handler idempotent on - `data.request_id`. - - **Latest state.** The event is a snapshot taken when scoring finished. The History row can - still be refined afterwards (its `ver` increases) and no second event is sent. Use the History - API for guaranteed reads and for the latest state. - - **Test deliveries.** The Test button in the analytics dashboard sends a fixed sample with keys - sorted alphabetically, second-precision timestamps and two-letter country values. Its - `detection_flags` lack `browser_automation` and `search_bot`: parse missing flags as `false`. - - Deliveries are free and do not use your included identifications. + Use History for recovery/latest state; later snapshot corrections do not automatically emit another identification.scored event. security: [] parameters: - $ref: '#/components/parameters/ShieldSignature' + - $ref: '#/components/parameters/ShieldEventId' requestBody: required: true description: The event as compact JSON. Verify the signature over these exact bytes. @@ -513,11 +490,203 @@ webhooks: $ref: '#/components/examples/IdentificationScoredRateLimited' testDelivery: $ref: '#/components/examples/IdentificationScoredTestDelivery' + current: + summary: Current contract with fp21 and HRE + value: + event_id: ce256c0f66536957adc5f719250f5b10ce331bd1 + site_id: 7 + event_type: identification.scored + schema_version: '2026-10-06' + created_at: '2026-10-06T00:00:02Z' + data: + result_version: '2026-10-06T00:00:01Z' + scoring_version: core:fixture + risk_events: + - code: vpn + detected: false + weight: 30 + contribution: 0 + status: evaluated + - code: proxy + detected: false + weight: 30 + contribution: 0 + status: evaluated + - code: privacy_relay + detected: false + weight: 30 + contribution: 0 + status: evaluated + - code: check_incomplete + detected: false + weight: 0 + contribution: 0 + status: evaluated + - code: network_check_incomplete + detected: false + weight: 30 + contribution: 0 + status: evaluated + - code: os_not_detected + detected: false + weight: 30 + contribution: 0 + status: evaluated + - code: timezone_mismatch + detected: false + weight: 10 + contribution: 0 + status: evaluated + - code: javascript_disabled + detected: false + weight: 90 + contribution: 0 + status: evaluated + - code: os_mismatch + detected: false + weight: 60 + contribution: 0 + status: evaluated + - code: os_mismatch2 + detected: false + weight: 60 + contribution: 0 + status: evaluated + - code: ip_mismatch + detected: false + weight: 45 + contribution: 0 + status: evaluated + - code: latency_test + detected: false + weight: 60 + contribution: 0 + status: evaluated + - code: anti_detect_browser + detected: false + weight: 60 + contribution: 0 + status: evaluated + - code: device_spoofing + detected: false + weight: 90 + contribution: 0 + status: evaluated + - code: banned_ip + detected: false + weight: 999 + contribution: 0 + status: evaluated + - code: incognito + detected: true + weight: 0 + contribution: 0 + status: evaluated + - code: search_bot + detected: false + weight: 0 + contribution: 0 + status: evaluated + - code: browser_automation + detected: false + weight: 60 + contribution: 0 + status: evaluated + - code: tor + detected: false + weight: 99 + contribution: 0 + status: evaluated + hre: + rules_version: fp21-fixture + account_sharing: + status: evaluated + level: high + reason: '' + devices: 4 + min_devices: 4 + account_takeover: + status: evaluated + level: medium + reason: fixture + impossible_travel: + status: evaluated + level: null + reason: below_distance + request_id: 13f84f05-7c2a-4e9b-9f1d-2a6b8c0e4d11 + visitor_id: 161dfbad-8e7f-4a6b-9c5d-0e1f2a3b4c5d + device_id: 5eb7fd5c-2a1b-4c3d-9e8f-7a6b5c4d3e2f + session_id: 7a1b2c3d-4e5f-4789-abcd-ef0123456789 + cookie_id: 3f2e1d0c-9b8a-4654-b210-fedcba987654 + user_hid: test-user + domain: example.com + public_ip: + ip: 203.0.113.1 + country: '' + local_ip: + ip: '' + country: '' + connection_type: direct + os: Windows + browser: Chrome + device_type: desktop + traffic_source: + channel: '' + referrer_domain: '' + landing_url: '' + click_id_type: '' + utm_source: '' + utm_medium: '' + utm_campaign: '' + utm_content: '' + utm_term: '' + risk_score: 0 + signals: [] + detection_flags: + os_mismatch2: false + device_spoofing: false + latency_test: false + banned_ip: false + vpn: false + privacy_relay: false + browser_vpn_proxy: false + tor: false + proxy: false + datacenter_ip: false + abuser: false + os_mismatch: false + os_not_detected: false + timezone_mismatch: false + anti_detect_browser: false + browser_automation: false + ip_mismatch: false + incognito: true + search_bot: false + suspicious_paid_click: false + javascript_disabled: false + stun_not_checked: false + check_incomplete: false + observed_at: '2026-10-06T00:00:00Z' + fingerprint: + outcome: new_record + record_id: sample-record + hardware_id: sample-hardware + rules_version: fp21-fixture + sharing: + level: high + devices: 4 + min_devices: 4 + takeover: + level: medium + reason: fixture + travel: + level: '' + reason: below_distance responses: 2XX: description: Delivery accepted. The response body is ignored. 4XX: - description: Delivery rejected, for example with `401` when the signature does not verify. Any status other than 2xx, and a timeout after 1 second, counts as a failed delivery; failed deliveries are not retried today. + description: Permanent 4xx are terminal; 429 is retried. 2xx acknowledges durable acceptance. webhook.ping: post: operationId: webhookPing @@ -541,6 +710,7 @@ webhooks: security: [] parameters: - $ref: '#/components/parameters/ShieldSignature' + - $ref: '#/components/parameters/ShieldEventId' requestBody: required: true description: The ping as compact JSON with sorted keys. @@ -751,6 +921,17 @@ components: webhookPing: summary: Signature of the webhook.ping example value: sha256=ea2685733d254f7028fb031c4214583b0650de01e6c8c93131236024edd9fdd8 + ShieldEventId: + name: X-Shield-Event-Id + in: header + required: false + description: Convenience mirror of signed body event_id. Trust the body after HMAC verification. Absent on older bodies. Retries reuse the logical ID across attempts; it is not a separate delivery-attempt identity. + schema: + type: string + examples: + event: + summary: Logical event identity + value: 79e23184cd0b5aae43125e3b14910f13b107a54b schemas: RequestId: type: string @@ -1418,8 +1599,9 @@ components: SchemaVersion: type: string minLength: 1 - description: Version of the webhook payload contract. Every event sent today carries `2026-06-01`. Accept other values, so that a future version does not break your handler. + description: Webhook contract version. Current release 2026-10-06; parsers also accept legacy 2026-06-01. examples: + - '2026-10-06' - '2026-06-01' Rfc3339Timestamp: type: string @@ -1570,7 +1752,7 @@ components: DetectionFlags: type: object description: |- - Stable yes/no verdicts for the identification. Always all 19 keys. Branch on these flags and on + Stable yes/no verdicts for the identification. Legacy 19 keys are always present; the four extension flags are present in schema 2026-10-06. Branch on these flags and on the Risk Score; signal names are for display and logging. When `search_bot` is `true`, `incognito`, `check_incomplete`, `ip_mismatch` and @@ -1653,9 +1835,109 @@ components: check_incomplete: type: boolean description: Part of the browser checks timed out, so the verdict rests on partial data. Informational. + os_mismatch2: + type: boolean + device_spoofing: + type: boolean + latency_test: + type: boolean + banned_ip: + type: boolean + RiskEvent: + type: object + required: + - code + - detected + - weight + - contribution + - status + properties: + code: + type: string + detected: + type: boolean + description: Final scoring flag. false does not assert that every underlying probe completed. + weight: + type: integer + description: Catalogue weight, not an additive score. Banned IP 999 is a marker. + contribution: + type: integer + description: Matching score details, may contain corrections. Never recompute risk_score by summing. + status: + type: string + description: The final scoring flag has been evaluated. Probe incompleteness is reported by dedicated risk events. + examples: + - evaluated + HREResult: + type: object + required: + - status + - level + - reason + properties: + status: + type: string + examples: + - evaluated + - not_evaluated + - not_applicable + level: + type: + - string + - 'null' + examples: + - medium + - high + - null + reason: + type: string + devices: + type: integer + minimum: 0 + min_devices: + type: integer + minimum: 1 + HRE: + type: object + required: + - account_sharing + - account_takeover + - impossible_travel + properties: + rules_version: + type: string + account_sharing: + $ref: '#/components/schemas/HREResult' + account_takeover: + $ref: '#/components/schemas/HREResult' + impossible_travel: + $ref: '#/components/schemas/HREResult' + description: Three completed on-demand handler results. Technical errors block emission; no_history/skipped are explicit not_evaluated results. Anonymous checks are not_applicable. Multiaccount is separate. + Fingerprint: + type: object + required: + - outcome + - rules_version + properties: + outcome: + type: string + record_id: + type: string + hardware_id: + type: string + description: FP21 hardware identity, distinct from device_id. + rules_version: + type: string + sharing: + type: object + takeover: + type: object + travel: + type: object + description: FP21 for tracked users. Legacy sharing/takeover/travel mirrors are retained; prefer data.hre. Absent on anonymous checks. IdentificationScoredData: type: object - description: The scored identification. Every key is always present (no key is ever omitted); only `user_hid` can be `null`. + description: Final identification. risk_score is this scan only; no all-time entity risk. New extension fields are required by version 2026-10-06; legacy bodies remain accepted. required: - request_id - visitor_id @@ -1720,7 +2002,21 @@ components: $ref: '#/components/schemas/DetectionFlags' observed_at: $ref: '#/components/schemas/Rfc3339Timestamp' - description: When scoring finished and the event was built (not the page view time); identical to the envelope `created_at`. RFC 3339 in UTC with up to 9 fractional digits. + description: Original snapshot scan clock, distinct from envelope created_at. RFC 3339 in UTC with up to 9 fractional digits. + result_version: + type: string + minLength: 1 + scoring_version: + type: string + description: Core build source revision; core:unversioned on local builds. + risk_events: + type: array + items: + $ref: '#/components/schemas/RiskEvent' + hre: + $ref: '#/components/schemas/HRE' + fingerprint: + $ref: '#/components/schemas/Fingerprint' IdentificationScoredEvent: type: object description: 'Body of an `identification.scored` delivery. The signature is not part of the body: it arrives in the `X-Shield-Signature` header.' @@ -1738,9 +2034,47 @@ components: $ref: '#/components/schemas/SchemaVersion' created_at: $ref: '#/components/schemas/Rfc3339Timestamp' - description: When the event was built. Equal to `data.observed_at`. + description: When the event envelope was created, distinct from data.observed_at (scan time). data: $ref: '#/components/schemas/IdentificationScoredData' + event_id: + type: string + minLength: 1 + description: Logical site/request/final-result-version/type identity. Stable across retries and endpoints. + site_id: + type: integer + minimum: 1 + description: Site scope when available; legacy domain-only accounts omit it. + allOf: + - if: + properties: + schema_version: + const: '2026-10-06' + required: + - schema_version + then: + required: + - event_id + properties: + data: + required: + - result_version + - scoring_version + - risk_events + - hre + type: object + additionalProperties: true + properties: + result_version: {} + scoring_version: {} + risk_events: {} + hre: {} + event_id: + type: string + minLength: 1 + description: Logical site/request/final-result-version/type identity. Stable across retries and endpoints. + type: object + additionalProperties: true WebhookPingEvent: type: object description: Body of a `webhook.ping` delivery, sent when you verify an endpoint. It has no `data`. The keys arrive sorted alphabetically and `created_at` has second precision. @@ -1758,6 +2092,9 @@ components: created_at: $ref: '#/components/schemas/Rfc3339Timestamp' description: When the ping was sent, with second precision. + event_id: + type: string + minLength: 1 examples: HistoryPage: summary: Five identifications diff --git a/spec/components/parameters/ShieldEventId.yaml b/spec/components/parameters/ShieldEventId.yaml new file mode 100644 index 0000000..04ecca3 --- /dev/null +++ b/spec/components/parameters/ShieldEventId.yaml @@ -0,0 +1,12 @@ +name: X-Shield-Event-Id +in: header +required: false +description: Convenience mirror of signed body event_id. Trust the body after + HMAC verification. Absent on older bodies. Retries reuse the logical ID across + attempts; it is not a separate delivery-attempt identity. +schema: + type: string +examples: + event: + summary: Logical event identity + value: 79e23184cd0b5aae43125e3b14910f13b107a54b diff --git a/spec/components/schemas/DetectionFlags.yaml b/spec/components/schemas/DetectionFlags.yaml index 41ae9c8..6970562 100644 --- a/spec/components/schemas/DetectionFlags.yaml +++ b/spec/components/schemas/DetectionFlags.yaml @@ -1,9 +1,15 @@ type: object -description: |- - Stable yes/no verdicts for the identification. Always all 19 keys. Branch on these flags and on +description: >- + Stable yes/no verdicts for the identification. Legacy 19 keys are always + present; the four extension flags are present in schema 2026-10-06. Branch on + these flags and on + the Risk Score; signal names are for display and logging. - When `search_bot` is `true`, `incognito`, `check_incomplete`, `ip_mismatch` and + + When `search_bot` is `true`, `incognito`, `check_incomplete`, `ip_mismatch` + and + `javascript_disabled` are always `false`. required: - vpn @@ -34,9 +40,8 @@ properties: description: A privacy relay such as iCloud Private Relay was detected. browser_vpn_proxy: type: boolean - description: >- - A VPN or proxy built into the browser or one of its extensions. `true` exactly when - `connection_type` is `browser_vpn_proxy`. + description: A VPN or proxy built into the browser or one of its extensions. + `true` exactly when `connection_type` is `browser_vpn_proxy`. tor: type: boolean description: The request came through the Tor network. @@ -51,10 +56,12 @@ properties: description: The public IP has a record of abuse in IP intelligence. os_mismatch: type: boolean - description: The operating system seen on the network differs from the one the browser reports. + description: The operating system seen on the network differs from the one the + browser reports. os_not_detected: type: boolean - description: The operating system could not be determined from the User-Agent or the network. + description: The operating system could not be determined from the User-Agent or + the network. timezone_mismatch: type: boolean description: The browser timezone differs from the timezone of the IP location. @@ -63,12 +70,12 @@ properties: description: An anti-detect browser was detected. browser_automation: type: boolean - description: Browser automation was detected, for example a WebDriver-controlled browser. + description: Browser automation was detected, for example a WebDriver-controlled + browser. ip_mismatch: type: boolean - description: >- - The public IP differs from the local IP found by the browser network check. Informational: - it does not add to the score. + description: "The public IP differs from the local IP found by the browser + network check. Informational: it does not add to the score." incognito: type: boolean description: The browser runs in a private window. @@ -77,18 +84,25 @@ properties: description: A search-engine crawler. Its Risk Score is always 0. suspicious_paid_click: type: boolean - description: >- - The visit came from a paid ad click (Google Ads, Meta, TikTok, Microsoft Ads, LinkedIn, - Pinterest or X) and the Risk Score is 60 or more (the 999 marker included). + description: The visit came from a paid ad click (Google Ads, Meta, TikTok, + Microsoft Ads, LinkedIn, Pinterest or X) and the Risk Score is 60 or more + (the 999 marker included). javascript_disabled: type: boolean description: JavaScript, or the browser APIs the checks need, were unavailable. stun_not_checked: type: boolean - description: >- - The browser network (STUN) check did not complete. Cleared again when a late network result - arrives. + description: The browser network (STUN) check did not complete. Cleared again + when a late network result arrives. check_incomplete: type: boolean - description: >- - Part of the browser checks timed out, so the verdict rests on partial data. Informational. + description: Part of the browser checks timed out, so the verdict rests on + partial data. Informational. + os_mismatch2: + type: boolean + device_spoofing: + type: boolean + latency_test: + type: boolean + banned_ip: + type: boolean diff --git a/spec/components/schemas/Fingerprint.yaml b/spec/components/schemas/Fingerprint.yaml new file mode 100644 index 0000000..38ac3a5 --- /dev/null +++ b/spec/components/schemas/Fingerprint.yaml @@ -0,0 +1,22 @@ +type: object +required: + - outcome + - rules_version +properties: + outcome: + type: string + record_id: + type: string + hardware_id: + type: string + description: FP21 hardware identity, distinct from device_id. + rules_version: + type: string + sharing: + type: object + takeover: + type: object + travel: + type: object +description: FP21 for tracked users. Legacy sharing/takeover/travel mirrors are + retained; prefer data.hre. Absent on anonymous checks. diff --git a/spec/components/schemas/HRE.yaml b/spec/components/schemas/HRE.yaml new file mode 100644 index 0000000..73e4ede --- /dev/null +++ b/spec/components/schemas/HRE.yaml @@ -0,0 +1,17 @@ +type: object +required: + - account_sharing + - account_takeover + - impossible_travel +properties: + rules_version: + type: string + account_sharing: + $ref: ./HREResult.yaml + account_takeover: + $ref: ./HREResult.yaml + impossible_travel: + $ref: ./HREResult.yaml +description: Three completed on-demand handler results. Technical errors block + emission; no_history/skipped are explicit not_evaluated results. Anonymous + checks are not_applicable. Multiaccount is separate. diff --git a/spec/components/schemas/HREResult.yaml b/spec/components/schemas/HREResult.yaml new file mode 100644 index 0000000..81b9cf3 --- /dev/null +++ b/spec/components/schemas/HREResult.yaml @@ -0,0 +1,28 @@ +type: object +required: + - status + - level + - reason +properties: + status: + type: string + examples: + - evaluated + - not_evaluated + - not_applicable + level: + type: + - string + - "null" + examples: + - medium + - high + - null + reason: + type: string + devices: + type: integer + minimum: 0 + min_devices: + type: integer + minimum: 1 diff --git a/spec/components/schemas/IdentificationScoredData.yaml b/spec/components/schemas/IdentificationScoredData.yaml index 220fdb9..5f52a3e 100644 --- a/spec/components/schemas/IdentificationScoredData.yaml +++ b/spec/components/schemas/IdentificationScoredData.yaml @@ -1,7 +1,7 @@ type: object -description: >- - The scored identification. Every key is always present (no key is ever omitted); only - `user_hid` can be `null`. +description: Final identification. risk_score is this scan only; no all-time + entity risk. New extension fields are required by version 2026-10-06; legacy + bodies remain accepted. required: - request_id - visitor_id @@ -36,20 +36,19 @@ properties: $ref: ./UserHid.yaml domain: type: string - description: Registered domain of your site (the request host when no registered domain matched). + description: Registered domain of your site (the request host when no registered + domain matched). examples: - example.com public_ip: $ref: ./IpInfo.yaml - description: >- - Public IPv4 address of the HTTP request and its country. `ip` is empty when the request did - not arrive over IPv4. + description: Public IPv4 address of the HTTP request and its country. `ip` is + empty when the request did not arrive over IPv4. local_ip: $ref: ./IpInfo.yaml - description: >- - Local IP address found by the browser network check (WebRTC): the leaked address when a - local network leak was found, otherwise the address ShieldLabs observed. Both keys are empty - when the check found nothing. + description: "Local IP address found by the browser network check (WebRTC): the + leaked address when a local network leak was found, otherwise the address + ShieldLabs observed. Both keys are empty when the check found nothing." connection_type: $ref: ./ConnectionType.yaml os: @@ -64,15 +63,28 @@ properties: $ref: ./RiskScore.yaml signals: type: array - description: >- - Weighted risk signals behind `risk_score`, in scoring order. Can be empty. The rate-limit - marker carries exactly one entry, `{"name":"rate_limited","weight":999}`. + description: Weighted risk signals behind `risk_score`, in scoring order. Can be + empty. The rate-limit marker carries exactly one entry, + `{"name":"rate_limited","weight":999}`. items: $ref: ./Signal.yaml detection_flags: $ref: ./DetectionFlags.yaml observed_at: $ref: ./Rfc3339Timestamp.yaml - description: >- - When scoring finished and the event was built (not the page view time); identical to the - envelope `created_at`. RFC 3339 in UTC with up to 9 fractional digits. + description: Original snapshot scan clock, distinct from envelope created_at. + RFC 3339 in UTC with up to 9 fractional digits. + result_version: + type: string + minLength: 1 + scoring_version: + type: string + description: Core build source revision; core:unversioned on local builds. + risk_events: + type: array + items: + $ref: ./RiskEvent.yaml + hre: + $ref: ./HRE.yaml + fingerprint: + $ref: ./Fingerprint.yaml diff --git a/spec/components/schemas/IdentificationScoredEvent.yaml b/spec/components/schemas/IdentificationScoredEvent.yaml index bc5b576..4b4b5e7 100644 --- a/spec/components/schemas/IdentificationScoredEvent.yaml +++ b/spec/components/schemas/IdentificationScoredEvent.yaml @@ -1,7 +1,6 @@ type: object -description: >- - Body of an `identification.scored` delivery. The signature is not part of the body: it arrives - in the `X-Shield-Signature` header. +description: "Body of an `identification.scored` delivery. The signature is not + part of the body: it arrives in the `X-Shield-Signature` header." required: - event_type - schema_version @@ -16,6 +15,43 @@ properties: $ref: ./SchemaVersion.yaml created_at: $ref: ./Rfc3339Timestamp.yaml - description: When the event was built. Equal to `data.observed_at`. + description: When the event envelope was created, distinct from data.observed_at + (scan time). data: $ref: ./IdentificationScoredData.yaml + event_id: &a1 + type: string + minLength: 1 + description: Logical site/request/final-result-version/type identity. Stable + across retries and endpoints. + site_id: + type: integer + minimum: 1 + description: Site scope when available; legacy domain-only accounts omit it. +allOf: + - if: + properties: + schema_version: + const: 2026-10-06 + required: + - schema_version + then: + required: + - event_id + properties: + data: + required: + - result_version + - scoring_version + - risk_events + - hre + type: object + additionalProperties: true + properties: + result_version: {} + scoring_version: {} + risk_events: {} + hre: {} + event_id: *a1 + type: object + additionalProperties: true diff --git a/spec/components/schemas/RiskEvent.yaml b/spec/components/schemas/RiskEvent.yaml new file mode 100644 index 0000000..3ed547a --- /dev/null +++ b/spec/components/schemas/RiskEvent.yaml @@ -0,0 +1,27 @@ +type: object +required: + - code + - detected + - weight + - contribution + - status +properties: + code: + type: string + detected: + type: boolean + description: Final scoring flag. false does not assert that every underlying + probe completed. + weight: + type: integer + description: Catalogue weight, not an additive score. Banned IP 999 is a marker. + contribution: + type: integer + description: Matching score details, may contain corrections. Never recompute + risk_score by summing. + status: + type: string + description: The final scoring flag has been evaluated. Probe incompleteness is + reported by dedicated risk events. + examples: + - evaluated diff --git a/spec/components/schemas/SchemaVersion.yaml b/spec/components/schemas/SchemaVersion.yaml index 16f66ca..116e459 100644 --- a/spec/components/schemas/SchemaVersion.yaml +++ b/spec/components/schemas/SchemaVersion.yaml @@ -1,7 +1,7 @@ type: string minLength: 1 -description: >- - Version of the webhook payload contract. Every event sent today carries `2026-06-01`. Accept - other values, so that a future version does not break your handler. +description: Webhook contract version. Current release 2026-10-06; parsers also + accept legacy 2026-06-01. examples: - - '2026-06-01' + - 2026-10-06 + - 2026-06-01 diff --git a/spec/components/schemas/WebhookPingEvent.yaml b/spec/components/schemas/WebhookPingEvent.yaml index 2cf2846..4eb3cd0 100644 --- a/spec/components/schemas/WebhookPingEvent.yaml +++ b/spec/components/schemas/WebhookPingEvent.yaml @@ -1,7 +1,7 @@ type: object -description: >- - Body of a `webhook.ping` delivery, sent when you verify an endpoint. It has no `data`. The keys - arrive sorted alphabetically and `created_at` has second precision. +description: Body of a `webhook.ping` delivery, sent when you verify an + endpoint. It has no `data`. The keys arrive sorted alphabetically and + `created_at` has second precision. required: - event_type - schema_version @@ -16,3 +16,6 @@ properties: created_at: $ref: ./Rfc3339Timestamp.yaml description: When the ping was sent, with second precision. + event_id: + type: string + minLength: 1 diff --git a/spec/webhooks/identification-scored.yaml b/spec/webhooks/identification-scored.yaml index 2b41630..9f8ecde 100644 --- a/spec/webhooks/identification-scored.yaml +++ b/spec/webhooks/identification-scored.yaml @@ -3,39 +3,29 @@ post: tags: - Webhooks summary: Identification scored - description: |- - Sent to every enabled webhook endpoint of your domain once for each identification, when its - scoring is final: usually about 300 ms after the browser check, and at most about 10 seconds - later when follow-up network checks run. + description: >- + Sent after final snapshot scoring and mandatory fp21 + three HRE handlers. + Risk score is the identification score only. AI bots and AI browser are not + implemented and not emitted. - **Verify, then parse.** Compute HMAC-SHA256 over the raw request body and compare it with - `X-Shield-Signature` before you parse the JSON: - - key: the endpoint's signing secret as UTF-8 bytes, including the `whsec_` prefix (not hex- - or base64-decoded, not stripped); - - message: the exact bytes received; re-serializing parsed JSON changes them (for example, `&` - arrives escaped as `\u0026`); - - expected header: `sha256=` followed by the lowercase hex digest, compared in constant time. - There is no timestamp, delivery ID or event-type header. Rotating a secret replaces it at - once, so accept both the old and the new secret until your deployment has switched. + Verify HMAC-SHA256 over exact raw bytes with the endpoint secret. Body + event_id is signed; X-Shield-Event-Id is its convenience mirror. Persist and + deduplicate by event_id before returning 2xx within the 1-second send + timeout. Legacy bodies without event_id can use data.request_id. Retries + preserve body and ID. Network/timeouts/429/5xx are retried with backoff up + to 8 failed sends or 15 minutes of retry age; exhaustion/permanent 4xx goes + to DLQ. Delivery is at-least-once attempts within that window, not + guaranteed eternal delivery or exactly-once. Endpoints are independent. + Redirects are not followed. - **Respond fast.** Answer any 2xx status within 1 second and process the event asynchronously; - do not redirect. Today each identification is delivered once per endpoint, with no retries. A - later release adds retries that resend identical bytes, so make your handler idempotent on - `data.request_id`. - **Latest state.** The event is a snapshot taken when scoring finished. The History row can - still be refined afterwards (its `ver` increases) and no second event is sent. Use the History - API for guaranteed reads and for the latest state. - - **Test deliveries.** The Test button in the analytics dashboard sends a fixed sample with keys - sorted alphabetically, second-precision timestamps and two-letter country values. Its - `detection_flags` lack `browser_automation` and `search_bot`: parse missing flags as `false`. - - Deliveries are free and do not use your included identifications. + Use History for recovery/latest state; later snapshot corrections do not + automatically emit another identification.scored event. security: [] parameters: - $ref: ../components/parameters/ShieldSignature.yaml + - $ref: ../components/parameters/ShieldEventId.yaml requestBody: required: true description: The event as compact JSON. Verify the signature over these exact bytes. @@ -50,11 +40,201 @@ post: $ref: ../components/examples/IdentificationScoredRateLimited.yaml testDelivery: $ref: ../components/examples/IdentificationScoredTestDelivery.yaml + current: + summary: Current contract with fp21 and HRE + value: + event_id: ce256c0f66536957adc5f719250f5b10ce331bd1 + site_id: 7 + event_type: identification.scored + schema_version: 2026-10-06 + created_at: 2026-10-06T00:00:02Z + data: + result_version: 2026-10-06T00:00:01Z + scoring_version: core:fixture + risk_events: + - code: vpn + detected: false + weight: 30 + contribution: 0 + status: evaluated + - code: proxy + detected: false + weight: 30 + contribution: 0 + status: evaluated + - code: privacy_relay + detected: false + weight: 30 + contribution: 0 + status: evaluated + - code: check_incomplete + detected: false + weight: 0 + contribution: 0 + status: evaluated + - code: network_check_incomplete + detected: false + weight: 30 + contribution: 0 + status: evaluated + - code: os_not_detected + detected: false + weight: 30 + contribution: 0 + status: evaluated + - code: timezone_mismatch + detected: false + weight: 10 + contribution: 0 + status: evaluated + - code: javascript_disabled + detected: false + weight: 90 + contribution: 0 + status: evaluated + - code: os_mismatch + detected: false + weight: 60 + contribution: 0 + status: evaluated + - code: os_mismatch2 + detected: false + weight: 60 + contribution: 0 + status: evaluated + - code: ip_mismatch + detected: false + weight: 45 + contribution: 0 + status: evaluated + - code: latency_test + detected: false + weight: 60 + contribution: 0 + status: evaluated + - code: anti_detect_browser + detected: false + weight: 60 + contribution: 0 + status: evaluated + - code: device_spoofing + detected: false + weight: 90 + contribution: 0 + status: evaluated + - code: banned_ip + detected: false + weight: 999 + contribution: 0 + status: evaluated + - code: incognito + detected: true + weight: 0 + contribution: 0 + status: evaluated + - code: search_bot + detected: false + weight: 0 + contribution: 0 + status: evaluated + - code: browser_automation + detected: false + weight: 60 + contribution: 0 + status: evaluated + - code: tor + detected: false + weight: 99 + contribution: 0 + status: evaluated + hre: + rules_version: fp21-fixture + account_sharing: + status: evaluated + level: high + reason: "" + devices: 4 + min_devices: 4 + account_takeover: + status: evaluated + level: medium + reason: fixture + impossible_travel: + status: evaluated + level: null + reason: below_distance + request_id: 13f84f05-7c2a-4e9b-9f1d-2a6b8c0e4d11 + visitor_id: 161dfbad-8e7f-4a6b-9c5d-0e1f2a3b4c5d + device_id: 5eb7fd5c-2a1b-4c3d-9e8f-7a6b5c4d3e2f + session_id: 7a1b2c3d-4e5f-4789-abcd-ef0123456789 + cookie_id: 3f2e1d0c-9b8a-4654-b210-fedcba987654 + user_hid: test-user + domain: example.com + public_ip: + ip: 203.0.113.1 + country: "" + local_ip: + ip: "" + country: "" + connection_type: direct + os: Windows + browser: Chrome + device_type: desktop + traffic_source: + channel: "" + referrer_domain: "" + landing_url: "" + click_id_type: "" + utm_source: "" + utm_medium: "" + utm_campaign: "" + utm_content: "" + utm_term: "" + risk_score: 0 + signals: [] + detection_flags: + os_mismatch2: false + device_spoofing: false + latency_test: false + banned_ip: false + vpn: false + privacy_relay: false + browser_vpn_proxy: false + tor: false + proxy: false + datacenter_ip: false + abuser: false + os_mismatch: false + os_not_detected: false + timezone_mismatch: false + anti_detect_browser: false + browser_automation: false + ip_mismatch: false + incognito: true + search_bot: false + suspicious_paid_click: false + javascript_disabled: false + stun_not_checked: false + check_incomplete: false + observed_at: 2026-10-06T00:00:00Z + fingerprint: + outcome: new_record + record_id: sample-record + hardware_id: sample-hardware + rules_version: fp21-fixture + sharing: + level: high + devices: 4 + min_devices: 4 + takeover: + level: medium + reason: fixture + travel: + level: "" + reason: below_distance responses: 2XX: description: Delivery accepted. The response body is ignored. 4XX: - description: >- - Delivery rejected, for example with `401` when the signature does not verify. Any status - other than 2xx, and a timeout after 1 second, counts as a failed delivery; failed - deliveries are not retried today. + description: Permanent 4xx are terminal; 429 is retried. 2xx acknowledges + durable acceptance. diff --git a/spec/webhooks/webhook-ping.yaml b/spec/webhooks/webhook-ping.yaml index 80f11fe..3d765f6 100644 --- a/spec/webhooks/webhook-ping.yaml +++ b/spec/webhooks/webhook-ping.yaml @@ -20,6 +20,7 @@ post: security: [] parameters: - $ref: ../components/parameters/ShieldSignature.yaml + - $ref: ../components/parameters/ShieldEventId.yaml requestBody: required: true description: The ping as compact JSON with sorted keys. @@ -34,4 +35,5 @@ post: 2XX: description: Endpoint verified. The response body is ignored. 4XX: - description: Verification failed. Any status other than 2xx, and a timeout after 5 seconds, fails it. + description: Verification failed. Any status other than 2xx, and a timeout after + 5 seconds, fails it. diff --git a/test/build.test.mjs b/test/build.test.mjs index edcd8d6..bce0f4b 100644 --- a/test/build.test.mjs +++ b/test/build.test.mjs @@ -208,7 +208,7 @@ describe('schemas', () => { const { doc } = freshBuild(); const flags = doc.components.schemas.DetectionFlags; assert.deepEqual([...flags.required].sort(), [...FLAGS].sort()); - assert.deepEqual(Object.keys(flags.properties).sort(), [...FLAGS].sort()); + assert.deepEqual(Object.keys(flags.properties).sort(), [...FLAGS, "os_mismatch2", "device_spoofing", "latency_test", "banned_ip"].sort()); for (const flag of FLAGS) assert.equal(flags.properties[flag].type, 'boolean', flag); }); @@ -216,7 +216,7 @@ describe('schemas', () => { const { doc } = freshBuild(); const data = doc.components.schemas.IdentificationScoredData; assert.deepEqual([...data.required].sort(), [...WEBHOOK_DATA_KEYS].sort()); - assert.deepEqual(Object.keys(data.properties).sort(), [...WEBHOOK_DATA_KEYS].sort()); + assert.deepEqual(Object.keys(data.properties).sort(), [...WEBHOOK_DATA_KEYS, "result_version", "scoring_version", "risk_events", "hre", "fingerprint"].sort()); assert.deepEqual(doc.components.schemas.UserHid.type, ['string', 'null']); }); diff --git a/test/contract-current.test.mjs b/test/contract-current.test.mjs new file mode 100644 index 0000000..e18a511 --- /dev/null +++ b/test/contract-current.test.mjs @@ -0,0 +1,22 @@ +import assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; +import {describe,it} from 'node:test'; +import {freshBuild,ROOT} from './helpers.mjs'; +import path from 'node:path'; +describe('Core 2026-10-06 fixtures',()=>{ + for(const name of ['named','anonymous','no-history','banned','incomplete'])it(name,()=>{ + const fixture=JSON.parse(readFileSync(path.join(ROOT,'test/contracts',name+'.json'),'utf8')); + const {doc,validator}=freshBuild();const schema=doc.components.schemas.IdentificationScoredEvent; + const result=validator.validate(schema,fixture);assert.ok(result.valid,JSON.stringify(result.errors)); + assert.equal(fixture.data.risk_events.length,19); + assert.equal(new Set(fixture.data.risk_events.map(r=>r.code)).size,19); + assert.ok(!fixture.data.risk_events.some(r=>r.code.startsWith('ai_'))); + assert.equal(fixture.data.entity_risks,undefined); + const missing=structuredClone(fixture);delete missing.event_id; + assert.equal(validator.validate(schema,missing).valid,false); + for(const key of ['risk_events','hre','result_version','scoring_version']){ + const broken=structuredClone(fixture);delete broken.data[key]; + assert.equal(validator.validate(schema,broken).valid,false,key+' required'); + } + }); +}); diff --git a/test/contracts/anonymous.json b/test/contracts/anonymous.json new file mode 100644 index 0000000..7c52ee8 --- /dev/null +++ b/test/contracts/anonymous.json @@ -0,0 +1,221 @@ +{ + "event_id": "ce256c0f66536957adc5f719250f5b10ce331bd1", + "site_id": 7, + "event_type": "identification.scored", + "schema_version": "2026-10-06", + "created_at": "2026-10-06T00:00:02Z", + "data": { + "result_version": "2026-10-06T00:00:01Z", + "scoring_version": "core:fixture", + "risk_events": [ + { + "code": "vpn", + "detected": false, + "weight": 30, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "proxy", + "detected": false, + "weight": 30, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "privacy_relay", + "detected": false, + "weight": 30, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "check_incomplete", + "detected": false, + "weight": 0, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "network_check_incomplete", + "detected": false, + "weight": 30, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "os_not_detected", + "detected": false, + "weight": 30, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "timezone_mismatch", + "detected": false, + "weight": 10, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "javascript_disabled", + "detected": false, + "weight": 90, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "os_mismatch", + "detected": false, + "weight": 60, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "os_mismatch2", + "detected": false, + "weight": 60, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "ip_mismatch", + "detected": false, + "weight": 45, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "latency_test", + "detected": false, + "weight": 60, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "anti_detect_browser", + "detected": false, + "weight": 60, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "device_spoofing", + "detected": false, + "weight": 90, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "banned_ip", + "detected": false, + "weight": 999, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "incognito", + "detected": true, + "weight": 0, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "search_bot", + "detected": false, + "weight": 0, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "browser_automation", + "detected": false, + "weight": 60, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "tor", + "detected": false, + "weight": 99, + "contribution": 0, + "status": "evaluated" + } + ], + "hre": { + "account_sharing": { + "status": "not_applicable", + "level": null, + "reason": "anonymous_user" + }, + "account_takeover": { + "status": "not_applicable", + "level": null, + "reason": "anonymous_user" + }, + "impossible_travel": { + "status": "not_applicable", + "level": null, + "reason": "anonymous_user" + } + }, + "request_id": "13f84f05-7c2a-4e9b-9f1d-2a6b8c0e4d11", + "visitor_id": "161dfbad-8e7f-4a6b-9c5d-0e1f2a3b4c5d", + "device_id": "5eb7fd5c-2a1b-4c3d-9e8f-7a6b5c4d3e2f", + "session_id": "7a1b2c3d-4e5f-4789-abcd-ef0123456789", + "cookie_id": "3f2e1d0c-9b8a-4654-b210-fedcba987654", + "user_hid": "anonymous", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.1", + "country": "" + }, + "local_ip": { + "ip": "", + "country": "" + }, + "connection_type": "direct", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "", + "referrer_domain": "", + "landing_url": "", + "click_id_type": "", + "utm_source": "", + "utm_medium": "", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 0, + "signals": [], + "detection_flags": { + "os_mismatch2": false, + "device_spoofing": false, + "latency_test": false, + "banned_ip": false, + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": false, + "datacenter_ip": false, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": false, + "browser_automation": false, + "ip_mismatch": false, + "incognito": true, + "search_bot": false, + "suspicious_paid_click": false, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-10-06T00:00:00Z" + } +} diff --git a/test/contracts/banned.json b/test/contracts/banned.json new file mode 100644 index 0000000..dacebf1 --- /dev/null +++ b/test/contracts/banned.json @@ -0,0 +1,248 @@ +{ + "event_id": "ce256c0f66536957adc5f719250f5b10ce331bd1", + "site_id": 7, + "event_type": "identification.scored", + "schema_version": "2026-10-06", + "created_at": "2026-10-06T00:00:02Z", + "data": { + "result_version": "2026-10-06T00:00:01Z", + "scoring_version": "core:fixture", + "risk_events": [ + { + "code": "vpn", + "detected": false, + "weight": 30, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "proxy", + "detected": false, + "weight": 30, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "privacy_relay", + "detected": false, + "weight": 30, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "check_incomplete", + "detected": false, + "weight": 0, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "network_check_incomplete", + "detected": false, + "weight": 30, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "os_not_detected", + "detected": false, + "weight": 30, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "timezone_mismatch", + "detected": false, + "weight": 10, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "javascript_disabled", + "detected": false, + "weight": 90, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "os_mismatch", + "detected": false, + "weight": 60, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "os_mismatch2", + "detected": false, + "weight": 60, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "ip_mismatch", + "detected": false, + "weight": 45, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "latency_test", + "detected": false, + "weight": 60, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "anti_detect_browser", + "detected": false, + "weight": 60, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "device_spoofing", + "detected": false, + "weight": 90, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "banned_ip", + "detected": true, + "weight": 999, + "contribution": 999, + "status": "evaluated" + }, + { + "code": "incognito", + "detected": true, + "weight": 0, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "search_bot", + "detected": false, + "weight": 0, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "browser_automation", + "detected": false, + "weight": 60, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "tor", + "detected": false, + "weight": 99, + "contribution": 0, + "status": "evaluated" + } + ], + "hre": { + "rules_version": "fp21-fixture", + "account_sharing": { + "status": "evaluated", + "level": "high", + "reason": "", + "devices": 4, + "min_devices": 4 + }, + "account_takeover": { + "status": "evaluated", + "level": "medium", + "reason": "fixture" + }, + "impossible_travel": { + "status": "evaluated", + "level": null, + "reason": "below_distance" + } + }, + "request_id": "13f84f05-7c2a-4e9b-9f1d-2a6b8c0e4d11", + "visitor_id": "161dfbad-8e7f-4a6b-9c5d-0e1f2a3b4c5d", + "device_id": "5eb7fd5c-2a1b-4c3d-9e8f-7a6b5c4d3e2f", + "session_id": "7a1b2c3d-4e5f-4789-abcd-ef0123456789", + "cookie_id": "3f2e1d0c-9b8a-4654-b210-fedcba987654", + "user_hid": "test-user", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.1", + "country": "" + }, + "local_ip": { + "ip": "", + "country": "" + }, + "connection_type": "direct", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "", + "referrer_domain": "", + "landing_url": "", + "click_id_type": "", + "utm_source": "", + "utm_medium": "", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 999, + "signals": [ + { + "name": "rate_limited", + "weight": 999 + } + ], + "detection_flags": { + "os_mismatch2": false, + "device_spoofing": false, + "latency_test": false, + "banned_ip": true, + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": false, + "datacenter_ip": false, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": false, + "browser_automation": false, + "ip_mismatch": false, + "incognito": true, + "search_bot": false, + "suspicious_paid_click": false, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-10-06T00:00:00Z", + "fingerprint": { + "outcome": "new_record", + "record_id": "sample-record", + "hardware_id": "sample-hardware", + "rules_version": "fp21-fixture", + "sharing": { + "level": "high", + "devices": 4, + "min_devices": 4 + }, + "takeover": { + "level": "medium", + "reason": "fixture" + }, + "travel": { + "level": "", + "reason": "below_distance" + } + } + } +} diff --git a/test/contracts/incomplete.json b/test/contracts/incomplete.json new file mode 100644 index 0000000..e193877 --- /dev/null +++ b/test/contracts/incomplete.json @@ -0,0 +1,248 @@ +{ + "event_id": "ce256c0f66536957adc5f719250f5b10ce331bd1", + "site_id": 7, + "event_type": "identification.scored", + "schema_version": "2026-10-06", + "created_at": "2026-10-06T00:00:02Z", + "data": { + "result_version": "2026-10-06T00:00:01Z", + "scoring_version": "core:fixture", + "risk_events": [ + { + "code": "vpn", + "detected": false, + "weight": 30, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "proxy", + "detected": false, + "weight": 30, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "privacy_relay", + "detected": false, + "weight": 30, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "check_incomplete", + "detected": true, + "weight": 0, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "network_check_incomplete", + "detected": true, + "weight": 30, + "contribution": 30, + "status": "evaluated" + }, + { + "code": "os_not_detected", + "detected": false, + "weight": 30, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "timezone_mismatch", + "detected": false, + "weight": 10, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "javascript_disabled", + "detected": false, + "weight": 90, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "os_mismatch", + "detected": false, + "weight": 60, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "os_mismatch2", + "detected": false, + "weight": 60, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "ip_mismatch", + "detected": false, + "weight": 45, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "latency_test", + "detected": false, + "weight": 60, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "anti_detect_browser", + "detected": false, + "weight": 60, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "device_spoofing", + "detected": false, + "weight": 90, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "banned_ip", + "detected": false, + "weight": 999, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "incognito", + "detected": true, + "weight": 0, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "search_bot", + "detected": false, + "weight": 0, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "browser_automation", + "detected": false, + "weight": 60, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "tor", + "detected": false, + "weight": 99, + "contribution": 0, + "status": "evaluated" + } + ], + "hre": { + "rules_version": "fp21-fixture", + "account_sharing": { + "status": "evaluated", + "level": "high", + "reason": "", + "devices": 4, + "min_devices": 4 + }, + "account_takeover": { + "status": "evaluated", + "level": "medium", + "reason": "fixture" + }, + "impossible_travel": { + "status": "evaluated", + "level": null, + "reason": "below_distance" + } + }, + "request_id": "13f84f05-7c2a-4e9b-9f1d-2a6b8c0e4d11", + "visitor_id": "161dfbad-8e7f-4a6b-9c5d-0e1f2a3b4c5d", + "device_id": "5eb7fd5c-2a1b-4c3d-9e8f-7a6b5c4d3e2f", + "session_id": "7a1b2c3d-4e5f-4789-abcd-ef0123456789", + "cookie_id": "3f2e1d0c-9b8a-4654-b210-fedcba987654", + "user_hid": "test-user", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.1", + "country": "" + }, + "local_ip": { + "ip": "", + "country": "" + }, + "connection_type": "direct", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "", + "referrer_domain": "", + "landing_url": "", + "click_id_type": "", + "utm_source": "", + "utm_medium": "", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 30, + "signals": [ + { + "name": "stun_not_checked", + "weight": 30 + } + ], + "detection_flags": { + "os_mismatch2": false, + "device_spoofing": false, + "latency_test": false, + "banned_ip": false, + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": false, + "datacenter_ip": false, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": false, + "browser_automation": false, + "ip_mismatch": false, + "incognito": true, + "search_bot": false, + "suspicious_paid_click": false, + "javascript_disabled": false, + "stun_not_checked": true, + "check_incomplete": true + }, + "observed_at": "2026-10-06T00:00:00Z", + "fingerprint": { + "outcome": "new_record", + "record_id": "sample-record", + "hardware_id": "sample-hardware", + "rules_version": "fp21-fixture", + "sharing": { + "level": "high", + "devices": 4, + "min_devices": 4 + }, + "takeover": { + "level": "medium", + "reason": "fixture" + }, + "travel": { + "level": "", + "reason": "below_distance" + } + } + } +} diff --git a/test/contracts/named.json b/test/contracts/named.json new file mode 100644 index 0000000..2daec37 --- /dev/null +++ b/test/contracts/named.json @@ -0,0 +1,243 @@ +{ + "event_id": "ce256c0f66536957adc5f719250f5b10ce331bd1", + "site_id": 7, + "event_type": "identification.scored", + "schema_version": "2026-10-06", + "created_at": "2026-10-06T00:00:02Z", + "data": { + "result_version": "2026-10-06T00:00:01Z", + "scoring_version": "core:fixture", + "risk_events": [ + { + "code": "vpn", + "detected": false, + "weight": 30, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "proxy", + "detected": false, + "weight": 30, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "privacy_relay", + "detected": false, + "weight": 30, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "check_incomplete", + "detected": false, + "weight": 0, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "network_check_incomplete", + "detected": false, + "weight": 30, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "os_not_detected", + "detected": false, + "weight": 30, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "timezone_mismatch", + "detected": false, + "weight": 10, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "javascript_disabled", + "detected": false, + "weight": 90, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "os_mismatch", + "detected": false, + "weight": 60, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "os_mismatch2", + "detected": false, + "weight": 60, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "ip_mismatch", + "detected": false, + "weight": 45, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "latency_test", + "detected": false, + "weight": 60, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "anti_detect_browser", + "detected": false, + "weight": 60, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "device_spoofing", + "detected": false, + "weight": 90, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "banned_ip", + "detected": false, + "weight": 999, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "incognito", + "detected": true, + "weight": 0, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "search_bot", + "detected": false, + "weight": 0, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "browser_automation", + "detected": false, + "weight": 60, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "tor", + "detected": false, + "weight": 99, + "contribution": 0, + "status": "evaluated" + } + ], + "hre": { + "rules_version": "fp21-fixture", + "account_sharing": { + "status": "evaluated", + "level": "high", + "reason": "", + "devices": 4, + "min_devices": 4 + }, + "account_takeover": { + "status": "evaluated", + "level": "medium", + "reason": "fixture" + }, + "impossible_travel": { + "status": "evaluated", + "level": null, + "reason": "below_distance" + } + }, + "request_id": "13f84f05-7c2a-4e9b-9f1d-2a6b8c0e4d11", + "visitor_id": "161dfbad-8e7f-4a6b-9c5d-0e1f2a3b4c5d", + "device_id": "5eb7fd5c-2a1b-4c3d-9e8f-7a6b5c4d3e2f", + "session_id": "7a1b2c3d-4e5f-4789-abcd-ef0123456789", + "cookie_id": "3f2e1d0c-9b8a-4654-b210-fedcba987654", + "user_hid": "test-user", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.1", + "country": "" + }, + "local_ip": { + "ip": "", + "country": "" + }, + "connection_type": "direct", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "", + "referrer_domain": "", + "landing_url": "", + "click_id_type": "", + "utm_source": "", + "utm_medium": "", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 0, + "signals": [], + "detection_flags": { + "os_mismatch2": false, + "device_spoofing": false, + "latency_test": false, + "banned_ip": false, + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": false, + "datacenter_ip": false, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": false, + "browser_automation": false, + "ip_mismatch": false, + "incognito": true, + "search_bot": false, + "suspicious_paid_click": false, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-10-06T00:00:00Z", + "fingerprint": { + "outcome": "new_record", + "record_id": "sample-record", + "hardware_id": "sample-hardware", + "rules_version": "fp21-fixture", + "sharing": { + "level": "high", + "devices": 4, + "min_devices": 4 + }, + "takeover": { + "level": "medium", + "reason": "fixture" + }, + "travel": { + "level": "", + "reason": "below_distance" + } + } + } +} diff --git a/test/contracts/no-history.json b/test/contracts/no-history.json new file mode 100644 index 0000000..4c6dcd6 --- /dev/null +++ b/test/contracts/no-history.json @@ -0,0 +1,243 @@ +{ + "event_id": "ce256c0f66536957adc5f719250f5b10ce331bd1", + "site_id": 7, + "event_type": "identification.scored", + "schema_version": "2026-10-06", + "created_at": "2026-10-06T00:00:02Z", + "data": { + "result_version": "2026-10-06T00:00:01Z", + "scoring_version": "core:fixture", + "risk_events": [ + { + "code": "vpn", + "detected": false, + "weight": 30, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "proxy", + "detected": false, + "weight": 30, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "privacy_relay", + "detected": false, + "weight": 30, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "check_incomplete", + "detected": false, + "weight": 0, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "network_check_incomplete", + "detected": false, + "weight": 30, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "os_not_detected", + "detected": false, + "weight": 30, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "timezone_mismatch", + "detected": false, + "weight": 10, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "javascript_disabled", + "detected": false, + "weight": 90, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "os_mismatch", + "detected": false, + "weight": 60, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "os_mismatch2", + "detected": false, + "weight": 60, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "ip_mismatch", + "detected": false, + "weight": 45, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "latency_test", + "detected": false, + "weight": 60, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "anti_detect_browser", + "detected": false, + "weight": 60, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "device_spoofing", + "detected": false, + "weight": 90, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "banned_ip", + "detected": false, + "weight": 999, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "incognito", + "detected": true, + "weight": 0, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "search_bot", + "detected": false, + "weight": 0, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "browser_automation", + "detected": false, + "weight": 60, + "contribution": 0, + "status": "evaluated" + }, + { + "code": "tor", + "detected": false, + "weight": 99, + "contribution": 0, + "status": "evaluated" + } + ], + "hre": { + "rules_version": "fp21-fixture", + "account_sharing": { + "status": "evaluated", + "level": null, + "reason": "", + "devices": 1, + "min_devices": 4 + }, + "account_takeover": { + "status": "not_evaluated", + "level": null, + "reason": "no_history" + }, + "impossible_travel": { + "status": "not_evaluated", + "level": null, + "reason": "skipped" + } + }, + "request_id": "13f84f05-7c2a-4e9b-9f1d-2a6b8c0e4d11", + "visitor_id": "161dfbad-8e7f-4a6b-9c5d-0e1f2a3b4c5d", + "device_id": "5eb7fd5c-2a1b-4c3d-9e8f-7a6b5c4d3e2f", + "session_id": "7a1b2c3d-4e5f-4789-abcd-ef0123456789", + "cookie_id": "3f2e1d0c-9b8a-4654-b210-fedcba987654", + "user_hid": "test-user", + "domain": "example.com", + "public_ip": { + "ip": "203.0.113.1", + "country": "" + }, + "local_ip": { + "ip": "", + "country": "" + }, + "connection_type": "direct", + "os": "Windows", + "browser": "Chrome", + "device_type": "desktop", + "traffic_source": { + "channel": "", + "referrer_domain": "", + "landing_url": "", + "click_id_type": "", + "utm_source": "", + "utm_medium": "", + "utm_campaign": "", + "utm_content": "", + "utm_term": "" + }, + "risk_score": 0, + "signals": [], + "detection_flags": { + "os_mismatch2": false, + "device_spoofing": false, + "latency_test": false, + "banned_ip": false, + "vpn": false, + "privacy_relay": false, + "browser_vpn_proxy": false, + "tor": false, + "proxy": false, + "datacenter_ip": false, + "abuser": false, + "os_mismatch": false, + "os_not_detected": false, + "timezone_mismatch": false, + "anti_detect_browser": false, + "browser_automation": false, + "ip_mismatch": false, + "incognito": true, + "search_bot": false, + "suspicious_paid_click": false, + "javascript_disabled": false, + "stun_not_checked": false, + "check_incomplete": false + }, + "observed_at": "2026-10-06T00:00:00Z", + "fingerprint": { + "outcome": "new_record", + "record_id": "sample-record", + "hardware_id": "sample-hardware", + "rules_version": "fp21-fixture", + "sharing": { + "level": "", + "devices": 1, + "min_devices": 4 + }, + "takeover": { + "level": "", + "reason": "no_history" + }, + "travel": { + "level": "", + "reason": "skipped" + } + } + } +}