npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@trustsig/server

v2.14.0

Published

Node.js and Edge SDK for TrustSig — token verification and local decryption

Readme

@trustsig/server

Verify TrustSig tokens on your server and decide what to do with the request.

Your frontend gets a token from @trustsig/react or @trustsig/client and sends it with the request. This package sends the token to TrustSig and returns a verdict: ALLOW, CHALLENGE, or BLOCK.

npm install @trustsig/server

Runs on Node.js 18 or newer, Bun, Deno, Cloudflare Workers, and Vercel Edge. It needs only the global fetch and uses no Node built-ins. ESM and CommonJS builds ship with TypeScript types.

Quickstart

  1. Create a project in the TrustSig console at trustsig.eu and copy its secret key.
  2. Put it in an environment variable named TRUSTSIG_SECRET_KEY. It must never reach a browser or source control.
  3. Verify the token in the route you want to protect.

This is a complete Next.js App Router route handler, app/api/signup/route.ts:

import { TrustSig } from '@trustsig/server';

const trustsig = new TrustSig({ secretKey: process.env.TRUSTSIG_SECRET_KEY! });

export async function POST(request: Request) {
  const verdict = await trustsig.verifyRemote(request.headers.get('X-TrustSig-Response'));

  if (verdict.action !== 'ALLOW') {
    return Response.json({ error: 'Request blocked' }, { status: 403 });
  }

  return Response.json(await createAccount(await request.json()));
}

Any runtime that hands you a standard Request works the same way. Create the TrustSig instance once and reuse it.

Express

import express from 'express';
import { TrustSig } from '@trustsig/server';

const app = express();
const trustsig = new TrustSig({ secretKey: process.env.TRUSTSIG_SECRET_KEY });

app.post('/api/signup', express.json(), async (req, res) => {
  const verdict = await trustsig.verifyRemote(req.get('X-TrustSig-Response'));

  if (verdict.action !== 'ALLOW') {
    return res.status(403).json({ error: 'Request blocked' });
  }

  res.json(await createAccount(req.body));
});

To protect several routes, move the check into middleware:

async function requireTrustSig(req, res, next) {
  const verdict = await trustsig.verifyRemote(req.get('X-TrustSig-Response'));
  if (verdict.action !== 'ALLOW') {
    return res.status(403).json({ error: 'Request blocked' });
  }
  res.locals.trustsig = verdict;
  next();
}

app.post('/api/login', express.json(), requireTrustSig, login);
app.post('/api/contact', express.json(), requireTrustSig, sendMessage);

Cloudflare Workers

import { TrustSig } from '@trustsig/server';

interface Env {
  TRUSTSIG_SECRET_KEY: string;
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const trustsig = new TrustSig({ secretKey: env.TRUSTSIG_SECRET_KEY });
    const verdict = await trustsig.verifyRemote(request.headers.get('X-TrustSig-Response'));

    if (verdict.action !== 'ALLOW') {
      return new Response('Forbidden', { status: 403 });
    }

    return fetch(request);
  },
};

Store the key with npx wrangler secret put TRUSTSIG_SECRET_KEY.

Plain HTML forms

A page that loads the TrustSig script with no JavaScript of its own (see @trustsig/client) sends the token as a form field named trustsig-response, not as a header:

app.post('/login', express.urlencoded({ extended: false }), async (req, res) => {
  const field = req.body['trustsig-response'];
  const token = field?.startsWith('ERROR:') ? null : field;

  const verdict = await trustsig.verifyRemote(token);
  if (verdict.action !== 'ALLOW') {
    return res.status(403).send('Request blocked');
  }

  res.redirect('/dashboard');
});

A value starting with ERROR: is not a token. It means the form was submitted before the script finished its analysis, so treat it as missing.

Acting on the verdict

verifyRemote never throws. It resolves to a verdict whose action is one of three values:

| action | Meaning | Typical response | | --- | --- | --- | | ALLOW | The request passed. | Continue. | | CHALLENGE | Suspicious, not conclusive. | Step up with a captcha, an email code, or MFA. With no step-up available, deny. | | BLOCK | Automated, or the token could not be verified. | Deny. |

When verification itself fails, the verdict is BLOCK and error says why:

| error | Cause | | --- | --- | | null | Verification ran, and action is TrustSig's answer. | | TOKEN_MISSING | The token was null, undefined, or ''. | | API_FAIL | Network error, timeout (timeoutMs, 5 seconds by default), or any non-2xx response. | | MALFORMED_RESPONSE | The response body could not be read as a verdict. |

So verdict.action !== 'ALLOW' fails closed: a missing token or an outage denies the request instead of letting it through. verdict.blocked is the same check as a boolean.

Two mistakes to avoid:

  • Gating on verdict.action === 'BLOCK'. It lets every CHALLENGE through.
  • Letting requests through on error === 'API_FAIL' to ride out an outage. API_FAIL covers every non-2xx response, not only outages, so a caller sending a garbage token can land there too.

Recipes

Every recipe below reads the verdict returned by verifyRemote. The full shape is in the verdict object.

Log verdicts

import { verdictSummary } from '@trustsig/server';

console.info(verdictSummary(verdict));

That writes one greppable line, leaving out anything absent:

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

Log verdict.request_id next to your own request id. It is what TrustSig support asks for.

Show why a request was blocked

import { explainVerdict } from '@trustsig/server';

for (const reason of explainVerdict(verdict)) {
  console.log(reason.code, reason.group, reason.description);
}
AUTOMATION_HEADLESS automation Browser is running without a real display environment.

Descriptions come from a catalogue bundled with the package, so this makes no network call. A few codes are positive findings rather than evidence against the device (EXECUTION_VERIFIED, DEVICE_BIOMETRIC_ENROLLED). explainVerdict marks those adverse: false. Leave them out of anything that counts fired codes.

Deny specific findings

action is TrustSig's overall decision. To be stricter on a given route, deny on individual findings from flags:

import { hasFlag } from '@trustsig/server';

if (verdict.action !== 'ALLOW') return deny();
if (hasFlag(verdict, 'automation') || hasFlag(verdict, 'anti_detect_browser')) return deny();

Some flags describe the visitor rather than accuse them: vpn, privacy_tooling, incognito, mobile_network, and new_device fire for real people every day. Use them to weigh other evidence, not to deny on their own. The full list is in flags.

Set your own risk threshold

risk.score is the continuous 0 to 100 grade behind the decision, higher meaning riskier. Use it to draw a stricter line on a sensitive route:

if (verdict.action !== 'ALLOW') return deny();
if ((verdict.risk?.score ?? 0) >= 60) return requireEmailCode();

risk.level bands the same score into low, medium, high, and critical.

Rate-limit by device

identity.device_id identifies the device. It is derived from the hardware rather than stored in the browser, so clearing cookies does not reset it, and it is scoped to your project, so the same machine has a different id on another site.

import { deviceSightings, isStableDeviceId } from '@trustsig/server';

if ((deviceSightings(verdict, 'last_1h') ?? 0) > 50) return tooManyRequests();

if (isStableDeviceId(verdict)) {
  const signups = await limiter.increment(`signup:${verdict.identity!.device_id}`);
  if (signups > 3) return tooManyRequests();
}

deviceSightings counts how often TrustSig saw this device on your project in a window: last_5m, last_1h, last_24h, last_7d, last_30d, or total.

isStableDeviceId returns false when the id names a group of identical devices instead of one machine. iPhones on Safari are the common case: devices of the same model report the same fingerprint, so banning that id bans all of them. Do not use the device id as an authentication factor. It identifies a device, not a person.

Check mouse movement at submit time

The token is created when the page loads, so mouse movement after that is not in the verdict. A form submitted soon after load often reports behavior.mouse.verdict: 'inconclusive'. To judge the movement leading up to the submit, the browser flushes it and gets a handle, and your server exchanges the handle.

In the browser, with @trustsig/client or the React hook:

const behaviorToken = await trustsig.flushMouse({ behavior: true });

await fetch('/api/checkout', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json', 'X-TrustSig-Response': token },
  body: JSON.stringify({ ...order, behaviorToken }),
});

On the server:

const behavior = await trustsig.getBehavior(body.behaviorToken);

if (behavior.error) {
  console.warn('behavior unavailable', behavior.error);
} else if (behavior.behavior.state === 'rich' && (behavior.behavior.human_score ?? 100) < 30) {
  return requireEmailCode();
}
  • state is how much movement there was: none, idle, partial, or rich. Only rich is enough to judge.
  • human_score runs from 0 (automated) to 100 (human) and is null until there is enough evidence.
  • Do not penalise none or idle. People who use a keyboard or a touchscreen produce no pointer movement.
  • On failure, error is set and the rest looks like a session with no movement. Check error first.
  • Handles are valid for 30 minutes. Each exchange is billed as a verification.

Get more device and network detail

By default the verdict carries a compact device and network block. Ask for more with include:

const verdict = await trustsig.verifyRemote(token, { include: ['network'] });

console.log(verdict.network?.ip, verdict.network?.asn, verdict.network?.asn_organization);

include: true asks for everything. The groups are listed under include. The response grows with every group, so ask only for what you read. To set a default for every call, pass include to the constructor.

Receive webhooks

Register an endpoint in the console, and TrustSig posts to it when a request is blocked (verdict.block) or a device is seen for the first time (device.first_seen). Verify the signature against the raw body before parsing it:

import { verifyWebhookSignature } from '@trustsig/server';

app.post('/hooks/trustsig', express.raw({ type: '*/*' }), (req, res) => {
  const raw = req.body.toString('utf8');
  const valid = verifyWebhookSignature(
    process.env.TRUSTSIG_WEBHOOK_SECRET,
    req.get('X-TrustSig-Signature'),
    raw,
  );
  if (!valid) return res.status(400).end();

  const event = JSON.parse(raw);
  res.status(200).end();
});

Parsing and re-serialising the body changes its bytes, and the signature will not match. A delivery whose timestamp is more than 5 minutes off is rejected to limit replay; change that with { toleranceSeconds } as the fourth argument, or 0 to turn the check off.

Query past requests

TrustSigRead reads the requests and devices TrustSig has recorded for your project. It uses a project API key (tsk_...) from the console, not the secret key.

import { TrustSigRead } from '@trustsig/server';

const read = new TrustSigRead({ apiKey: process.env.TRUSTSIG_API_KEY! });

const { requests } = await read.listRequests({ action: 'BLOCK', limit: 20 });
const device = await read.getDevice(requests[0].device_id);
console.log(device.sightings, device.first_seen);

Methods are listed under TrustSigRead. Reads never touch the verification path, so an outage of the analytics store cannot affect whether your routes are protected.

Troubleshooting

Every verdict is TOKEN_MISSING

The token is not reaching your server. Check the browser's network tab for the X-TrustSig-Response header. If the frontend is on another origin, your CORS configuration must list X-TrustSig-Response in Access-Control-Allow-Headers, or the browser drops it.

Every verdict is API_FAIL

Your server cannot reach TrustSig, or TrustSig rejected the call. Check that TRUSTSIG_SECRET_KEY is set and correct, that outbound HTTPS to edge.trustsig.eu is allowed, and that env is the same on the client and the server.

verifyLocal throws

It was removed. Tokens are sealed to a key TrustSig holds, so there is nothing to verify offline. Use verifyRemote.

The verdict has no risk, identity, or flags

The account is on the legacy contract and receives only action, is_bot, score, issued_at, and request_id. See contracts.

Reference

new TrustSig(options)

Throws SECRET_KEY_REQUIRED without secretKey.

| Option | Type | Default | Description | | --- | --- | --- | --- | | secretKey | string | required | Your project's secret key. Server-side only. | | env | TrustSigEnv | PROD | PROD, STAGING, or DEV. Selects edge, staging-edge, or dev-edge.trustsig.eu. | | endpoint | string | from env | Overrides the API base URL. | | timeoutMs | number | 5000 | Network timeout. A timeout returns BLOCK with API_FAIL. | | include | TrustSigInclude | none | Default include for every verifyRemote call. |

maxTokenAgeSeconds, clockSkewSeconds, replayProtection, and maxUsesPerToken are accepted and ignored so older configuration still compiles. Replay is capped by TrustSig across every process and instance, with nothing to configure.

Methods

| Method | Returns | Description | | --- | --- | --- | | verifyRemote(token, options?) | Promise<BotAnalysisResponse> | Verifies a token. Never throws. options.include overrides the constructor default. | | getBehavior(handle) | Promise<BehaviorResponse> | Exchanges a flushMouse({ behavior: true }) handle for the session's pointer result. Never throws. | | verifyLocal() | never | Removed. Always throws. |

The verdict object

A real response, as verifyRemote returns it with no include (device.user_agent shortened):

{
  "action": "ALLOW",
  "is_bot": false,
  "score": 0,
  "issued_at": 1785942067,
  "request_id": "d1e23499e6c16c6",
  "schema_version": 5,
  "error": null,
  "blocked": false,
  "risk": {
    "score": 17,
    "level": "low",
    "reason_codes": ["TAMPERED_ENVIRONMENT", "ANTI_DETECT_BROWSER", "PRIVACY_CANVAS_RANDOMIZATION"],
    "revision": 0,
    "revised": false
  },
  "identity": {
    "device_id": "af6aaf8b92b2233a",
    "confidence": 100,
    "degraded": false,
    "first_seen": 1785855667,
    "last_seen": 1785942067,
    "sightings": 12,
    "returning": true
  },
  "device": {
    "class": "desktop",
    "browser_family": "chrome",
    "os_family": "macintosh",
    "platform": "MacIntel",
    "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ...",
    "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)"
    }
  },
  "network": {
    "country": "EE",
    "connection_type": "residential",
    "vpn": false,
    "tor": false,
    "datacenter": false,
    "mobile": false,
    "tls_version": "TLSv1.3",
    "http_protocol": "HTTP/2"
  },
  "integrity": {
    "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 }
  },
  "behavior": {
    "mouse": {
      "verdict": "human",
      "analyzed": true,
      "human_score": 91,
      "automated": false,
      "sufficient_data": true,
      "samples": 1420,
      "batches": 6,
      "coverage": "rich"
    }
  },
  "velocity": {
    "device": {
      "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
  },
  "flags": {
    "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
  },
  "factors": ["TAMPERED_ENVIRONMENT", "ANTI_DETECT_BROWSER", "PRIVACY_CANVAS_RANDOMIZATION"],
  "evidence": {},
  "site_key": ""
}

This session ran an anti-detect browser and TrustSig still allowed it at low risk. Whether that is acceptable is your policy, which is what deny specific findings is for.

Rules that apply to every block:

  • An unmeasured field holds its type's zero (0, '', []) instead of being left out. device, network, identity, velocity, and integrity.spoofing carry a measured map, keyed by dotted path, that says which values are real readings. network.measured.city === false means no city was reported, not that the visitor has none.
  • risk, behavior, and flags carry no measured map. Every flag is always present, and false means "not observed".
  • schema_version changes when a block is renamed, moved, or changes meaning. New fields do not change it.

Top-level fields

| Field | Type | Description | | --- | --- | --- | | action | 'ALLOW' \| 'CHALLENGE' \| 'BLOCK' | The decision. Every failure becomes BLOCK. | | is_bot | boolean | Whether TrustSig classed the session as automated. | | score | number | 0 to 100 enforcement score, higher is riskier. Coarse-grained; use risk.score for thresholds. | | issued_at | number | Unix seconds when the token was created, not when you verified it. | | request_id | string | Id for this verification. Quote it in support requests. | | error | TrustSigErrorCode \| null | Added by the SDK. Why verification failed, or null. | | blocked | boolean | Added by the SDK. action !== 'ALLOW'. | | fail_open | boolean | true when TrustSig forced an ALLOW without reaching a verdict. | | factors | string[] | Deprecated copy of risk.reason_codes. | | evidence, site_key | | Reserved. Always {} and ''. |

action, is_bot, score, and issued_at keep their names, types, and meaning on every account and every contract version. Code that reads only those never has to change.

risk

| Field | Type | Description | | --- | --- | --- | | score | number | Continuous 0 to 100 grade for your own thresholds. Never lower than the top-level score. | | level | 'low' \| 'medium' \| 'high' \| 'critical' | Band derived from risk.score. | | reason_codes | TrustSigReasonCode[] | Why this verdict was reached. See reason codes. | | revision | number | How many times evidence that arrived after the token updated this session's assessment. | | revised | boolean | true when a revision changed the decision. |

identity

| Field | Type | Description | | --- | --- | --- | | device_id | string | 16 hex characters, scoped to your project. | | confidence | number | 0 to 100. How distinguishable the device is. | | degraded | boolean | The fingerprint is shared by a group of identical devices, such as iPhones of one model on Safari. | | linked_devices | number | Other device ids TrustSig has tied to this machine. | | first_seen, last_seen | number | Unix seconds. | | sightings | number | Times this project has seen the device. | | returning | boolean | sightings > 1. |

The id is anchored in TrustSig's identity graph, so it survives small fingerprint changes such as a browser update. isStableDeviceId(verdict) combines degraded and confidence into one check.

device

What the browser reported about itself. These are claims, not ground truth. PLATFORM_MISMATCH and DEVICE_COHERENCE_ANOMALY fire when the claims contradict each other.

Always present: class (mobile, tablet, desktop, unknown), browser_family, os_family, platform, user_agent, languages, timezone, font_count, incognito, screen.resolution, screen.pixel_ratio, screen.touch_points, hardware.cpu_cores, hardware.memory_gb, hardware.gpu_vendor, hardware.gpu_renderer. Everything else needs include.

network

| Field | Description | | --- | --- | | country, region, city | Location of the connecting IP address. | | connection_type | residential, datacenter, mobile, vpn, tor, or unknown. | | vpn | Commercial VPN range. | | tor | Known Tor exit node. | | datacenter | Hosting or cloud address space. | | mobile | Mobile carrier network. | | tls_version, http_protocol | Transport of the analysis request. |

The four booleans come from the network's autonomous system number (ASN). They recognise hosting, carrier, and known exit ranges, and do not catch residential proxies. None of them is a verdict: a VPN on a corporate laptop is ordinary, and on a mobile network thousands of unrelated people share one IP address, so weigh IP-based rules down there.

integrity

One boolean per finding about the browser environment.

| Field | Description | | --- | --- | | tampered | The combined anti-detect and consistency finding. | | automation | WebDriver, a remote debugging protocol, a headless browser, or stealth patches. | | anti_detect_browser | An anti-detect browser was identified. | | privacy_tooling | Canvas or WebGL output is randomised or disabled. Usually a privacy browser or extension. | | virtual_machine | A virtual machine, emulator, or software renderer. | | runtime_patched | Built-in browser APIs were replaced, or the JavaScript engine disagrees with the user agent. | | platform_mismatch | The declared OS or hardware conflicts with what was measured. | | rendering_anomaly | Graphics output does not match the declared hardware. | | spoofing | { detected, confidence, method, groups, linked_device_id, enforced }. The device matches one seen before while presenting a different fingerprint. |

spoofing.enforced is false while that detection is in observe mode, and an observe-mode finding never reaches risk.reason_codes.

behavior

behavior.mouse is the pointer-movement model's read of the session.

| Field | Description | | --- | --- | | verdict | automated, human, or inconclusive. Read this one if you read nothing else. | | human_score | 0 (automated) to 100 (human). null until scored. | | automated | Movement matches an automated tool imitating a person. | | sufficient_data | false means there was not enough movement to judge. It never means "clean". | | analyzed | The model scored at least one batch. | | samples, batches | How much pointer data arrived. | | coverage | none, idle, partial, or rich. |

inconclusive is its own outcome, not a weak human. The model runs after the token is created, so a verify right after page load often reports analyzed: false. For a verdict on the movement before a submit, use getBehavior.

velocity

| Field | Description | | --- | --- | | device.last_5m, device.last_1h, device.last_24h, device.last_7d, device.last_30d | Times this project saw the device in each window. | | device.total | Lifetime sightings by this project. | | device.first_seen, device.last_seen | Unix seconds. | | peak_requests_1m | The highest per-minute request count among the inputs to this decision. |

Counts are recorded when the token is created. The 7-day figure is exact to the hour and the 30-day figure to the day.

flags

Flat booleans over the whole verdict, always fully populated: 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.

bot mirrors is_bot. The rest are derived from risk.reason_codes, so a new detection that maps onto an existing code shows up here without an SDK update.

include

Pass any of these names in include, or include: true for all of them. Unknown names are ignored.

| Name | Adds | | --- | --- | | 'all' | Everything below. | | 'device' | Every device.* group below, plus os_version, browser_version, engine, online. | | 'device.screen' | available_resolution, color_depth, orientation. | | 'device.hardware' | gpu_family, webgpu_renderer, max_texture_size, webgl_limits, model, cpu_abi. | | 'device.viewport' | Inner and outer window dimensions. | | 'device.locale' | Timezone offsets and the full language list. | | 'device.connection' | effective_type, rtt_ms, downlink_mbps. | | 'device.audio' | sample_rate, state. | | 'device.fonts' | Detected font family names. | | 'device.media' | Codec support, camera and microphone counts, DRM key systems. | | 'device.storage' | Storage quota and usage, OPFS, Storage Buckets. | | 'device.plugins' | Plugin and MIME type lists. | | 'device.capabilities' | WebGL, WebGPU, WebRTC, WebAuthn, and CSS feature support. | | 'device.platform_identity' | oscpu, build_id, product_sub, and related legacy properties. | | 'device.frame' | Iframe depth and ancestor origins. | | 'network' | ip, ip_version, asn, asn_organization, region_code, postal_code, continent, timezone, latitude, longitude, tls_cipher, edge_location. |

Contracts

Accounts created before the response gained named blocks stay on the legacy contract. They receive action, is_bot, score, issued_at, and request_id, and every block is absent. The helpers treat an absent block as "not measured", so hasFlag answers false rather than throwing. GET /client/v1/account/edition reports which contract an account is on, and POST /client/v1/account/upgrade moves it to the current one. The upgrade applies to the whole account and cannot be undone.

Forward compatibility

verifyRemote passes every response field through, so a field TrustSig adds is readable before this package declares it. Undeclared fields type as unknown. The response body cannot overwrite the SDK's own action, score, is_bot, request_id, error, or blocked.

Helpers

Pure, synchronous functions over a verdict. All of them are safe on a legacy-contract or failed verdict.

| Function | Returns | | --- | --- | | verdictSummary(verdict) | One greppable log line. | | explainVerdict(verdict) | { code, group, description, adverse } for each fired reason code. | | hasFlag(verdict, flag) | true when that flag fired. | | firedFlags(verdict) | The names of every fired flag, sorted. | | verdictFlags(verdict) | Every flag resolved to a boolean. {} when the block is absent. | | hasReasonCode(verdict, ...codes) | true when any of the codes fired. | | hasReasonGroup(verdict, group) | true when any fired code belongs to the group. | | reasonCodesByGroup(verdict) | Fired codes grouped by reason-code group. | | mouseVerdict(verdict) | behavior.mouse.verdict, or 'inconclusive' without a behavior block. | | deviceSightings(verdict, window) | One velocity.device count, or undefined. | | isStableDeviceId(verdict, minConfidence = 50) | Whether identity.device_id names one machine. | | deviceSummary(verdict) | A one-line device description such as chrome on macintosh, 1440x900@2x, 8 cores, 8GB, Europe/Tallinn. | | tokenAgeSeconds(verdict, nowMs?) | Seconds since issued_at, or undefined. |

Reason codes

Reason codes explain a verdict, such as AUTOMATION_HEADLESS or NETWORK_DATACENTER. Each belongs to one group: automation, integrity, environment, behavior, challenge, velocity, reputation, network, device, trust, or token. Every group except trust is evidence against the device.

The catalogue is published at GET /api/v1/meta/reason-codes and bundled with this package, so lookups need no network call. A code newer than your installed version is not an error: lookups fall back to the code itself.

| Export | Description | | --- | --- | | REASON_CODES | The bundled catalogue: { code, group, description } per entry. | | REASON_CODE_CATALOG_VERSION | Version of the bundled catalogue. | | reasonCode(code) | The catalogue entry, or undefined. | | reasonCodeGroup(code) | The group, or undefined. | | describeReasonCode(code) | The description, or the code itself when unknown. | | isTrustReasonCode(code) | true for the trust group. | | groupReasonCodes(codes) | Groups an array of codes. Unknown codes go under unknown. | | TRUST_REASON_GROUP | 'trust'. | | ReasonCodeCatalog | The live catalogue, fetched and cached. |

To show descriptions for codes added after your release, use the live catalogue:

import { ReasonCodeCatalog } from '@trustsig/server';

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

catalog.describe('ANTI_DETECT_BROWSER');

refresh() caches for an hour (ttlMs), shares one request between concurrent callers, and returns false instead of throwing when the fetch fails, keeping the previous catalogue. Lookups (get, describe, group, all) are synchronous.

getBehavior response

{
  "schema_version": 1,
  "request_id": "9f2c1a7b4e0d3568",
  "checked_at": 1786000000,
  "behavior": {
    "state": "rich",
    "human_score": 87,
    "score": 0,
    "automated": false,
    "factors": [],
    "samples": 4210,
    "batches": 6,
    "scored_batches": 4,
    "revision": 4,
    "session_ended": false,
    "scored_at": 1785999997,
    "model": "20260810-201952-mv11"
  },
  "device": { "id": "a1b2c3d4e5f60718", "status": "resolved", "telemetry_at": 1785999880 },
  "error": null
}

| Field | Description | | --- | --- | | behavior.state | none, idle, partial, or rich. | | behavior.human_score | 0 (automated) to 100 (human). null unless there was enough movement. | | behavior.score | Extra risk the movement justifies, on the verdict's 0 to 100 scale. Stays 0 on partial evidence. | | behavior.automated, behavior.factors | Whether the movement looked automated, and why. | | behavior.scored_at | When the newest scored batch arrived. Compare it with the action you are gating. | | device.id | The device id, when the browser called flushMouse({ behavior: true, deviceId: true }). | | device.status | not_requested, resolved, pending (no data from the browser yet), or unavailable. | | error | TOKEN_MISSING, API_FAIL, MALFORMED_RESPONSE, or null. |

This is a different shape from behavior.mouse on a verdict, which has verdict, coverage, analyzed, and sufficient_data instead of state, factors, scored_batches, session_ended, scored_at, and model.

verifyWebhookSignature(secret, header, rawBody, options?)

Returns true only for a well-formed, valid signature with a timestamp inside the tolerance window. The header format is X-TrustSig-Signature: t=<unix>,v1=<hex>, an HMAC-SHA256 over "<t>.<body>".

| Option | Default | Description | | --- | --- | --- | | toleranceSeconds | 300 | Maximum age of a delivery. 0 disables the check. | | nowMs | Date.now() | Current time, for tests. |

TrustSigRead

new TrustSigRead({ apiKey, env?, endpoint?, timeoutMs? }). Throws API_KEY_REQUIRED without apiKey. Failed calls throw TrustSigReadError with status (0 for network errors).

| Method | Key scope | Returns | | --- | --- | --- | | listRequests(query?) | read | Recent verifications with verdict, reason codes, and device. Filter by action, device_id, from, to, limit, offset. | | getRequest(requestId) | read | One verification. Throws with status: 404 when unknown or expired. | | listDevices(query?) | read | Devices seen, with first and last sighting and counts. | | getDevice(deviceId) | read | One device. | | getUsage(window?) | analytics | Totals for a window. | | getTimeline(query?) | analytics | Requests, bots, and blocks per minute, hour, or day. | | getComposition(window?) | analytics | Breakdown by action, device class, country, and reason code. | | getReasonCodes() | none | The live reason-code catalogue. |

Windows (from, to) are unix seconds, default to the last 24 hours, and are clamped to your plan's retention. An unreachable analytics store throws a 5xx rather than returning an empty list, so "no traffic" and "could not look" stay distinguishable.

TrustSig Pro

TrustSig Pro is a separate product for account-level fraud: account takeover, duplicate and fake signups, account sharing, and abuse of promotions and payments. It links devices to your user accounts and adds an events API, an identity graph, reputation lists, and custom rules. Everything above this section is unaffected by it, and both can run in one app.

| | Bot protection (TrustSig) | Pro (TrustSigPro) | | --- | --- | --- | | Browser key | Site key | Publishable key (a UUID) | | Server credential | Secret key | Scoped API key (pro_sk_...) | | Decisions | ALLOW, CHALLENGE, BLOCK | ALLOW, REVIEW, BLOCK | | Browser SDK | TrustSigClient, TrustSigProvider | TrustSigProClient, TrustSigProProvider |

Pro exports come from @trustsig/server or the @trustsig/server/pro subpath.

Pro quickstart

Create an API key for your project in the console, give it the verify scope (the recipes below also use events), and store it as TRUSTSIG_PRO_KEY. The browser side uses TrustSigProClient or TrustSigProProvider with the project's publishable key.

import { TrustSigPro } from '@trustsig/server';

const pro = new TrustSigPro({ apiKey: process.env.TRUSTSIG_PRO_KEY! });

export async function POST(request: Request) {
  const { email, password } = await request.json();
  const user = await checkPassword(email, password);
  if (!user) return Response.json({ error: 'Invalid credentials' }, { status: 401 });

  const verdict = await pro.verify(request.headers.get('X-TrustSig-Response'), {
    user_id: user.id,
    traits: { email: user.email },
  });

  if (verdict.decision === 'BLOCK') return Response.json({ error: 'Blocked' }, { status: 403 });
  if (verdict.decision === 'REVIEW') return Response.json({ next: 'mfa' });

  return Response.json(await startSession(user));
}

verify checks the device token and links the device to user_id in one call.

| decision | Meaning | Typical response | | --- | --- | --- | | ALLOW | Passed. | Continue. | | REVIEW | Risky enough to confirm. | Step up with MFA, a captcha, an email code, or a manual review queue. Not a deny. | | BLOCK | Fraud, automation, or verification failed. | Deny. |

verify fails closed like verifyRemote: a missing token, network error, timeout, authentication failure, or exhausted quota returns decision: 'BLOCK' with error set, and verdict.blocked is decision !== 'ALLOW'. The one exception is a 400 or 422 response, which throws TrustSigProError, so a bug in your request is not mistaken for a bot.

Call verify at the moment you decide (login, signup, checkout), not on page load. Evidence keeps arriving after the token is created, and a later call sees it.

Pro recipes

Protect logins from account takeover

The account-takeover engine runs on login events. Verify the token, then report the login with the verify's request_id so the event joins that device:

import { contextFromRequest } from '@trustsig/server';

const token = request.headers.get('X-TrustSig-Response');
const { ip } = contextFromRequest(request, { trustProxy: 1 });
const user = await checkPassword(email, password);

if (!user) {
  await pro.submitEvent({
    kind: 'login',
    user_id: email,
    identity_confidence: 'claimed',
    token: token ?? undefined,
    ip,
  });
  return invalidCredentials();
}

const verdict = await pro.verify(token, { user_id: user.id });
if (verdict.decision === 'BLOCK') return deny();

const login = await pro.submitEvent({
  kind: 'login',
  user_id: user.id,
  request_id: verdict.request_id,
  ip,
});

if (login.decision === 'BLOCK') return deny();
if (verdict.decision === 'REVIEW' || login.decision === 'REVIEW') return requireMfa(user);
return startSession(user);

Failed attempts are sent as claimed because the typed email is not proven. They still count toward device and IP velocity, which is how credential stuffing becomes visible, but they do not link this device to that account. See identity confidence.

After a step-up succeeds, report it with submitEvent({ kind: 'mfa_success', user_id }). Also report password_reset and logout. Those three run no engine of their own; the account-takeover engine reads them on the next login.

Stop duplicate signups

Create the account, then verify with include: ['alt'] to ask whether this person already has one:

const user = await createUser(email);

const verdict = await pro.verify(token, {
  user_id: user.id,
  traits: { email },
  include: ['alt'],
});

if (verdict.decision === 'BLOCK') return rejectSignup(user);

if (verdict.alt?.is_alt) {
  const [match] = verdict.alt.accounts;
  return flagDuplicate(user, match.user_id, match.link, match.confidence);
}

if (verdict.alt?.degraded) {
  console.warn('alt check inconclusive', verdict.alt.degraded);
}

match.link says how the accounts are connected, strongest first:

| link | Meaning | | --- | --- | | same_device | The same device, used by another of your users. | | email_variant | The same mailbox once normalised: [email protected] and [email protected]. | | linked_device | A device TrustSig linked to theirs. Only from getAlts. |

  • alt.degraded means TrustSig could not tell, not that there is no duplicate: no_user_id, device_unresolved (asked too early), low_entropy_device (a model-level fingerprint such as an iPhone, where same-device matches are not evidence), timeout (partial result), or identity_claimed (you sent identity_confidence: 'claimed', so the check does not answer).
  • alt.linked_checked: false means only the fast checks ran. pro.getAlts(user.id) runs the full identity-graph search and returns the same shape.
  • To also run the signup-abuse engine and the signup model, send submitEvent({ kind: 'signup', user_id: user.id, request_id: verdict.request_id, traits: { email, name, phone } }). The model score comes back as ml.signup (p from 0 to 1, tier of hard, review or clear); fraud_policy.signup_ml sets its thresholds.

Limit giveaways and free trials per device

Ask the device how many of your accounts use it:

const deviceId = verdict.canonical_entity_id ?? verdict.entity_id;
if (deviceId) {
  const device = await pro.getEntityAccounts(deviceId);
  if (device.accounts_24h > 2) return rejectClaim();
}

count is the total, accounts_24h and accounts_7d count recent activity, and truncated is true past 500 accounts. Store canonical_entity_id, not entity_id: entity_id is the raw per-request fingerprint hash and changes as the browser updates.

The events API returns a similar count on every call as velocity.device_accounts_24h. To find the worst devices across your project, pro.searchEntities({ min_accounts: 3, limit: 50 }) lists devices with at least that many accounts, most first. Both count business events, so they stay empty for an integration that only calls verify.

Guard payments and other high-value actions

import { sessionHandleFrom } from '@trustsig/server';

const event = await pro.submitEvent({
  kind: 'action',
  action: 'withdrawal',
  user_id: user.id,
  session_handle: sessionHandleFrom(request),
  metadata: { amount, currency: 'EUR' },
});

if (event.decision === 'BLOCK') return deny();
if (event.decision === 'REVIEW') return holdForReview();

metadata is any JSON object up to 8 KB. Top-level values reach the engines, and metadata.amount (a number or a numeric string) drives payment scoring. It is echoed in the verdict, included in fraud webhooks, and stored with the event as metadata_json. To filter on a value later, put it in tags instead. Join the device with request_id from a verify, a token, or a session handle.

| kind | Engines that run | | --- | --- | | login | account takeover, sharing | | signup | signup abuse, signup model | | action, custom | action, sharing | | payment_success | action | | promo_redemption | promo abuse, promo model | | mfa_success, password_reset, logout | none, recorded for the next login |

Any kind carrying a promo block also runs the promo engines. occurred_at_ms accepts times from 90 days back to 7 days ahead, for backfills. Every event is billed as one check. submitEvent requires kind and user_id; without them it returns BLOCK with error: 'VALIDATION' and sends nothing.

Stop promo code and referral abuse

Report the redemption with the promo it applies. The engine remembers every redemption per device, per normalised email, and per IP, and checks whether another account got there first:

const event = await pro.submitEvent({
  kind: 'promo_redemption',
  user_id: user.id,
  request_id: verdict.request_id,
  ip,
  traits: { email: user.email },
  promo: { code: 'SUMMER10', campaign: 'summer-2026', referrer_user_id: referrer?.id },
});

if (event.decision === 'BLOCK') return rejectPromo();
if (event.decision === 'REVIEW') return holdReward();

campaign groups different codes from one offer into one promo; without it, the code is the promo. Matching ignores case and surrounding spaces. A signup that applies a code can send kind: 'signup' with the same promo block instead, and both engines score it in one call.

With the default policy, PROMO_DEVICE_REUSE (another account redeemed it on this device), PROMO_EMAIL_REUSE (same mailbox once Gmail dots and + aliases are removed), and PROMO_SELF_REFERRAL block on their own. PROMO_DEVICE_PRIOR_REDEMPTION and PROMO_LINKED_DEVICE_REUSE send to review. PROMO_IP_REUSE (two or more other accounts from this IP in 24 hours) is not enough to act on alone.

  • Device checks need a device join (request_id, token, or session_handle), the email check needs an email on the account, and the IP check needs ip.
  • Redemptions count for 365 days whatever their decision, so a blocked attempt still counts against the next account on that device.
  • On devices whose fingerprint names a phone or GPU model rather than one machine, a prior redemption counts only from the same network; otherwise it is reported as suppressed under LOW_ENTROPY_DEVICE_COHORT.
  • A claimed identity is scored but its redemption is not remembered.

event.promo reports the counts behind the decision (device_accounts, email_accounts, linked_device_accounts, device_other_promo_accounts, ip_accounts_24h, self_referral) and whether this redemption was recorded. event.ml.promo carries the promo model's score.

Stop stolen tokens from being replayed

A token is a bearer credential: copied out of a browser, it verifies from a script too. Describe the connection your server saw, and TrustSig compares it with the browser that created the token:

import { contextFromRequest } from '@trustsig/server';

const verdict = await pro.verify(token, {
  user_id: user.id,
  context: contextFromRequest(request, { trustProxy: 1 }),
});

if (verdict.blocked) return deny();
console.info('binding', verdict.binding?.overall);

trustProxy has no default, because a wrong guess blocks real users:

| trustProxy | Client IP comes from | | --- | --- | | false | The socket address. No forwarding header is read. | | 1, 2, ... | X-Forwarded-For, counting that many entries from the right. Use the number of proxies you run: 1 behind one load balancer. | | 'cloudflare' | CF-Connecting-IP. | | 'fastly' | Fastly-Client-IP. | | 'akamai' | True-Client-IP. |

Never trust the leftmost X-Forwarded-For entry; the client wrote it. If you already resolved the IP, pass { trustProxy: false, ip }.

binding.overall is match, related, mismatch, or unknown, and an IP mismatch adds BIND_IP_MISMATCH to reason_codes. With the default project policy an IP or user-agent mismatch blocks and a TLS mismatch sends to review. It is safe to turn on:

  • The same network or the same browser major version is related and never blocks.
  • IPv6 addresses are compared on the /64.
  • A client that reached TrustSig over IPv6 and you over IPv4 is unknown, not a mismatch.
  • TLS fingerprints are compared only when your edge provides one of the same kind.
  • Anything you leave out is unknown and has no effect.

Log verdict.binding but do not send it to the browser. Telling an attacker which component mismatched tells them what to fix.

Require a live browser for a sensitive action

Binding shows the caller resembles the browser. An action proof shows the browser is still there, for one specific request: your server issues a challenge, the TrustSig script in the page answers it, and your server checks the answer offline with two HMACs. No call to TrustSig, no quota, no added latency.

import { bindRequest, createChallenge, verifyProofRequest } from '@trustsig/server';

const proofSecret = process.env.TRUSTSIG_PROOF_SECRET!;

app.post('/api/transfer', express.json(), async (req, res) => {
  const bind = bindRequest({ method: 'POST', path: '/api/transfer', userId: req.user.id });

  const proof = verifyProofRequest(req, {
    proofSecret,
    bind,
    expectedOrigin: 'https://app.example.com',
  });

  if (proof.reason === 'missing') {
    return res.status(401).json({ trustsig_challenge: createChallenge(proofSecret, { bind }) });
  }
  if (!proof.ok) return res.status(403).json({ error: proof.reason });

  res.json(await transfer(req.user, req.body));
});

In the browser, fetchWithProof from TrustSigProClient or useTrustSigPro handles the round trip: it sends the request, answers the 401 challenge, and retries once with the proof.

  • The proof secret comes from the console. Keep it on the server, next to the API key. During a rotation, pass the outgoing one as previousSecret.
  • Always pass expectedOrigin. It is what stops a proof obtained on another site from authorising an action on yours.
  • bindRequest must hash the same values when you issue the challenge and when you verify it. Include whatever makes the request distinct: method, path, user, a digest of the body.
  • The challenge carries its own signed expiry (20 seconds by default), so no challenge store is needed and it works on serverless runtimes. That also means a proof can be replayed until it expires. Keep a short-lived set of used nonces if that matters for the action.
  • A browser that cannot answer returns reason: 'unsupported' with clientReason. Decide per route whether that is allowed.

A valid proof means a live TrustSig session in a browser on your origin answered this challenge for this request. It does not prove a real person is present; pair it with verify for that. It turns replaying a stolen token from copying a string into keeping a real browser in the loop for every protected action. It does not stop an attacker who relays requests through a live browser.

Track a user after login without a new scan

A request_id is short-lived. For events after the decision point, such as a settings change or a later purchase, verify returns a session_handle (pss_...) that names the device for 30 days without another scan.

Return the handle to the browser:

const verdict = await pro.verify(token, { user_id: user.id });
return Response.json({ ok: !verdict.blocked, session_handle: verdict.session_handle });

The browser stores it with absorbSessionHandle and sends it back with sessionHeaders() (see @trustsig/client). Read it on later requests:

import { sessionHandleFrom } from '@trustsig/server';

await pro.submitEvent({
  kind: 'action',
  action: 'change_email',
  user_id: user.id,
  session_handle: sessionHandleFrom(request),
});

sessionHandleFrom reads the X-TrustSig-Session header from a Fetch Request, a Headers object, or a Node header map, and returns undefined for anything that is not a handle.

  • A handle attributes events to a device; it does not decide. The device's reputation, graph links, and velocity apply, but the original scan's score is not reused. Call verify when you make a decision.
  • A handle names one scan, not the device. Two scans on one machine give two unrelated handles.
  • Treat it like a session cookie: clear it on sign-out and keep it out of URLs. It expires 30 days after creation, however often it is used.
  • A request_id or token on the same call takes precedence over the handle.

Record product interactions

trackInteractions records clicks, page views, and form steps. No fraud engines run, and the route has its own limit of 600 calls per minute that does not count against the project limit. A batch counts as one call.

await pro.trackInteractions([
  { name: 'signup_step_1', user_id: user.id, request_id: verdict.request_id },
  { name: 'plan_selected', user_id: user.id, properties: { plan: 'pro' } },
]);

properties is any JSON object up to 8 KB. Lists over 100 are split into several calls and the results summed. Interactions are never retried, since a duplicated click is worse than a missing one. An interaction with only a user_id is linked to the device on that user's next verify.

Report confirmed fraud

Feed outcomes you learn later back to TrustSig:

await pro.reportFraud({ user_id: user.id, kind: 'chargeback' });

Pass one of user_id, entity_id, event_id, or request_id, and a kind of chargeback, abuse, multi_account, or account_takeover. submitLabel is the general form, for false positives and confirmations too.

Receive Pro webhooks

Register the endpoint once. The signing secret is returned only in this response, so store it:

const endpoint = await pro.registerWebhook({
  url: 'https://api.example.com/hooks/trustsig',
  events: ['verdict.block', 'verdict.revised', 'fraud.ato'],
});
await saveSecret(endpoint.secret);

Verify deliveries exactly like bot protection webhooks, with verifyWebhookSignature over the raw body.

Events: verdict.block, verdict.review, verdict.revised, verify.completed, fraud.ato, fraud.signup_abuse, fraud.sharing, fraud.action, fraud.promo_abuse, entity.classified, label.created, rule.matched, or *.

  • Subscribe to verdict.revised alongside verdict.block. A verdict that escalates after the token was created only reports through verdict.revised, once per escalation.
  • verify.completed fires on every analysed request. It is only sent to endpoints that list it explicitly; * and an empty list do not include it.
  • fraud.* fires when an engine returns BLOCK, or REVIEW with webhook_on_review set in your fraud policy. fraud.promo_abuse carries the event's promo report.
  • During a training window only label.created is sent.

Answer with any 2xx within 10 seconds. A failed delivery is retried three times quickly, then after 1 minute, 5 minutes, 15 minutes, 1 hour, 3 hours, and 6 hours. Every retry carries the same body id, so deduplicate on it. listWebhookDeliveries(id) shows pending retries with next_retry_at; disabling or deleting the endpoint cancels them. Bodies over 16 KB drop data.metadata first (metadata_truncated: true), then everything but short top-level fields (truncated: true).

Identity confidence

Every call that takes a user_id (verify, submitEvent, trackInteraction) also takes identity_confidence:

  • 'asserted', the default: user_id is your canonical account id, proven by a session, a token, or a completed login. TrustSig links the device to that account.
  • 'claimed': the user typed it and nothing proved it. Use it on failed logins, forgot-password forms, and email-existence checks.

A claimed call is scored, counts toward device and IP velocity, and returns a decision you can act on. It does not link the device to the account, index the email, affect that account's reputation, or run the duplicate-account check. Without it, an attacker on a flagged device could attach that device to any account by typing its email into a login form.

Reading the Pro verdict

| Field | Description | | --- | --- | | decision, blocked | The decision, and decision !== 'ALLOW'. | | error | null, or why verification failed. | | degraded | true when failOpen turned an outage into an ALLOW. | | trust_score | 0 to 100, higher is more trusted. bot_score is 100 - trust_score. | | reason_codes, flags | Why the decision was reached. | | request_id | Pass to submitEvent to join this device. | | canonical_entity_id | The device id to store. entity_id is the per-request fingerprint and changes over time. | | session_handle | See session handles. | | signals | Named booleans: is_vpn, is_tor, is_datacenter, automation, incognito, ua_platform_mismatch (the user agent contradicts the environment), fake_verified_bot (claims to be a crawler and is not). | | enrichment | geo (country, region, city, lat, lon), asn, asn_category (hosting, isp, vpn, tor, or ''), agent_label (such as Googlebot or curl), bot_class, device_class, velocity, entity_confidence, id_degraded, tls_version, http_protocol. | | behavior.mouse | { state, moved, score, sessions_scored, lifetime_score } for pointer activity. | | spoof | { detected, confidence, groups, method, linked_entity_id }. | | alt | Present with include: ['alt']. | | binding | Present when you send context. | | enforcement | Present while the project is in its training window. | | revised, revision, revision_final | Whether evidence after the token moved the verdict, and whether it can still move. | | detections, rule_matches | Evidenced detections, and the ids of your custom rules that fired. |

signals and enrichment are absent when error is set. is_vpn, is_tor, and is_datacenter come from the ASN and do not catch residential proxies. When enrichment.id_degraded is true, the device is a model-level fingerprint and device-based logic deserves less weight.

behavior.mouse.state is how much evidence there was, not how suspicious it was: none (no pointer data), idle (the cursor never really moved), partial, or rich. Do not block on none or idle. Keyboard and touchscreen users produce exactly that.

spoof.detected means the device matches one seen before while presenting a different fingerprint, or its fingerprint contradicts itself. Privacy extensions and randomising browsers do not trigger it. spoof.groups names which parts differed.

While a new project is in its training window, enforcement is present and decision is held at ALLOW on both verify and submitEvent. Scores, reason codes, detections, and enrichment are real throughout, and nothing in your integration changes when training ends. enforcement.computed_decision, the decision you would have received, is filled in only if TrustSig has enabled it for your project. getTrainingStatus() reports progress.

Verdicts only get stricter as evidence arrives; new evidence never clears a BLOCK or REVIEW. An idempotencyKey replays the cached response for 24 hours, so a repeated call under the same key never shows a revision.

