StartGet started
Documentation

Get started

Answer up to three questions to get the steps for your stack.

1 What are you protecting?
Start

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.

Your TrustSig account

API keys

A public Site Key for the browser. A Secret Key your server verifies tokens with.

Public, safe to expose
pk_live_...

Passed to the frontend SDK.

Private, keep server-side
sk_live_...

Your backend uses it to fetch verdicts.

Rotating keys
  • 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.
Your TrustSig account

Signing in to TrustSig

Sign in to TrustSig with a password, with Google, or with both. Two-factor authentication covers every method.

Password and Google sign-in
  • 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.

Two-factor authentication with Google
  • 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.
Organization ownership and transfer

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
What account deletion removes
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.

Your TrustSig account

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.

One project or several
  • 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.
Plan limits

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.

Allowed Domains

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:

  • localhost and IPv6 loopback (::1, [::1])
  • Link-local: IPv4 169.254.0.0/16, IPv6 fe80::/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
Troubleshooting rejected requests

A rejected client lands in the project's Troubleshoot log with one of these codes.

CodeMeaning
NON_WHITELISTED_DOMAINThe origin is not on the Allowed Domains list.
INVALID_SITE_KEYThe data-site-key does not match the project, commonly a stale frontend deploy after a rotation.
CHALLENGE_EXPIREDThe token was past its validity window at verification, usually from a form left open.
Your TrustSig account

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.

What counts as a request
  • One /verify or verifyRemote call counts as one request.
  • Nothing else counts: scans and unverified tokens are free.
Included volume per plan
PlanIncluded volumeDomainsOverage
Free5,000 / month2None
Scout30,000 / month10€2 per 1,000
Scale120,000 / month30€1 per 1,000
EnterpriseUnlimitedUnlimitedContracted

Your live limits and plan changes are in dashboard billing.

Overage

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.

Monitoring usage
Overview
The live monthly meter against your plan limit
Billing
Usage history, the overage ledger, and invoices
Reducing usage

Leave scanning on everywhere and call /verify only on the actions worth a verdict: submits, logins, checkout.

WordPress

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.

Install from WordPress.orgFree tier protection needs no account.
Install and activate
1
Plugins
Open Plugins, then Add New
2
TrustSig
Search for TrustSig
3
Install NowActivate
Install, then Activate

Protection starts on activation, with no keys and no setup.

Forms it protects
Custom forms detected automatically
No configuration per form.
  • WordPress login
  • User registration
  • Password reset
  • Comments
  • WooCommerce checkout
  • WooCommerce login
  • Contact Form 7
Connecting your keys
  • 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.

Frontend SDK

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>.

index.html, inside <head>
<script
  src="https://edge.trustsig.eu/trustsig.js"
  data-site-key="YOUR_SITE_KEY"
  data-auto-scan="true"
></script>
Configuration attributes
AttributeDescription
data-site-keyRequired. The public Site Key of the project (pk_live_...).
data-auto-scanOptional, defaults to true. Set it to false to scan only when you call the JavaScript API.
data-intercept-requestsOptional. Attaches the X-TrustSig-Response header to every fetch and XHR request the page makes.
data-require-consentOptional. Holds DOM and keystroke capture until you call setConsent(true). Device telemetry is collected either way.
data-keep-freshOptional. Refreshes the token in the background when automatic scanning is off. Ignored when it is on.
Content Security Policy

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.

script-srcLoads trustsig.js.https://edge.trustsig.eu
frame-srcBoots the sandbox the analysis runs in.https://edge.trustsig.eu
worker-srcRuns the analysis off the main thread.https://edge.trustsig.eu blob:
connect-srcFetches the token.https://edge.trustsig.eu

Appended to an existing policy, the header reads:

Content-Security-Policy
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.eu

A 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.

Frontend SDK

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.

login.html
<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>
Frontend SDK

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.
trustsig:ready event

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);
});
getResponse()

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: 'user@example.com' }),
  });
}
scan({ key })

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: 'user@example.com' }),
  });
}
Content Security Policy

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.

script-srcLoads trustsig.js.https://edge.trustsig.eu
frame-srcBoots the sandbox the analysis runs in.https://edge.trustsig.eu
worker-srcRuns the analysis off the main thread.https://edge.trustsig.eu blob:
connect-srcFetches the token.https://edge.trustsig.eu

Appended to an existing policy, the header reads:

Content-Security-Policy
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.eu

A blocked sandbox fails quietly: the script still loads, no error surfaces, and the token never arrives.

Frontend SDK

@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.

Install
npm install @trustsig/client
Usage
checkout.js
import { 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.

What load does
1. InjectThe script tag goes in with your configuration on data-* attributes.
2. AnalyseWith autoScan on, the first result is announced on a trustsig:ready window event, which the client caches.
3. AnswergetResponse() returns that cached result, or runs a scan when none has arrived.
Manual scanning

With autoScan: false, nothing runs until you call scan().

manual-scan.js
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();
Device id

getDeviceId() resolves the project-scoped device id, the same value /verify publishes as identity.device_id.

device-id.js
// 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 });
Exists afterThe first scan returns (the edge mints it, never the browser).
StoredNowhere on the device. Stable across reloads, different per project.
No waitThe same id rides 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.

Read the token without waiting

getCachedToken() returns the token in memory right now without starting a scan.

stamp.js
// Synchronous. Null before the first scan resolves, and after a failed scan.
const token = client.getCachedToken();

if (token) headers['X-TrustSig-Response'] = token;
normalizeScanResult

Turns a raw scan result into { request_id, token }. Use it when you listen for trustsig:ready yourself.

ready.js
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);
});
Options
OptionTypeDefaultDescription
siteKeystringrequiredYour public Site Key. The constructor throws SITE_KEY_REQUIRED without it.
autoScanbooleantrueAnalyses on load and announces the result on trustsig:ready.
interceptRequestsbooleanfalseAttaches the X-TrustSig-Response header to every fetch and XHR the page makes, cross-origin included.
requireConsentbooleanfalseHolds DOM and keystroke capture until setConsent(true).
keepFreshbooleanfalseRefreshes the token in the background when autoScan is off.
debugbooleanfalseSends swallowed errors and timeouts to console.warn.
noncestringnoneContent Security Policy nonce for the injected <script>.
envTrustSigEnvPRODPROD, STAGING, or DEV. Selects the script origin.
scriptUrlstringenv-derivedOverrides the script URL. Read the warning below before setting it.
scriptTimeoutMsnumber10000Rejects 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.

Methods
MethodReturnsDescription
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 | nullThe token in memory right now, synchronously. Never scans.
scan()Promise<TrustSigResponse | null>A fresh analysis, ignoring the cache.
setConsent(granted)voidGrants 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)() => voidCalls 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.
Content Security Policy

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.

script-srcLoads trustsig.js.https://edge.trustsig.eu
frame-srcBoots the sandbox the analysis runs in.https://edge.trustsig.eu
worker-srcRuns the analysis off the main thread.https://edge.trustsig.eu blob:
connect-srcFetches the token.https://edge.trustsig.eu

Appended to an existing policy, the header reads:

Content-Security-Policy
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.eu

A 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.

Frontend SDK

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.

Install
npm install @trustsig/react
Add the provider

Wrap 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.

app/layout.tsx
"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.

Use the hook

Call getResponse() in the submit handler. Send response.token in the X-TrustSig-Response header.

LoginForm.tsx
"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 },
    });
  };
}
Read token readiness from the result

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.

Provider props

siteKey is required. The rest match the browser loader options.

OptionTypeDefaultDescription
autoScanbooleantrueAnalyses on load and announces the result on trustsig:ready.
interceptRequestsbooleanfalseAttaches the X-TrustSig-Response header to every fetch and XHR the page makes, cross-origin included.
requireConsentbooleanfalseHolds DOM and keystroke capture until setConsent(true).
keepFreshbooleanfalseRefreshes the token in the background when autoScan is off.
debugbooleanfalseSends swallowed errors and timeouts to console.warn.
noncestringnoneContent Security Policy nonce for the injected <script>.
envTrustSigEnvPRODPROD, STAGING, or DEV. Selects the script origin.
scriptUrlstringenv-derivedOverrides the script URL. Read the warning below before setting it.
scriptTimeoutMsnumber10000Rejects script injection if it has not loaded in time.
useTrustSig()

