@trustsig/server
v2.14.0
Published
Node.js and Edge SDK for TrustSig — token verification and local decryption
Maintainers
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/serverRuns 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
- Create a project in the TrustSig console at trustsig.eu and copy its secret key.
- Put it in an environment variable named
TRUSTSIG_SECRET_KEY. It must never reach a browser or source control. - 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 everyCHALLENGEthrough. - Letting requests through on
error === 'API_FAIL'to ride out an outage.API_FAILcovers 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_RANDOMIZATIONLog 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();
}stateis how much movement there was:none,idle,partial, orrich. Onlyrichis enough to judge.human_scoreruns from 0 (automated) to 100 (human) and isnulluntil there is enough evidence.- Do not penalise
noneoridle. People who use a keyboard or a touchscreen produce no pointer movement. - On failure,
erroris set and the rest looks like a session with no movement. Checkerrorfirst. - 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, andintegrity.spoofingcarry ameasuredmap, keyed by dotted path, that says which values are real readings.network.measured.city === falsemeans no city was reported, not that the visitor has none. risk,behavior, andflagscarry nomeasuredmap. Every flag is always present, andfalsemeans "not observed".schema_versionchanges 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.degradedmeans 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), oridentity_claimed(you sentidentity_confidence: 'claimed', so the check does not answer).alt.linked_checked: falsemeans 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 asml.signup(pfrom 0 to 1,tierofhard,revieworclear);fraud_policy.signup_mlsets 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, orsession_handle), the email check needs an email on the account, and the IP check needsip. - 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
claimedidentity 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
relatedand 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
unknownand 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. bindRequestmust 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'withclientReason. 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
verifywhen 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_idortokenon 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.revisedalongsideverdict.block. A verdict that escalates after the token was created only reports throughverdict.revised, once per escalation. verify.completedfires 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 returnsBLOCK, orREVIEWwithwebhook_on_reviewset in your fraud policy.fraud.promo_abusecarries the event'spromoreport.- During a training window only
label.createdis 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_idis 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)acceptsuser_id,traits(email,name,phone,customwith up to 16 keys),identity_confidence,context,include: ['alt'],idempotencyKey, andclaimed_entity_id. Traits are stored only on the account record, never in analytics, exports, or webhooks.claimed_entity_idis a device id your frontend reported.entity_matchin the response says whether it equals the one TrustSig resolved.- Deprecated, not part of the documented v1 API:
telegramonverifyandsubmitEvent,wait_for_complete_max_ms,traits.email_hash,getBehavior, thespoofed_devicealt link, andactiveonregisterWebhook. They still work with this SDK but may stop being accepted. getAlts(userId)returns a normalisedProAltVerdictincludinglinked_devicematches (at most 25, each under its strongest evidence).getAccountAltsreturns the raw{ alts }payload.getEntityAlts(id)returns accounts on devices linked to this one, not accounts on this device; usegetEntityAccountsfor that.replaceRules(rules)replaces the whole rule set as a draft. Nothing is enforced untilactivateRules(), which throwsCONFLICTwhen there is no draft. A rule is{ id, name, description?, action, state, expr }withactionone ofblock,review,allow,labelandstateone ofactive,shadow,disabled.setReputationStatus(kind, value, { status, note?, value_raw? })setsallow,block,watch, ornone. Foripandip24, pass the raw IP asvalue_raw; TrustSig pseudonymises it.allowandblockare enforced at the edge within about two minutes.getReputationtakes the pseudonymised value foripandip24, not the raw address.getAnalytics(view)takestimeline,composition,detections,verify-gap,geo,taxonomy,velocity,fraud,behavior, orspoof.fromandtoare unix seconds (default: the last 24 hours) andgranularityisminute,hour(default), orday.fraudalso takesengine:ato,signup, orsharing. AnAPI_FAILhere means analytics is temporarily unavailable; show a degraded view rather than an empty one.exportObservationsandexportEventsreturn th
