Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 31 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,30 +235,36 @@ 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.
}
});
```

What to know about deliveries:

- The header is `X-Shield-Signature: sha256=<hex HMAC-SHA256 of the raw body>`, 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:
Expand Down Expand Up @@ -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.
425 changes: 384 additions & 41 deletions resources/shieldlabs-api.yaml

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions src/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,8 @@ export type {
WebhookPayload,
WebhookPingEvent,
WebhookSecret,
RiskEvent,
HREResult,
HRE,
Fingerprint,
} from './types.js';
2 changes: 1 addition & 1 deletion src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
115 changes: 81 additions & 34 deletions src/generated/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<string, never>;
takeover?: Record<string, never>;
travel?: Record<string, never>;
};
/** @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"];
Expand All @@ -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: {
Expand All @@ -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: {
/**
Expand All @@ -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: {
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down
Loading
Loading