@shieldlabs-ai/node 0.0.0-stage → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,72 @@
1
+ # Changelog
2
+
3
+ All notable changes to this package are documented in this file. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the package uses
5
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [1.0.0] - 2026-09-30
10
+
11
+ ### Added
12
+
13
+ - `ShieldLabs` History API client: `identifications.get` with wait-for-verdict polling,
14
+ `history.search`, and `history.iterate` with deduplication on `request_id`.
15
+ - `identifications.get` waits within one total `timeout` budget (default 10 s): the first poll
16
+ runs at once, then after 250 ms, 500 ms, 1 s, 1.5 s and every 2 s, and the last poll runs at
17
+ the deadline. A custom `pollInterval` p gives waits of p, 2p, 4p, 6p and then 8p, each capped
18
+ at 2 s or at p when p is longer, so 1 s waits 1 s and then every 2 s, and 3 s polls every 3 s.
19
+ Each poll is one HTTP attempt without retries; its timeout is the client timeout, shortened to
20
+ the time left but at least 1 s. A 429, a 5xx, a connection error or a timeout keeps it
21
+ polling. After a 429 the next wait is the longest of the scheduled wait, 1 s and its
22
+ `Retry-After` capped at 10 s (a missing `Retry-After`, `0` or a past date counts as 0), cut
23
+ short at the deadline; a capped `Retry-After` longer than the time left is thrown at once. At
24
+ the deadline the error of the last poll is thrown, or `null` is returned when that poll found
25
+ nothing. 400, 401, 403 and 404 are thrown at once.
26
+ - Client-side validation of lookup types, UUIDs, IPv4 addresses, `limit` and `offset`: invalid
27
+ arguments throw `ValidationError` and send nothing.
28
+ - User HID lookups are sent in canonical path form (`@`, `+`, `=`, `:` and similar characters
29
+ stay as they are), so such values match. A User HID that contains `/`, or is `.` or `..`,
30
+ throws `ValidationError`, because the History API cannot search it.
31
+ - `ShieldLabsManagement` client with `getProfile()`, domain normalization and no retries on 429.
32
+ - Keys, secrets and domains are checked when a client is created: a value with characters that
33
+ cannot be sent in an HTTP header (line breaks, NUL, spaces, non-ASCII) throws
34
+ `ValidationError`, and no error message or cause repeats a key or secret. `ConnectionError`
35
+ messages name the network cause, for example `connect ECONNREFUSED`.
36
+ - `webhooks.verifySignature` and `webhooks.constructEvent`, plus `verifySignatureAsync` and
37
+ `constructEventAsync` for every runtime. One secret or a list of secrets for rotation. Typed
38
+ `IdentificationScoredEvent`, `WebhookPingEvent` and `UnknownWebhookEvent`.
39
+ - One `Identification` model for webhook deliveries and History rows: 19 detection flags, weighted
40
+ risk signals and `observed_at` as RFC 3339 UTC with milliseconds.
41
+ - `evaluateIdentification`, `riskBand`, `isRateLimited`, `userHid`, `userHidAsync`, `SIGNALS`,
42
+ `RISK_BANDS`, `NIL_UUID` and `VERSION`. `evaluateIdentification` with `maxAge: Infinity` skips
43
+ the freshness check.
44
+ - Base URLs must use https. Plain http is accepted for `localhost`, `127.0.0.1` and `[::1]`, and
45
+ for other hosts only with `allowInsecureHttp: true`, so a mistyped URL never sends a key
46
+ unencrypted.
47
+ - Error hierarchy under `ShieldLabsError`, including `QuotaExceededError` (402); retries with
48
+ jittered exponential backoff and `Retry-After`; per-attempt timeouts; `AbortSignal` cancellation.
49
+ - Edge build for workers, edge runtimes, Deno and browsers, selected by export conditions. It never
50
+ loads `node:crypto`. A client created in a web page prints a one-time warning, because every
51
+ visitor could read its key there.
52
+ - ESM and CommonJS builds with TypeScript declarations, and the `examples/node-http` app.
53
+
54
+ ### Changed
55
+
56
+ - History requests go to `https://account.shieldlabs.ai/api/v1/history/...`. The preview doubled
57
+ the `/api` prefix (`/api/api/v1/...`) and got 404 responses. A base URL that ends in `/api` is
58
+ now normalized.
59
+
60
+ ### Removed
61
+
62
+ - The preview `ShieldLabsClient` and `verifyWebhook` exports, replaced by `ShieldLabs` and
63
+ `webhooks.verifySignature`.
64
+
65
+ ## [0.1.0] - 2026-09-06
66
+
67
+ ### Added
68
+
69
+ - Preview release: `verifyWebhook`, webhook payload types and a basic History API client.
70
+
71
+ [Unreleased]: https://github.com/ShieldLabs-ai/shieldlabs-node/compare/v1.0.0...HEAD
72
+ [1.0.0]: https://github.com/ShieldLabs-ai/shieldlabs-node/releases/tag/v1.0.0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ShieldLabs Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,487 @@
1
- # Temporary Holding Version
1
+ # @shieldlabs-ai/node
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Read ShieldLabs identifications, verify signed webhooks and apply risk checks from your Node.js or edge backend.
4
+
5
+ [![CI](https://github.com/ShieldLabs-ai/shieldlabs-node/actions/workflows/ci.yml/badge.svg)](https://github.com/ShieldLabs-ai/shieldlabs-node/actions/workflows/ci.yml)
6
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
7
+ [![npm](https://img.shields.io/npm/v/@shieldlabs-ai/node.svg)](https://www.npmjs.com/package/@shieldlabs-ai/node)
8
+
9
+ ## How it fits
10
+
11
+ 1. **Browser.** The ShieldLabs agent runs an identification and hands your page a `requestId`.
12
+ The browser never sees a Risk Score, a visitor ID or a device ID.
13
+ 2. **Your backend.** It receives the `requestId` together with the protected action (signup,
14
+ login, checkout) and reads the verdict with this SDK, or receives it in a signed
15
+ `identification.scored` webhook.
16
+ 3. **Decision.** Your backend acts on `risk_score`, the three risk bands, `detection_flags` and
17
+ the identifiers (for example, how many accounts one `device_id` has created).
18
+
19
+ This package covers steps 2 and 3. For step 1, use the browser SDK (`@shieldlabs-ai/js` or a
20
+ framework package). New to ShieldLabs? Start free at [app.shieldlabs.ai](https://app.shieldlabs.ai).
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ npm install @shieldlabs-ai/node
26
+ ```
27
+
28
+ Node.js 18 or later, with no runtime dependencies. The same package is designed for Bun, Deno and
29
+ edge runtimes with `fetch` and WebCrypto, such as Cloudflare Workers and Vercel Edge Functions
30
+ (see [Edge runtimes, Bun and Deno](#edge-runtimes-bun-and-deno)).
31
+
32
+ ## Quick start
33
+
34
+ ```ts
35
+ import { ShieldLabs, evaluateIdentification, webhooks } from '@shieldlabs-ai/node';
36
+
37
+ const shieldlabs = new ShieldLabs({ apiKey: process.env.SHIELDLABS_API_KEY! });
38
+
39
+ // One identification authorizes one action. This in-memory set keeps the example short; in
40
+ // production, claim request IDs atomically in Redis or your database (see "Apply a policy").
41
+ const usedRequestIds = new Set<string>();
42
+
43
+ function claimRequestId(requestId: string): boolean {
44
+ if (usedRequestIds.has(requestId)) return false;
45
+ usedRequestIds.add(requestId);
46
+ return true;
47
+ }
48
+
49
+ // 1. Call this with the requestId the browser sent together with the signup form.
50
+ export async function allowSignup(requestId: string): Promise<boolean> {
51
+ // Scoring is asynchronous: this waits (up to 10 s by default) until the verdict is stored.
52
+ const identification = await shieldlabs.identifications.get(requestId);
53
+
54
+ // 2. Missing, reused, stale, rate-limited, automated or dangerous: refuse.
55
+ const firstUse = identification !== null && claimRequestId(identification.request_id);
56
+ const verdict = evaluateIdentification(identification, { isReplay: () => !firstUse });
57
+ return verdict.ok;
58
+ }
59
+
60
+ // 3. Call this with the raw body and the X-Shield-Signature header of a webhook delivery.
61
+ export function handleWebhook(rawBody: string | Uint8Array, signatureHeader: string | null): void {
62
+ const event = webhooks.constructEvent(
63
+ rawBody,
64
+ signatureHeader,
65
+ process.env.SHIELDLABS_WEBHOOK_SECRET!,
66
+ );
67
+ if (event.event_type === 'identification.scored') {
68
+ console.log(event.data.request_id, event.data.risk_score, event.data.detection_flags.vpn);
69
+ }
70
+ }
71
+ ```
72
+
73
+ Environment variables used throughout: `SHIELDLABS_API_KEY` (Private API Key, `sec_...`),
74
+ `SHIELDLABS_WEBHOOK_SECRET` (endpoint signing secret, `whsec_...`), `SHIELDLABS_SECRET_KEY` and
75
+ `SHIELDLABS_DOMAIN` (Management API), and `SHIELDLABS_API_BASE_URL` /
76
+ `SHIELDLABS_MANAGEMENT_BASE_URL` to point at another host in development and tests (https, or
77
+ plain http on `localhost`). The SDK never reads the environment itself: pass the values to the
78
+ constructors. For development and staging, register a separate domain in the analytics dashboard
79
+ and use its keys.
80
+
81
+ A runnable version of this flow is in [`examples/node-http`](examples/node-http).
82
+
83
+ ## Guide
84
+
85
+ ### Wait for the verdict
86
+
87
+ The History row of an identification appears about 1 to 3 seconds after the browser call and can
88
+ be refined for up to about 10 seconds while follow-up checks finish (network checks such as the
89
+ local IP arrive in later versions of the row). Start the identification when the user begins the
90
+ action, for example when the signup form gets focus, so the verdict is usually stored by the time
91
+ the form is submitted. `identifications.get(requestId)` polls the History API until the row
92
+ appears and returns the first version it sees. How it waits:
93
+
94
+ - **Total budget.** `timeout` (default 10 000 ms) is the time budget of the whole call, counted
95
+ from the call and including the time requests take.
96
+ - **Schedule.** The first poll runs at once. With `pollInterval` p (default 250 ms), the waits
97
+ between polls are p, 2p, 4p, 6p and then 8p for every later wait, each capped at 2 s, or at p
98
+ when p is longer: 250 ms, 500 ms, 1 s, 1.5 s and then every 2 s by default. A `pollInterval`
99
+ of 1 000 waits 1 s and then every 2 s, and one of 3 000 polls every 3 s. A wait that would pass
100
+ the deadline is cut short, so the last poll runs at the deadline.
101
+ - **One attempt per poll.** Each poll is a single HTTP attempt that is never retried
102
+ (`maxRetries` does not apply). Its timeout is the client `timeout`, shortened to the time left
103
+ but never below 1 s.
104
+ - **Transient errors keep polling.** A 429, a 5xx, a connection error or an attempt timeout does
105
+ not end the wait.
106
+ - **429.** The next wait is the longest of the scheduled wait, 1 s (the History limit counts
107
+ requests per second) and `Retry-After` capped at 10 s. A missing `Retry-After`, `0` or a date in
108
+ the past counts as 0, so the 1 s floor still applies. That wait is cut short at the deadline like
109
+ any other, and the last poll runs there. When the capped `Retry-After` is longer than the time
110
+ left, that `RateLimitError` is thrown at once.
111
+ - **At the deadline.** If the last poll failed, its error is thrown. If it found nothing, the call
112
+ resolves `null`.
113
+ - **Errors that do not heal.** 400, 401, 403 and 404 are thrown at once: a wrong key or base URL
114
+ stays wrong.
115
+
116
+ ```ts
117
+ const identification = await shieldlabs.identifications.get(requestId, {
118
+ timeout: 5_000, // total budget: the last poll runs 5 s after the call
119
+ pollInterval: 250, // waits of p, 2p, 4p, 6p, then 8p, each at most max(2 s, p)
120
+ signal: AbortSignal.timeout(6_000), // hard limit, including the last request
121
+ });
122
+
123
+ if (identification === null) {
124
+ // Unverified, never "clean": treat it like a failed check.
125
+ }
126
+
127
+ // Read once, without waiting:
128
+ const latest = await shieldlabs.identifications.get(requestId, { wait: false });
129
+ ```
130
+
131
+ `null` means no row existed for the request ID when the last poll ran. It also covers an
132
+ identification that was never stored, for example when the visitor's IP went over the per-IP rate
133
+ limit of identifications: the browser still gets a request ID, but no row is written for it. With
134
+ `wait: false`, `identifications.get` reads once and uses the client's normal retries. To read the
135
+ refined row later, call `identifications.get(requestId, { wait: false })` again after about 10
136
+ seconds.
137
+
138
+ ### Apply a policy
139
+
140
+ `evaluateIdentification` turns the guard logic every integration needs into one call. Checks run
141
+ in this order and the first failure wins:
142
+
143
+ | Order | Check | `reason` |
144
+ |---|---|---|
145
+ | 1 | No identification (`null`) | `missing` |
146
+ | 2 | `isReplay(requestId)` returned true | `replayed` |
147
+ | 3 | Older than `maxAge` (default 5 minutes, by `observed_at`; `Infinity` skips this check) | `stale` |
148
+ | 4 | Risk Score above 100 (the 999 rate-limit marker) | `rate_limited` |
149
+ | 5 | All-zero device ID (no usable device signals) | `no_device_signals` |
150
+ | 6 | A flag in `blockFlags` (default `browser_automation`, `javascript_disabled`) | `blocked_flag` |
151
+ | 7 | A band in `blockBands` (default `dangerous`) | `blocked_band` |
152
+
153
+ ```ts
154
+ // Claim the request ID before evaluating: an atomic insert-if-absent, so that two concurrent
155
+ // requests with the same ID cannot both pass. With node-redis, SET NX answers 'OK' only once.
156
+ const claimed =
157
+ identification !== null &&
158
+ (await redis.set(`shieldlabs:rid:${identification.request_id}`, '1', { NX: true, EX: 600 })) ===
159
+ 'OK';
160
+
161
+ const verdict = evaluateIdentification(identification, {
162
+ maxAge: 2 * 60_000,
163
+ blockBands: ['dangerous'],
164
+ blockFlags: ['browser_automation', 'javascript_disabled', 'tor'],
165
+ isReplay: () => !claimed,
166
+ });
167
+ // A Tor identification: { ok: false, reason: 'blocked_flag', band: 'dangerous', flag: 'tor' }
168
+ ```
169
+
170
+ The defaults are a starting point to tune for each protected action. One identification should
171
+ authorize one action, and the SDK keeps no state: `isReplay` must answer synchronously, so claim
172
+ the request ID in a shared store first and pass the result. Use an atomic insert-if-absent (Redis
173
+ `SET key 1 NX EX 600`, or an insert into a table with a unique key on the request ID), not a
174
+ lookup followed by a separate write, which lets two concurrent requests with the same ID both
175
+ pass. If the protected action does not go ahead after a passing check, you can delete the claim
176
+ so the request ID can be used again.
177
+
178
+ A few rules that keep decisions sound:
179
+
180
+ - Branch on `detection_flags` and `risk_score`. Signal names are for display and logs.
181
+ - Never sum signal weights yourself: weights can be negative or informational.
182
+ - `riskBand(score)` returns `trusted` (0-29), `suspicious` (30-59) or `dangerous` (60-100), and
183
+ `rate_limited` for the 999 marker, which is never a score (`isRateLimited(score)` checks it).
184
+ - The device ID `00000000-0000-0000-0000-000000000000` (`NIL_UUID`) means no usable device
185
+ signals reached ShieldLabs.
186
+
187
+ ### Search history for account-abuse checks
188
+
189
+ `history.search(type, value)` reads identifications by one identifier, newest first. Lookup types
190
+ are `user_hid`, `device_id`, `visitor_id`, `ip` (IPv4), `request_id`, `session_id` and
191
+ `cookie_id`. Arguments are validated before anything is sent, because the server does not reject
192
+ an unknown type or a malformed UUID or IP.
193
+
194
+ ```ts
195
+ import { NIL_UUID } from '@shieldlabs-ai/node';
196
+
197
+ // User HID values that do not name one of your accounts (null is skipped as well).
198
+ const NOT_AN_ACCOUNT = new Set(['anonymous', 'fail', '-1', 'unknown']);
199
+
200
+ // How many accounts has this device been used with? (The all-zero device ID groups
201
+ // identifications without device signals, so skip it.)
202
+ const accounts = new Set<string>();
203
+ if (identification.device_id !== NIL_UUID) {
204
+ for await (const item of shieldlabs.history.iterate('device_id', identification.device_id, { maxItems: 500 })) {
205
+ if (item.user_hid !== null && !NOT_AN_ACCOUNT.has(item.user_hid)) accounts.add(item.user_hid);
206
+ }
207
+ }
208
+ if (accounts.size >= 3) {
209
+ // Route the signup to review.
210
+ }
211
+
212
+ // One page at a time:
213
+ const page = await shieldlabs.history.search('user_hid', hashedUserId, { limit: 50, offset: 0 });
214
+ console.log(page.total, page.data.map((item) => item.public_ip.country)); // "Germany", "France", ...
215
+ ```
216
+
217
+ `history.iterate` pages with `offset` (100 rows per request by default), skips rows repeated
218
+ across pages by `request_id` (new rows can shift offsets) and stops at `total`, at an empty page
219
+ or after `maxItems`. Each request counts toward the History API rate limit.
220
+
221
+ A User HID is sent as one URL path segment in canonical form, so values with `@`, `+`, `=` or
222
+ spaces (an email address, a base64 string) match exactly. A value that contains `/`, and the
223
+ values `.` and `..`, cannot be searched and throw `ValidationError`. User HIDs from `userHid()`
224
+ are 64 hex characters and always work.
225
+
226
+ ### Receive webhooks
227
+
228
+ Every scored identification is sent to each enabled endpoint as a signed `POST`. Verify the
229
+ signature over the exact bytes you received, before parsing anything:
230
+
231
+ ```js
232
+ import express from 'express';
233
+ import { SignatureVerificationError, webhooks } from '@shieldlabs-ai/node';
234
+
235
+ const app = express();
236
+
237
+ // express.raw keeps the body as a Buffer. Register this route before any global express.json().
238
+ app.post('/webhooks/shieldlabs', express.raw({ type: 'application/json' }), (req, res) => {
239
+ let event;
240
+ try {
241
+ event = webhooks.constructEvent(req.body, req.get('x-shield-signature'), process.env.SHIELDLABS_WEBHOOK_SECRET);
242
+ } catch (error) {
243
+ return res.sendStatus(error instanceof SignatureVerificationError ? 401 : 400);
244
+ }
245
+ res.sendStatus(200); // answer within 1 second, then process
246
+
247
+ if (event.event_type === 'identification.scored') {
248
+ jobs.enqueue(event.data); // make the job idempotent on event.data.request_id
249
+ }
250
+ });
251
+ ```
252
+
253
+ What to know about deliveries:
254
+
255
+ - The header is `X-Shield-Signature: sha256=<hex HMAC-SHA256 of the raw body>`, keyed with the
256
+ full signing secret including its `whsec_` prefix. It is the only ShieldLabs header, so the
257
+ idempotency key comes from the body: `data.request_id`.
258
+ - Today each identification is delivered once per enabled endpoint: one attempt with a 1-second
259
+ timeout and no retries. Future retries will resend identical bytes, so make handlers
260
+ idempotent on `data.request_id` now.
261
+ - For guaranteed reads, use the History API: a missed delivery is not sent again, and a History
262
+ row can be refined after its webhook was sent.
263
+ - To rotate a secret without downtime, pass both secrets: `constructEvent(body, header, [newSecret, oldSecret])`.
264
+ In an environment variable, keep them comma-separated and split them:
265
+ `process.env.SHIELDLABS_WEBHOOK_SECRET!.split(',').map((secret) => secret.trim())`.
266
+ - `event_type` is `identification.scored`, `webhook.ping` (Verify in the analytics dashboard, no
267
+ `data`) or an event type this version does not know yet, returned as an `UnknownWebhookEvent`
268
+ with the parsed body in `raw` instead of throwing.
269
+ - The Test delivery from the analytics dashboard parses like production traffic; flags it does
270
+ not carry are `false`.
271
+
272
+ `webhooks.verifySignature(payload, header, secret)` returns a boolean instead of throwing.
273
+
274
+ ### Read the domain profile
275
+
276
+ The Management API returns the remaining included identifications of the account and the masked
277
+ keys of the domain.
278
+
279
+ ```ts
280
+ import { ShieldLabsManagement } from '@shieldlabs-ai/node';
281
+
282
+ const management = new ShieldLabsManagement({
283
+ secretKey: process.env.SHIELDLABS_SECRET_KEY!,
284
+ domain: process.env.SHIELDLABS_DOMAIN!, // "https://www.Example.com/" is sent as "example.com"
285
+ });
286
+
287
+ const profile = await management.getProfile();
288
+ // { domain: 'example.com', remaining_identifications: 148230, public_key_masked: '****...a3f8', ... }
289
+ ```
290
+
291
+ `remaining_identifications` is negative when the account is over its included volume. Call the
292
+ Management API sparingly and cache the profile: it allows about 15 requests per minute per IP and
293
+ then blocks the IP for 10 minutes, so this client never retries a 429.
294
+
295
+ ### Create a User HID
296
+
297
+ The browser agent links identifications to your accounts through a User HID. Compute it on your
298
+ server and pass it to the browser SDK, instead of a raw email address or account ID:
299
+
300
+ ```ts
301
+ import { userHid } from '@shieldlabs-ai/node';
302
+
303
+ // hidSecret: a long random value you create once and keep on your server. Changing it changes
304
+ // every User HID, so treat it as permanent.
305
+ const hid = userHid(user.id, hidSecret); // HMAC-SHA256, 64 lowercase hex characters
306
+ ```
307
+
308
+ The same account always gets the same User HID, and the value cannot be reversed. Search its
309
+ history with `history.search('user_hid', hid)`.
310
+
311
+ ### Rate limits
312
+
313
+ | API | Limit | What the SDK does |
314
+ |---|---|---|
315
+ | History API | About 15 requests per second per domain, shared by every caller of that domain | `identifications.get` polls on a backoff schedule, and inside its wait a 429 waits at least 1 s. Ordinary calls (`history.search`, `history.iterate`, `identifications.get` with `wait: false`) follow `Retry-After` as sent, up to 10 s, and wait at least 1 s after a 429 without it |
316
+ | Management API | About 15 requests per minute per IP, then the IP is blocked for 10 minutes | A 429 is raised at once, never retried |
317
+
318
+ History reads and Management calls never use your included identifications.
319
+
320
+ ### Edge runtimes, Bun and Deno
321
+
322
+ Bundlers and runtimes that set the `worker`, `workerd`, `edge-light`, `browser` or `deno` export
323
+ condition get a build that uses only `fetch` and WebCrypto and never loads `node:crypto`. There,
324
+ use the Async webhook helpers; the synchronous ones throw an error that points to them.
325
+
326
+ The `browser` condition serves edge bundlers. The Private API Key and the Secret Key belong on
327
+ your server: a client created in a web page (a `window` with a `document`) prints a one-time
328
+ warning, because every visitor could read its key there.
329
+
330
+ ```ts
331
+ // Cloudflare Worker
332
+ import { webhooks } from '@shieldlabs-ai/node';
333
+
334
+ export default {
335
+ async fetch(request: Request, env: { SHIELDLABS_WEBHOOK_SECRET: string }): Promise<Response> {
336
+ const body = new Uint8Array(await request.arrayBuffer());
337
+ try {
338
+ const event = await webhooks.constructEventAsync(body, request.headers.get('x-shield-signature'), env.SHIELDLABS_WEBHOOK_SECRET);
339
+ // handle event
340
+ return new Response(null, { status: 200 });
341
+ } catch {
342
+ return new Response(null, { status: 401 });
343
+ }
344
+ },
345
+ };
346
+ ```
347
+
348
+ `userHidAsync` computes a User HID with WebCrypto. Bun resolves the Node.js build, where both
349
+ flavors work.
350
+
351
+ ## Reference
352
+
353
+ ### `new ShieldLabs(options)`
354
+
355
+ History API client, authenticated with the Private API Key of one domain. Safe for concurrent use:
356
+ create one per process.
357
+
358
+ | Option | Default | Notes |
359
+ |---|---|---|
360
+ | `apiKey` | required | Private API Key (`sec_...`), visible ASCII characters only. A key in another format triggers a one-time warning |
361
+ | `baseUrl` | `https://account.shieldlabs.ai` | Origin of the History API; a trailing `/api` is removed |
362
+ | `timeout` | `10000` | Milliseconds per HTTP attempt |
363
+ | `maxRetries` | `2` | Retries for connection errors, timeouts, 429 and 5xx |
364
+ | `fetch` | global `fetch` | Custom fetch implementation |
365
+ | `allowInsecureHttp` | `false` | Accept a plain http `baseUrl` on a host other than `localhost`, `127.0.0.1` or `[::1]`, for a test server. Without it such a URL throws `ValidationError` |
366
+
367
+ | Method | Returns | Notes |
368
+ |---|---|---|
369
+ | `identifications.get(requestId, { wait?, timeout?, pollInterval?, signal? })` | `Promise<Identification \| null>` | `wait` true, `timeout` 10 000 ms (total budget of the wait), `pollInterval` 250 ms by default. `pollInterval` p gives waits of p, 2p, 4p, 6p and then 8p, each at most max(2 s, p). See [Wait for the verdict](#wait-for-the-verdict) |
370
+ | `history.search(type, value, { limit?, offset?, signal? })` | `Promise<HistoryPage>` | `limit` 1-100 (default 20), `offset` 0 or more (default 0). A `user_hid` value cannot contain `/` |
371
+ | `history.iterate(type, value, { pageSize?, maxItems?, signal? })` | `AsyncGenerator<Identification>` | `pageSize` 1-100 (default 100), `maxItems` 0 or more (default no limit; 0 yields nothing) |
372
+
373
+ ### `new ShieldLabsManagement(options)`
374
+
375
+ | Option | Default | Notes |
376
+ |---|---|---|
377
+ | `secretKey` | required | Secret Key of the domain, visible ASCII characters only |
378
+ | `domain` | required | Registered domain; trimmed, lowercased, without scheme, path or leading `www.`. International domains in punycode (`xn--...`) |
379
+ | `baseUrl` | `https://api.shieldlabs.ai` | Origin of the Management API |
380
+ | `timeout` | `10000` | Milliseconds per HTTP attempt |
381
+ | `maxRetries` | `2` | Retries for connection errors, timeouts and 5xx (never 429) |
382
+ | `fetch` | global `fetch` | Custom fetch implementation |
383
+ | `allowInsecureHttp` | `false` | Same as for `ShieldLabs` |
384
+
385
+ | Method | Returns |
386
+ |---|---|
387
+ | `getProfile({ signal? })` | `Promise<DomainProfile>` |
388
+
389
+ ### Functions and constants
390
+
391
+ | Export | Description |
392
+ |---|---|
393
+ | `webhooks.verifySignature(payload, header, secret)` | `boolean`. `payload`: string, `Uint8Array`/`Buffer` or `ArrayBuffer`; `secret`: string or list |
394
+ | `webhooks.constructEvent(payload, header, secret)` | `WebhookEvent`; throws `SignatureVerificationError` or `WebhookParseError` |
395
+ | `webhooks.verifySignatureAsync(...)`, `webhooks.constructEventAsync(...)` | Promise-based equivalents for every runtime |
396
+ | `evaluateIdentification(identification, options?)` | `{ ok, reason, band, flag? }` (see [Apply a policy](#apply-a-policy)) |
397
+ | `riskBand(score)` | `'trusted' \| 'suspicious' \| 'dangerous' \| 'rate_limited'` |
398
+ | `isRateLimited(score)` | `true` for the 999 marker (any score above 100) |
399
+ | `userHid(userId, secret)`, `userHidAsync(userId, secret)` | HMAC-SHA256 User HID as 64 lowercase hex characters |
400
+ | `SIGNALS` | Known risk signal slugs, for example `SIGNALS.VPN === 'vpn'`. Signal names are an open set |
401
+ | `RISK_BANDS` | `{ trusted: { min: 0, max: 29 }, suspicious: { min: 30, max: 59 }, dangerous: { min: 60, max: 100 } }` |
402
+ | `NIL_UUID` | `'00000000-0000-0000-0000-000000000000'` |
403
+ | `VERSION` | SDK version, also sent in the `User-Agent` header |
404
+
405
+ ### `Identification`
406
+
407
+ The same shape whether it comes from a webhook or from a History row. Property names follow the
408
+ webhook JSON.
409
+
410
+ | Property | Type | Notes |
411
+ |---|---|---|
412
+ | `request_id`, `visitor_id`, `device_id`, `session_id`, `cookie_id` | `string` | UUIDs; the all-zero UUID is possible |
413
+ | `user_hid` | `string \| null` | `"anonymous"` for anonymous checks; `null` when it was empty |
414
+ | `domain` | `string` | Registered domain of the site |
415
+ | `public_ip`, `local_ip` | `{ ip, country }` | IPv4 or `""`; English country name (`"Germany"`) or `""` |
416
+ | `connection_type` | `string` | `direct`, `mobile`, `vpn`, `proxy`, `tor`, `privacy_relay`, `browser_vpn_proxy`, `unknown` |
417
+ | `os`, `browser`, `device_type` | `string` | `device_type` is `desktop`, `mobile`, `tablet` or `unknown` |
418
+ | `traffic_source` | object | `channel`, `referrer_domain`, `landing_url`, `click_id_type`, `utm_*` (`""` when absent) |
419
+ | `risk_score` | `number` | 0-100, or 999 for the rate-limit marker |
420
+ | `signals` | `{ name, weight, description }[]` | Weighted risk signals; `description` is `null` for webhooks |
421
+ | `detection_flags` | object of 19 booleans | `vpn`, `privacy_relay`, `browser_vpn_proxy`, `tor`, `proxy`, `datacenter_ip`, `abuser`, `os_mismatch`, `os_not_detected`, `timezone_mismatch`, `anti_detect_browser`, `browser_automation`, `ip_mismatch`, `incognito`, `search_bot`, `suspicious_paid_click`, `javascript_disabled`, `stun_not_checked`, `check_incomplete` |
422
+ | `observed_at` | `string \| null` | RFC 3339 UTC with milliseconds, for example `2026-09-30T12:34:56.123Z` |
423
+ | `source` | `'webhook' \| 'history'` | Which payload it was built from |
424
+ | `raw` | object | The original payload, including fields the model omits |
425
+
426
+ Other exported types: `IdentificationSignal`, `DetectionFlags`, `TrafficSource`, `IpInfo`,
427
+ `HistoryPage`, `DomainProfile`, `WebhookEvent` (`IdentificationScoredEvent`, `WebhookPingEvent`,
428
+ `UnknownWebhookEvent`), `LookupType`, `RiskBand`, `Evaluation` and the option types.
429
+
430
+ ## Errors and retries
431
+
432
+ Every error extends `ShieldLabsError`.
433
+
434
+ | Class | When |
435
+ |---|---|
436
+ | `ValidationError` | Invalid arguments, detected before any request is sent |
437
+ | `ApiError` | Any other error status, or a response body the SDK cannot use; has `status`, `body`, `headers` |
438
+ | `BadRequestError` | 400 |
439
+ | `AuthenticationError` | 401 or 403: wrong key, secret or domain, or a disabled domain |
440
+ | `QuotaExceededError` | 402. Neither the History API nor the Management API returns it today: an account over its included volume shows a negative `remaining_identifications` |
441
+ | `NotFoundError` | 404: usually a wrong base URL |
442
+ | `RateLimitError` | 429; `retryAfter` holds the seconds from `Retry-After` when present |
443
+ | `ServerError` | 5xx, including gateway errors |
444
+ | `ConnectionError` | The request never got a response; the message names the network cause, for example `connect ECONNREFUSED` |
445
+ | `TimeoutError` | An attempt took longer than `timeout` |
446
+ | `SignatureVerificationError` | Missing, malformed or wrong `X-Shield-Signature`, or no secret |
447
+ | `WebhookParseError` | The body is authentic but is not a usable event |
448
+
449
+ Requests are retried (`maxRetries`, default 2) on connection errors, timeouts, 429 and 5xx, with
450
+ exponential backoff and jitter (0.5 s base, doubling, capped at 8 s). `Retry-After` is followed as
451
+ sent, capped at 10 s (`0` retries at once), and a 429 without it waits at least 1 s. 400, 401, 402,
452
+ 403 and 404 are never retried, and neither is a Management API 429.
453
+ While `identifications.get` waits for a verdict, its polling schedule takes the place of these
454
+ retries: one attempt per poll (see [Wait for the verdict](#wait-for-the-verdict)).
455
+ Pass an `AbortSignal` as `signal` to cancel a call; it rejects with the signal's reason. Error
456
+ messages never include keys or secrets: a key, secret or domain with characters that cannot be
457
+ sent in an HTTP header (line breaks, NUL, spaces, non-ASCII) throws `ValidationError` when the
458
+ client is created, without repeating the value.
459
+
460
+ ## Compatibility
461
+
462
+ - Node.js 18, 20, 22 and 24, tested in CI. The package is also designed for Bun, Deno, Cloudflare
463
+ Workers and Vercel Edge Functions: their builds use only `fetch` and WebCrypto.
464
+ - ESM and CommonJS builds with TypeScript declarations.
465
+ - Webhook schema version `2026-06-01`. Unknown fields, flags and event types are tolerated; an
466
+ unknown `schema_version` is accepted with a one-time warning.
467
+ - Semantic versioning: breaking changes only in a new major version.
468
+
469
+ ## Development
470
+
471
+ ```bash
472
+ npm ci
473
+ npm run typecheck
474
+ npm run lint
475
+ npm test -- --coverage
476
+ npm run build
477
+ npm run test:package # export conditions, edge bundles, published files
478
+ npm run test:runtime # built package, Node.js entry and edge entry
479
+ npm run test:example # examples/node-http end to end
480
+ ```
481
+
482
+ See [CONTRIBUTING.md](CONTRIBUTING.md). Documentation: [docs.shieldlabs.ai](https://docs.shieldlabs.ai).
483
+ Support: [contact@shieldlabs.ai](mailto:contact@shieldlabs.ai).
484
+
485
+ ## License
486
+
487
+ [MIT](LICENSE)