From 8b85fdeefb3c25c98fbef423ec26ccd877df67b2 Mon Sep 17 00:00:00 2001 From: Konstantin Serebryakov <97249355+KonstantinSerebryakov@users.noreply.github.com> Date: Tue, 6 Oct 2026 01:29:54 +0300 Subject: [PATCH 1/2] feat(webhooks): support current final-risk contract --- CHANGELOG.md | 4 + README.md | 40 +++- resources/shieldlabs-api.yaml | 403 +++++++++++++++++++++++++++++++--- src/common.ts | 4 + src/constants.ts | 2 +- src/generated/api.ts | 115 +++++++--- src/normalize.ts | 61 ++++- src/types.ts | 46 ++++ src/webhooks.ts | 8 +- test/contract-current.test.ts | 28 +++ test/contracts/current.json | 243 ++++++++++++++++++++ 11 files changed, 874 insertions(+), 80 deletions(-) create mode 100644 test/contract-current.test.ts create mode 100644 test/contracts/current.json diff --git a/CHANGELOG.md b/CHANGELOG.md index c562d3c..758c2f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ All notable changes to this package are documented in this file. The format foll ## [Unreleased] +### Added + +- Accept webhook contract `2026-10-06` with signed event IDs, final result/scoring versions, the complete risk-event catalogue, three HRE results and distinct fp21 hardware identity. Legacy webhook bodies remain supported. + ## [1.0.1] - 2026-10-01 ### Fixed diff --git a/README.md b/README.md index 9838965..93483de 100644 --- a/README.md +++ b/README.md @@ -235,17 +235,26 @@ import { SignatureVerificationError, webhooks } from '@shieldlabs-ai/node'; const app = express(); // express.raw keeps the body as a Buffer. Register this route before any global express.json(). -app.post('/webhooks/shieldlabs', express.raw({ type: 'application/json' }), (req, res) => { +app.post('/webhooks/shieldlabs', express.raw({ type: 'application/json' }), async (req, res) => { let event; try { event = webhooks.constructEvent(req.body, req.get('x-shield-signature'), process.env.SHIELDLABS_WEBHOOK_SECRET); } catch (error) { return res.sendStatus(error instanceof SignatureVerificationError ? 401 : 400); } - res.sendStatus(200); // answer within 1 second, then process + // Persist before acknowledging; replace storeEventIfAbsent with your durable inbox. + if (event.event_type === 'webhook.ping' && !event.event_id) return res.sendStatus(200); + const id = event.event_id ?? (event.event_type === 'identification.scored' ? event.data.request_id : null); + if (!id) return res.sendStatus(400); + try { + await storeEventIfAbsent(id, req.body); + } catch { + return res.sendStatus(503); + } + res.sendStatus(200); // asynchronous processing happens from the inbox if (event.event_type === 'identification.scored') { - jobs.enqueue(event.data); // make the job idempotent on event.data.request_id + // Process the persisted event asynchronously, deduplicated by event.event_id. } }); ``` @@ -253,12 +262,9 @@ app.post('/webhooks/shieldlabs', express.raw({ type: 'application/json' }), (req What to know about deliveries: - The header is `X-Shield-Signature: sha256=`, keyed with the - full signing secret including its `whsec_` prefix. It is the only ShieldLabs header, so the - idempotency key comes from the body: `data.request_id`. -- Today each identification is delivered once per enabled endpoint: one attempt with a 1-second - timeout and no retries. Future retries will resend identical bytes, so make handlers - idempotent on `data.request_id` now. -- For guaranteed reads, use the History API: a missed delivery is not sent again, and a History + full signing secret including its `whsec_` prefix. X-Shield-Event-Id mirrors the signed body event_id, which is the deduplication key. +- Each enabled endpoint has independent delivery state and a 1-second send timeout with bounded retries. Retried bytes and event_id stay unchanged. +- For guaranteed reads, use the History API: retries can exhaust, and a History row can be refined after its webhook was sent. - To rotate a secret without downtime, pass both secrets: `constructEvent(body, header, [newSecret, oldSecret])`. In an environment variable, keep them comma-separated and split them: @@ -487,3 +493,19 @@ Support: [contact@shieldlabs.ai](mailto:contact@shieldlabs.ai). ## License [MIT](LICENSE) + + +### Webhook contract 2026-10-06 + +Current events include signed `event_id`, optional `site_id`, the complete `data.risk_events` +catalogue (including zero-weight events), `data.fingerprint` (FP21 hardware ID distinct from +`device_id`), and `data.hre` for sharing/takeover/travel with explicit statuses. Older envelopes +remain supported. Only identification risk score is sent; AI bots/browser are planned only, +and all-time entity risks are excluded. + +Persist the verified event in a durable inbox **before** returning 2xx and deduplicate by +`event_id` (legacy scored bodies: `data.request_id`). Timeout/network/429/5xx retry with +backoff in a bounded window (8 failed sends / 15-minute retry age), then DLQ. Other 4xx are +terminal. Retried bodies and event IDs stay unchanged. `X-Shield-Event-Id` mirrors the body ID; +trust the signed body. Signature verification remains raw-body HMAC-SHA256. Delivery is not +exactly-once and later History corrections do not automatically create a new webhook event. diff --git a/resources/shieldlabs-api.yaml b/resources/shieldlabs-api.yaml index 2838959..b26b53a 100644 --- a/resources/shieldlabs-api.yaml +++ b/resources/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/src/common.ts b/src/common.ts index faafa82..ae0db57 100644 --- a/src/common.ts +++ b/src/common.ts @@ -52,4 +52,8 @@ export type { WebhookPayload, WebhookPingEvent, WebhookSecret, + RiskEvent, + HREResult, + HRE, + Fingerprint, } from './types.js'; diff --git a/src/constants.ts b/src/constants.ts index ec50236..bfd2369 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -76,7 +76,7 @@ export const FLAG_KEYS: readonly FlagKey[] = [ ]; /** Webhook schema version this SDK was built for. */ -export const KNOWN_SCHEMA_VERSION = '2026-06-01'; +export const KNOWN_SCHEMA_VERSION = '2026-10-06'; export const DEFAULT_HISTORY_BASE_URL = 'https://account.shieldlabs.ai'; export const DEFAULT_MANAGEMENT_BASE_URL = 'https://api.shieldlabs.ai'; diff --git a/src/generated/api.ts b/src/generated/api.ts index 8bafbca..60d644d 100644 --- a/src/generated/api.ts +++ b/src/generated/api.ts @@ -149,35 +149,11 @@ export interface webhooks { put?: never; /** * 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. + * 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. */ post: operations["identificationScored"]; delete?: never; @@ -655,7 +631,8 @@ export interface components { status: "ok"; }; /** - * @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. + * @example 2026-10-06 * @example 2026-06-01 */ SchemaVersion: string; @@ -776,7 +753,7 @@ export interface components { weight: number; }; /** - * @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 @@ -821,8 +798,61 @@ export interface components { stun_not_checked: boolean; /** @description Part of the browser checks timed out, so the verdict rests on partial data. Informational. */ check_incomplete: boolean; + os_mismatch2?: boolean; + device_spoofing?: boolean; + latency_test?: boolean; + banned_ip?: boolean; + }; + RiskEvent: { + code: string; + /** @description Final scoring flag. false does not assert that every underlying probe completed. */ + detected: boolean; + /** @description Catalogue weight, not an additive score. Banned IP 999 is a marker. */ + weight: number; + /** @description Matching score details, may contain corrections. Never recompute risk_score by summing. */ + contribution: number; + /** + * @description The final scoring flag has been evaluated. Probe incompleteness is reported by dedicated risk events. + * @example evaluated + */ + status: string; }; - /** @description The scored identification. Every key is always present (no key is ever omitted); only `user_hid` can be `null`. */ + HREResult: { + /** + * @example evaluated + * @example not_evaluated + * @example not_applicable + */ + status: string; + /** + * @example medium + * @example high + * @example null + */ + level: string | null; + reason: string; + devices?: number; + min_devices?: number; + }; + /** @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. */ + HRE: { + rules_version?: string; + account_sharing: components["schemas"]["HREResult"]; + account_takeover: components["schemas"]["HREResult"]; + impossible_travel: components["schemas"]["HREResult"]; + }; + /** @description FP21 for tracked users. Legacy sharing/takeover/travel mirrors are retained; prefer data.hre. Absent on anonymous checks. */ + Fingerprint: { + outcome: string; + record_id?: string; + /** @description FP21 hardware identity, distinct from device_id. */ + hardware_id?: string; + rules_version: string; + sharing?: Record; + takeover?: Record; + travel?: Record; + }; + /** @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. */ IdentificationScoredData: { request_id: components["schemas"]["RequestId"]; visitor_id: components["schemas"]["VisitorId"]; @@ -848,8 +878,14 @@ export interface components { /** @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}`. */ signals: components["schemas"]["Signal"][]; detection_flags: components["schemas"]["DetectionFlags"]; - /** @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. */ observed_at: components["schemas"]["Rfc3339Timestamp"]; + result_version?: string; + /** @description Core build source revision; core:unversioned on local builds. */ + scoring_version?: string; + risk_events?: components["schemas"]["RiskEvent"][]; + hre?: components["schemas"]["HRE"]; + fingerprint?: components["schemas"]["Fingerprint"]; }; /** @description Body of an `identification.scored` delivery. The signature is not part of the body: it arrives in the `X-Shield-Signature` header. */ IdentificationScoredEvent: { @@ -859,10 +895,14 @@ export interface components { */ event_type: "identification.scored"; schema_version: components["schemas"]["SchemaVersion"]; - /** @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). */ created_at: components["schemas"]["Rfc3339Timestamp"]; data: components["schemas"]["IdentificationScoredData"]; - }; + /** @description Logical site/request/final-result-version/type identity. Stable across retries and endpoints. */ + event_id?: string; + /** @description Site scope when available; legacy domain-only accounts omit it. */ + site_id?: number; + } & unknown; /** @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. */ WebhookPingEvent: { /** @@ -873,6 +913,7 @@ export interface components { schema_version: components["schemas"]["SchemaVersion"]; /** @description When the ping was sent, with second precision. */ created_at: components["schemas"]["Rfc3339Timestamp"]; + event_id?: string; }; }; responses: { @@ -1021,6 +1062,8 @@ export interface components { * `whsec_00112233445566778899aabbccddeeff`. */ ShieldSignature: string; + /** @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. */ + ShieldEventId: string; }; requestBodies: never; headers: { @@ -1249,6 +1292,8 @@ export interface operations { * `whsec_00112233445566778899aabbccddeeff`. */ "X-Shield-Signature": components["parameters"]["ShieldSignature"]; + /** @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. */ + "X-Shield-Event-Id"?: components["parameters"]["ShieldEventId"]; }; path?: never; cookie?: never; @@ -1267,7 +1312,7 @@ export interface operations { }; content?: never; }; - /** @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. */ "4XX": { headers: { [name: string]: unknown; @@ -1291,6 +1336,8 @@ export interface operations { * `whsec_00112233445566778899aabbccddeeff`. */ "X-Shield-Signature": components["parameters"]["ShieldSignature"]; + /** @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. */ + "X-Shield-Event-Id"?: components["parameters"]["ShieldEventId"]; }; path?: never; cookie?: never; diff --git a/src/normalize.ts b/src/normalize.ts index 8d85683..c30d76e 100644 --- a/src/normalize.ts +++ b/src/normalize.ts @@ -243,7 +243,7 @@ export function parseRfc3339(value: unknown): string | null { type HistoryFlagKey = Exclude; -const HISTORY_FLAG_COLUMNS: Readonly> = { +const HISTORY_FLAG_COLUMNS: Readonly>> = { vpn: 'is_vpn', privacy_relay: 'is_privacy_relay', tor: 'is_tor', @@ -348,7 +348,8 @@ export function fromHistoryRow(row: JsonObject): Identification { flags[key] = !searchBot && (ipLeakDetail || (publicIp !== '' && localIp !== '' && publicIp !== localIp)); } else { - flags[key] = isTruthy(own(row, HISTORY_FLAG_COLUMNS[key])); + const column = HISTORY_FLAG_COLUMNS[key]; + flags[key] = column ? isTruthy(own(row, column)) : false; } } @@ -400,6 +401,8 @@ export function fromWebhookData(data: JsonObject): Identification { const flagSource = isPlainObject(rawFlags) ? rawFlags : {}; const flags = {} as DetectionFlags; for (const key of FLAG_KEYS) flags[key] = isTruthy(own(flagSource, key)); + for (const key of ['os_mismatch2', 'device_spoofing', 'latency_test', 'banned_ip'] as const) + if (typeof flagSource[key] === 'boolean') flags[key] = flagSource[key]; const rawTraffic = own(data, 'traffic_source'); const trafficSource = isPlainObject(rawTraffic) ? rawTraffic : {}; @@ -437,6 +440,7 @@ export function fromWebhookData(data: JsonObject): Identification { signals, detection_flags: flags, observed_at: parseRfc3339(own(data, 'observed_at')), + ...webhookExtensions(data), source: 'webhook', raw: data, }; @@ -454,3 +458,56 @@ export function profileFromResponse(body: JsonObject): DomainProfile { raw: body, }; } + +function webhookExtensions( + data: JsonObject, +): Pick< + Identification, + 'risk_events' | 'hre' | 'fingerprint' | 'result_version' | 'scoring_version' +> { + const out: Pick< + Identification, + 'risk_events' | 'hre' | 'fingerprint' | 'result_version' | 'scoring_version' + > = {}; + if (typeof data.result_version === 'string') out.result_version = data.result_version; + if (typeof data.scoring_version === 'string') out.scoring_version = data.scoring_version; + if (Array.isArray(data.risk_events)) + out.risk_events = data.risk_events.filter(isPlainObject).map((r) => ({ + code: stringField(r, 'code'), + detected: r.detected === true, + weight: typeof r.weight === 'number' ? r.weight : 0, + contribution: typeof r.contribution === 'number' ? r.contribution : 0, + status: stringField(r, 'status'), + })); + if (isPlainObject(data.fingerprint)) { + const r = data.fingerprint; + out.fingerprint = { + outcome: stringField(r, 'outcome'), + rules_version: stringField(r, 'rules_version'), + }; + if (typeof r.hardware_id === 'string') out.fingerprint.hardware_id = r.hardware_id; + if (typeof r.record_id === 'string') out.fingerprint.record_id = r.record_id; + for (const k of ['sharing', 'takeover', 'travel'] as const) + if (isPlainObject(r[k])) out.fingerprint[k] = r[k]; + } + if (isPlainObject(data.hre)) { + const h = data.hre; + const result = (v: unknown) => { + const r = isPlainObject(v) ? v : {}; + return { + status: stringField(r, 'status') || 'unavailable', + reason: stringField(r, 'reason'), + level: typeof r.level === 'string' ? r.level : null, + ...(typeof r.devices === 'number' ? { devices: r.devices } : {}), + ...(typeof r.min_devices === 'number' ? { min_devices: r.min_devices } : {}), + }; + }; + out.hre = { + account_sharing: result(h.account_sharing), + account_takeover: result(h.account_takeover), + impossible_travel: result(h.impossible_travel), + }; + if (typeof h.rules_version === 'string') out.hre.rules_version = h.rules_version; + } + return out; +} diff --git a/src/types.ts b/src/types.ts index 93266c4..dbc8f97 100644 --- a/src/types.ts +++ b/src/types.ts @@ -52,6 +52,10 @@ export interface IdentificationSignal { /** The 19 stable detection flags. A flag the server did not send is false. */ export interface DetectionFlags { + os_mismatch2?: boolean; + device_spoofing?: boolean; + latency_test?: boolean; + banned_ip?: boolean; vpn: boolean; privacy_relay: boolean; browser_vpn_proxy: boolean; @@ -78,6 +82,11 @@ export interface DetectionFlags { * webhook `data` object or a History API row. Property names follow the webhook JSON. */ export interface Identification { + result_version?: string; + scoring_version?: string; + risk_events?: RiskEvent[]; + hre?: HRE; + fingerprint?: Fingerprint; /** UUID of the identification, created in the browser. */ request_id: string; /** Server-side visitor identifier (sticky to the device). */ @@ -158,6 +167,8 @@ export type UnknownEventType = UnknownEventTypeMarker; /** A verified `identification.scored` delivery. */ export interface IdentificationScoredEvent { + event_id?: string; + site_id?: number; event_type: 'identification.scored'; schema_version: string; /** Envelope timestamp as sent (RFC 3339). */ @@ -169,6 +180,8 @@ export interface IdentificationScoredEvent { /** A verified `webhook.ping` delivery (sent by Verify in the analytics dashboard). */ export interface WebhookPingEvent { + event_id?: string; + site_id?: number; event_type: 'webhook.ping'; schema_version: string; created_at: string; @@ -177,6 +190,8 @@ export interface WebhookPingEvent { /** A verified delivery with an event type this SDK version does not model. */ export interface UnknownWebhookEvent { + event_id?: string; + site_id?: number; event_type: UnknownEventType; schema_version: string; created_at: string; @@ -212,3 +227,34 @@ export interface FetchRequestInit { /** A fetch-compatible function (the global `fetch` satisfies it). */ export type FetchLike = (url: string, init: FetchRequestInit) => Promise; + +/** Final scoring flags, including zero-weight events. Weights are not additive. */ +export interface RiskEvent { + code: string; + detected: boolean; + weight: number; + contribution: number; + status: string; +} +export interface HREResult { + status: string; + level: string | null; + reason: string; + devices?: number; + min_devices?: number; +} +export interface HRE { + rules_version?: string; + account_sharing: HREResult; + account_takeover: HREResult; + impossible_travel: HREResult; +} +export interface Fingerprint { + outcome: string; + record_id?: string; + hardware_id?: string; + rules_version: string; + sharing?: Record; + takeover?: Record; + travel?: Record; +} diff --git a/src/webhooks.ts b/src/webhooks.ts index 8e224da..cc4cf09 100644 --- a/src/webhooks.ts +++ b/src/webhooks.ts @@ -181,7 +181,7 @@ export function parseEvent(bytes: Uint8Array): WebhookEvent { } const schemaVersion = typeof parsed.schema_version === 'string' ? parsed.schema_version : ''; const createdAt = typeof parsed.created_at === 'string' ? parsed.created_at : ''; - if (schemaVersion !== KNOWN_SCHEMA_VERSION) { + if (schemaVersion !== KNOWN_SCHEMA_VERSION && schemaVersion !== '2026-06-01') { warnOnce( `schema-version:${schemaVersion}`, `Received a webhook with schema_version "${preview(schemaVersion)}" while this SDK version targets "${KNOWN_SCHEMA_VERSION}". The event was parsed; update @shieldlabs-ai/node for typed support of newer fields.`, @@ -195,6 +195,8 @@ export function parseEvent(bytes: Uint8Array): WebhookEvent { event_type: 'identification.scored', schema_version: schemaVersion, created_at: createdAt, + ...(typeof parsed.event_id === 'string' ? { event_id: parsed.event_id } : {}), + ...(typeof parsed.site_id === 'number' ? { site_id: parsed.site_id } : {}), data: fromWebhookData(parsed.data), raw: parsed, }; @@ -204,6 +206,8 @@ export function parseEvent(bytes: Uint8Array): WebhookEvent { event_type: 'webhook.ping', schema_version: schemaVersion, created_at: createdAt, + ...(typeof parsed.event_id === 'string' ? { event_id: parsed.event_id } : {}), + ...(typeof parsed.site_id === 'number' ? { site_id: parsed.site_id } : {}), raw: parsed, }; } @@ -211,6 +215,8 @@ export function parseEvent(bytes: Uint8Array): WebhookEvent { event_type: eventType as UnknownEventType, schema_version: schemaVersion, created_at: createdAt, + ...(typeof parsed.event_id === 'string' ? { event_id: parsed.event_id } : {}), + ...(typeof parsed.site_id === 'number' ? { site_id: parsed.site_id } : {}), raw: parsed, }; } diff --git a/test/contract-current.test.ts b/test/contract-current.test.ts new file mode 100644 index 0000000..ce96e32 --- /dev/null +++ b/test/contract-current.test.ts @@ -0,0 +1,28 @@ +import { readFileSync } from 'node:fs'; +import { createHmac } from 'node:crypto'; +import { expect, it } from 'vitest'; +import { webhooks } from '../src/index.js'; +it('preserves current signed Core identity, fp21, HRE and zero-weight events', () => { + const raw = readFileSync(new URL('./contracts/current.json', import.meta.url)); + const header = 'sha256=' + createHmac('sha256', 'secret').update(raw).digest('hex'); + const event = webhooks.constructEvent(raw, header, 'secret'); + expect(event.event_id).toBeTruthy(); + expect(event.site_id).toBe(7); + if (event.event_type !== 'identification.scored') throw new Error('unexpected type'); + expect(event.data.risk_events).toHaveLength(19); + expect(event.data.risk_events?.find((r) => r.code === 'incognito')).toMatchObject({ + weight: 0, + detected: true, + }); + expect(event.data.fingerprint?.hardware_id).toBe('sample-hardware'); + expect(event.data.device_id).not.toBe(event.data.fingerprint?.hardware_id); + expect(event.data.hre?.account_takeover).toMatchObject({ + status: 'not_evaluated', + reason: 'no_history', + level: null, + }); + expect(event.data.scoring_version).toBe('core:fixture'); + expect(webhooks.verifySignature(Buffer.concat([raw, Buffer.from(' ')]), header, 'secret')).toBe( + false, + ); +}); diff --git a/test/contracts/current.json b/test/contracts/current.json new file mode 100644 index 0000000..4c6dcd6 --- /dev/null +++ b/test/contracts/current.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" + } + } + } +} From e8bf86176365cd4cb64f0425692a7cef7a62c689 Mon Sep 17 00:00:00 2001 From: Konstantin Serebryakov <97249355+KonstantinSerebryakov@users.noreply.github.com> Date: Tue, 6 Oct 2026 01:49:10 +0300 Subject: [PATCH 2/2] docs: align API introduction with current delivery guarantees --- resources/shieldlabs-api.yaml | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/resources/shieldlabs-api.yaml b/resources/shieldlabs-api.yaml index b26b53a..03edf61 100644 --- a/resources/shieldlabs-api.yaml +++ b/resources/shieldlabs-api.yaml @@ -131,19 +131,25 @@ info: ## Webhooks - ShieldLabs sends one signed `POST` for each identification to every enabled endpoint of the - domain. A delivery has a 1-second timeout and is not retried today; a later release adds - retries that resend identical bytes. Answer 2xx within a second, process the event - asynchronously, make the handler idempotent on `data.request_id`, and use the History API for - guaranteed reads. A History row can be refined after its webhook was sent (its `ver` - increases); the webhook is not sent again. See the `identification.scored` and `webhook.ping` - entries for the signature algorithm. + ShieldLabs creates one logical signed event per identification and enabled endpoint. + Contract `2026-10-06` includes the identification score, final risk-event catalogue, + fp21 and three completed on-demand HRE results. AI bots/browser are planned only; + multiaccount is separate and all-time entity risks are not exported. + Delivery attempts have a 1-second HTTP timeout. Network errors, timeouts, 429 and 5xx + retry with backoff, up to 8 failed sends or a 15-minute retry age; other 4xx are terminal. + Worker/Redis waits can extend wall time. Retries preserve the exact signed body and + `event_id`; duplicate deliveries remain possible after an accepted request loses its response. + Verify the signature, durably store the event using its signed `event_id` (legacy fallback: + `data.request_id`), then answer 2xx within a second and process asynchronously. + Redirects are not followed. A later History refinement does not create a new webhook event. + Use the History API for the latest stored result. See the `identification.scored` and + `webhook.ping` entries for the signature algorithm. ## Compatibility Ignore fields you do not know, keep unknown values of string fields (such as new signal names or channels) instead of failing, and accept webhook `schema_version` values other than - `2026-06-01`. + `2026-10-06` (current) or `2026-06-01` (legacy). Start free at https://app.shieldlabs.ai. Guides: https://docs.shieldlabs.ai. contact: