@shieldlabs-ai/node 0.0.0-stage → 1.0.0
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 +72 -0
- package/LICENSE +21 -0
- package/README.md +486 -2
- package/dist/edge.cjs +1595 -0
- package/dist/edge.cjs.map +1 -0
- package/dist/edge.js +1569 -0
- package/dist/edge.js.map +1 -0
- package/dist/index.cjs +1585 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +594 -0
- package/dist/index.d.ts +594 -0
- package/dist/index.js +1559 -0
- package/dist/index.js.map +1 -0
- package/package.json +138 -4
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
|
-
#
|
|
1
|
+
# @shieldlabs-ai/node
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Read ShieldLabs identifications, verify signed webhooks and apply risk checks from your Node.js or edge backend.
|
|
4
|
+
|
|
5
|
+
[](https://github.com/ShieldLabs-ai/shieldlabs-node/actions/workflows/ci.yml)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
[](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)
|