Get started
Answer up to three questions to get the steps for your stack.
How it works
The SDK collects browser signals and fetches a token. Your server posts that token to the TrustSig edge and acts on the verdict it returns.
The software development kit (SDK) runs in the browser, collects device and environment signals, and fetches a token.
The token travels with the request you protect, as a hidden form field or a request header.
API keys
A public Site Key for the browser. A Secret Key your server verifies tokens with.
Passed to the frontend SDK.
Your backend uses it to fetch verdicts.
- Rotate keys from the project settings in the dashboard.
- Rotation replaces the Site Key too, so deploy the new one to your frontend at the same time.
Signing in to TrustSig
Sign in to TrustSig with a password, with Google, or with both. Two-factor authentication covers every method.
- Both methods reach the same TrustSig account, matched on your email address.
- Your first Google sign-in connects Google to it, and your password keeps working.
Every change below is on the account page.
- Connect Google
- The Google address matches your account address
- Disconnect Google
- A password is set
- Disable password sign-in
- Google connected, confirmed with your password
- Set or re-enable a password
- A secure link is emailed to you
Disabling password sign-in deletes the password credential.
The Set password email reverses it.
- TrustSig two-factor authentication (2FA) applies to every sign-in, by authenticator app or email code.
- A Google sign-in is challenged for a code like a password sign-in.
- Sign in with Google only, and its own 2FA carries the account.
Your account owns a personal organization, which owns your projects and subscription. Inviting members shares it.
- Transfer ownership
- On the team page
- What moves
- Projects, members, pending invites, the subscription, billing history
- What you keep
- Admin access to the organization
- What the new owner needs
- An accepted membership, a verified email, and an empty personal organization
- Deleted
- Your personal organization: its projects, their verification data, and your subscription, cancelled at Stripe
- Untouched
- Organizations you were invited to, whatever your role. You leave their member lists
Deletion cannot be undone. Remove your own members or transfer ownership first.
Projects and domains
A project holds one application: its own keys, its own Allowed Domains list, its own traffic stats and Troubleshoot log.
Create projects in the dashboard. Each one comes with its own keys.
- A project maps to one application, not to one hostname.
- Production, staging and country domains for that application stay in one project, on its Allowed Domains list.
- A separate application gets its own project: its own keys, traffic stats and Troubleshoot log.
Your plan caps projects and domains account-wide, and both meters are on the projects page. Monthly request volume is a separate limit, in usage and quotas.
The edge issues tokens only to origins on the project's Allowed Domains list. Add every production and staging hostname that serves the SDK.
Local development hosts pass without being added:
localhostand IPv6 loopback (::1,[::1])- Link-local: IPv4
169.254.0.0/16, IPv6fe80::/10 - RFC 1918 private IPv4:
10.0.0.0/8,172.16.0.0/12,192.168.0.0/16 - Any hostname ending in
.localhost,.local, or.test
A rejected client lands in the project's Troubleshoot log with one of these codes.
| Code | Meaning |
|---|---|
| NON_WHITELISTED_DOMAIN | The origin is not on the Allowed Domains list. |
| INVALID_SITE_KEY | The data-site-key does not match the project, commonly a stale frontend deploy after a rotation. |
| CHALLENGE_EXPIRED | The token was past its validity window at verification, usually from a form left open. |
Usage and quotas
One server-side verify call counts as one request against your monthly volume.
Usage resets at the start of each billing period.
- One
/verifyorverifyRemotecall counts as one request. - Nothing else counts: scans and unverified tokens are free.
| Plan | Included volume | Domains | Overage |
|---|---|---|---|
| Free | 5,000 / month | 2 | None |
| Scout | 30,000 / month | 10 | €2 per 1,000 |
| Scale | 120,000 / month | 30 | €1 per 1,000 |
| Enterprise | Unlimited | Unlimited | Contracted |
Your live limits and plan changes are in dashboard billing.
Paid plans keep working past the included volume and meter the overage. An overage threshold you set in dashboard billing, at most 100 EUR, pauses service instead of charging further.
Leave scanning on everywhere and call /verify only on the actions worth a verdict: submits, logins, checkout.
WordPress plugin
Install the plugin from WordPress.org and every form on the site is protected. The plugin runs both halves of the integration for you.
Protection starts on activation, with no keys and no setup.
- WordPress login
- User registration
- Password reset
- Comments
- WooCommerce checkout
- WooCommerce login
- Contact Form 7
- Add your project keys in the plugin settings to link the site to your account.
- The dashboard then shows this site's traffic and its Troubleshoot log.
Protection runs the same with or without keys.
Script tag
One script tag loads the SDK on any site. Three attributes control the site key, automatic scanning, and request interception.
Paste one <script> tag into your <head>.
<script
src="https://edge.trustsig.eu/trustsig.js"
data-site-key="YOUR_SITE_KEY"
data-auto-scan="true"
></script>| Attribute | Description |
|---|---|
| data-site-key | Required. The public Site Key of the project (pk_live_...). |
| data-auto-scan | Optional, defaults to true. Set it to false to scan only when you call the JavaScript API. |
| data-intercept-requests | Optional. Attaches the X-TrustSig-Response header to every fetch and XHR request the page makes. |
| data-require-consent | Optional. Holds DOM and keystroke capture until you call setConsent(true). Device telemetry is collected either way. |
| data-keep-fresh | Optional. Refreshes the token in the background when automatic scanning is off. Ignored when it is on. |
The script boots a sandbox on the asset origin and talks to the edge from inside it, so allowing script-src alone is not enough. A policy needs all four directives, or the browser never loads the SDK.
Appended to an existing policy, the header reads:
script-src 'self' https://edge.trustsig.eu;
frame-src https://edge.trustsig.eu;
worker-src https://edge.trustsig.eu blob:;
connect-src 'self' https://edge.trustsig.euA blocked sandbox fails quietly: the script still loads, no error surfaces, and the token never arrives.
Scanning is free, however often it runs. Only a token your backend verifies counts toward your monthly volume.
Automatic form protection
With automatic scanning on, the SDK hooks standard HTML form submits and injects the trustsig-response field for you.
With data-auto-scan="true", the SDK adds a hidden trustsig-response field carrying the token to every standard form submit. You write no JavaScript.
<form ="/login" method="POST">
<input type="email" name="email" required />
<input type="password" name="password" required />
<input type="hidden" name="trustsig-response" value="eyJhbGciOiJkaXIiLCJlbmMi..." />
<button type="submit">Log in</button>
</form>JavaScript API
Request a token from window.TrustSig for AJAX submits, single-page apps, and buttons you enable once a token exists.
window.TrustSig.getResponse()The token the current scan produced.window.TrustSig.scan()A fresh scan.trustsig:readyFires on window when the first scan resolves.Enable your submit button when trustsig:ready fires on window. The token or the error arrives in event.detail.
// Fires once the first scan resolves, with a token or an error.
window.addEventListener('trustsig:ready', (e) => {
const { token, error } = e.detail;
// Release the button either way. Your server enforces the verdict.
document.getElementById('submit-btn').disabled = false;
if (error) console.warn('TrustSig scan failed:', error);
});Call window.TrustSig.getResponse() to send a token with an AJAX request. It starts no scan, so it answers only when automatic scanning is on or scan() has run.
async function submitForm() {
// Waits for the running scan, or returns the cached token immediately.
const { token } = await window.TrustSig.getResponse();
await fetch('/api/login', {
method: 'POST',
headers: { 'X-TrustSig-Response': token },
body: JSON.stringify({ email: '[email protected]' }),
});
}window.TrustSig.scan() runs a fresh scan and resolves with a token. Pass { key } only when the script tag carries no data-site-key.
async function submitForm() {
const result = await window.TrustSig.scan({
key: 'pk_live_3426256ed11ff6e0f55af31c8328afe5',
});
const token = result?.token ?? null;
await fetch('/api/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-TrustSig-Response': token,
},
body: JSON.stringify({ email: '[email protected]' }),
});
}The script boots a sandbox on the asset origin and talks to the edge from inside it, so allowing script-src alone is not enough. A policy needs all four directives, or the browser never loads the SDK.
Appended to an existing policy, the header reads:
script-src 'self' https://edge.trustsig.eu;
frame-src https://edge.trustsig.eu;
worker-src https://edge.trustsig.eu blob:;
connect-src 'self' https://edge.trustsig.euA blocked sandbox fails quietly: the script still loads, no error surfaces, and the token never arrives.
@trustsig/client
The browser loader package: it injects the edge script, runs the analysis, and hands your app a token, a device id, and pointer handles.
@trustsig/client injects the edge script, runs the device analysis, and hands your app a token to send to your backend. React apps use @trustsig/react, which wraps it.
npm install @trustsig/clientimport { TrustSigClient } from '@trustsig/client';
const client = new TrustSigClient({ siteKey: 'pk_live_YOUR_SITE_KEY' });
// Resolves to { request_id, token }, or null when no scan produced a token.
const response = await client.getResponse();
await fetch('/api/checkout', {
method: 'POST',
headers: { 'X-TrustSig-Response': response?.token ?? '' },
body: JSON.stringify(order),
});getResponse() and scan() never throw: they resolve to null under server-side rendering, on a script load failure or timeout, or when a scan yields no usable token. Set debug: true to log the reason.
data-* attributes.autoScan on, the first result is announced on a trustsig:ready window event, which the client caches.getResponse() returns that cached result, or runs a scan when none has arrived.With autoScan: false, nothing runs until you call scan().
const client = new TrustSigClient({
siteKey: 'pk_live_YOUR_SITE_KEY',
autoScan: false,
// Without this the token ages out and verification starts failing closed.
keepFresh: true,
});
const response = await client.scan();requireConsent holds DOM and keystroke capture until you release it. Device telemetry is collected either way.
const client = new TrustSigClient({
siteKey: 'pk_live_YOUR_SITE_KEY',
requireConsent: true,
});
onCookieBannerAccept(() => client.setConsent(true));
onCookieBannerReject(() => client.setConsent(false));Keystroke capture records press and release timing only. The key itself is never read, stored, or transmitted.
getDeviceId() resolves the project-scoped device id, the same value /verify publishes as identity.device_id.
// Waits for the next scan, and starts one when none is in flight.
const deviceId = await client.getDeviceId(); // "3f2a9c14b7e05d68"
// Bound wait. Resolves null when the scan has not returned in time.
const quick = await client.getDeviceId({ timeout: 2000 });trustsig:ready as detail.device_id.Not an authentication factor: it identifies a device, not a person, and a determined visitor can produce a new one.
getCachedToken() returns the token in memory right now without starting a scan.
// Synchronous. Null before the first scan resolves, and after a failed scan.
const token = client.getCachedToken();
if (token) headers['X-TrustSig-Response'] = token;Turns a raw scan result into { request_id, token }. Use it when you listen for trustsig:ready yourself.
import { normalizeScanResult } from '@trustsig/client';
window.addEventListener('trustsig:ready', (e) => {
// Null for a missing or ERROR:-prefixed token.
const response = normalizeScanResult(e.detail);
if (response) stampPendingRequests(response.token);
});| Option | Type | Default | Description |
|---|---|---|---|
| siteKey | string | required | Your public Site Key. The constructor throws SITE_KEY_REQUIRED without it. |
| autoScan | boolean | true | Analyses on load and announces the result on trustsig:ready. |
| interceptRequests | boolean | false | Attaches the X-TrustSig-Response header to every fetch and XHR the page makes, cross-origin included. |
| requireConsent | boolean | false | Holds DOM and keystroke capture until setConsent(true). |
| keepFresh | boolean | false | Refreshes the token in the background when autoScan is off. |
| debug | boolean | false | Sends swallowed errors and timeouts to console.warn. |
| nonce | string | none | Content Security Policy nonce for the injected <script>. |
| env | TrustSigEnv | PROD | PROD, STAGING, or DEV. Selects the script origin. |
| scriptUrl | string | env-derived | Overrides the script URL. Read the warning below before setting it. |
| scriptTimeoutMs | number | 10000 | Rejects script injection if it has not loaded in time. |
A self-hosted script reports to production. The script takes its API origin from its own src and trusts only *.trustsig.eu and loopback hosts, so a copy served from your domain sends all telemetry to the production edge whatever env says.
| Method | Returns | Description |
|---|---|---|
| load() | Promise<void> | Injects the script. Idempotent per instance. Rejects with SCRIPT_LOAD_FAIL or SCRIPT_LOAD_TIMEOUT. |
| getResponse() | Promise<TrustSigResponse | null> | The cached scan result, the token the script already holds, or a fresh scan(). |
| getCachedToken() | string | null | The token in memory right now, synchronously. Never scans. |
| scan() | Promise<TrustSigResponse | null> | A fresh analysis, ignoring the cache. |
| setConsent(granted) | void | Grants or withdraws consent for DOM and keystroke capture. |
| flushMouse(options?) | Promise<string> | Flushes buffered pointer samples. With { behavior: true } it resolves with a behaviour handle. |
| onBehaviorReady(cb) | () => void | Calls cb({ token, at }) for every handle the script mints. Returns an unsubscribe function. |
| getDeviceId(options?) | Promise<string | null> | The project-scoped device id. options.timeout in milliseconds, default 30000. |
The script boots a sandbox on the asset origin and talks to the edge from inside it, so allowing script-src alone is not enough. A policy needs all four directives, or the browser never loads the SDK.
Appended to an existing policy, the header reads:
script-src 'self' https://edge.trustsig.eu;
frame-src https://edge.trustsig.eu;
worker-src https://edge.trustsig.eu blob:;
connect-src 'self' https://edge.trustsig.euA blocked sandbox fails quietly: the script still loads, no error surfaces, and the token never arrives.
With autoScan on, the script also injects a hidden trustsig-response field into every form. It can hold the literal ERROR:pending before the scan finishes, so treat any ERROR:-prefixed value as no token.
React and Next.js
The @trustsig/react package gives you the TrustSigProvider component and the useTrustSig hook.
@trustsig/react wraps @trustsig/client: a context provider that scans on mount, and a hook that hands you the token at submit time. It supports React 18 and 19 and works in the Next.js App Router.
npm install @trustsig/reactWrap the app, or the subtree that needs protection, in TrustSigProvider. Every entry point is marked "use client", so a Server Component can render it directly.
"use client";
import { TrustSigProvider } from '@trustsig/react';
export default function RootLayout({ children }) {
return (
<TrustSigProvider siteKey="pk_live_..." autoScan>
{children}
</TrustSigProvider>
);
}Mount one provider per document, with stable props. A changed prop builds a new client, but the edge script initialises only once, so the first key stays authoritative for the page.
Call getResponse() in the submit handler. Send response.token in the X-TrustSig-Response header.
"use client";
import { useTrustSig } from '@trustsig/react';
export function LoginForm() {
const { getResponse } = useTrustSig();
const handleSubmit = async (e) => {
e.preventDefault();
// Resolves as soon as the scan that started on mount finishes.
const { token } = await getResponse();
await fetch('/api/login', {
method: 'POST',
headers: { 'X-TrustSig-Response': token },
});
};
}isLoaded flips on the script load event. The handshake, sandbox boot and first scan follow, so getResponse() can still take seconds after it.
Derive token readiness from the result when you need it. On a null, submit an empty token and let the server apply its policy.
siteKey is required. The rest match the browser loader options.
| Option | Type | Default | Description |
|---|---|---|---|
| autoScan | boolean | true | Analyses on load and announces the result on trustsig:ready. |
| interceptRequests | boolean | false | Attaches the X-TrustSig-Response header to every fetch and XHR the page makes, cross-origin included. |
| requireConsent | boolean | false | Holds DOM and keystroke capture until setConsent(true). |
| keepFresh | boolean | false | Refreshes the token in the background when autoScan is off. |
| debug | boolean | false | Sends swallowed errors and timeouts to console.warn. |
| nonce | string | none | Content Security Policy nonce for the injected <script>. |
| env | TrustSigEnv | PROD | PROD, STAGING, or DEV. Selects the script origin. |
| scriptUrl | string | env-derived | Overrides the script URL. Read the warning below before setting it. |
| scriptTimeoutMs | number | 10000 | Rejects script injection if it has not loaded in time. |
Throws when called outside a <TrustSigProvider>.
| Field | Type | Description |
|---|---|---|
| isLoaded | boolean | The script bytes arrived. It does not mean a token exists. |
| error | Error | null | SCRIPT_LOAD_FAIL or SCRIPT_LOAD_TIMEOUT. |
| getResponse() | Promise<TrustSigResponse | null> | The cached result, the token the script already holds, or a fresh scan. |
| getCachedToken() | string | null | The token in memory right now. Never scans. |
| scan() | Promise<TrustSigResponse | null> | A fresh analysis, ignoring the cache. |
| setConsent(granted) | void | Releases DOM and keystroke capture held by requireConsent. |
| flushMouse(options?) | Promise<string> | Flushes buffered pointer samples and can resolve with a behaviour handle. |
| onBehaviorReady(cb) | () => void | Receives every handle the script mints. Returns an unsubscribe function. |
| getDeviceId(options?) | Promise<string | null> | The project-scoped device id, waiting for the next scan when none has returned. |
getDeviceId, flushMouse and onBehaviorReady behave as they do on the browser loader.
Pointer behaviour
Flush the pointer stream for a handle, then trade it server-side for what the cursor did after the token was minted.
Verification judges the token minted on page load, without the movement that follows. A verify on submit races the browser’s next flush of pointer samples.
flushMouse({ behavior: true })flushes now and resolves with an opaque handle. Your backend exchanges it for the session’s pointer result.
// deviceId: true waits for a scan, so the exchange can answer with the id.
const handle = await client.flushMouse({ : true, deviceId: true });
await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...order, behavior_token: handle }),
});- Handles are valid for 30 minutes, and a later handle supersedes an earlier one for the same session.
- The handle is sealed to your project, so a page cannot inspect it or mint one.
flushMouse()with no arguments resolves with an empty string, as does any call that minted no handle.
Register onBehaviorReady once and every scheduled flush hands you a handle, so one is ready before the visitor submits.
let behaviorToken = '';
// Every scheduled flush hands you a handle, so one is ready before the submit.
const stop = client.onBehaviorReady(({ token }) => {
behaviorToken = token;
});Flushes go out on their own, with nothing to configure:
- Once the cursor has produced enough movement to judge, and again right after the first scan returns.
- On a click,
Enter, a form submit, a route change, or enough accumulated movement. - On an interval backstop at 10s, then 20s, then 45s, relaxing to two minutes past five minutes of session age.
getBehavior(handle) exchanges the handle on your server, billed as one verification.
import { TrustSig } from '@trustsig/server';
const ts = new TrustSig({ secretKey: process.env.TRUSTSIG_SECRET_KEY });
const = await ts.getBehavior(handle);
// Check error first: it is not the same answer as "no evidence".
if (.error) return proceedWithoutPointerEvidence();
if (..state === 'rich' && ..human_score < 30) {
return reject('automated pointer path');
}
..id; // the device id, when the flush asked for itFailures fail quiet, not closed: a missing handle, a timeout, a non-2xx response or a malformed body sets error and answers state: 'none', the same shape as a session that streamed nothing.
state reports what evidence was available, not how suspicious it was. Real people fill in forms with a keyboard or a touchscreen, so a session that never moved is not a finding.
| Field | Meaning |
|---|---|
| none | No pointer stream reached the edge. |
| idle | The stream arrived and the cursor never meaningfully moved. |
| partial | Some movement, too little to judge confidently. |
| rich | Enough movement to judge. |
| Field | Type | Description |
|---|---|---|
| behavior.state | String | The evidence tier the session reached: none, idle, partial, or rich. |
| behavior.human_score | Integer | 0 = certainly automated, 100 = certainly human. Null unless the session cleared the evidence bar. |
| behavior.score | Integer | The extra risk the pointer evidence justifies, on the verdict's 0 to 100 scale. Zero in observe mode and on partial evidence. |
| behavior.automated | Boolean | The one-line form of factors. |
| behavior.factors | String[] | What the model found in the pointer path. |
| behavior.samples | Integer | Pointer samples the session streamed. |
| behavior.batches | Integer | Flushes that arrived. |
| behavior.scored_batches | Integer | Batches that cleared the evidence bar and reached the model. |
| behavior.session_ended | Boolean | The session declared itself finished. |
| behavior.scored_at | Integer | When the newest scored batch arrived. Compare it against the action you are gating. |
| behavior.model | String | The model build that produced the score. |
| device.id | String | The device id, when the flush asked for it. Null unless status is resolved. |
| device.status | String | not_requested, resolved, pending (no telemetry yet), or unavailable (telemetry resolved no id). |
| error | String | Set when the session could not be read at all. Read it before treating absent evidence as a finding. POST /api/v1/behavior is the route behind the call. |
This shape differs from behavior.mouse on a verify response: only the human score, the automated flag and the sample and batch counts carry over.
Verify endpoint
POST the token to the TrustSig edge from any language or runtime, and read the verdict plus the evidence behind it.
The evidence behind the verdict is assembled here, not sealed into the token. Each verify call counts one request against your monthly volume. The browser scan does not.
https://edge.trustsig.eu/verifyapplication/json| Field | Description |
|---|---|
| secret | Your Secret Key (sk_live_...). Send it from the server only. |
| token | The scan token the page collected, however your integration carries it. |
Gate on is_bot.
app.post('/login', async (req, res) => {
const token =
req.body['trustsig-response'] ||
req.headers['x-trustsig-response'];
const result = await fetch('https://edge.trustsig.eu/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
secret: process.env.TRUSTSIG_SECRET_KEY,
token,
}),
});
const v = await result.json();
// is_bot is true once the session crossed the block threshold.
if (v.) {
log.warn(
{
device_id: v..device_id,
risk_score: v..score,
reason_codes: v..reason_codes,
},
'login blocked',
);
return res.status(403).json({ error: 'Access denied.' });
}
// A clean session on a hosting network still deserves a second factor.
if (v..datacenter) {
return stepUp(v..device_id);
}
// proceed with login
});A desktop Chrome session on a residential connection in Estonia passes, with three reason codes behind its risk grade of 17.
{
"": "ALLOW",
"": false,
"": 0,
"": 1785942067,
"": "d1e23499e6c16c6",
"": 5,
"": {
"score": 17,
"level": "low",
"reason_codes": [
"TAMPERED_ENVIRONMENT",
"ANTI_DETECT_BROWSER",
"PRIVACY_CANVAS_RANDOMIZATION"
],
"revision": 0,
"revised": false
},
"": {
"device_id": "af6aaf8b92b2233a",
"confidence": 100,
"degraded": false,
"first_seen": 1785855667,
"last_seen": 1785942067,
"sightings": 12,
"returning": true
},
"": {
"class": "desktop",
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36",
"browser_family": "chrome",
"os_family": "macintosh",
"platform": "MacIntel",
"languages": ["en-US"],
"timezone": "Europe/Tallinn",
"font_count": 17,
"incognito": false,
"screen": {
"resolution": "1440x900",
"pixel_ratio": 2,
"touch_points": 0
},
"hardware": {
"cpu_cores": 8,
"memory_gb": 8,
"gpu_vendor": "Google Inc. (Apple)",
"gpu_renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M3, Unspecified Version)"
}
},
"": {
"country": "EE",
"region": "Harjumaa",
"city": "Tallinn",
"connection_type": "residential",
"vpn": false,
"tor": false,
"datacenter": false,
"mobile": false,
"tls_version": "TLSv1.3",
"http_protocol": "HTTP/2"
},
"": {
"tampered": true,
"automation": false,
"anti_detect_browser": true,
"privacy_tooling": true,
"virtual_machine": false,
"runtime_patched": false,
"platform_mismatch": false,
"rendering_anomaly": false,
"spoofing": {
"detected": false
}
},
"": {
"mouse": {
"verdict": "human",
"analyzed": true,
"human_score": 91,
"automated": false,
"sufficient_data": true,
"samples": 1420,
"batches": 6,
"coverage": "rich"
}
},
"": {
"": {
"first_seen": 1785855667,
"last_seen": 1785942067,
"total": 12,
"last_5m": 1,
"last_1h": 3,
"last_24h": 12,
"last_7d": 12,
"last_30d": 12
},
"peak_requests_1m": 1
},
"": {
"bot": false,
"automation": false,
"anti_detect_browser": true,
"tampered_environment": true,
"privacy_tooling": true,
"virtual_machine": false,
"runtime_patched": false,
"platform_mismatch": false,
"rendering_anomaly": false,
"spoofed_device": false,
"incognito": false,
"vpn": false,
"tor": false,
"datacenter": false,
"mobile_network": false,
"synthetic_behavior": false,
"automated_mouse": false,
"challenge_failed": false,
"high_velocity": false,
"bad_reputation": false,
"outdated_client": false,
"token_problem": false,
"new_device": false
}
}Response schema
Every field the verify call returns, what each one means, and the error codes you get when verification fails.
| Field | Type | Description |
|---|---|---|
| action | String | ALLOW, CHALLENGE, or BLOCK. |
| is_bot | Boolean | True when the session crossed the block threshold. |
| score | Integer | The enforcement score, 0 to 100. It moves in steps, so read risk.score for a continuous grade. |
| issued_at | Integer | UNIX timestamp of token creation. |
| request_id | String | The session this verdict belongs to. |
| schema_version | Integer | Moves when a block is renamed, moved, or given a new meaning. Added fields do not move it. |
action, is_bot, score and issued_at are frozen: same names, types and arithmetic on every account and contract. An integration that reads only action never has to change.
These come from verifyRemote rather than the edge, so a raw /verify call does not carry them.
| Field | Type | Description |
|---|---|---|
| blocked | Boolean | Equals action !== 'ALLOW'. |
| error | String | null when verification completed. The verdict may still be BLOCK for a real bot. |
| factors | String[] | Deprecated mirror of risk.reason_codes, plus any failure code on a synthetic verdict. The edge no longer sends it. |
| evidence | Object | Reserved. {} today. |
| site_key | String | Reserved. Empty today. |
| Field | Type | Description |
|---|---|---|
| risk.score | Integer | Continuous 0 to 100 grade of the same evidence. Only a hard signal reaches 100, so anything below it is accumulated evidence rather than a single verdict. Never reported below the enforcement score. |
| risk.level | String | The display band for risk.score. |
| risk.reason_codes | String[] | Why the verdict was reached, as stable coarse codes. Always an array. The catalogue is at GET /api/v1/meta/reason-codes. |
| risk.revision | Integer | How many times evidence arriving after the token has revised this session. |
| risk.revised | Boolean | True when a revision actually changed the enforced answer. |
Every catalogue code belongs to one group. Route on the group rather than the code, and a code added later lands in handling you already wrote.
| Group | Meaning |
|---|---|
| automation | A driver, a headless build, a debugging protocol session, or a layer that hides one. |
| integrity | Built-in browser APIs were replaced, or the engine does not behave like the browser it claims to be. |
| environment | The declared platform, hardware, and rendering surface do not hold together, or the browser randomises them. |
| behavior | Pointer movement or input was injected rather than produced by a person. |
| challenge | A device challenge was failed, was not answered, or reported inconsistent timings. |
| velocity | Request or token volume beyond what this device should produce. |
| reputation | This device has a history on the network, or is inside a block window. |
| network | The connecting network is anonymising. |
| device | Mobile app integrity: rooting, an attached debugger, mocked location, a signature mismatch. |
| trust | A positive finding. The client completed verification, or the device has biometrics enrolled. |
| token | The token itself: missing, expired, unbound, over its use cap, or not produced by the session presenting it. |
Fetch the catalogue from GET /api/v1/meta/reason-codes and cache it. Codes are added between releases, so treat an unknown code as valid and undescribed.
| Field | Type | Description |
|---|---|---|
| identity.device_id | String | 16 hex characters, no prefix. One machine, scoped to your project. Survives cleared cookies and private windows. |
| identity.confidence | Integer | 0 to 100. How much of the identifying surface the browser actually exposed. |
| identity.degraded | Boolean | The fingerprint is shared by a large cohort, so device_id is cohort-grade. |
| identity.linked_devices | Integer | How many other device ids the graph has resolved to this machine. Present once more than one has been seen. |
| identity.first_seen | Integer | UNIX timestamp of the first time this project saw this device. |
| identity.last_seen | Integer | UNIX timestamp of the most recent sighting. |
| identity.sightings | Integer | Lifetime sighting count for this project. |
| identity.returning | Boolean | True once this project has seen the device more than once. |
Use identity.device_id for repeat visits, rate limits, and account binding.
What the client claims about its own platform. Detection findings are in integrity and flags.
Fields without a note are always returned. The rest need their group named in include, below.
| Field | Type | Description |
|---|---|---|
| device.class | String | desktop, mobile, tablet, or unknown. |
| device.user_agent | String | As sent by the browser. |
| device.browser_family | String | chrome, firefox, safari, edge, or opera. |
| device.os_family | String | windows, macintosh, linux, cros, android, iphone, or ipad. |
| device.platform | String | The platform string the browser reports. |
| device.languages | String[] | Accepted languages, in browser order. |
| device.timezone | String | IANA zone name resolved in the page. |
| device.font_count | Integer | Number of fonts that resolved on the machine. How many, not which. |
| device.incognito | Boolean | The page is running in a private window. |
| device.screen.resolution | String | Width by height in CSS pixels, such as 2560x1440. |
| device.screen.pixel_ratio | Number | Device pixel ratio. |
| device.screen.touch_points | Integer | Maximum simultaneous touch points. |
| device.hardware.cpu_cores | Integer | Logical cores the browser exposes. |
| device.hardware.memory_gb | Number | Memory bucket the browser exposes. Absent on Safari, which is what drives identity.degraded. |
| device.hardware.gpu_vendor | String | Unmasked WebGL vendor. |
| device.hardware.gpu_renderer | String | Unmasked WebGL renderer. |
| device.measured | Object | Which fields hold a real reading, keyed by dotted path for nested ones. A 0 with measured false was never reported, not measured as zero. Pruned with the block, so it only describes the fields you were sent. |
| device.os_version | String | Dotted and normalised, such as 10.15.7 or 10.0. Empty where the user agent publishes none rather than guessed. Needs include: ["device"]. |
| device.browser_version | String | Read from the most specific user-agent token, so Edge does not report as Chrome. Needs include: ["device"]. |
| device.engine | String | blink, gecko, or webkit. Needs include: ["device"]. |
| device.online | Boolean | The navigator reported a connection. Needs include: ["device"]. |
| device.screen.available_resolution | String | Screen minus the space the OS reserves for its own chrome. Needs include: ["device.screen"]. |
| device.screen.color_depth | Integer | Bits per pixel. Needs include: ["device.screen"]. |
| device.screen.orientation | String | Such as landscape-primary. Needs include: ["device.screen"]. |
| device.hardware.gpu_family | String | Normalised vendor: nvidia, amd, intel, apple, arm, qualcomm, software. Needs include: ["device.hardware"]. |
| device.hardware.webgpu_renderer | String | Adapter description, where WebGPU answered at all. Needs include: ["device.hardware"]. |
| device.hardware.max_texture_size | Integer | Largest WebGL texture the driver accepts. Needs include: ["device.hardware"]. |
| device.hardware.webgl_limits | Object | GL parameter name to value, such as MAX_VERTEX_ATTRIBS. Needs include: ["device.hardware"]. |
| device.hardware.model | String | Mobile only. Needs include: ["device.hardware"]. |
| device.hardware.cpu_abi | String | Mobile only. Needs include: ["device.hardware"]. |
| device.viewport.inner_width | Integer | Content area width. Needs include: ["device.viewport"]. |
| device.viewport.inner_height | Integer | Content area height. Needs include: ["device.viewport"]. |
| device.viewport.outer_width | Integer | Window width including browser chrome. Needs include: ["device.viewport"]. |
| device.viewport.outer_height | Integer | Window height including browser chrome. Needs include: ["device.viewport"]. |
| device.locale.timezone | String | IANA zone name. Needs include: ["device.locale"]. |
| device.locale.timezone_offset_minutes | Integer | Offset the page reported. Needs include: ["device.locale"]. |
| device.locale.utc_offset_minutes | Integer | Offset read from the clock rather than from Intl. A session that rewrites one and not the other disagrees with itself here. Needs include: ["device.locale"]. |
| device.locale.local_offset_minutes | Integer | Local-time offset from the same reading. Needs include: ["device.locale"]. |
| device.locale.languages | String[] | Accepted languages, in browser order. Needs include: ["device.locale"]. |
| device.locale.primary_language | String | First entry of languages. Needs include: ["device.locale"]. |
| device.connection.effective_type | String | 4g, 3g, 2g, or slow-2g, from the Network Information API. Absent outside Chromium. Needs include: ["device.connection"]. |
| device.connection.rtt_ms | Integer | Round-trip estimate the browser reports. Needs include: ["device.connection"]. |
| device.connection.downlink_mbps | Number | Bandwidth estimate the browser reports. Needs include: ["device.connection"]. |
| device.audio.sample_rate | Integer | Sample rate of the audio context. Needs include: ["device.audio"]. |
| device.audio.state | String | running or suspended. Needs include: ["device.audio"]. |
| device.fonts.count | Integer | Same number as device.font_count. Needs include: ["device.fonts"]. |
| device.fonts.detected | String[] | Family names that resolved on the machine. Needs include: ["device.fonts"]. |
| device.media.codecs | Object | Codec name to support level: 0 no, 1 maybe, 2 probably. Keys are h264, hevc, aac, ac3, vp9. Needs include: ["device.media"]. |
| device.media.hardware_video_decode | Boolean | The platform reported hardware decoding for the queried profile. Needs include: ["device.media"]. |
| device.media.video_inputs | Integer | Cameras present. Device labels are never published, only counts. Needs include: ["device.media"]. |
| device.media.audio_inputs | Integer | Microphones present. Needs include: ["device.media"]. |
| device.media.audio_outputs | Integer | Speakers present. Needs include: ["device.media"]. |
| device.media.drm | Object[] | One entry per key system the content decryption module accepted, with key_system, persistent_state, distinctive_identifier, session_types, video_robustness, and audio_robustness. Needs include: ["device.media"]. |
| device.storage.quota_bytes | Integer | Storage the origin may use. Needs include: ["device.storage"]. |
| device.storage.usage_bytes | Integer | Storage the origin already holds. Needs include: ["device.storage"]. |
| device.storage.opfs | Boolean | The Origin Private File System answered. Needs include: ["device.storage"]. |
| device.storage.buckets | Boolean | Storage Buckets answered. Needs include: ["device.storage"]. |
| device.storage.persisted | Boolean | The origin holds persistent storage. Needs include: ["device.storage"]. |
| device.storage.durability | String | relaxed or strict. Needs include: ["device.storage"]. |
| device.plugins.count | Integer | Plugins the navigator lists. Needs include: ["device.plugins"]. |
| device.plugins.names | String[] | Plugin names, in navigator order. Needs include: ["device.plugins"]. |
| device.plugins.entries | Object[] | name, description, filename, and mime_types per plugin. Needs include: ["device.plugins"]. |
| device.plugins.mime_types | Object[] | mime_type, suffixes, and description per registered type. Needs include: ["device.plugins"]. |
| device.capabilities.webgl | Boolean | A WebGL context was obtained. Needs include: ["device.capabilities"]. |
| device.capabilities.webgpu | Boolean | A WebGPU adapter answered. Needs include: ["device.capabilities"]. |
| device.capabilities.webrtc | Boolean | The WebRTC stack answered. Needs include: ["device.capabilities"]. |
| device.capabilities.webauthn | Boolean | WebAuthn is available. Needs include: ["device.capabilities"]. |
| device.capabilities.platform_authenticator | Boolean | A built-in authenticator such as Touch ID or Windows Hello is available. Needs include: ["device.capabilities"]. |
| device.capabilities.conditional_mediation | Boolean | Passkey autofill is supported. Needs include: ["device.capabilities"]. |
| device.capabilities.webauthn_details | Object | The client-capabilities dictionary as the platform returned it. Needs include: ["device.capabilities"]. |
| device.capabilities.battery_api | Boolean | The Battery Status API is present. Needs include: ["device.capabilities"]. |
| device.capabilities.media_devices_api | Boolean | navigator.mediaDevices is present. Needs include: ["device.capabilities"]. |
| device.capabilities.speech_recognition_api | Boolean | Speech recognition is present. Needs include: ["device.capabilities"]. |
| device.capabilities.file_system_access_api | Boolean | The File System Access API is present. Needs include: ["device.capabilities"]. |
| device.capabilities.standalone | Boolean | The page is running as an installed app. Needs include: ["device.capabilities"]. |
| device.capabilities.touch_events | Boolean | Touch events are constructible. Needs include: ["device.capabilities"]. |
| device.capabilities.css | Object | CSS feature and media-query name to whether it matched. Needs include: ["device.capabilities"]. |
| device.platform_identity.oscpu | String | Firefox only. Needs include: ["device.platform_identity"]. |
| device.platform_identity.cpu_class | String | Legacy, absent everywhere current. Needs include: ["device.platform_identity"]. |
| device.platform_identity.build_id | String | Firefox only. Needs include: ["device.platform_identity"]. |
| device.platform_identity.product_sub | String | 20030107 on every Chromium and WebKit build. Needs include: ["device.platform_identity"]. |
| device.platform_identity.vendor_sub | String | Usually empty. Needs include: ["device.platform_identity"]. |
| device.platform_identity.ua_platform | String | Platform from the user-agent client hints. Needs include: ["device.platform_identity"]. |
| device.frame.depth | Integer | How deeply the script was embedded. Needs include: ["device.frame"]. |
| device.frame.top_accessible | Boolean | The top window was same-origin. Needs include: ["device.frame"]. |
| device.frame.opener_present | Boolean | The page was opened by another window. Needs include: ["device.frame"]. |
| device.frame.ancestor_origins | String[] | Origins the page was embedded under. Parent URLs and referrers are never published. Needs include: ["device.frame"]. |
The connecting network, classified by autonomous system. The category is what most integrations act on; the address and the operator behind it need include.
| Field | Type | Description |
|---|---|---|
| network.connection_type | String | residential, datacenter, mobile, vpn, tor, or unknown. The single answer, resolved from the four booleans below. |
| network.vpn | Boolean | The request arrived over a commercial VPN provider. |
| network.tor | Boolean | The request arrived over a Tor exit node. |
| network.datacenter | Boolean | The request arrived from hosting infrastructure rather than a consumer network. |
| network.mobile | Boolean | The request arrived over a mobile carrier. Carrier NAT means many subscribers share one address, so do not rate-limit on the address alone. |
| network.country | String | Two-letter country of the connecting address. |
| network.region | String | Subdivision of the connecting address. |
| network.city | String | City of the connecting address. |
| network.tls_version | String | TLS version the connection negotiated. |
| network.http_protocol | String | HTTP version the request used. |
| network.measured | Object | Which fields hold a real reading. Same meaning as device.measured. |
| network.ip | String | The connecting address. Needs include: ["network"]. |
| network.ip_version | Integer | 4 or 6. Needs include: ["network"]. |
| network.asn | Integer | Autonomous system number of the connecting address. Needs include: ["network"]. |
| network.asn_organization | String | Operator that announces the prefix. Needs include: ["network"]. |
| network.region_code | String | Subdivision code. Needs include: ["network"]. |
| network.postal_code | String | Postal code of the connecting address. Needs include: ["network"]. |
| network.continent | String | Two-letter continent code. Needs include: ["network"]. |
| network.timezone | String | IANA zone of the connecting address, which is the one to compare against device.timezone. Needs include: ["network"]. |
| network.latitude | Number | Coarse: it locates the network, not the person. Absent rather than 0 when the edge had nothing. Needs include: ["network"]. |
| network.longitude | Number | As latitude. Needs include: ["network"]. |
| network.tls_cipher | String | Cipher suite the connection negotiated. Needs include: ["network"]. |
| network.edge_location | String | Edge location that served the analysis request. Needs include: ["network"]. |
device and network default to a narrow form, which keeps a verdict used for a single gate around a third of its wide size. Name the groups you read in the verify request:
{
"token": "<scan token>",
"include": [".media", ""]
}| Field | Adds |
|---|---|
| all | Everything below. include: true is the same thing. |
| device | Every device.* group, plus os_version, browser_version, engine, and online. |
| device.screen | available_resolution, color_depth, orientation. |
| device.hardware | gpu_family, webgpu_renderer, max_texture_size, webgl_limits, model, cpu_abi. |
| device.viewport | Window dimensions, inner and outer. |
| device.locale | Timezone offsets read from the clock, and the language list. |
| device.connection | effective_type, rtt_ms, downlink_mbps. |
| device.audio | sample_rate, state. |
| device.fonts | The detected family names, not just the count. |
| device.media | Codec support, capture-device counts, and the DRM key systems and robustness levels the CDM granted. |
| device.storage | Quota, usage, OPFS, and Storage Buckets. |
| device.plugins | The plugin and MIME inventory. |
| device.capabilities | WebGL, WebGPU, WebRTC, WebAuthn, the platform APIs, and a CSS feature map. |
| device.platform_identity | oscpu, build_id, product_sub, and friends. |
| device.frame | Embedding depth and ancestor origins. |
| network | ip, ip_version, asn, asn_organization, region_code, postal_code, continent, timezone, latitude, longitude, tls_cipher, edge_location. |
{
"": {
"class": "desktop",
"media": {
"codecs": { "h264": 2, "hevc": 0, "aac": 2, "ac3": 0, "vp9": 2 },
"hardware_video_decode": true,
"video_inputs": 1,
"audio_inputs": 2,
"audio_outputs": 3,
"drm": [
{
"key_system": "com.widevine.alpha",
"persistent_state": "required",
"distinctive_identifier": "not-allowed",
"session_types": ["temporary"],
"video_robustness": ["HW_SECURE_ALL", "SW_SECURE_DECODE"],
"audio_robustness": ["SW_SECURE_CRYPTO"]
}
]
},
"...": "..."
},
"": {
"ip": "203.0.113.9",
"ip_version": 4,
"asn": 24940,
"asn_organization": "Hetzner Online GmbH",
"country": "DE",
"region_code": "HE",
"postal_code": "60313",
"continent": "EU",
"timezone": "Europe/Berlin",
"latitude": 50.1109,
"longitude": 8.6821,
"connection_type": "datacenter",
"tls_cipher": "AEAD-AES128-GCM-SHA256",
"edge_location": "FRA",
"...": "..."
}
}The edge ignores names it does not recognise, so requesting a group an older deployment does not serve yet is safe. An unmeasured field carries its type’s zero. device.measured and network.measured say which zeros are real, keyed by dotted path for nested fields.
Each boolean is one finding, so a patched JavaScript engine and implausible hardware report separately.
| Field | Type | Description |
|---|---|---|
| integrity.tampered | Boolean | The browser shows the interception pattern an anti-detect build leaves behind. Which probes fired is not published. |
| integrity.automation | Boolean | A driver, a headless build, a debugging protocol session, or a layer that hides one. |
| integrity.anti_detect_browser | Boolean | The session runs in an anti-detect browser engine. |
| integrity.privacy_tooling | Boolean | A privacy browser or an anti-fingerprinting extension is masking the device. Not necessarily an adversary. |
| integrity.virtual_machine | Boolean | A virtual machine or an emulator. |
| integrity.runtime_patched | Boolean | Built-in APIs were replaced, or the engine does not behave like the browser it claims to be. |
| integrity.platform_mismatch | Boolean | The declared operating system or hardware conflicts with what was measured. |
| integrity.rendering_anomaly | Boolean | Graphics output is inconsistent with the declared hardware. |
| integrity.spoofing | Object | What was found when a fingerprint was rewritten: how sure the finding is, how it was caught, and the real device it links back to. An unenforced finding is reported but never reaches risk.reason_codes. |
The pointer model runs after the token is minted. A verify straight after page load reports analyzed: false and fills in on a later verify of the same token.
| Field | Type | Description |
|---|---|---|
| behavior.mouse.verdict | String | automated, human, or inconclusive. The single field to read if you only read one. |
| behavior.mouse.human_score | Integer | 0 = certainly automated, 100 = certainly human. Null until the model has scored. |
| behavior.mouse.automated | Boolean | Cursor motion matches an automated humanizer rather than a person. |
| behavior.mouse.sufficient_data | Boolean | False means there was not enough movement to judge, never that it was clean. |
| behavior.mouse.samples | Integer | Pointer samples the session has streamed so far. |
| behavior.mouse.batches | Integer | How many pointer flushes have been folded in. |
| behavior.mouse.coverage | String | none (no stream), idle (a stream with no movement), partial, or rich. |
inconclusive is no evidence of a person. Gate on verdict or sufficient_data.
How often this project has seen this device. Counts are taken when the token is issued, not at verify time.
| Field | Type | Description |
|---|---|---|
| velocity.device.last_5m | Integer | Sightings of this device by this project in the last five minutes. |
| velocity.device.last_1h | Integer | Sightings in the last hour. |
| velocity.device.last_24h | Integer | Sightings in the last 24 hours. |
| velocity.device.last_7d | Integer | Sightings in the last seven days. Exact to the hour. |
| velocity.device.last_30d | Integer | Sightings in the last 30 days. Exact to the day. |
| velocity.device.total | Integer | Lifetime sightings of this device by this project. |
| velocity.device.first_seen | Integer | UNIX timestamp of the first sighting. |
| velocity.device.last_seen | Integer | UNIX timestamp of the most recent sighting. |
| velocity.peak_requests_1m | Integer | Highest request count seen in the last minute for any velocity key on this session. |
One boolean per detection, always present, for one-line gating. false means not observed.
- bot
- automation
- anti_detect_browser
- tampered_environment
- privacy_tooling
- virtual_machine
- runtime_patched
- platform_mismatch
- rendering_anomaly
- spoofed_device
- incognito
- vpn
- tor
- datacenter
- mobile_network
- synthetic_behavior
- automated_mouse
- challenge_failed
- high_velocity
- bad_reputation
- outdated_client
- token_problem
- new_device
error carries one of these when verification could not complete.
| Code | Meaning |
|---|---|
| TOKEN_MISSING | No token was supplied to the verify call. |
| API_FAIL | The edge call failed: network, non-200, or timeout. |
| MALFORMED_RESPONSE | The edge response parsed but did not match the expected schema. |
These older codes are retired, so a gate matching on them never fires.
| Code | Meaning |
|---|---|
| TOKEN_EXPIRED | An expired token now comes back as a BLOCK verdict carrying the same reason code. |
| TOKEN_REUSED | An over-used token now comes back as a BLOCK verdict carrying the TOKEN_USES_EXCEEDED reason code. |
| CRYPTO_FAIL | No longer produced. Nothing is decrypted outside the platform. |
@trustsig/server
Verify tokens from Node.js and edge runtimes with verifyRemote and read the telemetry it returns.
@trustsig/server runs on Node.js and on edge runtimes. It never throws: every call resolves to a BotAnalysisResponse, so the gate is the only branch you write.
npm install @trustsig/serverimport { TrustSig } from '@trustsig/server';
const ts = new TrustSig({ secretKey: process.env.TRUSTSIG_SECRET_KEY });
const token = request.headers.get('X-TrustSig-Response');
const result = await ts.verifyRemote(token);
// is_bot is true once the session crossed the block threshold.
if (result.) throw new Error('Access denied');verifyRemote(token, options?)Validates the token against the TrustSig edge and returns the verdict with its evidence. include widens the device and network blocks.
getBehavior(handle)Trades a browser pointer handle for that session’s behaviour result, billed as one verification.
verifyRemote is the only way to read a verdict. Tokens are sealed to a key the platform holds and your backend does not, so verifyLocal throws.
The gate is one line. The rest of the response describes the visitor behind the verdict. An unmeasured field is absent, not zero.
import { TrustSig, verdictSummary, hasFlag, mouseVerdict, deviceSightings } from '@trustsig/server';
const result = await ts.verifyRemote(token);
// is_bot is true once the session crossed the block threshold.
if (result.) {
log.warn(verdictSummary(result));
return res.status(403).json({ error: 'Access denied.' });
}
const deviceId = result.?.device_id;
if (hasFlag(result, 'automation')) return deny();
if (hasFlag(result, 'tampered_environment')) return stepUp(deviceId);
if (hasFlag(result, 'datacenter') && !isKnownCrawler(result)) return stepUp(deviceId);
// 'inconclusive' means there was not enough movement to judge, so it is not a pass.
if (mouseVerdict(result) === 'automated') return requireSecondFactor(deviceId);
if ((deviceSightings(result, 'last_1h') ?? 0) > 20) return rateLimit(deviceId);
if (result..score >= 60) return requireSecondFactor(deviceId);
return completeLogin({
device_id: deviceId,
device_class: result.?.class,
browser: result.?.browser_family,
returning: result.?.returning,
});verdictSummary leaves out whatever is absent, so a synthetic verdict logs as action=BLOCK error=TOKEN_MISSING score=100 rather than a row of empty fields.
action=ALLOW risk=17/low score=0
req=d1e23499e6c16c6 device=af6aaf8b92b2233a country=EE
flags=anti_detect_browser,privacy_tooling,tampered_environment
codes=TAMPERED_ENVIRONMENT,ANTI_DETECT_BROWSER,PRIVACY_CANVAS_RANDOMIZATIONflagsis the verdict’s flat boolean summary. These helpers read it and answer false instead of throwing when a block is missing.
| Field | Meaning |
|---|---|
| hasFlag(result, flag) | true when that flag fired. |
| firedFlags(result) | The names of every flag that fired, sorted. |
| verdictFlags(result) | Every flag resolved to a boolean, so each one gates directly. |
| mouseVerdict(result) | automated, human, or inconclusive when there is no behaviour block. |
| deviceSightings(result, window) | One device-velocity window, undefined when the block is absent. |
isStableDeviceId decides whether identity.device_id is stable, checking degraded and confidence together against a floor that defaults to 50.
import { isStableDeviceId, deviceSummary, tokenAgeSeconds } from '@trustsig/server';
const key = isStableDeviceId(result) ? result..device_id : `ip:${clientIp}`;
await rateLimit(key);
await auditLog.write({
device_id: result.?.device_id,
first_seen: result.?.first_seen,
sightings: result.?.sightings,
: deviceSummary(result),
token_age_s: tokenAgeSeconds(result),
});deviceSummary renders the device block as one line, such as chrome on macintosh, 1440x900@2x, 8 cores, 8GB, Europe/Tallinn. tokenAgeSeconds reports how long the page held the token before you verified it.
risk.reason_codes names the evidence behind the verdict as stable coarse codes. The catalogue is public and versioned, served by GET /api/v1/meta/reason-codes, and bundled in the package so a lookup costs no network call.
import { explainVerdict, hasReasonGroup, reasonCodesByGroup } from '@trustsig/server';
for (const r of explainVerdict(result)) {
console.log(r.code, r.group, r.description, r.adverse);
}
if (hasReasonGroup(result, '')) return rateLimit(result..device_id);
if (hasReasonGroup(result, 'automation')) return deny();
const byGroup = reasonCodesByGroup(result);Route on the code group rather than the code: velocity asks for a rate limit, automation for a denial, environment for a step-up.
| Export | Description |
|---|---|
| REASON_CODES | The bundled catalogue, one { code, group, description } per entry. |
| REASON_CODE_CATALOG_VERSION | Version of that snapshot. Compare it to a fetched catalogue to notice you are behind. |
| reasonCode(code) | The catalogue entry, or undefined for a code this release does not know. |
| reasonCodeGroup(code) | Its group, or undefined. |
| describeReasonCode(code) | Its description, falling back to the code itself. |
| isTrustReasonCode(code) | True for the trust group. |
| groupReasonCodes(codes) | Buckets a code array by group. Unknown codes land under unknown. |
| ReasonCodeCatalog | The live catalogue, fetched and cached. |
The bundle is a snapshot, so a lookup on a newer code falls back to the code itself. Fetch the live list when you render descriptions to operators.
import { ReasonCodeCatalog } from '@trustsig/server';
const catalog = new ReasonCodeCatalog();
await catalog.refresh();
catalog.describe('ANTI_DETECT_BROWSER');
catalog.group('ANTI_DETECT_BROWSER');
catalog.all();| Option | Type | Default | Description |
|---|---|---|---|
| secretKey | string | required | Your Secret Key. The constructor throws SECRET_KEY_REQUIRED without it. |
| env | TrustSigEnv | PROD | PROD, STAGING, or DEV. Selects the default endpoint host. |
| endpoint | string | env-derived | Overrides the verification endpoint. A trailing slash is stripped. |
| timeoutMs | number | 5000 | Network timeout for verifyRemote. An abort fails closed. |
| include | TrustSigInclude | none | Default include for every call. The per-call argument wins. |
const ts = new TrustSig({
secretKey: process.env.TRUSTSIG_SECRET_KEY,
timeoutMs: 5000,
// The default include for every call. A per-call argument wins.
include: [''],
});The edge nonce caps replay across every process and instance, so a serverless runtime that starts each request with an empty cache is covered like any other.
The options that used to cap reuse in process memory are deprecated no-ops, kept on TrustSigOptions so existing configuration still compiles: replayProtection, maxUsesPerToken, maxTokenAgeSeconds, clockSkewSeconds.
getBehavior(handle) returns the pointer result a verify on submit cannot. See pointer behaviour.
Accounts created before the response was restructured receive only the frozen four fields, with every block absent. The helpers read the missing blocks as not measured, so absent evidence never reads as a finding.
Read API and webhooks
Read your own traffic back with a project API key, and verify the signature on every webhook delivery.
TrustSigRead reads your own traffic back: requests, devices, and how the composition breaks down. It takes a project API key (tsk_...) minted in the console instead of the Secret Key, and the key implies the project.
import { TrustSigRead } from '@trustsig/server';
// A project API key (tsk_...), not the Secret Key.
const read = new TrustSigRead({ apiKey: process.env.TRUSTSIG_API_KEY });
const { requests } = await read.listRequests({ : 'BLOCK', limit: 20 });
const = await read.getDevice(requests[0].device_id);
console.log(.sightings, .first_seen);| Method | Scope | Description |
|---|---|---|
| listRequests(query?) | read | Recent checked requests with their verdict, reason codes, and device. |
| getRequest(id) | read | One request by request_id. |
| listDevices(query?) | read | Devices seen, with first and last seen and sighting counts. |
| getDevice(id) | read | One device. |
| getUsage(window?) | analytics | Totals for a window. |
| getTimeline(query?) | analytics | Bucketed volume, bots, and blocks. |
| getComposition(window?) | analytics | Breakdown by action, device class, country, and reason code. |
| getReasonCodes() | none | The reason-code catalogue. |
Scopes are enforced per route, so a read-only key gets a 401 from the analytics routes. Windows are clamped to your plan’s retention period.
An unreachable store answers 5xx, not an empty list, so an empty result always means no traffic. Verification never touches this storage, so an outage here leaves your forms protected.
Register an endpoint in the console and TrustSig posts to it. Deliveries carry X-TrustSig-Signature: t=<unix>,v1=<hex>.
| Event | Meaning |
|---|---|
| verdict.block | A request was blocked. |
| device.first_seen | A device was seen for the first time. |
import { verifyWebhookSignature } from '@trustsig/server';
// Verify the raw body. A re-serialised body will not match.
const raw = await readRawBody(req);
const signature = req.headers['x-trustsig-signature'];
if (!verifyWebhookSignature(process.env.TRUSTSIG_WEBHOOK_SECRET, signature, raw)) {
return res.status(400).end();
}
const event = JSON.parse(raw);verifyWebhookSignature returns true only for a well-formed, fresh, valid signature. A timestamp outside the tolerance window, 300 seconds by default, is rejected.