Throws when called outside a <TrustSigProvider>.

FieldTypeDescription
isLoadedbooleanThe script bytes arrived. It does not mean a token exists.
errorError | nullSCRIPT_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 | nullThe token in memory right now. Never scans.
scan()Promise<TrustSigResponse | null>A fresh analysis, ignoring the cache.
setConsent(granted)voidReleases DOM and keystroke capture held by requireConsent.
flushMouse(options?)Promise<string>Flushes buffered pointer samples and can resolve with a behaviour handle.
onBehaviorReady(cb)() => voidReceives 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.

Frontend SDK

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.

Flush the pointer stream

flushMouse({ behavior: true })flushes now and resolves with an opaque handle. Your backend exchanges it for the session’s pointer result.

checkout.js
// 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.
Receive handles passively

Register onBehaviorReady once and every scheduled flush hands you a handle, so one is ready before the visitor submits.

behavior.js
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.
Exchange the handle

getBehavior(handle) exchanges the handle on your server, billed as one verification.

checkout.ts
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 it

Failures 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.

Evidence tiers

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.

FieldMeaning
noneNo pointer stream reached the edge.
idleThe stream arrived and the cursor never meaningfully moved.
partialSome movement, too little to judge confidently.
richEnough movement to judge.
Response
FieldTypeDescription
behavior.stateStringThe evidence tier the session reached: none, idle, partial, or rich.
behavior.human_scoreInteger0 = certainly automated, 100 = certainly human. Null unless the session cleared the evidence bar.
behavior.scoreIntegerThe extra risk the pointer evidence justifies, on the verdict's 0 to 100 scale. Zero in observe mode and on partial evidence.
behavior.automatedBooleanThe one-line form of factors.
behavior.factorsString[]What the model found in the pointer path.
behavior.samplesIntegerPointer samples the session streamed.
behavior.batchesIntegerFlushes that arrived.
behavior.scored_batchesIntegerBatches that cleared the evidence bar and reached the model.
behavior.session_endedBooleanThe session declared itself finished.
behavior.scored_atIntegerWhen the newest scored batch arrived. Compare it against the action you are gating.
behavior.modelStringThe model build that produced the score.
device.idStringThe device id, when the flush asked for it. Null unless status is resolved.
device.statusStringnot_requested, resolved, pending (no telemetry yet), or unavailable (telemetry resolved no id).
errorStringSet 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.

Backend API

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.

Endpoint
URLhttps://edge.trustsig.eu/verify
MethodPOST
Content-Typeapplication/json
Request body
FieldDescription
secretYour Secret Key (sk_live_...). Send it from the server only.
tokenThe scan token the page collected, however your integration carries it.
Node.js example

Gate on is_bot.

server.js
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
});
Example response

A desktop Chrome session on a residential connection in Estonia passes, with three reason codes behind its risk grade of 17.

200 OK
{
  "": "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
  }
}
Backend API

Response schema

Every field the verify call returns, what each one means, and the error codes you get when verification fails.

Verdict
FieldTypeDescription
actionStringALLOW, CHALLENGE, or BLOCK.
is_botBooleanTrue when the session crossed the block threshold.
scoreIntegerThe enforcement score, 0 to 100. It moves in steps, so read risk.score for a continuous grade.
issued_atIntegerUNIX timestamp of token creation.
request_idStringThe session this verdict belongs to.
schema_versionIntegerMoves 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.

Fields the server SDK adds

These come from verifyRemote rather than the edge, so a raw /verify call does not carry them.

FieldTypeDescription
blockedBooleanEquals action !== 'ALLOW'.
errorStringnull when verification completed. The verdict may still be BLOCK for a real bot.
factorsString[]Deprecated mirror of risk.reason_codes, plus any failure code on a synthetic verdict. The edge no longer sends it.
evidenceObjectReserved. {} today.
site_keyStringReserved. Empty today.
risk
FieldTypeDescription
risk.scoreIntegerContinuous 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.levelStringThe display band for risk.score.
risk.reason_codesString[]Why the verdict was reached, as stable coarse codes. Always an array. The catalogue is at GET /api/v1/meta/reason-codes.
risk.revisionIntegerHow many times evidence arriving after the token has revised this session.
risk.revisedBooleanTrue when a revision actually changed the enforced answer.
low0–24
medium25–59
high60–89
critical90–100
Reason code groups

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.

GroupMeaning
automationA driver, a headless build, a debugging protocol session, or a layer that hides one.
integrityBuilt-in browser APIs were replaced, or the engine does not behave like the browser it claims to be.
environmentThe declared platform, hardware, and rendering surface do not hold together, or the browser randomises them.
behaviorPointer movement or input was injected rather than produced by a person.
challengeA device challenge was failed, was not answered, or reported inconsistent timings.
velocityRequest or token volume beyond what this device should produce.
reputationThis device has a history on the network, or is inside a block window.
networkThe connecting network is anonymising.
deviceMobile app integrity: rooting, an attached debugger, mocked location, a signature mismatch.
trustA positive finding. The client completed verification, or the device has biometrics enrolled.
tokenThe 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.

identity
FieldTypeDescription
identity.device_idString16 hex characters, no prefix. One machine, scoped to your project. Survives cleared cookies and private windows.
identity.confidenceInteger0 to 100. How much of the identifying surface the browser actually exposed.
identity.degradedBooleanThe fingerprint is shared by a large cohort, so device_id is cohort-grade.
identity.linked_devicesIntegerHow many other device ids the graph has resolved to this machine. Present once more than one has been seen.
identity.first_seenIntegerUNIX timestamp of the first time this project saw this device.
identity.last_seenIntegerUNIX timestamp of the most recent sighting.
identity.sightingsIntegerLifetime sighting count for this project.
identity.returningBooleanTrue once this project has seen the device more than once.
device_id is the join key

Use identity.device_id for repeat visits, rate limits, and account binding.

Derived fromThe hardware, anchored in the identity graph so it survives fingerprint drift.
Scoped toScoped to one project. The same machine reads differently under every other.
SurvivesCleared cookies and private windows.
DegradedThe browser reports a fingerprint millions of devices share, so the id names a cohort. Read identity.confidence.
device

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.

FieldTypeDescription
device.classStringdesktop, mobile, tablet, or unknown.
device.user_agentStringAs sent by the browser.
device.browser_familyStringchrome, firefox, safari, edge, or opera.
device.os_familyStringwindows, macintosh, linux, cros, android, iphone, or ipad.
device.platformStringThe platform string the browser reports.
device.languagesString[]Accepted languages, in browser order.
device.timezoneStringIANA zone name resolved in the page.
device.font_countIntegerNumber of fonts that resolved on the machine. How many, not which.
device.incognitoBooleanThe page is running in a private window.
device.screen.resolutionStringWidth by height in CSS pixels, such as 2560x1440.
device.screen.pixel_ratioNumberDevice pixel ratio.
device.screen.touch_pointsIntegerMaximum simultaneous touch points.
device.hardware.cpu_coresIntegerLogical cores the browser exposes.
device.hardware.memory_gbNumberMemory bucket the browser exposes. Absent on Safari, which is what drives identity.degraded.
device.hardware.gpu_vendorStringUnmasked WebGL vendor.
device.hardware.gpu_rendererStringUnmasked WebGL renderer.
device.measuredObjectWhich 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_versionStringDotted 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_versionStringRead from the most specific user-agent token, so Edge does not report as Chrome. Needs include: ["device"].
device.engineStringblink, gecko, or webkit. Needs include: ["device"].
device.onlineBooleanThe navigator reported a connection. Needs include: ["device"].
device.screen.available_resolutionStringScreen minus the space the OS reserves for its own chrome. Needs include: ["device.screen"].
device.screen.color_depthIntegerBits per pixel. Needs include: ["device.screen"].
device.screen.orientationStringSuch as landscape-primary. Needs include: ["device.screen"].
device.hardware.gpu_familyStringNormalised vendor: nvidia, amd, intel, apple, arm, qualcomm, software. Needs include: ["device.hardware"].
device.hardware.webgpu_rendererStringAdapter description, where WebGPU answered at all. Needs include: ["device.hardware"].
device.hardware.max_texture_sizeIntegerLargest WebGL texture the driver accepts. Needs include: ["device.hardware"].
device.hardware.webgl_limitsObjectGL parameter name to value, such as MAX_VERTEX_ATTRIBS. Needs include: ["device.hardware"].
device.hardware.modelStringMobile only. Needs include: ["device.hardware"].
device.hardware.cpu_abiStringMobile only. Needs include: ["device.hardware"].
device.viewport.inner_widthIntegerContent area width. Needs include: ["device.viewport"].
device.viewport.inner_heightIntegerContent area height. Needs include: ["device.viewport"].
device.viewport.outer_widthIntegerWindow width including browser chrome. Needs include: ["device.viewport"].
device.viewport.outer_heightIntegerWindow height including browser chrome. Needs include: ["device.viewport"].
device.locale.timezoneStringIANA zone name. Needs include: ["device.locale"].
device.locale.timezone_offset_minutesIntegerOffset the page reported. Needs include: ["device.locale"].
device.locale.utc_offset_minutesIntegerOffset 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_minutesIntegerLocal-time offset from the same reading. Needs include: ["device.locale"].
device.locale.languagesString[]Accepted languages, in browser order. Needs include: ["device.locale"].
device.locale.primary_languageStringFirst entry of languages. Needs include: ["device.locale"].
device.connection.effective_typeString4g, 3g, 2g, or slow-2g, from the Network Information API. Absent outside Chromium. Needs include: ["device.connection"].
device.connection.rtt_msIntegerRound-trip estimate the browser reports. Needs include: ["device.connection"].
device.connection.downlink_mbpsNumberBandwidth estimate the browser reports. Needs include: ["device.connection"].
device.audio.sample_rateIntegerSample rate of the audio context. Needs include: ["device.audio"].
device.audio.stateStringrunning or suspended. Needs include: ["device.audio"].
device.fonts.countIntegerSame number as device.font_count. Needs include: ["device.fonts"].
device.fonts.detectedString[]Family names that resolved on the machine. Needs include: ["device.fonts"].
device.media.codecsObjectCodec 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_decodeBooleanThe platform reported hardware decoding for the queried profile. Needs include: ["device.media"].
device.media.video_inputsIntegerCameras present. Device labels are never published, only counts. Needs include: ["device.media"].
device.media.audio_inputsIntegerMicrophones present. Needs include: ["device.media"].
device.media.audio_outputsIntegerSpeakers present. Needs include: ["device.media"].
device.media.drmObject[]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_bytesIntegerStorage the origin may use. Needs include: ["device.storage"].
device.storage.usage_bytesIntegerStorage the origin already holds. Needs include: ["device.storage"].
device.storage.opfsBooleanThe Origin Private File System answered. Needs include: ["device.storage"].
device.storage.bucketsBooleanStorage Buckets answered. Needs include: ["device.storage"].
device.storage.persistedBooleanThe origin holds persistent storage. Needs include: ["device.storage"].
device.storage.durabilityStringrelaxed or strict. Needs include: ["device.storage"].
device.plugins.countIntegerPlugins the navigator lists. Needs include: ["device.plugins"].
device.plugins.namesString[]Plugin names, in navigator order. Needs include: ["device.plugins"].
device.plugins.entriesObject[]name, description, filename, and mime_types per plugin. Needs include: ["device.plugins"].
device.plugins.mime_typesObject[]mime_type, suffixes, and description per registered type. Needs include: ["device.plugins"].
device.capabilities.webglBooleanA WebGL context was obtained. Needs include: ["device.capabilities"].
device.capabilities.webgpuBooleanA WebGPU adapter answered. Needs include: ["device.capabilities"].
device.capabilities.webrtcBooleanThe WebRTC stack answered. Needs include: ["device.capabilities"].
device.capabilities.webauthnBooleanWebAuthn is available. Needs include: ["device.capabilities"].
device.capabilities.platform_authenticatorBooleanA built-in authenticator such as Touch ID or Windows Hello is available. Needs include: ["device.capabilities"].
device.capabilities.conditional_mediationBooleanPasskey autofill is supported. Needs include: ["device.capabilities"].
device.capabilities.webauthn_detailsObjectThe client-capabilities dictionary as the platform returned it. Needs include: ["device.capabilities"].
device.capabilities.battery_apiBooleanThe Battery Status API is present. Needs include: ["device.capabilities"].
device.capabilities.media_devices_apiBooleannavigator.mediaDevices is present. Needs include: ["device.capabilities"].
device.capabilities.speech_recognition_apiBooleanSpeech recognition is present. Needs include: ["device.capabilities"].
device.capabilities.file_system_access_apiBooleanThe File System Access API is present. Needs include: ["device.capabilities"].
device.capabilities.standaloneBooleanThe page is running as an installed app. Needs include: ["device.capabilities"].
device.capabilities.touch_eventsBooleanTouch events are constructible. Needs include: ["device.capabilities"].
device.capabilities.cssObjectCSS feature and media-query name to whether it matched. Needs include: ["device.capabilities"].
device.platform_identity.oscpuStringFirefox only. Needs include: ["device.platform_identity"].
device.platform_identity.cpu_classStringLegacy, absent everywhere current. Needs include: ["device.platform_identity"].
device.platform_identity.build_idStringFirefox only. Needs include: ["device.platform_identity"].
device.platform_identity.product_subString20030107 on every Chromium and WebKit build. Needs include: ["device.platform_identity"].
device.platform_identity.vendor_subStringUsually empty. Needs include: ["device.platform_identity"].
device.platform_identity.ua_platformStringPlatform from the user-agent client hints. Needs include: ["device.platform_identity"].
device.frame.depthIntegerHow deeply the script was embedded. Needs include: ["device.frame"].
device.frame.top_accessibleBooleanThe top window was same-origin. Needs include: ["device.frame"].
device.frame.opener_presentBooleanThe page was opened by another window. Needs include: ["device.frame"].
device.frame.ancestor_originsString[]Origins the page was embedded under. Parent URLs and referrers are never published. Needs include: ["device.frame"].
network

The connecting network, classified by autonomous system. The category is what most integrations act on; the address and the operator behind it need include.

FieldTypeDescription
network.connection_typeStringresidential, datacenter, mobile, vpn, tor, or unknown. The single answer, resolved from the four booleans below.
network.vpnBooleanThe request arrived over a commercial VPN provider.
network.torBooleanThe request arrived over a Tor exit node.
network.datacenterBooleanThe request arrived from hosting infrastructure rather than a consumer network.
network.mobileBooleanThe request arrived over a mobile carrier. Carrier NAT means many subscribers share one address, so do not rate-limit on the address alone.
network.countryStringTwo-letter country of the connecting address.
network.regionStringSubdivision of the connecting address.
network.cityStringCity of the connecting address.
network.tls_versionStringTLS version the connection negotiated.
network.http_protocolStringHTTP version the request used.
network.measuredObjectWhich fields hold a real reading. Same meaning as device.measured.
network.ipStringThe connecting address. Needs include: ["network"].
network.ip_versionInteger4 or 6. Needs include: ["network"].
network.asnIntegerAutonomous system number of the connecting address. Needs include: ["network"].
network.asn_organizationStringOperator that announces the prefix. Needs include: ["network"].
network.region_codeStringSubdivision code. Needs include: ["network"].
network.postal_codeStringPostal code of the connecting address. Needs include: ["network"].
network.continentStringTwo-letter continent code. Needs include: ["network"].
network.timezoneStringIANA zone of the connecting address, which is the one to compare against device.timezone. Needs include: ["network"].
network.latitudeNumberCoarse: it locates the network, not the person. Absent rather than 0 when the edge had nothing. Needs include: ["network"].
network.longitudeNumberAs latitude. Needs include: ["network"].
network.tls_cipherStringCipher suite the connection negotiated. Needs include: ["network"].
network.edge_locationStringEdge location that served the analysis request. Needs include: ["network"].
Asking for more detail

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:

POST /verify
{
  "token": "<scan token>",
  "include": [".media", ""]
}
FieldAdds
allEverything below. include: true is the same thing.
deviceEvery device.* group, plus os_version, browser_version, engine, and online.
device.screenavailable_resolution, color_depth, orientation.
device.hardwaregpu_family, webgpu_renderer, max_texture_size, webgl_limits, model, cpu_abi.
device.viewportWindow dimensions, inner and outer.
device.localeTimezone offsets read from the clock, and the language list.
device.connectioneffective_type, rtt_ms, downlink_mbps.
device.audiosample_rate, state.
device.fontsThe detected family names, not just the count.
device.mediaCodec support, capture-device counts, and the DRM key systems and robustness levels the CDM granted.
device.storageQuota, usage, OPFS, and Storage Buckets.
device.pluginsThe plugin and MIME inventory.
device.capabilitiesWebGL, WebGPU, WebRTC, WebAuthn, the platform APIs, and a CSS feature map.
device.platform_identityoscpu, build_id, product_sub, and friends.
device.frameEmbedding depth and ancestor origins.
networkip, ip_version, asn, asn_organization, region_code, postal_code, continent, timezone, latitude, longitude, tls_cipher, edge_location.
200 OK
{
  "": {
    "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.

integrity

Each boolean is one finding, so a patched JavaScript engine and implausible hardware report separately.

FieldTypeDescription
integrity.tamperedBooleanThe browser shows the interception pattern an anti-detect build leaves behind. Which probes fired is not published.
integrity.automationBooleanA driver, a headless build, a debugging protocol session, or a layer that hides one.
integrity.anti_detect_browserBooleanThe session runs in an anti-detect browser engine.
integrity.privacy_toolingBooleanA privacy browser or an anti-fingerprinting extension is masking the device. Not necessarily an adversary.
integrity.virtual_machineBooleanA virtual machine or an emulator.
integrity.runtime_patchedBooleanBuilt-in APIs were replaced, or the engine does not behave like the browser it claims to be.
integrity.platform_mismatchBooleanThe declared operating system or hardware conflicts with what was measured.
integrity.rendering_anomalyBooleanGraphics output is inconsistent with the declared hardware.
integrity.spoofingObjectWhat 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.
behavior

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.

FieldTypeDescription
behavior.mouse.verdictStringautomated, human, or inconclusive. The single field to read if you only read one.
behavior.mouse.human_scoreInteger0 = certainly automated, 100 = certainly human. Null until the model has scored.
behavior.mouse.automatedBooleanCursor motion matches an automated humanizer rather than a person.
behavior.mouse.sufficient_dataBooleanFalse means there was not enough movement to judge, never that it was clean.
behavior.mouse.samplesIntegerPointer samples the session has streamed so far.
behavior.mouse.batchesIntegerHow many pointer flushes have been folded in.
behavior.mouse.coverageStringnone (no stream), idle (a stream with no movement), partial, or rich.

inconclusive is no evidence of a person. Gate on verdict or sufficient_data.

velocity

How often this project has seen this device. Counts are taken when the token is issued, not at verify time.

FieldTypeDescription
velocity.device.last_5mIntegerSightings of this device by this project in the last five minutes.
velocity.device.last_1hIntegerSightings in the last hour.
velocity.device.last_24hIntegerSightings in the last 24 hours.
velocity.device.last_7dIntegerSightings in the last seven days. Exact to the hour.
velocity.device.last_30dIntegerSightings in the last 30 days. Exact to the day.
velocity.device.totalIntegerLifetime sightings of this device by this project.
velocity.device.first_seenIntegerUNIX timestamp of the first sighting.
velocity.device.last_seenIntegerUNIX timestamp of the most recent sighting.
velocity.peak_requests_1mIntegerHighest request count seen in the last minute for any velocity key on this session.
flags

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 codes

error carries one of these when verification could not complete.

CodeMeaning
TOKEN_MISSINGNo token was supplied to the verify call.
API_FAILThe edge call failed: network, non-200, or timeout.
MALFORMED_RESPONSEThe edge response parsed but did not match the expected schema.

These older codes are retired, so a gate matching on them never fires.

CodeMeaning
TOKEN_EXPIREDAn expired token now comes back as a BLOCK verdict carrying the same reason code.
TOKEN_REUSEDAn over-used token now comes back as a BLOCK verdict carrying the TOKEN_USES_EXCEEDED reason code.
CRYPTO_FAILNo longer produced. Nothing is decrypted outside the platform.
Backend API

@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.

Install
npm install @trustsig/server
Usage
verify.ts
import { 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');
Methods
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.

Reading the telemetry

The gate is one line. The rest of the response describes the visitor behind the verdict. An unmeasured field is absent, not zero.

login.ts
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.

log output
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_RANDOMIZATION

flagsis the verdict’s flat boolean summary. These helpers read it and answer false instead of throwing when a block is missing.

FieldMeaning
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.
Identity and device

isStableDeviceId decides whether identity.device_id is stable, checking degraded and confidence together against a floor that defaults to 50.

rate-limit.ts
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.

Reason codes

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.

explain.ts
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.

ExportDescription
REASON_CODESThe bundled catalogue, one { code, group, description } per entry.
REASON_CODE_CATALOG_VERSIONVersion 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.
ReasonCodeCatalogThe 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.

catalog.ts
import { ReasonCodeCatalog } from '@trustsig/server';

const catalog = new ReasonCodeCatalog();
await catalog.refresh();

catalog.describe('ANTI_DETECT_BROWSER');
catalog.group('ANTI_DETECT_BROWSER');
catalog.all();
Options
OptionTypeDefaultDescription
secretKeystringrequiredYour Secret Key. The constructor throws SECRET_KEY_REQUIRED without it.
envTrustSigEnvPRODPROD, STAGING, or DEV. Selects the default endpoint host.
endpointstringenv-derivedOverrides the verification endpoint. A trailing slash is stripped.
timeoutMsnumber5000Network timeout for verifyRemote. An abort fails closed.
includeTrustSigIncludenoneDefault include for every call. The per-call argument wins.
trustsig.ts
const ts = new TrustSig({
  secretKey: process.env.TRUSTSIG_SECRET_KEY,
  timeoutMs: 5000,
  // The default include for every call. A per-call argument wins.
  include: [''],
});
Replay protection

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.

Pointer behaviour

getBehavior(handle) returns the pointer result a verify on submit cannot. See pointer behaviour.

Legacy contract accounts

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.

Backend API

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.

traffic.ts
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);
Methods
MethodScopeDescription
listRequests(query?)readRecent checked requests with their verdict, reason codes, and device.
getRequest(id)readOne request by request_id.
listDevices(query?)readDevices seen, with first and last seen and sighting counts.
getDevice(id)readOne device.
getUsage(window?)analyticsTotals for a window.
getTimeline(query?)analyticsBucketed volume, bots, and blocks.
getComposition(window?)analyticsBreakdown by action, device class, country, and reason code.
getReasonCodes()noneThe 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.

Webhooks

Register an endpoint in the console and TrustSig posts to it. Deliveries carry X-TrustSig-Signature: t=<unix>,v1=<hex>.

EventMeaning
verdict.blockA request was blocked.
device.first_seenA device was seen for the first time.
hooks/trustsig.ts
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.