Reading the event verdict

submitEvent returns:

| Field | Description | | --- | --- | | decision, blocked, error | As on verify. | | risk, scores | 0 to 100 overall, and per engine. | | reasons | { code, engine, weight, detail }. Negative weights lower risk. getReasonCodes() lists every code. | | advisory | Engines that scored the event but are not enforcing for this project. They did not affect decision or risk. | | account, device | The account record and the joined device, or device: null. | | device_join | How the device was found: request_id, token, session (a handle, no fresh verdict behind it), or ''. Stored with the event. | | session_handle | A new or refreshed handle whenever a device was identified, so event-only integrations get handles without calling verify. | | velocity | device_accounts_24h, ip_accounts_1h, account_devices_28d. | | promo | The promo report when the promo engine ran, otherwise null. | | ml | signup and promo model scores: { p, tier, model? }. | | sharing, alts_hint, alt | Account sharing finding, a count of possible alts, and the full alt block with include: ['alt']. | | binding | Present when you send context. Only context is used for binding, not the top-level ip. | | metadata | What you sent, echoed back. | | degraded | An engine could not run fully; ENGINE_DEGRADED is in reasons. Still a real decision. |

Errors, retries, and outages

| Methods | On failure | | --- | --- | | verify, submitEvent | Return decision: 'BLOCK' with error set. Never throw, except on 400 or 422. | | getBehavior (deprecated) | Returns state: 'none' with error set. Throws on 400 or 422. | | Everything else | Throws TrustSigProError with code, status, retryAfter, requestId, and apiCode. |

import { TrustSigProError } from '@trustsig/server';

try {
  return await pro.getAccount(userId);
} catch (err) {
  if (err instanceof TrustSigProError && err.code === 'NOT_FOUND') return null;
  throw err;
}

Error codes: TOKEN_MISSING, UNAUTHORIZED (401), FORBIDDEN (403, often a missing scope), NOT_FOUND (404), QUOTA_EXCEEDED (402: monthly checks used up, or the subscription is not active), CONFLICT (409), RATE_LIMITED (429), VALIDATION (400, 422), API_FAIL, MALFORMED_RESPONSE. API_FAIL covers 5xx, including 503 when a dependency is briefly down. status is 0 for network failures. message and apiCode come from the API's { error: { code, message, request_id } } body, and requestId from the X-Request-Id header; include it when contacting support.

Retries (retries, default 2, with exponential backoff) apply to reads (except the two exports) and to verify, submitEvent, and getBehavior, on network errors, timeouts, 409, 429, and 5xx. Writes such as replaceRules, registerWebhook, and trackInteractions are never retried. A 429 is retried only when its Retry-After is 2 seconds or less; otherwise read err.retryAfter and schedule the retry yourself.

submitEvent always sends an Idempotency-Key (yours via { idempotencyKey }, or a generated one), so a retry after a lost response does not record or bill the event twice. verify sends one only when you pass idempotencyKey. A 409 means another call holding the same key has not finished yet.

failOpen: true changes what verify and submitEvent return when TrustSig is unreachable. After retries, a timeout, network error, 409, 429, or 5xx returns decision: 'ALLOW' with degraded: true and error set, instead of BLOCK. Authentication, quota, and validation failures still fail closed. A degraded verdict has blocked === false, so check verdict.degraded if an outage should be handled differently from a clean pass.

Method reference

Each method needs a scope on the API key. * grants all of them, and a key with no scopes gets FORBIDDEN on every call.

| Area | Methods | Scope | | --- | --- | --- | | Verify | verify(token, input?), getBehavior(handle) (deprecated) | verify | | Events | submitEvent(event, { idempotencyKey? }), listEvents(filters?) | events, read | | Interactions | trackInteraction(i), trackInteractions(list), listInteractions(filters?) | interactions, read | | Requests | getRequest(requestId): the full analysis behind a verdict | read | | Accounts | getAccount(userId), getAccountSharing(userId), getAlts(userId), getAccountAlts(userId) | read | | Devices | resolveEntity(stableId), getEntity(id), getEntityLinks(id), getEntityCluster(id), getEntityAlts(id), getEntityAccounts(id), getEntityMouse(id), getEntitySpoof(id), searchEntities(filters?) | read | | Reputation | getReputation(kind, value), setReputationStatus(kind, value, body) | read, lists | | Rules | getRules(), replaceRules(rules, description?), activateRules() | read, rules | | Config | getDetectionCatalog(), getActiveConfig(), getTrainingStatus() | read | | Analytics | getAnalytics(view, query?), getAudit(query?) | read | | Export | exportObservations(query?), exportEvents(query?) | export | | Feedback | submitLabel(body), reportFraud(body), listLabels(filters?) | feedback, read | | Webhooks | registerWebhook(body), listWebhooks(), getWebhook(id), updateWebhook(id, patch), deleteWebhook(id), testWebhook(id), listWebhookDeliveries(id) | webhooks, read | | Usage | getUsage() | any valid key | | Meta | getReasonCodes(), getWebhookEvents() | none | | Static | TrustSigPro.verifyWebhookSignature(...) | none |

Notes on the less obvious ones:

  • verify(token, input) accepts user_id, traits (email, name, phone, custom with up to 16 keys), identity_confidence, context, include: ['alt'], idempotencyKey, and claimed_entity_id. Traits are stored only on the account record, never in analytics, exports, or webhooks.
  • claimed_entity_id is a device id your frontend reported. entity_match in the response says whether it equals the one TrustSig resolved.
  • Deprecated, not part of the documented v1 API: telegram on verify and submitEvent, wait_for_complete_max_ms, traits.email_hash, getBehavior, the spoofed_device alt link, and active on registerWebhook. They still work with this SDK but may stop being accepted.
  • getAlts(userId) returns a normalised ProAltVerdict including linked_device matches (at most 25, each under its strongest evidence). getAccountAlts returns the raw { alts } payload. getEntityAlts(id) returns accounts on devices linked to this one, not accounts on this device; use getEntityAccounts for that.
  • replaceRules(rules) replaces the whole rule set as a draft. Nothing is enforced until activateRules(), which throws CONFLICT when there is no draft. A rule is { id, name, description?, action, state, expr } with action one of block, review, allow, label and state one of active, shadow, disabled.
  • setReputationStatus(kind, value, { status, note?, value_raw? }) sets allow, block, watch, or none. For ip and ip24, pass the raw IP as value_raw; TrustSig pseudonymises it. allow and block are enforced at the edge within about two minutes. getReputation takes the pseudonymised value for ip and ip24, not the raw address.
  • getAnalytics(view) takes timeline, composition, detections, verify-gap, geo, taxonomy, velocity, fraud, behavior, or spoof. from and to are unix seconds (default: the last 24 hours) and granularity is minute, hour (default), or day. fraud also takes engine: ato, signup, or sharing. An API_FAIL here means analytics is temporarily unavailable; show a degraded view rather than an empty one.
  • exportObservations and exportEvents return th