This is the full developer documentation for FingerTrackID # FingerTrackID documentation > Device intelligence and access risk for your Web product. FingerTrackID assesses and recommends; your system always decides and acts. [1. Onboarding](/getting-started/onboarding/)Create your company workspace, register an application and follow the managed request status. [2. Collection domain](/getting-started/collection-domain/)The DNS records you publish for your collection subdomain to enable first-party collection. [3. Web SDK](/web-sdk/installation/)Install the approved package and collect one event context per business action. [4. Assessment API](/getting-started/quickstart/)Sign POST /v1/assessments from your backend and read the recommendation. [Build with AI agents](/getting-started/ai-agents/)The integration file, the rules for coding agents and the local MCP server. [Console](/console/access/)First access, second factor, roles and what each screen shows. [Handoff to your developer](/getting-started/implementation-handoff/)Prepare browser collection and the signed backend call together, from the exact public contracts. [Production status](/reference/availability/)What is live in production, the request profiles, application activation and SDK distribution. # HMAC authentication > How to sign every call with FTID-HMAC-SHA256-V1, the public test vector and the Node.js reference signer. Every call is signed with **`FTID-HMAC-SHA256-V1`**: HMAC-SHA-256 keyed with the bytes of your credential’s secret, over a canonical string. ## The canonical string [Section titled “The canonical string”](#the-canonical-string) Exactly **ten lines** in UTF-8, separated by LF (`\n`), **without** a trailing LF. The ninth line is empty: it is the `Content-Encoding`, which does not exist in this protocol. The MAC is lowercase hexadecimal, 64 characters, and goes in the `X-FTID-Signature` header. Canonical string ```text FTID-HMAC-SHA256-V1 POST /v1/assessments external_event_id: request_id: application/json ``` Header ```http X-FTID-Signature: v1=: ``` ## Rules that break integrations most often [Section titled “Rules that break integrations most often”](#rules-that-break-integrations-most-often) * **The key is the decoded bytes of the secret**, never the hexadecimal text. The console delivers the production secret once as 32 random bytes encoded as 64 hexadecimal characters. Read them only from your backend’s secret manager and call `decodeSecret(text, 'hex')` before signing. The reference signer also supports an explicitly issued canonical base64 encoding; never guess the encoding or paste the value into a browser, AI or local file. * **The hash covers the bytes you send.** Re-serializing the JSON after signing (different key order, spacing or escaping) produces `401`. * `key_id` and `request_id` are canonical, **lowercase** UUIDv7. * The signed path is `/v1/assessments`, literally, without origin, query or trailing slash. * There is no downgrade: if any part of the v1 envelope fails, the server does not try another protocol. ## The `401` is uniform — on purpose [Section titled “The 401 is uniform — on purpose”](#the-401-is-uniform--on-purpose) Every authentication failure — missing or malformed signature, unknown `key_id`, wrong MAC, revoked or suspended credential, or a credential outside its overlap window — returns **the same** response: `401`, `type` `authentication-failed`, title `Autenticação falhou` (the contract fixes this Portuguese title), no `detail`, with `WWW-Authenticate: FTID-HMAC-V1 realm="assessments"`. The response does not reveal whether the `key_id` exists. Code against `status` and `type`, not against that header or the title. Only after the signature has been verified for a known credential can two other `401` types appear, about the attempt rather than the credential: `timestamp-out-of-window` and `request-replayed` ([below](#timestamp-and-request_id)). They never answer a request whose signature or credential failed. With a valid signature and a credential that lacks the route’s capability, the response is `403 forbidden`. ## Timestamp and `request_id` [Section titled “Timestamp and request_id”](#timestamp-and-request_id) * `X-FTID-Timestamp` is the second at which you **sign the attempt**, not the event’s `occurred_at`. Outside ±300 s of the server clock: `401 timestamp-out-of-window`. * `X-FTID-Request-Id` is the nonce. Resending the same `request_id` gives `401 request-replayed`. Never reuse a `request_id`, not even after `401`, `403`, `429` or `503`. * Generate UUIDv7 with `crypto.randomUUIDv7()`, available from Node.js 24.16.0 on the 24 line and from 26.1.0; Node.js 22 and 25 do not have it. Do **not** use `crypto.randomUUID()`: it generates version 4, which the API rejects. * Keep your backend clock synchronized (NTP). Once per attempt ```js import { randomUUIDv7 } from 'node:crypto' // The second at which you sign this attempt, not the event's occurred_at. const timestamp = String(Math.floor(Date.now() / 1000)) // A new nonce for every attempt. randomUUID() makes version 4, which the API rejects. const requestId = randomUUIDv7() ``` Headers of one attempt ```http X-FTID-Timestamp: 1788523200 X-FTID-Request-Id: X-FTID-Signature: v1=: ``` The [reference signer](#reference-signer-nodejs-2416-or-newer) does both for you. ## Credential, capabilities and rotation [Section titled “Credential, capabilities and rotation”](#credential-capabilities-and-rotation) * A credential has a closed set of capabilities, frozen in its version. Changing a capability requires a new credential. * **Rotation changes the `key_id`.** The managed workflow creates a new credential and requires secure installation of its new material. The previous credential is revoked at the end of an overlap of **at most 24 hours**. A revoked `key_id` never comes back. * Suspected leak: tell FingerTrackID immediately. The emergency rotation has no overlap. * Prepare your backend to hold **two credentials** during a rotation and to switch without a deploy. ## Public test vector [Section titled “Public test vector”](#public-test-vector) A public synthetic secret — the 32-byte sequence `00..1f` — that authenticates nothing, anywhere. Sign the vector’s body with it and you must get exactly the MAC on the right. The full vector, with body, canonical string and headers: [assessment-hmac-v1-test-vector.json](/downloads/assessment-hmac-v1-test-vector.json). | Vector | Route | Expected MAC | | ---------- | ---------------------- | ------------------------------------------------------------------ | | Assessment | `POST /v1/assessments` | `df9016da863e7fc4949f1845fb9457d1e3f165d427d1c8efd47af44eff86851b` | ## Reference signer (Node.js 24.16 or newer) [Section titled “Reference signer (Node.js 24.16 or newer)”](#reference-signer-nodejs-2416-or-newer) Two files, with no dependency other than `node:crypto`, tested against the vector above on every change. They need `crypto.randomUUIDv7()`: Node.js 24.16.0 or later on the 24 line, or 26.1.0 or later. On Node.js 22 or 25 the signer fails as soon as it is imported (on Vercel, set the project’s Node.js version to 24.x). To keep a login independent of that, load it with `await import()` inside your fail-open `try`, as the [integration guidance wrapper](/getting-started/integration-guidance/#a-minimal-fail-open-wrapper) does. * [ftid-hmac-v1.mjs](/downloads/ftid-hmac-v1.mjs) — the signer * [ftid-hmac-v1-self-check.mjs](/downloads/ftid-hmac-v1-self-check.mjs) — the self-check ### Check your environment [Section titled “Check your environment”](#check-your-environment) Save both files in the same folder and run the self-check on the same Node.js that runs your backend. It prints `OK` and the vector’s MAC. The self-check carries the vector values inline; it only needs the signer next to it. Terminal ```sh node ftid-hmac-v1-self-check.mjs ``` Expected output ```text OK df9016da863e7fc4949f1845fb9457d1e3f165d427d1c8efd47af44eff86851b ``` ### Use in your backend [Section titled “Use in your backend”](#use-in-your-backend) One send per attempt, always over the same bytes. `readSecretFromYourVault`, `assessmentRequest`, `keyId` and the timeout are yours. In production, `apiOrigin` is `'https://api.fingertrackid.com'`, exactly as written: `https://`, lowercase host, no trailing slash, path, query or credentials. The dash shows the same value as **Endereço da API de risco**. * `prepareAttempt` only signs a body whose text is exactly what `JSON.stringify` writes; any other text gets `FTID_BODY` without being signed. That is how it blocks duplicate keys at any level. Build the body with a single `JSON.stringify` call. * `postAttempt` does not retry on its own, does not follow redirects and reads at most 65536 bytes of response; one byte more becomes `FTID_INDETERMINATE`. Validate the response against the schema before using it. * If you already have an HTTP client, use only `prepareAttempt`, send `attempt.body` and `attempt.headers` unchanged, and cap the response read at the same limit. server.mjs ```js import { decodeSecret, postAttempt, prepareAttempt } from './ftid-hmac-v1.mjs' // Once, at startup, from your secret manager. The console delivers it as hex. const secret = decodeSecret(await readSecretFromYourVault(), 'hex') // Once per logical operation: validate against the schema, then keep these exact bytes. const body = Buffer.from(JSON.stringify(assessmentRequest), 'utf8') // Once per transport attempt, including every retry of the same operation. const attempt = prepareAttempt({ secret, keyId, body }) // Above the 5000 ms default budget of a `null` LOGIN/v1.1 body plus the network. const result = await postAttempt(apiOrigin, attempt, 6500) // result.status 201/200: parse result.body by its schema_version (event-assessment.v1.1 or // event-assessment.v1) and check the event and its tag (v1.1) or journey (v1). // Retryable Problem or FTID_INDETERMINATE: wait, then call prepareAttempt again with the same body. ``` Caution This code runs **only in your backend**. Apart from its single reveal in the console, the secret never goes to a browser, app, log or analytics tool. ### Full code [Section titled “Full code”](#full-code) ftid-hmac-v1.mjs ```js // FTID-HMAC-SHA256-V1 signing for the FingerTrackID server-to-server API. // // Node.js 24.16.0 or newer, ES module: `crypto.randomUUIDv7()` was added in 24.16.0, and an // older release fails at import time instead of signing. It imports only `node:crypto` and uses // platform globals (`Buffer`, `fetch`, `AbortSignal`, `TextDecoder`). It belongs in your // backend: the inbound secret must never reach a browser, a mobile app, a log or an analytics // tool. // // Contract: contracts/openapi/assessment.v1.yaml (securitySchemes.ftidHmacV1) and // contracts/openapi/feedback.v1.yaml. Normative vectors reproduced byte for byte: // contracts/openapi/assessment-hmac-v1-test-vector.json and hmac-v1-test-vector.json. import { createHash, createHmac, randomUUIDv7 } from 'node:crypto' export const PROTOCOL = 'FTID-HMAC-SHA256-V1' export const ASSESSMENT_PATH = '/v1/assessments' export const MAX_BODY_BYTES = 16384 // Every response of the three routes is at most this many bytes, inclusive: the // `x-ftid-max-response-body-bytes` both contracts publish. export const MAX_RESPONSE_BYTES = 65536 export const MAX_TIMESTAMP = 253402300799 // `AbortSignal.timeout()` takes a 32-bit signed delay; above it Node aborts at once or throws. export const MAX_TIMEOUT_MS = 2147483647 const MIN_SECRET_BYTES = 32 const UUID_V7 = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ const EXTERNAL_EVENT_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/ const FEEDBACK_PATH = /^\/v1\/assessments\/([0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})\/(?:customer-actions|outcomes)$/ const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']) /** * Decodes the secret received once in the console. The HMAC key is the decoded bytes, never the * hexadecimal or base64 text itself. Surrounding whitespace from copy and paste is removed; * anything else that is not canonical hex/base64 is rejected instead of silently truncated. */ export function decodeSecret(text, encoding) { if (typeof text !== 'string') throw new TypeError('FTID_SECRET_ENCODING') const value = text.trim() let secret if (encoding === 'hex') { secret = Buffer.from(value, 'hex') if (secret.toString('hex') !== value.toLowerCase()) throw new TypeError('FTID_SECRET_ENCODING') } else if (encoding === 'base64') { secret = Buffer.from(value, 'base64') if (secret.toString('base64') !== value) throw new TypeError('FTID_SECRET_ENCODING') } else { throw new TypeError('FTID_SECRET_ENCODING') } if (secret.byteLength < MIN_SECRET_BYTES) throw new TypeError('FTID_SECRET') return secret } /** * A canonical lowercase UUIDv7 for `X-FTID-Request-Id`, new for every transport attempt, from * the platform generator. `crypto.randomUUID()` produces version 4, which the API rejects, and * silently ignores a `{ version: 7 }` option; the format check below catches that swap. */ export function newRequestId() { const id = randomUUIDv7() if (!UUID_V7.test(id)) throw new Error('FTID_REQUEST_ID') return id } /** The signed resource tag is fixed by the path, never chosen by the caller. */ function signedIdTag(path, signedId) { if (path === ASSESSMENT_PATH) { if (typeof signedId !== 'string' || !EXTERNAL_EVENT_ID.test(signedId)) { throw new TypeError('FTID_EXTERNAL_EVENT_ID') } return 'external_event_id' } const match = typeof path === 'string' ? FEEDBACK_PATH.exec(path) : null if (match === null) throw new TypeError('FTID_PATH') if (signedId !== match[1]) throw new TypeError('FTID_ASSESSMENT_ID') return 'assessment_id' } /** * The ten lines the MAC covers, joined by LF and without a final LF. The last line hashes * the exact body bytes: never parse and re-serialize JSON between signing and sending. */ export function canonicalString({ keyId, path, timestamp, signedId, requestId, body }) { const tag = signedIdTag(path, signedId) if (typeof keyId !== 'string' || !UUID_V7.test(keyId)) throw new TypeError('FTID_KEY_ID') if (!Number.isSafeInteger(timestamp) || timestamp < 0 || timestamp > MAX_TIMESTAMP) { throw new TypeError('FTID_TIMESTAMP') } if (typeof requestId !== 'string' || !UUID_V7.test(requestId)) { throw new TypeError('FTID_REQUEST_ID') } if (!(body instanceof Uint8Array) || body.byteLength === 0 || body.byteLength > MAX_BODY_BYTES) { throw new TypeError('FTID_BODY') } return [ PROTOCOL, keyId, 'POST', path, String(timestamp), `${tag}:${signedId}`, `request_id:${requestId}`, 'application/json', '', createHash('sha256').update(body).digest('hex'), ].join('\n') } /** HMAC-SHA-256 over the UTF-8 canonical string, as 64 lowercase hexadecimal characters. */ export function sign({ secret, ...input }) { if (!(secret instanceof Uint8Array) || secret.byteLength < MIN_SECRET_BYTES) { throw new TypeError('FTID_SECRET') } return createHmac('sha256', secret).update(canonicalString(input), 'utf8').digest('hex') } /** Exactly these five headers. Do not add X-Client-Id, a nonce header or Content-Encoding. */ export function buildHeaders({ keyId, path, timestamp, signedId, requestId, mac }) { const resourceHeader = path === ASSESSMENT_PATH ? 'X-FTID-External-Event-Id' : 'X-FTID-Assessment-Id' return { 'Content-Type': 'application/json', 'X-FTID-Timestamp': String(timestamp), 'X-FTID-Request-Id': requestId, [resourceHeader]: signedId, 'X-FTID-Signature': `v1=${keyId}:${mac}`, } } /** * Every route reads one JSON object in strict UTF-8 without a BOM, so anything else is refused * here instead of being sent. `fatal: true` rejects invalid UTF-8 instead of replacing it, and * `ignoreBOM: true` keeps a leading BOM as U+FEFF, which `JSON.parse` rejects. * * The text must also be exactly what `JSON.stringify` writes for it. The API refuses a repeated * member at any depth, even one spelled with escapes, while `JSON.parse` silently keeps the * last one. Serializing the parsed value again writes each member once, so a body that repeats * one can never equal its own re-serialization and is refused here without a JSON reader of * its own. This also refuses some bodies the API accepts but `JSON.stringify` never writes: * whitespace, a trailing newline, escapes such as `\/` and number spellings such as `1.5e5`. * Build the body with one `JSON.stringify` call, whose output always passes. * * Every check runs inside the `try`, so a failure of any kind, including a stack overflow on a * deeply nested body, is reported as `FTID_BODY`. */ function signedIdFrom(path, body) { let document let accepted try { const text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(body) document = JSON.parse(text) accepted = JSON.stringify(document) === text && document !== null && typeof document === 'object' && !Array.isArray(document) && (path === ASSESSMENT_PATH || feedbackStrings(document)) } catch { throw new TypeError('FTID_BODY') } if (!accepted) throw new TypeError('FTID_BODY') if (path === ASSESSMENT_PATH) return document.external_event_id return FEEDBACK_PATH.exec(path)?.[1] } /** * The feedback routes refuse any member name or string value, once decoded, that is not * well-formed UTF-16 (a lone surrogate from `\ud800`) or that contains U+0000. `JSON.stringify` * only escapes both, so they reach the API unless refused here. */ function feedbackStrings(value) { if (typeof value === 'string') return value.isWellFormed() && !value.includes('\u0000') if (value === null || typeof value !== 'object') return true return Object.entries(value).every(([key, item]) => feedbackStrings(key) && feedbackStrings(item)) } /** * Prepares one transport attempt. Serialize the logical request once, keep those exact bytes * with your operation record and call this again for every retry: each call returns a new * request ID, the current timestamp and a new signature over the same bytes. */ export function prepareAttempt({ secret, keyId, path = ASSESSMENT_PATH, body, nowMs = Date.now(), }) { // Bounded before the copy, so an oversized body is never copied or parsed; `sign` checks the // copy again. if (!(body instanceof Uint8Array) || body.byteLength === 0 || body.byteLength > MAX_BODY_BYTES) { throw new TypeError('FTID_BODY') } const bytes = Buffer.from(body) const signedId = signedIdFrom(path, bytes) const timestamp = Math.floor(nowMs / 1000) const requestId = newRequestId() const mac = sign({ secret, keyId, path, timestamp, signedId, requestId, body: bytes }) return { path, body: bytes, requestId, headers: buildHeaders({ keyId, path, timestamp, signedId, requestId, mac }), } } /** * Reads the response body, counting the bytes that actually arrive instead of trusting a header, * and stops at the first byte past `MAX_RESPONSE_BYTES`. The API never sends more and never * persists an assessment whose response would not fit, so a longer body is a failed read: the * stream is cancelled and `postAttempt` reports `FTID_INDETERMINATE`. */ async function readResponse(response) { if (response.body === null) return new Uint8Array(0) const reader = response.body.getReader() const chunks = [] let size = 0 for (;;) { const { done, value } = await reader.read() if (done) return Buffer.concat(chunks, size) size += value.byteLength if (size > MAX_RESPONSE_BYTES) { await reader.cancel() throw new RangeError('response above MAX_RESPONSE_BYTES') } chunks.push(value) } } /** * Sends one prepared attempt without retrying or following redirects. `FTID_INDETERMINATE` * means the outcome is unknown (network error, timeout, a response that cannot be read or is * above `MAX_RESPONSE_BYTES`): recover by preparing a NEW attempt over the same bytes, never by * resending this one. Plain HTTP is accepted only for a loopback test double. */ export async function postAttempt(apiOrigin, attempt, timeoutMs) { // Node's own error for an unparsable URL carries the rejected text, credentials included, in // `error.input`; only the fixed code leaves here. let origin try { origin = new URL(apiOrigin) } catch { throw new TypeError('FTID_API_ORIGIN') } const loopback = LOOPBACK_HOSTS.has(origin.hostname) && origin.protocol === 'http:' // An origin only: no path, query, fragment, credentials or trailing slash. The scheme's // default port may be spelled out (`https://api.example:443`): URL drops it from `.origin`. const defaultPort = origin.protocol === 'https:' ? '443' : '80' const spelled = apiOrigin === origin.origin || apiOrigin === `${origin.origin}:${defaultPort}` if (!spelled || (origin.protocol !== 'https:' && !loopback)) { throw new TypeError('FTID_API_ORIGIN') } if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > MAX_TIMEOUT_MS) { throw new TypeError('FTID_TIMEOUT') } // The path is checked again here, not trusted from `prepareAttempt`: an edited `attempt.path` // such as `@evil.example/x` would move the signed request to another host. Only the fixed // routes pass, and the final URL must still be on the API origin. const routePath = attempt?.path if ( typeof routePath !== 'string' || (routePath !== ASSESSMENT_PATH && !FEEDBACK_PATH.test(routePath)) ) { throw new TypeError('FTID_PATH') } const target = new URL(routePath, origin.origin) if (target.origin !== origin.origin) throw new TypeError('FTID_PATH') let response let bytes try { response = await fetch(target.href, { method: 'POST', headers: attempt.headers, body: attempt.body, redirect: 'error', signal: AbortSignal.timeout(timeoutMs), }) bytes = await readResponse(response) } catch { throw new Error('FTID_INDETERMINATE') } let text try { text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes) } catch { throw new Error('FTID_INDETERMINATE') } return { status: response.status, contentType: response.headers.get('content-type'), correlationId: response.headers.get('x-correlation-id'), retryAfter: response.headers.get('retry-after'), body: text, } } ``` ftid-hmac-v1-self-check.mjs ```js // Checks ftid-hmac-v1.mjs against the published synthetic vector for POST /v1/assessments // (contracts/openapi/assessment-hmac-v1-test-vector.json). Run it next to that file: // // node ftid-hmac-v1-self-check.mjs // // It prints `OK ` and exits 0 on a byte-for-byte match, or prints MISMATCH and exits 1. // The secret is the public test sequence 00..1f: it authenticates nothing anywhere. import { buildHeaders, canonicalString, decodeSecret, sign } from './ftid-hmac-v1.mjs' const VECTOR = { secretHex: '000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f', keyId: '01996f2a-1111-7000-8000-000000000001', path: '/v1/assessments', timestamp: 1788523200, signedId: 'login_20260904_000001', requestId: '01a06c4a-3e00-7000-8000-000000000002', body: '{"schema_version":"event-assessment-request.v1","external_event_id":"login_20260904_000001","idempotency_key":"idem_login_20260904_000001","occurred_at":"2026-09-04T12:00:00Z","subject_ref":"subject_123","account_ref":"account_456","event_context_token":"ect_login_20260904_000001","journey":"LOGIN","action":"LOGIN_ATTEMPT","journey_context":{},"deadline_ms":null}', canonicalString: [ 'FTID-HMAC-SHA256-V1', '01996f2a-1111-7000-8000-000000000001', 'POST', '/v1/assessments', '1788523200', 'external_event_id:login_20260904_000001', 'request_id:01a06c4a-3e00-7000-8000-000000000002', 'application/json', '', 'f7cdd806f169384d0cc3bf5f63654999d5b78aa68045da4ddce075b09953bd44', ].join('\n'), mac: 'df9016da863e7fc4949f1845fb9457d1e3f165d427d1c8efd47af44eff86851b', signatureHeader: 'v1=01996f2a-1111-7000-8000-000000000001:df9016da863e7fc4949f1845fb9457d1e3f165d427d1c8efd47af44eff86851b', } const input = { keyId: VECTOR.keyId, path: VECTOR.path, timestamp: VECTOR.timestamp, signedId: VECTOR.signedId, requestId: VECTOR.requestId, body: Buffer.from(VECTOR.body, 'utf8'), } const canonical = canonicalString(input) const mac = sign({ secret: decodeSecret(VECTOR.secretHex, 'hex'), ...input }) const headers = buildHeaders({ ...input, mac }) if ( canonical === VECTOR.canonicalString && mac === VECTOR.mac && headers['X-FTID-Signature'] === VECTOR.signatureHeader ) { process.stdout.write(`OK ${mac}\n`) } else { console.error('MISMATCH: this signer does not reproduce the published vector') process.exitCode = 1 } ``` # Error catalog (Problems) > Every Problem type of the FingerTrackID API, with HTTP status, retryable and what to do in each case. Every error comes as `application/problem+json` (RFC 9457), with `type`, `title`, `status`, `retryable` and, when present, `correlation_id`. Download the exact [Problem schema](/downloads/contracts/json-schema/assessment-problem.v1.schema.json) and [v1 OpenAPI contract](/downloads/contracts/openapi/assessment.v1.yaml) for validation. ## Problem `type` identifiers [Section titled “Problem type identifiers”](#problem-type-identifiers) The `type` is a full URI under `https://fingertrackid.dev/problems/`, for example `https://fingertrackid.dev/problems/rate-limited`. * **It is a stable identifier.** Do not fetch it at runtime and do not treat a failed lookup as a problem. Compare the full `type` string. * **This page is the documentation for every type.** Each one has its own section below, at `https://docs.fingertrackid.com/api/errors/#` (for example [`#rate-limited`](#rate-limited)), and opening a `type` URI in a browser redirects to that section. * **Code against `type` and `status`.** The `title` is human-readable text; it is only fixed for `authentication-failed` (`Autenticação falhou`, in Portuguese as the contract defines) and `assessment-result-expired` (`Assessment result expired`). Do not branch on `title`. * The contract allows `errors[]` (with `pointer` and `code`) only in `validation-failed`, as an optional field. Do not rely on it to find the wrong field. ## Summary [Section titled “Summary”](#summary) | `type` suffix | HTTP | `retryable` | Routes | | --------------------------------------------------------- | ---- | ----------- | -------------------- | | [`validation-failed`](#validation-failed) | 400 | `false` | Assessment, feedback | | [`event-context-invalid`](#event-context-invalid) | 400 | `false` | Assessment | | [`occurred-at-out-of-window`](#occurred-at-out-of-window) | 400 | `false` | Assessment | | [`authentication-failed`](#authentication-failed) | 401 | `false` | Assessment, feedback | | [`timestamp-out-of-window`](#timestamp-out-of-window) | 401 | `true` | Assessment, feedback | | [`request-replayed`](#request-replayed) | 401 | `true` | Assessment, feedback | | [`forbidden`](#forbidden) | 403 | `false` | Assessment, feedback | | [`not-found`](#not-found) | 404 | `false` | Feedback | | [`idempotency-conflict`](#idempotency-conflict) | 409 | `false` | Assessment, feedback | | [`binding-conflict`](#binding-conflict) | 409 | `false` | Assessment, feedback | | [`assessment-result-expired`](#assessment-result-expired) | 410 | `false` | Assessment | | [`payload-too-large`](#payload-too-large) | 413 | `false` | Assessment, feedback | | [`unsupported-media-type`](#unsupported-media-type) | 415 | `false` | Assessment, feedback | | [`rate-limited`](#rate-limited) | 429 | `true` | Assessment, feedback | | [`internal`](#internal) | 500 | `false` | Assessment, feedback | | [`assessment-unavailable`](#assessment-unavailable) | 503 | `true` | Assessment, feedback | The feedback routes have their own closed Problem catalog in the [feedback OpenAPI](/downloads/contracts/openapi/feedback.v1.yaml), with English titles. The Web SDK’s collection endpoint uses a few more types, listed in [seen in the browser](#seen-in-the-browser-web-sdk), and the [types the API does not send](#types-the-api-does-not-send) are listed after them. ## Assessment and feedback types [Section titled “Assessment and feedback types”](#assessment-and-feedback-types) ### `validation-failed` [Section titled “validation-failed”](#validation-failed) `400`, not retryable. The request does not match the schema, or the route is a variation of `/v1/assessments` (query string, trailing slash, method other than `POST`), or the request carries `Expect`/`Trailer` or a duplicated framing header. Fix the request against the schema. For a v1.1 request this includes a `tag` outside the grammar (uppercase, hyphen, space, more than 64 characters, 11 or more digits in total) and a v1-only field (`journey`, `action`, `journey_context`). There is no separate Problem type for the tag. 400 · application/problem+json ```json { "type": "https://fingertrackid.dev/problems/validation-failed", "title": "Request inválido", "status": 400, "correlation_id": "corr-problem-validation-0001", "retryable": false, "errors": [ { "pointer": "/journey", "code": "INVALID_ENUM" } ] } ``` ### `event-context-invalid` [Section titled “event-context-invalid”](#event-context-invalid) `400`, not retryable. The `event_context_token` is present but invalid, expired, already used or from another scope. Nothing is stored. Under the managed production profile a token expires no later than your application’s evidence window (60 or 300 seconds) after its collection reached FingerTrackID ([the evidence window](/web-sdk/collection/#the-evidence-window)). Do not retry: the same body gets the same answer, and a different body under the same operation IDs is an `idempotency-conflict`. To still assess the event, send it once as a new operation (new `external_event_id` and `idempotency_key`) with `event_context_token: null`. When the browser has no token, send `null` from the start ([integration guidance](/getting-started/integration-guidance/#when-the-browser-has-no-event-context)). 400 · application/problem+json ```json { "type": "https://fingertrackid.dev/problems/event-context-invalid", "title": "Contexto do evento inválido", "status": 400, "correlation_id": "corr-problem-context-invalid-0001", "retryable": false } ``` ### `occurred-at-out-of-window` [Section titled “occurred-at-out-of-window”](#occurred-at-out-of-window) `400`, not retryable. The `occurred_at` of a new operation is outside ±300 s of the server clock. Check the clock and where the timestamp comes from; this route is not for backfill. 400 · application/problem+json ```json { "type": "https://fingertrackid.dev/problems/occurred-at-out-of-window", "title": "Evento fora da janela online", "status": 400, "correlation_id": "corr-problem-occurred-at-0001", "retryable": false } ``` ### `authentication-failed` [Section titled “authentication-failed”](#authentication-failed) `401`, not retryable. The uniform authentication failure ([see authentication](/api/authentication/)): wrong bytes, headers, `key_id`, secret decoding, or a revoked, suspended or blocked credential. Treat it as a configuration error and alert. 401 · application/problem+json ```json { "type": "https://fingertrackid.dev/problems/authentication-failed", "title": "Autenticação falhou", "status": 401, "correlation_id": "corr-problem-authentication-0001", "retryable": false } ``` ### `timestamp-out-of-window` [Section titled “timestamp-out-of-window”](#timestamp-out-of-window) `401`, retryable. Your clock is outside ±300 s of the server clock. Synchronize and send a new attempt. 401 · application/problem+json ```json { "type": "https://fingertrackid.dev/problems/timestamp-out-of-window", "title": "Timestamp da assinatura fora da janela", "status": 401, "correlation_id": "corr-problem-timestamp-0001", "retryable": true } ``` ### `request-replayed` [Section titled “request-replayed”](#request-replayed) `401`, retryable. A `request_id` was reused. Send a new attempt, and fix the ID generator. 401 · application/problem+json ```json { "type": "https://fingertrackid.dev/problems/request-replayed", "title": "Identificador da tentativa já consumido", "status": 401, "correlation_id": "corr-problem-replay-0001", "retryable": true } ``` ### `forbidden` [Section titled “forbidden”](#forbidden) `403`, not retryable. The credential is valid but lacks the route’s capability. Create a server credential with that permission in the console. 403 · application/problem+json ```json { "type": "https://fingertrackid.dev/problems/forbidden", "title": "Capability de assessment ausente", "status": 403, "correlation_id": "corr-problem-forbidden-0001", "retryable": false } ``` ### `not-found` [Section titled “not-found”](#not-found) `404`, not retryable, feedback routes only. The target assessment does not exist or is outside the authenticated scope; both cases share this response. Check the `assessment_id` in the path. ### `idempotency-conflict` [Section titled “idempotency-conflict”](#idempotency-conflict) `409`, not retryable. The idempotency key or external ID was reused with different content. Reconcile; do not invent new IDs automatically. The `tag` is content: a retry with the same IDs and another tag is a conflict. So is an ID of a v1 operation sent again in a v1.1 body, or the reverse: v1 and v1.1 operations never share IDs. 409 · application/problem+json ```json { "type": "https://fingertrackid.dev/problems/idempotency-conflict", "title": "Chave de idempotência já usada com outro conteúdo", "status": 409, "correlation_id": "corr-problem-idempotency-0001", "retryable": false } ``` ### `binding-conflict` [Section titled “binding-conflict”](#binding-conflict) `409`, not retryable. Person, account or session references are incompatible with bindings already stored. On the assessment route the usual cause is an `account_ref` that is already bound to another `subject_ref`: an account never changes person. No event or assessment is stored, and the same body gets the same answer, so do not retry it. Fix the mapping in your backend (keep each reference’s key and prefix unchanged over time, see [person and account references](/api/overview/#person-and-account-references)), and continue your normal flow: for a login, the login goes on. 409 · application/problem+json ```json { "type": "https://fingertrackid.dev/problems/binding-conflict", "title": "Vínculo de identidade ou sessão incompatível", "status": 409, "correlation_id": "corr-problem-binding-conflict-0001", "retryable": false } ``` ### `assessment-result-expired` [Section titled “assessment-result-expired”](#assessment-result-expired) `410`, not retryable, assessment route only. The authenticated logical operation exists, but its result is outside the approved history window. Stop retrying; do not create new IDs to regenerate the old operation. 410 · application/problem+json ```json { "type": "https://fingertrackid.dev/problems/assessment-result-expired", "title": "Assessment result expired", "status": 410, "retryable": false } ``` ### `payload-too-large` [Section titled “payload-too-large”](#payload-too-large) `413`, not retryable. The body is above 16384 bytes. Reduce it and sign again. 413 · application/problem+json ```json { "type": "https://fingertrackid.dev/problems/payload-too-large", "title": "Payload excede o limite", "status": 413, "correlation_id": "corr-problem-payload-0001", "retryable": false } ``` ### `unsupported-media-type` [Section titled “unsupported-media-type”](#unsupported-media-type) `415`, not retryable. `Content-Type` is not exactly `application/json`, or `Content-Encoding` is present. 415 · application/problem+json ```json { "type": "https://fingertrackid.dev/problems/unsupported-media-type", "title": "Representação do request não suportada", "status": 415, "correlation_id": "corr-problem-media-type-0001", "retryable": false } ``` ### `rate-limited` [Section titled “rate-limited”](#rate-limited) `429`, retryable. Above your server client’s rate. Wait for `Retry-After`, then send a new attempt with the same body ([limits](/api/limits/)). 429 · application/problem+json ```json { "type": "https://fingertrackid.dev/problems/rate-limited", "title": "Limite de assessments atingido", "status": 429, "correlation_id": "corr-problem-rate-limit-0001", "retryable": true } ``` ### `internal` [Section titled “internal”](#internal) `500`, not retryable. A known internal failure before anything was stored. Do not retry automatically; open a support request with the `correlation_id`. 500 · application/problem+json ```json { "type": "https://fingertrackid.dev/problems/internal", "title": "Falha interna não retentável", "status": 500, "correlation_id": "corr-problem-internal-0001", "retryable": false } ``` ### `assessment-unavailable` [Section titled “assessment-unavailable”](#assessment-unavailable) `503`, retryable. Unavailable or unknown state. Wait for `Retry-After` (today always 1 second; wait at least 1 second if the header is ever missing), then send a new attempt with the same body: a **new** `request_id`, current timestamp and new signature, because the `request_id` of the answered attempt may already be consumed. A retained assessment returns `200`, an expired result returns definitive `410`. Retry a bounded number of times inside your business deadline, never in a loop. 503 · application/problem+json ```json { "type": "https://fingertrackid.dev/problems/assessment-unavailable", "title": "Assessment temporariamente indisponível", "status": 503, "detail": "Repita a mesma operação lógica com novo request_id; o commit pode ter ocorrido.", "correlation_id": "corr-assessment-timeout-0001", "retryable": true } ``` ## Seen in the browser (Web SDK) [Section titled “Seen in the browser (Web SDK)”](#seen-in-the-browser-web-sdk) The Web SDK talks to your collection domain and handles these responses itself, returning a status such as `UNAVAILABLE` to your code. You never parse them, but they can appear in the browser’s network panel. | `type` suffix | HTTP | Meaning | | ------------------------ | ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `origin-not-allowed` | 403 | The page origin is not one of the application’s allowed origins under this collection domain. The response carries no CORS approval, so the browser hides it and the SDK reports `TRANSPORT_ERROR` | | `sdk-session-conflict` | 409 | A conflict with the SDK session’s current state; the SDK handles it | | `event-context-conflict` | 409 | A conflicting event context request for the same SDK event; the SDK handles it | | `sdk-unavailable` | 503 | Collection is temporarily unavailable; continue with a `null` context | The collection endpoint also uses `validation-failed`, `authentication-failed`, `not-found`, `idempotency-conflict`, `payload-too-large`, `unsupported-media-type`, `rate-limited` and `internal` with the same meaning as above; there, `internal` is retryable by the SDK. ## Types the API does not send [Section titled “Types the API does not send”](#types-the-api-does-not-send) FingerTrackID’s catalog reserves a few more types. A `type` URI with one of these suffixes still lands here, but the assessment, feedback and collection routes never return them: code that handles the types above handles every response. | `type` suffix | What you receive instead | | ---------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `signature-invalid`, `unknown-client`, `client-suspended` | [`authentication-failed`](#authentication-failed). Authentication failures are uniform, so a refusal never tells a caller whether the signature, the key or the credential’s state was wrong | | `nonce-replayed` | [`request-replayed`](#request-replayed) | | `external-event-conflict` | [`idempotency-conflict`](#idempotency-conflict) | | `assessment-timeout`, `assessment-state-unknown` | [`assessment-unavailable`](#assessment-unavailable): a deadline that ran out or a state that could not be confirmed is retried with the same body | | `journey-key-cardinality-exceeded`, `assessment-profile-not-activated` | Nothing: they are reserved for a future request profile that is not accepted today. The v1 and v1.1 requests never return them | ## 401, 403, 429 and 503 in one sentence each [Section titled “401, 403, 429 and 503 in one sentence each”](#401-403-429-and-503-in-one-sentence-each) * **401** — authentication of the attempt failed. `authentication-failed` is uniform and is not retried; `timestamp-out-of-window` and `request-replayed` are retried with a new attempt. * **403** — you are authenticated, but the credential lacks the route’s capability. * **429** — above the server client’s rate. Wait for `Retry-After`, then retry with a new `request_id`, timestamp and signature; retrying without waiting can block the credential for 10 minutes ([limits](/api/limits/)). * **503** — unavailable or unknown state. Wait for `Retry-After`, then retry with the same body and a new `request_id`, timestamp and signature, a bounded number of times; a retained assessment returns `200`; an expired result returns definitive `410`. ## Useful headers [Section titled “Useful headers”](#useful-headers) * On the server-to-server API (assessment and feedback), `429` and `503` carry `Retry-After` in whole seconds, at least 1; today they always send `1`. Read the header instead of hard-coding a value, and wait at least 1 second if it is ever missing. The collection endpoint’s `429` computes its value: at the new-session rate, the time until the next session open fits; at the application’s concurrent-session limit, up to 1800 seconds. The Web SDK honours it for you. * Every response carries `X-Correlation-Id`, opaque and free of personal data. Keep it for support. * A `POST` to a path that does not start with `/v1/assessments` receives `404`. Variations of `/v1/assessments` (query, trailing slash) and any method other than `POST` receive `400 validation-failed`. # Feedback — customer action and outcome > Report the decision your product actually executed and later evidence through signed, idempotent v1 feedback calls. After an assessment, your backend can report what happened when its active credential has the required capabilities. Feedback uses the independently authenticated S2S path and commits a fact, immutable receipt and audit together. It never executes an action in your product. | Route | Capability | Fact | | ------------------------------------------------------- | --------------- | --------------------------------------------------------------------- | | `POST /v1/assessments/{assessment_id}/customer-actions` | `action:write` | The action your product actually executed | | `POST /v1/assessments/{assessment_id}/outcomes` | `outcome:write` | Later observed truth/evidence, or a correction of the current outcome | These are separate from `assessment:write`: request them with the permission **Avaliações, ações e resultados** when you create the server credential in the console, and confirm with FingerTrackID that they are active. A published contract or an assessment credential alone does not enable feedback. Production status The feedback routes are **live in production** with the signed assessment API, on the same signed runtime. See [production status](/reference/availability/). ## Exact public contracts [Section titled “Exact public contracts”](#exact-public-contracts) * [Feedback OpenAPI](/downloads/contracts/openapi/feedback.v1.yaml) * [Customer action request](/downloads/contracts/json-schema/customer-action-request.v1.schema.json) * [Customer action receipt](/downloads/contracts/json-schema/customer-action-receipt.v1.schema.json) * [Customer outcome request](/downloads/contracts/json-schema/customer-outcome-request.v1.schema.json) * [Customer outcome receipt](/downloads/contracts/json-schema/customer-outcome-receipt.v1.schema.json) * [Normative customer-action HMAC vector](/downloads/hmac-v1-test-vector.json) * [Lossless feedback canonicalization vectors](/downloads/contracts/examples/feedback-canonicalization-v1.json) Retain `assessment.v1.yaml` beside the feedback OpenAPI for its shared references. Historical implementation annotations and the example server are not issued runtime settings. Validate the closed schemas **and** the normative raw-input/cross-field rules; a JSON Schema alone does not reject duplicate decoded keys, NUL or every lossless-number violation. ## Signing the feedback call [Section titled “Signing the feedback call”](#signing-the-feedback-call) Use [the same reference signer](/api/authentication/). The canonical path includes the exact original synchronous `assessment_id`, with no query/trailing slash. The sixth signed line is `assessment_id:` and `X-FTID-Assessment-Id` must match the path. Do not send the assessment route’s external-event header instead. `secret` comes only from your backend’s secret manager. Request bodies are at most 16384 bytes; responses are at most 65536 bytes. Keep the exact original bytes/idempotency key across retries and create a new UUIDv7 request ID/timestamp/signature for every attempt. ```js // Validate once, then preserve these exact bytes for the logical feedback operation. const body = Buffer.from(JSON.stringify(customerActionRequest), 'utf8') const path = `/v1/assessments/${assessmentId}/customer-actions` // Each transport attempt receives a new request ID, timestamp and signature. const attempt = prepareAttempt({ secret, keyId, path, body }) const result = await postAttempt(apiOrigin, attempt, 2000) // Validate the exact receipt or feedback Problem before accepting the result. ``` ## Customer action (`customer-action.v1`) [Section titled “Customer action (customer-action.v1)”](#customer-action-customer-actionv1) All seven fields are required: `schema_version`, `external_action_id`, `idempotency_key`, `action`, `basis`, `occurred_at` and `reason_code`. * Actions: `ALLOWED`, `CHALLENGED`, `HELD`, `REVIEWED`, `BLOCKED`, `CANCELLED`. * Basis: `ASSESSMENT`, `CLIENT_POLICY`, `FALLBACK`, `MANUAL_REVIEW`. * The reason must match its basis in the schema. `ASSESSMENT` requires `FOLLOWED_FTID_RECOMMENDATION` and an action aligned with the stored recommendation. * `occurred_at` is your backend’s actual action time, not a browser-selected timestamp. It cannot precede the original event or exceed the server’s allowed future window. FingerTrackID derives `recommendation_alignment`; never include it in the request. `CANCELLED` is `NOT_COMPARABLE`. Matching a recommendation does not prove causality or fraud. The receipt acknowledges the recorded action, not a newly executed block. * Request body customer-action.v1 ```json { "schema_version": "customer-action.v1", "external_action_id": "action:synthetic-001", "idempotency_key": "synthetic-action-001", "action": "ALLOWED", "basis": "ASSESSMENT", "occurred_at": "2026-09-05T12:00:00.123456789+03:00", "reason_code": "FOLLOWED_FTID_RECOMMENDATION" } ``` * Server SDK ```ts await ftid?.recordCustomerAction(assessmentId, { action: 'ALLOWED', basis: 'CLIENT_POLICY', reasonCode: 'CLIENT_POLICY_RULE', }) ``` The request body is a published [canonicalization vector](/downloads/contracts/examples/feedback-canonicalization-v1.json). ## Outcome (`customer-outcome.v1`) [Section titled “Outcome (customer-outcome.v1)”](#outcome-customer-outcomev1) Every field, including nullable fields, is required: `schema_version`, `external_outcome_id`, `idempotency_key`, `truth_status`, `label_maturity`, `fraud_type`, `evidence_source`, `evidence_reference`, `evidence_confidence`, `observed_at`, `reason_code` and `corrects_external_outcome_id`. Truth is `PENDING`, `INSUFFICIENT_EVIDENCE`, `LEGITIMATE` or `FRAUD_CONFIRMED`. Maturity is `PROVISIONAL` or `MATURE`; `PENDING` must remain provisional. Conclusive truth requires the complete evidence triple, and `FRAUD_CONFIRMED` additionally requires the active fraud-type catalog entry. Never put PII or secret material in an evidence reference. The initial observation has `INITIAL_OBSERVATION` and a null predecessor. A correction must reference the current leaf’s `external_outcome_id`, use a new outcome ID and the reason matching the actual change. A stale predecessor, competing successor or second root is a conflict. Corrections append facts; they do not overwrite the original record or recommendation. Submitted maturity/evidence does not itself qualify the label for detector calibration. Confirmed-fraud outcomes also feed the console item **Contas vizinhas com fraude confirmada pela empresa** (neighbouring accounts with fraud confirmed by your company) in [**Contexto do acesso**](/console/screens/#contexto-do-acesso); without outcomes it shows **Sem dado: a empresa não envia resultados**. * Request body customer-outcome.v1 ```json { "schema_version": "customer-outcome.v1", "external_outcome_id": "outcome:synthetic-001", "idempotency_key": "synthetic-outcome-001", "truth_status": "PENDING", "label_maturity": "PROVISIONAL", "fraud_type": null, "evidence_source": null, "evidence_reference": null, "evidence_confidence": null, "observed_at": "2026-09-05T12:00Z", "reason_code": "INITIAL_OBSERVATION", "corrects_external_outcome_id": null } ``` * Server SDK ```ts await ftid?.recordOutcome(assessmentId, { truthStatus: 'PENDING', labelMaturity: 'PROVISIONAL', reasonCode: 'INITIAL_OBSERVATION', }) ``` ## Receipts, retry and unavailable results [Section titled “Receipts, retry and unavailable results”](#receipts-retry-and-unavailable-results) `201` acknowledges a new committed fact; `200` acknowledges the same logical fact without a second effect. Divergent reuse returns `409`. A timeout/lost acknowledgement is indeterminate: reconcile using the same POST/body with a fresh signed attempt, subject to the actual Problem’s `retryable` field and bounded backoff. `429`/`503` use `Retry-After`. The feedback Problem catalog belongs to the feedback OpenAPI; do not assume every assessment Problem is a feedback response. Target absence and a target outside the authenticated scope share `404`. No unauthenticated receipt lookup or assessment `GET` is provided. Three separate facts A recommendation, the customer action and outcome truth are distinct. Blocking, cancelling, reviewing or a low score does not establish `FRAUD_CONFIRMED`. # Idempotency and retry > The logical operation and the send attempt, what the server does with each and when to retry. There are two levels of identity, and they do not mix: * **Logical operation:** `idempotency_key` + `external_event_id`, in the body. They stay **the same on every retry** and are never reused for another operation, not even after the data expires. * **Send attempt:** `X-FTID-Request-Id`, `X-FTID-Timestamp` and the signature. They **change on every attempt.** The practical rule Store the bytes of the first send together with your operation record. Every retry resends **those same bytes**, with a new `request_id`, the current timestamp and a new signature. ## What the server does with the operation [Section titled “What the server does with the operation”](#what-the-server-does-with-the-operation) | Situation | Response | | --------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | New key | `201`, with the newly stored assessment | | Same key, same content, result still within its approved history window | `200`, with **exactly** the assessment already stored, without recomputing — even if the original `occurred_at` is already outside the ±300 s window | | Same key, same content, result outside its approved history window | `410 assessment-result-expired`, with no historical result or assessment identifier; no recomputation or new usage | | Same `idempotency_key` or same `external_event_id` with different content | `409 idempotency-conflict`, with no partial effect | | Person, account or session references incompatible with bindings already stored, for example an `account_ref` first sent with another `subject_ref` | [`409 binding-conflict`](/api/errors/#binding-conflict), with nothing stored; not retryable | “Content” means the ten body fields other than `deadline_ms`. Changing only `deadline_ms` keeps the same operation. Changing any other field creates a conflict — including replacing `null` with a token, changing the token or rewriting `occurred_at` with a different spelling (`Z` and `+00:00` are different spellings). The `event_context_token` is **single-use** and is consumed together with the assessment. A retry of the same operation does not consume it again; a new operation with an already used token receives `400 event-context-invalid`. 409 · same key, different content ```json { "type": "https://fingertrackid.dev/problems/idempotency-conflict", "title": "Chave de idempotência já usada com outro conteúdo", "status": 409, "correlation_id": "corr-problem-idempotency-0001", "retryable": false } ``` ## When to retry [Section titled “When to retry”](#when-to-retry) | Result | Retry? | How | | ------------------------------------------------------------------------ | ------ | ------------------------------------------------------------------------------------------------------------------------------------------- | | `201` or `200` | No | Use the assessment | | `401 timestamp-out-of-window` | Yes | Fix the clock; new attempt | | `401 request-replayed` | Yes | New attempt — and fix the code that reused the `request_id` | | `429 rate-limited` | Yes | Wait for `Retry-After` (today 1 s; at least 1 s if it is missing); new attempt | | `503 assessment-unavailable` | Yes | Wait for `Retry-After` (today 1 s; at least 1 s if it is missing); new attempt. The state may be indeterminate | | `410 assessment-result-expired` | No | The old result is no longer available. Stop reconciliation retries; preserve the original operation IDs and do not regenerate the operation | | Network error, timeout, truncated response or response above 65536 bytes | Yes | Treat as indeterminate; new attempt with the same body | | Any other `4xx`, or `500` | No | Fix the cause ([see errors](/api/errors/)) | A **new attempt** is always: same body, **new** `request_id`, current timestamp and new signature. This applies to `429` and `503` too: the `request_id` of the answered attempt may already have been consumed. Retry a bounded number of times, never in a loop. One operation, two attempts ```text Attempt 1 same body bytes X-FTID-Request-Id: X-FTID-Timestamp: X-FTID-Signature: → 503 assessment-unavailable, Retry-After: 1 wait at least Retry-After Attempt 2 the same body bytes X-FTID-Request-Id: X-FTID-Timestamp: X-FTID-Signature: → 200 with the assessment already stored, or 201 if the first attempt stored nothing ``` ## Timeout without a response [Section titled “Timeout without a response”](#timeout-without-a-response) After a timeout, the assessment may have been stored without the response reaching you. A retry with the same body resolves it: a retained result returns `200`; an expired result returns non-retryable `410`. Use a bounded retry policy — backoff, `Retry-After` and the deadline of your business operation. Once that deadline is exhausted, apply your own fallback and reconcile later, with the same body. A body that carries an `event_context_token` reconciles this way only if the first attempt was stored, or while the token is still before its `expiresAt` (at most your application’s [evidence window](/web-sdk/collection/#the-evidence-window), 60 or 300 seconds, after its collection). Otherwise the same body returns `400 event-context-invalid`; assess the event as a new operation with `null` instead ([integration guidance](/getting-started/integration-guidance/#time-budget)). There is no recovery `GET`: recovery is the same `POST`. # Journeys and tags > A journey is the moment your backend asks for an assessment. In the v1.1 request you name it with your own tag; the v1 profile keeps its three fixed journeys. A **journey** is the moment your backend asks for an assessment (`POST /v1/assessments`): a login, a withdrawal, an order. It is not a page; the Web SDK collects on whatever page the action happens. There are two request profiles on the same route, with the same HMAC authentication: | Profile | `schema_version` | How the moment is named | | -------- | ------------------------------- | ------------------------------------------------------------------------- | | **v1.1** | `event-assessment-request.v1.1` | A free `tag` that you choose (`login`, `saque`, `pix_transfer`, `trade`…) | | **v1** | `event-assessment-request.v1` | One of three fixed journeys: `LOGIN`, `WITHDRAWAL` or `TRADE` | Each call picks its profile by its `schema_version`; there is nothing to configure per application. New integrations should use v1.1. If you are on v1 today, see [moving from v1 to v1.1](/api/migrating-to-v1-1/). Both profiles are accepted in production Production accepts the v1.1 request with your `tag`. The v1 request is unchanged and stays accepted, so an integration on v1 keeps working as it is. ## The v1.1 tag [Section titled “The v1.1 tag”](#the-v11-tag) `tag` is a slug that names the call site in your system. It has no registration, no limit, no activation and no list to keep in sync with FingerTrackID: send it on each call. * Grammar: `^[a-z0-9][a-z0-9_]{0,63}$`. Lowercase ASCII letters, digits and underscore, 1 to 64 characters, starting with a letter or a digit. There is no normalization: `Login`, `pix-transfer` or `saque `(with a space) are refused, not corrected. * At most ten digits in total. Eleven or more digits are refused, together or apart (`cpf_12345678901` and `cpf_123_456_789_01` alike), because that is the shape of a tax ID or a phone number. This check does not prove the tag is free of personal data: that guarantee is yours. * **No personal data.** Never put an email, tax ID, phone number, name, user ID, account number or Pix key in the tag. A tag names a moment of your product, never a person. * The tag is part of the operation’s identity. A retry must repeat the whole body, tag included; the same `external_event_id` or `idempotency_key` with another tag is [`409 idempotency-conflict`](/api/errors/#idempotency-conflict). ### What the tag changes, and what it does not [Section titled “What the tag changes, and what it does not”](#what-the-tag-changes-and-what-it-does-not) The tag organises your reading. On the console you filter accesses by tag, see the volume and status of each tag, export it, and read how many events with the same tag reached the same account (when you send `account_ref`) or the same device in the last minute, five minutes and hour. The tag does not change how FingerTrackID assesses. Every v1.1 request runs the same internal behaviour, whatever its tag: * the same evidence and the same policy give the same status, score and recommendation under any tag; * the `deadline_ms` range is the same for every tag (250–5000 ms, default 5000 ms when `null`); * the tag never selects a policy, a detector, a provider, a fallback, a quota or a price; * the tag never appears in FingerTrackID’s metrics, logs or traces. Pick tags that are useful to read. The [journey templates](/api/templates/) suggest when to call, what to send and what to look at for ten common moments; they are guidance only and have no effect on the assessment. ### Example [Section titled “Example”](#example) The v1.1 body has no amount, currency, order or beneficiary field. FingerTrackID assesses the device and the access; your system already has the transaction. Context items that depend on an amount or a beneficiary come back not evaluated for a v1.1 event, with a reason that says the request does not carry them. ```json { "schema_version": "event-assessment-request.v1.1", "external_event_id": "evt_withdrawal_0001", "idempotency_key": "idem_evt_withdrawal_0001", "occurred_at": "2026-11-03T14:05:22.481Z", "subject_ref": "usr_5c0e", "account_ref": "acc_77d2", "event_context_token": "ect_withdrawal_0001", "tag": "saque", "deadline_ms": null } ``` ## The v1 profile [Section titled “The v1 profile”](#the-v1-profile) The v1 profile has three fixed journeys. Each one defines the `action`, which references are required, the shape of `journey_context` and the technical budget `deadline_ms`. It stays accepted, unchanged, for integrations that use it. | Journey | `action` | `subject_ref` and `account_ref` | `journey_context` | `deadline_ms` when `null` | Explicit range | | ------------ | -------------------- | --------------------------------------------------- | --------------------------------------------- | ------------------------- | -------------- | | `LOGIN` | `LOGIN_ATTEMPT` | May be `null`; `account_ref` requires `subject_ref` | `{}` | 5000 | 250–5000 | | `WITHDRAWAL` | `WITHDRAWAL_REQUEST` | Both required | `amount_minor`, `currency`, `beneficiary_ref` | 1000 | 500–1000 | | `TRADE` | `TRADE_REQUEST` | Both required | `{}` | 750 | 250–750 | ### `WITHDRAWAL` [Section titled “WITHDRAWAL”](#withdrawal) * `amount_minor` is a positive integer in the currency’s **minor unit** — centavos for BRL: `150000` = R$ 1,500.00. Never a decimal. * `currency` is the uppercase ISO 4217 code (`^[A-Z]{3}$`), and the currency must be on the allowlist of your application’s policy. * `beneficiary_ref` is an opaque pseudonym of the beneficiary, created by your backend. ```json { "amount_minor": 150000, "currency": "BRL", "beneficiary_ref": "beneficiary_789" } ``` ### `TRADE` [Section titled “TRADE”](#trade) An order does not trigger a new collection or an external lookup per order: it reuses the session’s fresh evidence. In the browser, several orders can come from the same accepted collection, each with its own event context ([see the SDK](/web-sdk/collection/)). In v1.1 the same flow uses the tag `trade` ([orders, one assessment each](/api/trade/)). ## `deadline_ms` [Section titled “deadline_ms”](#deadline_ms) * It is an internal technical budget, counted from when the request enters the service. A number can only **reduce** the default. * This is not an SLA. It does not change the score or the evidence and is not part of the operation’s identity: changing only `deadline_ms` on a retry keeps the same operation. * Your HTTP client timeout must add network latency on top of the budget. For a `LOGIN` (v1) or any v1.1 tag sent with `null`, that means a timeout above about 6 s (5000 ms plus the network). A body that signs `deadline_ms` explicitly may send any value up to 5000, or `null`; a body already signed with 750 or 1000 stays valid and keeps exactly that budget. ## Action when there is no score [Section titled “Action when there is no score”](#action-when-there-is-no-score) When the assessment does not end in `COMPLETED` (`INCONCLUSIVE`, `UNSCORABLE` or `FAILED`), the recommendation is `MONITOR`: for every v1 journey and for every v1.1 tag. `MONITOR` is a neutral contingency. It is not a favourable score, an authorization or an action taken; your system still decides what to do. `recommendation_basis` tells you why there is no score. Assessments created before this change may carry `STEP_UP` (`LOGIN`) or `REVIEW` (`WITHDRAWAL`) without a score, and the v1 schema still accepts them. Code against the fields you receive (`recommended_action`, `recommendation_basis`, `fallback_policy_version`), not against a fixed table. No state without a score receives `ALLOW`. # Limits > Rate, burst, time windows and sizes of the assessment API, and how to ask for a higher rate. ## Request rate [Section titled “Request rate”](#request-rate) Each server client (integration client) has its own limit: a **sustained rate** in requests per second and a **burst** that fits on top of it. The limiter is a token bucket per client, shared by every instance of the API: the bucket holds up to the burst, refills at the sustained rate, and each authenticated request takes one token. A peak of logins at 9 a.m. is absorbed by the burst; a steady flow above the sustained rate is not. | Tier | Sustained | Burst | When | | -------- | ------------- | ----- | --------------------------------------------------------------------- | | Standard | 5 per second | 10 | Default for every new application and new server client | | 10 | 10 per second | 20 | On request in the console, approved at once and applied automatically | | Legacy | 1 per second | 1 | Applications set up before 2026-10-07, until a change is requested | The console shows the limit in force for each server client under **Integração e saúde → Limite de requisições**. The limit belongs to the server client, not to the credential: rotating the secret keeps it, and changing it keeps the `key_id` and the secret. ### Above the limit: `429` [Section titled “Above the limit: 429”](#above-the-limit-429) Exceeding the rate gives `429 rate-limited` with `Retry-After` in whole seconds (today always `1`). The request was authenticated and its `request_id` is already consumed, so a retry is a **new attempt**: same body, **new** `request_id`, current timestamp and **new** signature (see [idempotency and retry](/api/idempotency-and-retry/)). Reusing the old `request_id` gives `401 request-replayed`. Every authenticated attempt, including those answered `429`, also spends a **security budget** that is twice the tier (10 per second with a burst of 20 on the standard tier). Honouring `Retry-After` never reaches it. A client that keeps retrying without waiting exhausts it, and the credential is then blocked for at least 10 minutes, during which every request is refused with `401`. Back off on `429`; do not retry in a tight loop. Momentary service saturation shows up as `503 assessment-unavailable`, also with `Retry-After`; retry it the same way, as a new attempt. Headers of a 429 ```http HTTP/1.1 429 Too Many Requests Content-Type: application/problem+json Retry-After: 1 X-Correlation-Id: ``` 429 · body ```json { "type": "https://fingertrackid.dev/problems/rate-limited", "title": "Limite de assessments atingido", "status": 429, "correlation_id": "corr-problem-rate-limit-0001", "retryable": true } ``` ### Asking for a higher rate [Section titled “Asking for a higher rate”](#asking-for-a-higher-rate) In the console, open **Integração e saúde → Limite de requisições**, choose the server client and click **Pedir aumento de limite**. A member with the *Administrador da empresa* or *Gestor de integração* role can ask. What happens next: 1. **Approval:** the request is approved as soon as it is sent, every time, including the first time a tier is used in the application. Nobody has to review it. 2. **Applying it:** the platform applies the new limit automatically within moments, even while that client keeps sending assessments; nothing changes in your integration (same key identifier and secret). A request only ever raises the limit, so requests already in flight stay valid under the higher one. The console offers up to 10 per second. For more than 10 per second, contact support at `contato@fingertrackid.com` with the application name and the server client identifier. A new server client created in the console receives the application’s current tier. ## Web SDK sessions [Section titled “Web SDK sessions”](#web-sdk-sessions) The browser SDK has its own per-application limits, separate from the server client’s request rate: | Limit | Value | | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | New SDK sessions | 5 per second, burst of 20 | | Concurrent live SDK sessions | Up to 1000 per application | | SDK session lifetime | 30 minutes, never extended | | Collections per SDK session | 1; SDK 0.2.0 and later open a new session for each new collection, at most one per second per client | | Collection and event-context requests per SDK session | One per 250 ms, which the SDK respects by itself | | Event contexts per SDK session | 128 | | Browser evidence and event context | Per application: 60 seconds for applications provisioned before the 300-second window was introduced (October 2026), 300 seconds for those provisioned since, from when the collection reaches FingerTrackID ([the evidence window](/web-sdk/collection/#the-evidence-window)) | | Request headers | Exactly those the SDK sends; any header added by page instrumentation makes the CORS preflight fail ([tracing headers](/web-sdk/installation/#tracing-and-other-request-headers)) | A refused session makes `collect()` return `status: 'UNAVAILABLE'`; the assessment is still sent with `event_context_token: null`. Details are in [collection and event context](/web-sdk/collection/#session-limits), and the SDK’s own request deadlines in [timing and latency](/web-sdk/collection/#timing-and-latency). ## Other limits [Section titled “Other limits”](#other-limits) | Limit | Value | Note | | ---------------------------------------- | --------------- | --------------------------------------------------- | | Signature timestamp window | ±300 s | `401 timestamp-out-of-window` | | `occurred_at` window for a new operation | ±300 s | `400 occurred-at-out-of-window` | | `request_id` retention | At least 601 s | Never reuse one, not even after the window | | Request body | 16384 bytes | `413 payload-too-large` | | Response body | 65536 bytes | Never larger; above that, treat it as indeterminate | | `event_context_token` | 4096 characters | `400` | None of these numbers is a commercial quota or a throughput guarantee, and the `deadline_ms` of the [journeys](/api/journeys/) is not an SLA. # Moving from v1 to v1.1 > A short checklist for an integration on event-assessment-request.v1 that moves to the tagged v1.1 request. The v1.1 request is the v1 request with a `tag` in place of the fixed journey. The route, the HMAC signature, the headers, the 16384-byte limit, the references, the browser token and the retry rules do not change. This page lists what does. Switch call by call Production accepts the v1.1 request with your `tag`. The v1 request is unchanged and stays accepted during the move, so you can switch call by call. Build and test the new body against the published v1.1 contract files: the [v1.1 OpenAPI contract](/downloads/contracts/openapi/assessment.v1.1.yaml), its [request](/downloads/contracts/json-schema/event-assessment-request.v1.1.schema.json) and [response](/downloads/contracts/json-schema/event-assessment.v1.1.schema.json) schemas and the [synthetic examples](/downloads/contracts/examples/event-assessment-v1.1.examples.json). ## The body [Section titled “The body”](#the-body) | v1 | v1.1 | | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | `"schema_version": "event-assessment-request.v1"` | `"schema_version": "event-assessment-request.v1.1"` | | `journey`, `action` and `journey_context` | Removed. Sending any of them is `400 validation-failed` | | — | `tag`, required: your name for the moment, at most ten digits in total ([journeys and tags](/api/journeys/#the-v11-tag)) | | `deadline_ms`: the journey’s range | `deadline_ms`: 250–5000, `null` = 5000, for every tag | | Everything else | Unchanged: `external_event_id`, `idempotency_key`, `occurred_at`, `subject_ref`, `account_ref`, `event_context_token` | ## The response [Section titled “The response”](#the-response) The response of a v1.1 request is `event-assessment.v1.1`. It is the v1 response with `tag` in place of `journey`: the same status, score, recommendation, indicators, versions and size limit. Check that `external_event_id` and `tag` are the ones you sent, as you checked `journey` before. A v1 request still receives `event-assessment.v1`. ## Steps [Section titled “Steps”](#steps) 1. Pick one tag per call site. For a login-only integration, `login` is enough. See the [journey templates](/api/templates/) for other moments. 2. Build the v1.1 body: change `schema_version`, drop `journey`, `action` and `journey_context`, add `tag`. Keep serializing once and signing the exact bytes you send. 3. Use **new** `external_event_id` and `idempotency_key` values for v1.1 operations. An ID already used by a v1 operation, sent again as v1.1, is `409 idempotency-conflict`: it is another operation, even for the same event. 4. During a retry, never switch profiles. A v1 operation is retried as v1, with the same bytes. 5. Read the response by its `schema_version`: `event-assessment.v1` has `journey`, `event-assessment.v1.1` has `tag`. 6. On the console, the accesses of a v1.1 call show the tag; the earlier v1 accesses keep their journey name. What does not change: the score, the policy and the recommendation for the same evidence; your credential and its permissions; the Web SDK; the feedback routes, which still address the `assessment_id`. A v1 `LOGIN` call, as a v1.1 body: ```json { "schema_version": "event-assessment-request.v1.1", "external_event_id": "evt_login_0001", "idempotency_key": "idem_evt_login_0001", "occurred_at": "2026-11-03T14:05:22.481Z", "subject_ref": "usr_5c0e", "account_ref": null, "event_context_token": "ect_login_0001", "tag": "login", "deadline_ms": null } ``` # POST /v1/assessments — the request > Endpoint, headers and body of the assessment call, in the v1.1 (tag) and v1 request profiles. Your backend requests an assessment with a single synchronous call to `POST https://api.fingertrackid.com/v1/assessments`, signed with [HMAC](/api/authentication/). The production API origin is **`https://api.fingertrackid.com`**; the dash also shows it, with a copy button, as **Endereço da API de risco** in the **Credencial servidor a servidor** step of **Início**. Configure it exactly as written, with no trailing slash or path. The path is exactly `/v1/assessments`: no query string, no trailing slash and no normalization. The body is a single JSON object in UTF-8. One attempt ```http POST /v1/assessments HTTP/1.1 Host: api.fingertrackid.com Content-Type: application/json X-FTID-Timestamp: X-FTID-Request-Id: X-FTID-External-Event-Id: evt:tag-0001 X-FTID-Signature: v1=: {"schema_version":"event-assessment-request.v1.1","external_event_id":"evt:tag-0001","idempotency_key":"idem-tag-0001","occurred_at":"2026-10-11T12:00:00.000Z","subject_ref":null,"account_ref":null,"event_context_token":null,"tag":"login","deadline_ms":null} ``` Production status The signed assessment API is **live in production in shadow mode** at `https://api.fingertrackid.com/v1/assessments`: responses are recommendations and never enforce anything. Your application needs its installed credential and active admission. Under the current managed production profile, an assessment stored without a browser `event_context_token` is `INCONCLUSIVE` with `trust_score: null`, never `COMPLETED`; an operational Problem such as `503` remains possible. See [production status](/reference/availability/). Download the exact contract files of each profile. For **v1.1**: the [v1.1 OpenAPI contract](/downloads/contracts/openapi/assessment.v1.1.yaml), [request schema](/downloads/contracts/json-schema/event-assessment-request.v1.1.schema.json), [response schema](/downloads/contracts/json-schema/event-assessment.v1.1.schema.json) and [synthetic examples](/downloads/contracts/examples/event-assessment-v1.1.examples.json). For **v1**: the [v1 OpenAPI contract](/downloads/contracts/openapi/assessment.v1.yaml), [request schema](/downloads/contracts/json-schema/event-assessment-request.v1.schema.json), [response schema](/downloads/contracts/json-schema/event-assessment.v1.schema.json) and [synthetic examples](/downloads/contracts/examples/event-assessment-v1.examples.json). Both profiles share the [Problem schema](/downloads/contracts/json-schema/assessment-problem.v1.schema.json), and the v1.1 contract also references the v1 examples for two Problems. Keep their relative directory layout when using the OpenAPI references. Their original example server and implementation-status annotation are retained; ignore that example server and use `https://api.fingertrackid.com`. Production accepts both profiles: a correctly signed v1.1 body with your `tag` is answered with `event-assessment.v1.1`, and the v1 request is unchanged. ## Headers [Section titled “Headers”](#headers) | Header | Value | Rule | | -------------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------ | | `Content-Type` | `application/json` | Exactly this. `application/json; charset=utf-8` is rejected with `415` | | `X-FTID-Timestamp` | UTC epoch in seconds, e.g. `1788523200` | Digits only, no leading zero, at most `253402300799`; accepted within ±300 s of the server clock | | `X-FTID-Request-Id` | Canonical lowercase UUIDv7 | New for every send attempt; it is the anti-replay nonce | | `X-FTID-External-Event-Id` | The body’s `external_event_id` | Byte-for-byte equal to the one in the body and in the signed string | | `X-FTID-Signature` | `v1=:` | A single signature; `mac` is 64 lowercase hexadecimal characters | Do not send these headers: | Header sent | Response | | ------------------------------------------------------------------------------------------------------------------------ | ---------------------------- | | `Content-Encoding`, any value, including `identity` | `415 unsupported-media-type` | | `Expect` or `Trailer`; duplicated `Host`, `Content-Length`, `Content-Type` or `Transfer-Encoding` | `400 validation-failed` | | `X-Client-Id`, `X-Signature`, `X-Timestamp`, `X-Nonce` | `401 authentication-failed` | | `X-FTID-Client-Id`, `X-FTID-Key-Id`, `X-FTID-Nonce`, `X-FTID-Event-Id`, `X-FTID-Business-Event-Id`, `X-FTID-Delivery-Id` | `401 authentication-failed` | | `X-FTID-Assessment-Id` on this route | `401 authentication-failed` | If your HTTP client adds `Expect: 100-continue` or `; charset=...` on its own, turn that off. Do not compress the request. There is no `X-Client-Id`, no `client_id` in the body and no client-side choice of company, application or environment: all of that is derived from the `key_id` once the signature checks out. ## Body [Section titled “Body”](#body) The body has two profiles on this route, chosen by `schema_version` on every call: **v1.1**, which names the moment with your own `tag`, and **v1**, with three fixed journeys ([journeys and tags](/api/journeys/)). v1.1 is the recommended profile for new integrations and is accepted in production; v1 remains supported, unchanged. Every field is required, including the ones that accept `null`: omitting a field is not the same as sending `null`. An unknown field is an error, and so is a field of the other profile. | Field | Format | Rule | | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | `schema_version` | `"event-assessment-request.v1.1"` or `"event-assessment-request.v1"` | Selects the profile | | `external_event_id` | `^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$` | ID of your business event; never reused | | `idempotency_key` | 8 to 255 visible ASCII characters (`0x21`–`0x7E`) | Identifies the logical operation; never reused | | `occurred_at` | RFC 3339 with seconds, explicit offset and up to 3 decimal places | When the event happened in your backend; a new operation must be within ±300 s of the server clock | | `subject_ref` | Opaque reference (same grammar as `external_event_id`, at most 128 characters) or `null` | Stable pseudonym of the person in your system ([references](#person-and-account-references)) | | `account_ref` | Opaque reference (same grammar as `external_event_id`, at most 128 characters) or `null`. Requires a non-null `subject_ref`, otherwise `400 validation-failed` | Stable pseudonym of the account. It stays bound to the `subject_ref` it was first sent with ([references](#person-and-account-references)) | | `event_context_token` | 1 to 4096 visible ASCII characters, or `null` | Web SDK token for this action; `null` = no browser context supplied | | `tag` | v1.1 only. `^[a-z0-9][a-z0-9_]{0,63}$`, with at most 10 digits in total | Your name for the moment; never personal data ([the tag](/api/journeys/#the-v11-tag)) | | `journey` | v1 only. `LOGIN`, `WITHDRAWAL` or `TRADE` | [The v1 profile](/api/journeys/#the-v1-profile) | | `action` | v1 only. Fixed per journey | [The v1 profile](/api/journeys/#the-v1-profile) | | `journey_context` | v1 only. Closed object per journey | [The v1 profile](/api/journeys/#the-v1-profile) | | `deadline_ms` | Integer within the range, or `null` | v1.1: 250–5000 for every tag; v1: the journey’s range. `null` uses the default | No personal data `subject_ref`, `account_ref` and `beneficiary_ref` are pseudonyms created by your backend, and `tag` names a moment of your product, never a person. Do not send tax IDs, email, phone, name, account number, secrets or any reversible identifier. The restricted alphabet does not detect personal data: that guarantee is yours. Unlike the tag, references have no digit limit, so an 11-digit tax ID matches their grammar and FingerTrackID cannot tell it from a pseudonym. Browser context Prepare browser collection and the backend together. Each assessed action retains its own token or `null` across retries. Send the assessment even when there is no token: `null` is a valid value. A missing context does not promise a particular status or HTTP result: validate the actual [response](/api/response/) under the issued policy. See [integration guidance](/getting-started/integration-guidance/#when-the-browser-has-no-event-context). ## Person and account references [Section titled “Person and account references”](#person-and-account-references) `subject_ref` names the person and `account_ref` names an account of that person in your system. A person may have several accounts; an account belongs to exactly one person. * **Build them as keyed pseudonyms, in your backend.** For example, HMAC-SHA-256 over your internal ID with a secret key that only your backend holds, written in hexadecimal after a short prefix of your own (`usr_…`, `acc_…`). A plain hash of a tax ID or an email is not a pseudonym: anyone can recompute it by guessing the input. Hexadecimal always fits the grammar. base64url needs a prefix, because it may start with `-` or `_`; standard base64 (`+`, `/`, `=`) is refused. The whole reference, prefix included, has at most 128 characters. * **Keep the pair stable.** Send the same pair for the same person and account on every call. An `account_ref` stays bound to the `subject_ref` it was first sent with: the same `account_ref` with another `subject_ref` returns [`409 binding-conflict`](/api/errors/#binding-conflict), and no event or assessment is stored. Changing the HMAC key or the prefix creates new people and new accounts, so their history starts again. Never change the key of `subject_ref` alone while keeping `account_ref`: every existing account would then return `409`. A new key for `subject_ref` also makes each person a second, new person on the browsers they already used: for 30 days `DEVICE_MULTI_USER` counts both pseudonyms, so it overcounts the people on those devices (it is `DETECTED` above three people) until the old pseudonyms leave its window. * **Send them whenever the user is identified.** Send `subject_ref` on every assessed moment where your backend knows the user; for a login, after your backend has authenticated the user. Send `account_ref` with it when the moment concerns one of that person’s accounts; it always travels with its `subject_ref`. Each [journey template](/api/templates/#the-ten-templates) says which references its moment takes: a password recovery, for example, sends `account_ref: null`. * **What they change.** On the console, `account_ref` feeds the account items of [**Contexto do acesso**](/console/screens/#contexto-do-acesso) (access context), the accounts in **Vínculos** and the [search by account](/console/screens/#busca-por-id). The 13 indicators and `evidence_coverage` are the same with or without it; `trust_score` and the recommendation change only when your company has activated a context rule on an account item ([company risk rules](/api/response/#recommended_action)). `DEVICE_MULTI_USER` counts people through `subject_ref`, so `subject_ref` changes the responses of later collections ([why an indicator was not evaluated](/api/response/#why-an-indicator-was-not-evaluated)). * **History starts at the first event that carries the reference.** Earlier events are never linked to it afterwards. The first access of each account with a completed browser collection shows **Primeira vez: ainda sem histórico** (first time: no history yet) on the items that compare with the account’s past; without a completed browser collection they show **Sem coleta concluída do navegador** (no completed browser collection). A later login evaluates **Dispositivo novo para esta conta** only when an earlier access of the same account in the last 90 days also had a browser collection whose browser installation was linked; an account idle for more than 90 days shows **Primeira vez** again. * **One account per person.** If your product has a single account per person, still send both, with a distinct, stable pseudonym for the account. ## Body byte rules [Section titled “Body byte rules”](#body-byte-rules) * At most **16384 bytes**, counted before any parsing or signature check. Above that: `413 payload-too-large`. * UTF-8 without BOM, a single JSON document, nothing after it and **no duplicate key** at any level (including keys that only become equal after unescaping). * Only objects, strings, `null` and integers; arrays and booleans are rejected. * Numbers must have an exact, safe integer value (up to 9007199254740991). Send plain integers. * The signature and the server read **the same bytes**. Serialize once, with a single `JSON.stringify` call, keep the bytes, and sign and send exactly those bytes. ## Request examples [Section titled “Request examples”](#request-examples) A synthetic v1.1 example. Each [journey template](/api/templates/) has its own. ```json { "schema_version": "event-assessment-request.v1.1", "external_event_id": "evt_login_0001", "idempotency_key": "idem_evt_login_0001", "occurred_at": "2026-11-03T14:05:22.481Z", "subject_ref": "usr_5c0e", "account_ref": "acc_77d2", "event_context_token": "ect_login_0001", "tag": "login", "deadline_ms": null } ``` Synthetic v1 examples, one per journey. LOGIN ```json { "schema_version": "event-assessment-request.v1", "external_event_id": "login_20260904_000001", "idempotency_key": "idem_login_20260904_000001", "occurred_at": "2026-09-04T12:00:00Z", "subject_ref": "subject_123", "account_ref": "account_456", "event_context_token": "ect_login_20260904_000001", "journey": "LOGIN", "action": "LOGIN_ATTEMPT", "journey_context": {}, "deadline_ms": null } ``` WITHDRAWAL ```json { "schema_version": "event-assessment-request.v1", "external_event_id": "withdrawal_20260904_000001", "idempotency_key": "idem_withdrawal_20260904_000001", "occurred_at": "2026-09-04T12:00:00Z", "subject_ref": "subject_123", "account_ref": "account_456", "event_context_token": "ect_withdrawal_20260904_000001", "journey": "WITHDRAWAL", "action": "WITHDRAWAL_REQUEST", "journey_context": { "amount_minor": 150000, "currency": "BRL", "beneficiary_ref": "beneficiary_789" }, "deadline_ms": 1000 } ``` TRADE ```json { "schema_version": "event-assessment-request.v1", "external_event_id": "trade_20260904_000001", "idempotency_key": "idem_trade_20260904_000001", "occurred_at": "2026-09-04T12:00:00Z", "subject_ref": "subject_123", "account_ref": "account_456", "event_context_token": "ect_trade_20260904_000001", "journey": "TRADE", "action": "TRADE_REQUEST", "journey_context": {}, "deadline_ms": 750 } ``` The examples are formatted for reading. In the real call, send the compact JSON that `JSON.stringify(object)` produces. Next step: [HMAC authentication](/api/authentication/). # The response — status, score and recommendation > How to read event-assessment.v1.1 and event-assessment.v1 — assessment_status, trust_score, recommended_action, indicators and the other fields. `201` (new assessment) and `200` (repeat of the same operation) carry the same shape: `event-assessment.v1.1` for a v1.1 request, `event-assessment.v1` for a v1 request. The two are identical except for one field: v1.1 carries your `tag` where v1 carries `journey`. Always check that the response’s `external_event_id` and `tag` (or `journey`) are the ones you sent. Validate a v1.1 response against the exact [v1.1 response schema](/downloads/contracts/json-schema/event-assessment.v1.1.schema.json) and a v1 response against the exact [v1 response schema](/downloads/contracts/json-schema/event-assessment.v1.schema.json). Store the response as received; do not reconstruct fields. ## Assessment status [Section titled “Assessment status”](#assessment-status) | `assessment_status` | `trust_score` | `recommendation_basis` | When | | ------------------- | --------------------- | ----------------------- | ---------------------------------------------------------------------------------------- | | `COMPLETED` | Integer from 0 to 100 | `EVALUATION` | Web SDK evidence was present, all required evidence was fresh and a policy was available | | `INCONCLUSIVE` | `null` | `INSUFFICIENT_EVIDENCE` | The effective policy could not evaluate sufficient evidence | | `UNSCORABLE` | `null` | `OUT_OF_POLICY_SCOPE` | The event is outside the policy’s scope | | `FAILED` | `null` | `TECHNICAL_FAILURE` | Technical failure | A stored assessment always has `retryable: false`, including `INCONCLUSIVE`, `UNSCORABLE` and `FAILED`. Repeating the call does not produce a new score. Missing browser context If no context is available, preserve `event_context_token: null` on every retry of that action. Under the current managed production profile, an assessment stored without a context is `INCONCLUSIVE`, never `COMPLETED`. The input alone guarantees neither: another effective policy could produce another no-score state, and runtime availability can produce an operational Problem. Read the returned status, recommendation and versions; never infer success from the input. * COMPLETED 201 · login ```json { "schema_version": "event-assessment.v1.1", "assessment_id": "01996f2a-0000-7000-8000-000000000101", "assessment_version": 1, "supersedes_assessment_id": null, "business_event_id": "01996f2a-0000-7000-8000-000000000102", "external_event_id": "login_20260904_000001", "tag": "login", "assessment_status": "COMPLETED", "retryable": false, "trust_score": 100, "device_intelligence_snapshot_id": "01996f2a-0000-7000-8000-000000000103", "device_intelligence_snapshot_version": 2, "device_id": "dev_AZlvKYwefUqy8GqePF17GA", "device_match_confidence": null, "evidence_coverage": 100, "data_freshness": { "evidence_cutoff_at": "2026-09-04T12:00:00.105Z", "oldest_required_observed_at": "2026-09-04T11:59:57.318Z", "maximum_required_age_ms": 300000, "all_required_fresh": true }, "signals": [ { "signal_code": "ANTI_FINGERPRINTING", "channel": "WEB", "signal_state": "NOT_DETECTED", "applicability": "APPLICABLE", "source": "SDK", "detector_version": "ftid-anti-fingerprinting.v2", "detector_confidence": 0.4, "observed_at": "2026-09-04T11:59:57.318Z", "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": 2825, "not_evaluated_reason_code": null, "applicability_reason_code": null }, { "signal_code": "BOT_AUTOMATION", "channel": "WEB", "signal_state": "NOT_DETECTED", "applicability": "APPLICABLE", "source": "SDK", "detector_version": "ftid-bot-automation.probe.v3", "detector_confidence": 0.6, "observed_at": "2026-09-04T11:59:57.318Z", "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": 2825, "not_evaluated_reason_code": null, "applicability_reason_code": null }, { "signal_code": "BROWSER_PRIVACY_BRAVE", "channel": "WEB", "signal_state": "NOT_DETECTED", "applicability": "APPLICABLE", "source": "SDK", "detector_version": "ftid-browser-privacy-brave.v1", "detector_confidence": 0.8, "observed_at": "2026-09-04T11:59:57.318Z", "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": 2825, "not_evaluated_reason_code": null, "applicability_reason_code": null }, { "signal_code": "BROWSER_PRIVACY_SAFARI", "channel": "WEB", "signal_state": "NOT_DETECTED", "applicability": "APPLICABLE", "source": "SDK", "detector_version": "ftid-browser-privacy-safari.v1", "detector_confidence": 0.5, "observed_at": "2026-09-04T11:59:57.318Z", "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": 2825, "not_evaluated_reason_code": null, "applicability_reason_code": null }, { "signal_code": "BROWSER_PRIVACY_SAMSUNG_INTERNET", "channel": "WEB", "signal_state": "NOT_DETECTED", "applicability": "APPLICABLE", "source": "SDK", "detector_version": "ftid-browser-privacy-samsung-internet.v1", "detector_confidence": 0.6, "observed_at": "2026-09-04T11:59:57.318Z", "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": 2825, "not_evaluated_reason_code": null, "applicability_reason_code": null }, { "signal_code": "BROWSER_SPOOFING", "channel": "WEB", "signal_state": "NOT_DETECTED", "applicability": "APPLICABLE", "source": "SDK", "detector_version": "browser-spoofing-coherence.v2", "detector_confidence": 0.5, "observed_at": "2026-09-04T11:59:57.318Z", "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": 2825, "not_evaluated_reason_code": null, "applicability_reason_code": null }, { "signal_code": "DEVICE_MULTI_USER", "channel": "WEB", "signal_state": "NOT_DETECTED", "applicability": "APPLICABLE", "source": "SERVER_CONTEXT", "detector_version": "ftid-device-multi-user.v1", "detector_confidence": 0.5, "observed_at": "2026-09-04T11:59:57.318Z", "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": 2825, "not_evaluated_reason_code": null, "applicability_reason_code": null }, { "signal_code": "EMULATED_ENVIRONMENT", "channel": "WEB", "signal_state": "NOT_DETECTED", "applicability": "APPLICABLE", "source": "SDK", "detector_version": "emulated-environment-coherence.v2", "detector_confidence": 0.3, "observed_at": "2026-09-04T11:59:57.318Z", "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": 2825, "not_evaluated_reason_code": null, "applicability_reason_code": null }, { "signal_code": "NETWORK_HOSTING", "channel": "WEB", "signal_state": "NOT_DETECTED", "applicability": "APPLICABLE", "source": "PROVIDER", "detector_version": "ftid-network-hosting.v1", "detector_confidence": 0.6, "observed_at": "2026-09-04T11:59:57.318Z", "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": 2825, "not_evaluated_reason_code": null, "applicability_reason_code": null }, { "signal_code": "NETWORK_PROXY", "channel": "WEB", "signal_state": "NOT_DETECTED", "applicability": "APPLICABLE", "source": "PROVIDER", "detector_version": "ftid-network-proxy.v1", "detector_confidence": 0.4, "observed_at": "2026-09-04T11:59:57.318Z", "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": 2825, "not_evaluated_reason_code": null, "applicability_reason_code": null }, { "signal_code": "NETWORK_TOR", "channel": "WEB", "signal_state": "NOT_DETECTED", "applicability": "APPLICABLE", "source": "PROVIDER", "detector_version": "ftid-network-tor.v1", "detector_confidence": 0.9, "observed_at": "2026-09-04T11:59:57.318Z", "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": 2825, "not_evaluated_reason_code": null, "applicability_reason_code": null }, { "signal_code": "NETWORK_VPN", "channel": "WEB", "signal_state": "NOT_DETECTED", "applicability": "APPLICABLE", "source": "PROVIDER", "detector_version": "ftid-network-vpn.v1", "detector_confidence": 0.4, "observed_at": "2026-09-04T11:59:57.318Z", "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": 2825, "not_evaluated_reason_code": null, "applicability_reason_code": null }, { "signal_code": "PRIVATE_BROWSING", "channel": "WEB", "signal_state": "NOT_DETECTED", "applicability": "APPLICABLE", "source": "SDK", "detector_version": "ftid-private-browsing.storage-mode.v2", "detector_confidence": 0.5, "observed_at": "2026-09-04T11:59:57.318Z", "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": 2825, "not_evaluated_reason_code": null, "applicability_reason_code": null } ], "limitation_codes": [], "reason_codes": [ "NO_ADVERSE_INDICATOR_MATCH" ], "recommended_action": "MONITOR", "recommendation_basis": "EVALUATION", "policy_version": "production-detectors-01996f29-4d2c-7b18-9e6a-3f0c8d1b5a72-login", "fallback_policy_version": "production-observation-fallback-v2", "ruleset_version": "d95-detectors.v1", "signal_catalog_version": "web-detectors-v3", "feature_schema_version": "configured-assessment-features.v1", "resolver_version": "first-party-installation-v1", "sdk_version": "0.2.1", "provider_versions": [ "asn-reputation.v1", "cloud-ranges.v1", "icloud-private-relay.v1", "ip2proxy-lite-px2.v1", "tor-exit.v1", "x4b-vpn.v1" ], "evidence_cutoff_at": "2026-09-04T12:00:00.105Z", "evaluated_at": "2026-09-04T12:00:00.143Z", "expires_at": "2026-09-04T12:01:00.143Z", "correlation_id": "01996f2a-1111-7000-8000-000000000105" } ``` * INCONCLUSIVE 201 · no browser token ```json { "schema_version": "event-assessment.v1.1", "assessment_id": "01996f2a-0000-7000-8000-000000000401", "assessment_version": 1, "supersedes_assessment_id": null, "business_event_id": "01996f2a-0000-7000-8000-000000000402", "external_event_id": "login_20260904_000002", "tag": "login", "assessment_status": "INCONCLUSIVE", "retryable": false, "trust_score": null, "device_intelligence_snapshot_id": null, "device_intelligence_snapshot_version": null, "device_id": null, "device_match_confidence": null, "evidence_coverage": 0, "data_freshness": { "evidence_cutoff_at": "2026-09-04T12:00:00.105Z", "oldest_required_observed_at": null, "maximum_required_age_ms": 300000, "all_required_fresh": false }, "signals": [ { "signal_code": "ANTI_FINGERPRINTING", "channel": "WEB", "signal_state": "NOT_EVALUATED", "applicability": "APPLICABLE", "source": "DERIVED", "detector_version": "unavailable-web-detectors.v1", "detector_confidence": null, "observed_at": null, "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": null, "not_evaluated_reason_code": "COLLECTOR_NOT_LOADED", "applicability_reason_code": null }, { "signal_code": "BOT_AUTOMATION", "channel": "WEB", "signal_state": "NOT_EVALUATED", "applicability": "APPLICABLE", "source": "DERIVED", "detector_version": "unavailable-web-detectors.v1", "detector_confidence": null, "observed_at": null, "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": null, "not_evaluated_reason_code": "COLLECTOR_NOT_LOADED", "applicability_reason_code": null }, { "signal_code": "BROWSER_PRIVACY_BRAVE", "channel": "WEB", "signal_state": "NOT_EVALUATED", "applicability": "APPLICABLE", "source": "DERIVED", "detector_version": "unavailable-web-detectors.v1", "detector_confidence": null, "observed_at": null, "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": null, "not_evaluated_reason_code": "COLLECTOR_NOT_LOADED", "applicability_reason_code": null }, { "signal_code": "BROWSER_PRIVACY_SAFARI", "channel": "WEB", "signal_state": "NOT_EVALUATED", "applicability": "APPLICABLE", "source": "DERIVED", "detector_version": "unavailable-web-detectors.v1", "detector_confidence": null, "observed_at": null, "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": null, "not_evaluated_reason_code": "COLLECTOR_NOT_LOADED", "applicability_reason_code": null }, { "signal_code": "BROWSER_PRIVACY_SAMSUNG_INTERNET", "channel": "WEB", "signal_state": "NOT_EVALUATED", "applicability": "APPLICABLE", "source": "DERIVED", "detector_version": "unavailable-web-detectors.v1", "detector_confidence": null, "observed_at": null, "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": null, "not_evaluated_reason_code": "COLLECTOR_NOT_LOADED", "applicability_reason_code": null }, { "signal_code": "BROWSER_SPOOFING", "channel": "WEB", "signal_state": "NOT_EVALUATED", "applicability": "APPLICABLE", "source": "DERIVED", "detector_version": "unavailable-web-detectors.v1", "detector_confidence": null, "observed_at": null, "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": null, "not_evaluated_reason_code": "COLLECTOR_NOT_LOADED", "applicability_reason_code": null }, { "signal_code": "DEVICE_MULTI_USER", "channel": "WEB", "signal_state": "NOT_EVALUATED", "applicability": "APPLICABLE", "source": "DERIVED", "detector_version": "unavailable-web-detectors.v1", "detector_confidence": null, "observed_at": null, "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": null, "not_evaluated_reason_code": "COLLECTOR_NOT_LOADED", "applicability_reason_code": null }, { "signal_code": "EMULATED_ENVIRONMENT", "channel": "WEB", "signal_state": "NOT_EVALUATED", "applicability": "APPLICABLE", "source": "DERIVED", "detector_version": "unavailable-web-detectors.v1", "detector_confidence": null, "observed_at": null, "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": null, "not_evaluated_reason_code": "COLLECTOR_NOT_LOADED", "applicability_reason_code": null }, { "signal_code": "NETWORK_HOSTING", "channel": "WEB", "signal_state": "NOT_EVALUATED", "applicability": "APPLICABLE", "source": "DERIVED", "detector_version": "unavailable-web-detectors.v1", "detector_confidence": null, "observed_at": null, "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": null, "not_evaluated_reason_code": "COLLECTOR_NOT_LOADED", "applicability_reason_code": null }, { "signal_code": "NETWORK_PROXY", "channel": "WEB", "signal_state": "NOT_EVALUATED", "applicability": "APPLICABLE", "source": "DERIVED", "detector_version": "unavailable-web-detectors.v1", "detector_confidence": null, "observed_at": null, "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": null, "not_evaluated_reason_code": "COLLECTOR_NOT_LOADED", "applicability_reason_code": null }, { "signal_code": "NETWORK_TOR", "channel": "WEB", "signal_state": "NOT_EVALUATED", "applicability": "APPLICABLE", "source": "DERIVED", "detector_version": "unavailable-web-detectors.v1", "detector_confidence": null, "observed_at": null, "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": null, "not_evaluated_reason_code": "COLLECTOR_NOT_LOADED", "applicability_reason_code": null }, { "signal_code": "NETWORK_VPN", "channel": "WEB", "signal_state": "NOT_EVALUATED", "applicability": "APPLICABLE", "source": "DERIVED", "detector_version": "unavailable-web-detectors.v1", "detector_confidence": null, "observed_at": null, "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": null, "not_evaluated_reason_code": "COLLECTOR_NOT_LOADED", "applicability_reason_code": null }, { "signal_code": "PRIVATE_BROWSING", "channel": "WEB", "signal_state": "NOT_EVALUATED", "applicability": "APPLICABLE", "source": "DERIVED", "detector_version": "unavailable-web-detectors.v1", "detector_confidence": null, "observed_at": null, "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": null, "not_evaluated_reason_code": "COLLECTOR_NOT_LOADED", "applicability_reason_code": null } ], "limitation_codes": [ "COLLECTOR_NOT_LOADED", "REQUIRED_EVIDENCE_MISSING" ], "reason_codes": [ "REQUIRED_EVIDENCE_MISSING" ], "recommended_action": "MONITOR", "recommendation_basis": "INSUFFICIENT_EVIDENCE", "policy_version": "production-detectors-01996f29-4d2c-7b18-9e6a-3f0c8d1b5a72-login", "fallback_policy_version": "production-observation-fallback-v2", "ruleset_version": "d95-detectors.v1", "signal_catalog_version": "web-detectors-v3", "feature_schema_version": "configured-assessment-features.v1", "resolver_version": "first-party-installation-v1", "sdk_version": null, "provider_versions": [], "evidence_cutoff_at": "2026-09-04T12:00:00.105Z", "evaluated_at": "2026-09-04T12:00:00.143Z", "expires_at": "2026-09-04T12:01:00.143Z", "correlation_id": "01996f2a-1111-7000-8000-000000000405" } ``` * FAILED 201 · policy evaluation unavailable ```json { "schema_version": "event-assessment.v1.1", "assessment_id": "01996f2a-0000-7000-8000-000000000701", "assessment_version": 1, "supersedes_assessment_id": null, "business_event_id": "01996f2a-0000-7000-8000-000000000702", "external_event_id": "pix_20260904_000001", "tag": "pix_transfer", "assessment_status": "FAILED", "retryable": false, "trust_score": null, "device_intelligence_snapshot_id": "01996f2a-0000-7000-8000-000000000703", "device_intelligence_snapshot_version": 2, "device_id": "dev_AZlvKYwefUqy8GqePF17GA", "device_match_confidence": null, "evidence_coverage": 100, "data_freshness": { "evidence_cutoff_at": "2026-09-04T12:00:00.105Z", "oldest_required_observed_at": "2026-09-04T11:59:57.318Z", "maximum_required_age_ms": 300000, "all_required_fresh": false }, "signals": [ { "signal_code": "ANTI_FINGERPRINTING", "channel": "WEB", "signal_state": "NOT_DETECTED", "applicability": "APPLICABLE", "source": "SDK", "detector_version": "ftid-anti-fingerprinting.v2", "detector_confidence": 0.4, "observed_at": "2026-09-04T11:59:57.318Z", "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": 2825, "not_evaluated_reason_code": null, "applicability_reason_code": null }, { "signal_code": "BOT_AUTOMATION", "channel": "WEB", "signal_state": "DETECTED", "applicability": "APPLICABLE", "source": "SDK", "detector_version": "ftid-bot-automation.probe.v3", "detector_confidence": 0.6, "observed_at": "2026-09-04T11:59:57.318Z", "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": 2825, "not_evaluated_reason_code": null, "applicability_reason_code": null }, { "signal_code": "BROWSER_PRIVACY_BRAVE", "channel": "WEB", "signal_state": "NOT_DETECTED", "applicability": "APPLICABLE", "source": "SDK", "detector_version": "ftid-browser-privacy-brave.v1", "detector_confidence": 0.8, "observed_at": "2026-09-04T11:59:57.318Z", "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": 2825, "not_evaluated_reason_code": null, "applicability_reason_code": null }, { "signal_code": "BROWSER_PRIVACY_SAFARI", "channel": "WEB", "signal_state": "NOT_DETECTED", "applicability": "APPLICABLE", "source": "SDK", "detector_version": "ftid-browser-privacy-safari.v1", "detector_confidence": 0.5, "observed_at": "2026-09-04T11:59:57.318Z", "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": 2825, "not_evaluated_reason_code": null, "applicability_reason_code": null }, { "signal_code": "BROWSER_PRIVACY_SAMSUNG_INTERNET", "channel": "WEB", "signal_state": "NOT_DETECTED", "applicability": "APPLICABLE", "source": "SDK", "detector_version": "ftid-browser-privacy-samsung-internet.v1", "detector_confidence": 0.6, "observed_at": "2026-09-04T11:59:57.318Z", "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": 2825, "not_evaluated_reason_code": null, "applicability_reason_code": null }, { "signal_code": "BROWSER_SPOOFING", "channel": "WEB", "signal_state": "NOT_DETECTED", "applicability": "APPLICABLE", "source": "SDK", "detector_version": "browser-spoofing-coherence.v2", "detector_confidence": 0.5, "observed_at": "2026-09-04T11:59:57.318Z", "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": 2825, "not_evaluated_reason_code": null, "applicability_reason_code": null }, { "signal_code": "DEVICE_MULTI_USER", "channel": "WEB", "signal_state": "NOT_DETECTED", "applicability": "APPLICABLE", "source": "SERVER_CONTEXT", "detector_version": "ftid-device-multi-user.v1", "detector_confidence": 0.5, "observed_at": "2026-09-04T11:59:57.318Z", "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": 2825, "not_evaluated_reason_code": null, "applicability_reason_code": null }, { "signal_code": "EMULATED_ENVIRONMENT", "channel": "WEB", "signal_state": "NOT_DETECTED", "applicability": "APPLICABLE", "source": "SDK", "detector_version": "emulated-environment-coherence.v2", "detector_confidence": 0.3, "observed_at": "2026-09-04T11:59:57.318Z", "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": 2825, "not_evaluated_reason_code": null, "applicability_reason_code": null }, { "signal_code": "NETWORK_HOSTING", "channel": "WEB", "signal_state": "NOT_DETECTED", "applicability": "APPLICABLE", "source": "PROVIDER", "detector_version": "ftid-network-hosting.v1", "detector_confidence": 0.6, "observed_at": "2026-09-04T11:59:57.318Z", "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": 2825, "not_evaluated_reason_code": null, "applicability_reason_code": null }, { "signal_code": "NETWORK_PROXY", "channel": "WEB", "signal_state": "NOT_DETECTED", "applicability": "APPLICABLE", "source": "PROVIDER", "detector_version": "ftid-network-proxy.v1", "detector_confidence": 0.4, "observed_at": "2026-09-04T11:59:57.318Z", "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": 2825, "not_evaluated_reason_code": null, "applicability_reason_code": null }, { "signal_code": "NETWORK_TOR", "channel": "WEB", "signal_state": "NOT_DETECTED", "applicability": "APPLICABLE", "source": "PROVIDER", "detector_version": "ftid-network-tor.v1", "detector_confidence": 0.9, "observed_at": "2026-09-04T11:59:57.318Z", "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": 2825, "not_evaluated_reason_code": null, "applicability_reason_code": null }, { "signal_code": "NETWORK_VPN", "channel": "WEB", "signal_state": "NOT_DETECTED", "applicability": "APPLICABLE", "source": "PROVIDER", "detector_version": "ftid-network-vpn.v1", "detector_confidence": 0.4, "observed_at": "2026-09-04T11:59:57.318Z", "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": 2825, "not_evaluated_reason_code": null, "applicability_reason_code": null }, { "signal_code": "PRIVATE_BROWSING", "channel": "WEB", "signal_state": "NOT_DETECTED", "applicability": "APPLICABLE", "source": "SDK", "detector_version": "ftid-private-browsing.storage-mode.v2", "detector_confidence": 0.5, "observed_at": "2026-09-04T11:59:57.318Z", "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": 2825, "not_evaluated_reason_code": null, "applicability_reason_code": null } ], "limitation_codes": [ "UNSUPPORTED_RUNTIME" ], "reason_codes": [ "POLICY_EVALUATION_UNAVAILABLE" ], "recommended_action": "MONITOR", "recommendation_basis": "TECHNICAL_FAILURE", "policy_version": "risk-login-v3", "fallback_policy_version": "production-observation-fallback-v2", "ruleset_version": "risk-login-rules.v3", "signal_catalog_version": "web-detectors-v3", "feature_schema_version": "configured-assessment-features.v1", "resolver_version": "first-party-installation-v1", "sdk_version": "0.2.1", "provider_versions": [ "asn-reputation.v1", "cloud-ranges.v1", "icloud-private-relay.v1", "ip2proxy-lite-px2.v1", "tor-exit.v1", "x4b-vpn.v1" ], "evidence_cutoff_at": "2026-09-04T12:00:00.105Z", "evaluated_at": "2026-09-04T12:00:00.143Z", "expires_at": "2026-09-04T12:01:00.143Z", "correlation_id": "01996f2a-1111-7000-8000-000000000705" } ``` Synthetic examples from the published [v1.1 examples](/downloads/contracts/examples/event-assessment-v1.1.examples.json). ## `trust_score` [Section titled “trust_score”](#trust_score) * Ranges from **0 to 100**, and **higher is better**: more confidence **in this access**. * Exists only in `COMPLETED`. In the other states it is `null` — and `null` is not 0, not 100 and not “all good”. * **It is not a probability**: 90 does not mean “90% chance of being legitimate”. * It is not a reputation of the person or the account, and it is not reused for another event. `expires_at` says until when the result is valid. A score is not a decision The score is an index ordered by the policy. It is not yet calibrated as a probability. Use it to compare and investigate, together with the recommendation — not as an automatic blocking rule. ## `recommended_action` [Section titled “recommended_action”](#recommended_action) | Value | Reading | Your action that counts as aligned | | ------------------- | ------------------------------------------------------- | ---------------------------------- | | `ALLOW` | Proceed | `ALLOWED` | | `MONITOR` | Proceed and watch | `ALLOWED` | | `STEP_UP` | Ask for additional verification | `CHALLENGED` | | `REVIEW` | Human review before concluding | `REVIEWED` or `HELD` | | `BLOCK_RECOMMENDED` | FingerTrackID recommends blocking; it did **not** block | `BLOCKED` | FingerTrackID recommends; your system decides and acts. During shadow validation, use the recommendation to observe and compare with your current decision, not as a blocking trigger. Under FingerTrackID’s managed policy, privacy, Tor, proxy/VPN, hosting or shared-device signals, on their own, never produce `BLOCK_RECOMMENDED`, and every recommendation is `MONITOR`. The exception is your company’s own risk rules: your administrator can make indicators **active** in [**Configuração de risco**](/console/screens/#configura%C3%A7%C3%A3o-de-risco), each with a weight you choose. Any rule can be published as active directly, but only your company can activate it. Besides the 13 indicators, the rules cover the 54 items of **Contexto do acesso**, computed during the assessment: each item fires on a ruler your company adjusts (**Incomum** or **Muito incomum**, each with its own points). The score starts at 100, each active rule that fires deducts its points, and the final score falls into the bands you set; a score inside your blocking band returns `BLOCK_RECOMMENDED`, even when a single rule caused it. In a scored (`COMPLETED`) response, an active context rule that fires is listed in `reason_codes` under the item’s code (for example `VELOCITY_ACCOUNT`); a response without a score carries no rule codes, even when a rule fired. It is still a recommendation: your backend executes the block, and FingerTrackID never blocks anything. Missing evidence, a timeout or a failure never produce `BLOCK_RECOMMENDED`: an active indicator without usable evidence leaves the assessment without a score. An active context item without data never deducts; if the score then falls into your allow band, the assessment stays without a score (`INCONCLUSIVE`, `MONITOR`). If the context step does not run inside the assessment, every context item counts as without data for it, even when the console later shows the items computed after it. Rules in **ghost** mode are measured but never change the response ([availability](/reference/availability/)). Your company’s block and allow lists also decide the recommendation of a scored access. When an active entry of [**Listas**](/console/screens/#listas) names the access’s device, account reference or IP, a `COMPLETED` response carries the entry’s action whatever the score: **Bloquear** returns `BLOCK_RECOMMENDED`, **Revisar** returns `REVIEW` and **Liberar** returns `ALLOW`. With several entries, block wins over review, which wins over allow. The score stays the policy’s, and `reason_codes` starts with one code per deciding entry, `CUSTOMER_LIST__` (for example `CUSTOMER_LIST_BLOCK_DEVICE`, `CUSTOMER_LIST_REVIEW_ACCOUNT` or `CUSTOMER_LIST_ALLOW_IP`). An access without a score keeps the policy’s recommendation: a list never turns an access FingerTrackID could not score into a block. The device and the IP come from the collection the `event_context_token` names: the IP is the address the collection edge recorded (still kept at the time of the access), not necessarily the one your backend sees. Without the token, an access can match by its account reference only. ## Indicators (`signals`) [Section titled “Indicators (signals)”](#indicators-signals) The response always carries the **13 Web indicators**, each one `DETECTED`, `NOT_DETECTED` or `NOT_EVALUATED`: `ANTI_FINGERPRINTING`, `BOT_AUTOMATION`, `BROWSER_SPOOFING`, `EMULATED_ENVIRONMENT`, `PRIVATE_BROWSING`, `BROWSER_PRIVACY_BRAVE`, `BROWSER_PRIVACY_SAFARI`, `BROWSER_PRIVACY_SAMSUNG_INTERNET`, `NETWORK_PROXY`, `NETWORK_VPN`, `NETWORK_HOSTING`, `NETWORK_TOR` and `DEVICE_MULTI_USER`. `NOT_EVALUATED` is **not** `NOT_DETECTED`: a missing, stale or failing detector never becomes “not detected”. Being on the list does not mean the indicator is evaluated today. Indicators without an active applicable detector come back `NOT_EVALUATED`; `limitation_codes` explains why. ### Why an indicator was not evaluated [Section titled “Why an indicator was not evaluated”](#why-an-indicator-was-not-evaluated) Each entry of `signals` carries its own reason. `DETECTED` and `NOT_DETECTED` come with `not_evaluated_reason_code: null`, a `detector_confidence`, `observed_at` and `freshness_ms`; `NOT_EVALUATED` comes with those three `null` and `not_evaluated_reason_code` set to one of the [limitation codes](#limitation-codes), which says why that indicator, and only it, was not evaluated. Every code found on an indicator is also listed in `limitation_codes`, the assessment-level list; the opposite does not hold (see below). Read the per-indicator code to know which indicator a limitation affected. Every indicator is `APPLICABLE` today, with `applicability_reason_code: null`. Without an `event_context_token`, all 13 indicators carry `COLLECTOR_NOT_LOADED`. **`DEVICE_MULTI_USER`** counts the distinct people (`subject_ref`) seen on the same browser installation in the 30 days before the collection; `account_ref` does not change it. It needs the first-party installation cookie, and it counts only the people FingerTrackID has already linked to that installation, a link recorded asynchronously after an assessment is stored. The first login on a new installation, and a login whose collection comes before the earlier link is recorded, therefore return it `NOT_EVALUATED` with `REQUIRED_EVIDENCE_MISSING`; later logins evaluate it. **The account reference feeds the account items of Contexto do acesso.** The 13 indicators and `evidence_coverage` are the same with or without `account_ref`. The account items of [**Contexto do acesso**](/console/screens/#contexto-do-acesso) need it; they change `trust_score` and the recommendation only when your company made a context rule on them active ([person and account references](/api/overview/#person-and-account-references)). **The person reference (`subject_ref`) feeds `DEVICE_MULTI_USER` for later collections.** An assessment stored without it links no person to the installation. While no assessment on a browser carried one in the last 30 days, later logins there count no one and return `DEVICE_MULTI_USER` `NOT_EVALUATED` with `REQUIRED_EVIDENCE_MISSING`, which lowers their `evidence_coverage`. When your company’s own rules make `DEVICE_MULTI_USER` active, sending `subject_ref` also changes the `trust_score` and recommendation of those later logins: an active indicator without usable evidence leaves the assessment without a score ([`recommended_action`](#recommended_action)). ## Other fields [Section titled “Other fields”](#other-fields) | Field | Purpose | | ---------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `assessment_id` | UUIDv7 of the assessment; it is the resource for [feedback](/api/feedback/) | | `business_event_id` | UUIDv7 of the event in FingerTrackID | | `assessment_version`, `supersedes_assessment_id` | `1` and `null` in the synchronous response | | `evidence_coverage` | 0 to 100: how much of the expected checks was actually evaluated | | `data_freshness` | Evidence cutoff, oldest required evidence and whether everything was fresh | | `limitation_codes` | Why something was not evaluated (e.g. `COLLECTOR_NOT_LOADED`); see [limitation codes](#limitation-codes). Empty (`[]`) when all 13 indicators were evaluated, even with a `null` `device_match_confidence` | | `reason_codes` | Reasons for the assessment | | `device_id`, `device_match_confidence` | Device continuity. `device_id` is an opaque alias (`dev_…`) when the browser installation was linked, otherwise `null`; it does not identify a physical device. `device_match_confidence` is always `null` today: no detector calibrates it yet, so do not wait for a value | | `policy_version`, `fallback_policy_version` and the other `*_version` fields | Versions that produced the result | | `evidence_cutoff_at`, `evaluated_at`, `expires_at` | Timeline of the result | | `correlation_id` | For support | ## Limitation codes [Section titled “Limitation codes”](#limitation-codes) `limitation_codes` lists why some evidence was missing or some indicator was not evaluated. They are never risk: no limitation code is a reason against the access, and none lowers the `trust_score`. Most describe evidence quality: a missing indicator only lowers `evidence_coverage`, and the assessment is `INCONCLUSIVE` when the policy’s minimum coverage or a required indicator is not met. `POLICY_UNAVAILABLE` marks a single indicator whose configuration was unusable: it only leaves that indicator unevaluated, and the assessment follows the coverage rule above. When no active, intact policy can be selected, no assessment is stored: the call answers [`503 assessment-unavailable`](/api/errors/#assessment-unavailable), an operational problem on FingerTrackID’s side, and you repeat the same logical operation. A `FAILED` assessment means the policy evaluation itself did not complete: it carries `POLICY_EVALUATION_UNAVAILABLE` in `reason_codes` and `UNSUPPORTED_RUNTIME` in `limitation_codes`, keeps the policy version that was selected, and recommends `MONITOR`. `limitation_codes` can therefore list a code that no indicator carries: every `INCONCLUSIVE` assessment lists `REQUIRED_EVIDENCE_MISSING`, and a `FAILED` one lists `UNSUPPORTED_RUNTIME`. The last column below says which indicators can carry each code under the current managed setup; a code marked as not sent today is part of the contract and may appear later, so keep handling it. | Code | Meaning | Carried today by | | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `NO_FIRST_PARTY_COOKIE` | The first-party installation anchor was not available (for example, the browser blocked or cleared the collection domain’s cookie) | `DEVICE_MULTI_USER` | | `INSTALLATION_COOKIE_REJECTED` | The installation cookie was invalid, expired, revoked or from another application, so it was not used | `DEVICE_MULTI_USER` | | `NO_EDGE_SIGNALS` | The network facts expected from FingerTrackID’s edge did not arrive from a trusted source | The four network indicators and `BROWSER_PRIVACY_SAFARI` | | `STALE_DEVICE_PROFILE` | The evidence was older than the freshness its version allows: the browser evidence beyond your application’s [evidence window](/web-sdk/collection/#the-evidence-window), or a server-side evidence source (such as a network list) older than its maximum age. Recollecting in the browser does not fix the second case | Any indicator | | `PROVIDER_UNAVAILABLE` | A required evidence source was not usable: missing or of another version, refused, not loaded after its retries, or built after the observation. A source that runs out of time is `DETECTOR_TIMEOUT` instead | The four network indicators and `BROWSER_PRIVACY_SAFARI` | | `PROVIDER_BUDGET_EXCEEDED` | A required data source call was skipped by its budget | None: not sent today | | `COLLECTOR_NOT_LOADED` | The browser SDK did not produce the observation the indicator needs (for example, no `event_context_token`) | All 13 without a token; with a token, the eight browser indicators whose part of the collection did not arrive | | `UNSUPPORTED_RUNTIME` | The indicator does not evaluate this browser, engine or operating system in its current version. On a `FAILED` assessment it also marks the policy evaluation that did not complete | `ANTI_FINGERPRINTING`, `BROWSER_SPOOFING`, `EMULATED_ENVIRONMENT`, `PRIVATE_BROWSING` and the three browser privacy indicators; never `BOT_AUTOMATION`, the network indicators or `DEVICE_MULTI_USER` | | `REQUIRED_EVIDENCE_MISSING` | The indicator had no usable evidence to decide: a field it needs was missing or invalid, or the evidence it received was inconclusive (for example, no person linked to this browser installation yet, or a network address its source does not cover) | Any indicator; also every `INCONCLUSIVE` assessment, at the assessment level | | `DETECTOR_TIMEOUT` | The indicator, or an evidence lookup it needed, did not finish in time | Every indicator except the browser privacy indicators for Brave and Samsung Internet and `DEVICE_MULTI_USER` | | `DETECTOR_ERROR` | The indicator failed without a conclusive result | Every indicator except the browser privacy indicators for Brave and Samsung Internet | | `NETWORK_TYPE_AMBIGUOUS` | The network could not be honestly classified as VPN, proxy or hosting: a source flagged one of them without telling which, or the access came through a known privacy relay or shared egress (such as iCloud Private Relay or unclassified Cloudflare egress) | `NETWORK_PROXY`, `NETWORK_VPN` and `NETWORK_HOSTING`; never `NETWORK_TOR` | | `CLIENT_CLOCK_SKEW` | The browser’s clock disagreed beyond the allowed limit; server-side times remain authoritative | None: not sent today | | `POLICY_UNAVAILABLE` | On a single indicator, its detector configuration was unusable (only that indicator is unevaluated). A missing policy is a `503 assessment-unavailable`, never an assessment | None under the managed setup today | **`UNSUPPORTED_RUNTIME` on a current browser.** The private browsing indicator evaluates Chrome, Edge and other Chromium browsers (version 83 and later), Safari (WebKit) and Firefox (Gecko). The browser privacy and private browsing indicators report `UNSUPPORTED_RUNTIME` when the SDK runs outside the top-level page (for example, inside an iframe), when the browser engine cannot be identified consistently, or in an in-app browser. On Chromium, the private browsing indicator also reports it when the browser refused or failed its storage probe (for example, IndexedDB disabled or blocked). If you see it on an ordinary top-level Chrome tab with storage enabled, send us the `assessment_id`. Anti-fingerprinting is not held back the same way: it can still be `DETECTED` from replaced browser functions when the engine is unknown. This is not the SDK error of the same name, which `initializeSdk` throws when the browser lacks a required feature ([failure reasons](/web-sdk/collection/#failure-reasons)). **Size:** every response — success or error — is at most **65536 bytes**. Read the body by counting the bytes as they arrive, not by `Content-Length`, and treat a larger response as an indeterminate send ([see retry](/api/idempotency-and-retry/)). There is no `GET` to fetch an assessment. Recovery is the same `POST`, with the same body. ## Example: inconclusive assessment without the SDK [Section titled “Example: inconclusive assessment without the SDK”](#example-inconclusive-assessment-without-the-sdk) A synthetic example; the values illustrate the shape only: INCONCLUSIVE — collector not loaded ```json { "schema_version": "event-assessment.v1", "assessment_id": "01996f2a-0000-7000-8000-000000000401", "assessment_version": 1, "supersedes_assessment_id": null, "business_event_id": "01996f2a-0000-7000-8000-000000000402", "external_event_id": "login_20260904_000002", "journey": "LOGIN", "assessment_status": "INCONCLUSIVE", "retryable": false, "trust_score": null, "device_intelligence_snapshot_id": null, "device_intelligence_snapshot_version": null, "device_id": null, "device_match_confidence": null, "evidence_coverage": 0, "data_freshness": { "evidence_cutoff_at": "2026-09-04T12:00:00.105Z", "oldest_required_observed_at": null, "maximum_required_age_ms": 300000, "all_required_fresh": false }, "signals": [ { "signal_code": "ANTI_FINGERPRINTING", "channel": "WEB", "signal_state": "NOT_EVALUATED", "applicability": "APPLICABLE", "source": "DERIVED", "detector_version": "unavailable-web-detectors.v1", "detector_confidence": null, "observed_at": null, "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": null, "not_evaluated_reason_code": "COLLECTOR_NOT_LOADED", "applicability_reason_code": null }, { "signal_code": "BOT_AUTOMATION", "channel": "WEB", "signal_state": "NOT_EVALUATED", "applicability": "APPLICABLE", "source": "DERIVED", "detector_version": "unavailable-web-detectors.v1", "detector_confidence": null, "observed_at": null, "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": null, "not_evaluated_reason_code": "COLLECTOR_NOT_LOADED", "applicability_reason_code": null }, { "signal_code": "BROWSER_PRIVACY_BRAVE", "channel": "WEB", "signal_state": "NOT_EVALUATED", "applicability": "APPLICABLE", "source": "DERIVED", "detector_version": "unavailable-web-detectors.v1", "detector_confidence": null, "observed_at": null, "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": null, "not_evaluated_reason_code": "COLLECTOR_NOT_LOADED", "applicability_reason_code": null }, { "signal_code": "BROWSER_PRIVACY_SAFARI", "channel": "WEB", "signal_state": "NOT_EVALUATED", "applicability": "APPLICABLE", "source": "DERIVED", "detector_version": "unavailable-web-detectors.v1", "detector_confidence": null, "observed_at": null, "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": null, "not_evaluated_reason_code": "COLLECTOR_NOT_LOADED", "applicability_reason_code": null }, { "signal_code": "BROWSER_PRIVACY_SAMSUNG_INTERNET", "channel": "WEB", "signal_state": "NOT_EVALUATED", "applicability": "APPLICABLE", "source": "DERIVED", "detector_version": "unavailable-web-detectors.v1", "detector_confidence": null, "observed_at": null, "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": null, "not_evaluated_reason_code": "COLLECTOR_NOT_LOADED", "applicability_reason_code": null }, { "signal_code": "BROWSER_SPOOFING", "channel": "WEB", "signal_state": "NOT_EVALUATED", "applicability": "APPLICABLE", "source": "DERIVED", "detector_version": "unavailable-web-detectors.v1", "detector_confidence": null, "observed_at": null, "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": null, "not_evaluated_reason_code": "COLLECTOR_NOT_LOADED", "applicability_reason_code": null }, { "signal_code": "DEVICE_MULTI_USER", "channel": "WEB", "signal_state": "NOT_EVALUATED", "applicability": "APPLICABLE", "source": "DERIVED", "detector_version": "unavailable-web-detectors.v1", "detector_confidence": null, "observed_at": null, "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": null, "not_evaluated_reason_code": "COLLECTOR_NOT_LOADED", "applicability_reason_code": null }, { "signal_code": "EMULATED_ENVIRONMENT", "channel": "WEB", "signal_state": "NOT_EVALUATED", "applicability": "APPLICABLE", "source": "DERIVED", "detector_version": "unavailable-web-detectors.v1", "detector_confidence": null, "observed_at": null, "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": null, "not_evaluated_reason_code": "COLLECTOR_NOT_LOADED", "applicability_reason_code": null }, { "signal_code": "NETWORK_HOSTING", "channel": "WEB", "signal_state": "NOT_EVALUATED", "applicability": "APPLICABLE", "source": "DERIVED", "detector_version": "unavailable-web-detectors.v1", "detector_confidence": null, "observed_at": null, "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": null, "not_evaluated_reason_code": "COLLECTOR_NOT_LOADED", "applicability_reason_code": null }, { "signal_code": "NETWORK_PROXY", "channel": "WEB", "signal_state": "NOT_EVALUATED", "applicability": "APPLICABLE", "source": "DERIVED", "detector_version": "unavailable-web-detectors.v1", "detector_confidence": null, "observed_at": null, "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": null, "not_evaluated_reason_code": "COLLECTOR_NOT_LOADED", "applicability_reason_code": null }, { "signal_code": "NETWORK_TOR", "channel": "WEB", "signal_state": "NOT_EVALUATED", "applicability": "APPLICABLE", "source": "DERIVED", "detector_version": "unavailable-web-detectors.v1", "detector_confidence": null, "observed_at": null, "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": null, "not_evaluated_reason_code": "COLLECTOR_NOT_LOADED", "applicability_reason_code": null }, { "signal_code": "NETWORK_VPN", "channel": "WEB", "signal_state": "NOT_EVALUATED", "applicability": "APPLICABLE", "source": "DERIVED", "detector_version": "unavailable-web-detectors.v1", "detector_confidence": null, "observed_at": null, "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": null, "not_evaluated_reason_code": "COLLECTOR_NOT_LOADED", "applicability_reason_code": null }, { "signal_code": "PRIVATE_BROWSING", "channel": "WEB", "signal_state": "NOT_EVALUATED", "applicability": "APPLICABLE", "source": "DERIVED", "detector_version": "unavailable-web-detectors.v1", "detector_confidence": null, "observed_at": null, "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": null, "not_evaluated_reason_code": "COLLECTOR_NOT_LOADED", "applicability_reason_code": null } ], "limitation_codes": [ "COLLECTOR_NOT_LOADED", "REQUIRED_EVIDENCE_MISSING" ], "reason_codes": [ "REQUIRED_EVIDENCE_MISSING" ], "recommended_action": "MONITOR", "recommendation_basis": "INSUFFICIENT_EVIDENCE", "policy_version": "production-detectors-01996f29-4d2c-7b18-9e6a-3f0c8d1b5a72-login", "fallback_policy_version": "production-observation-fallback-v2", "ruleset_version": "d95-detectors.v1", "signal_catalog_version": "web-detectors-v3", "feature_schema_version": "configured-assessment-features.v1", "resolver_version": "first-party-installation-v1", "sdk_version": null, "provider_versions": [], "evidence_cutoff_at": "2026-09-04T12:00:00.105Z", "evaluated_at": "2026-09-04T12:00:00.143Z", "expires_at": "2026-09-04T12:01:00.143Z", "correlation_id": "01996f2a-1111-7000-8000-000000000405" } ``` # Server SDK for Node.js > @fingertrackid/server signs assessments and feedback, retries only what is safe and never throws after startup: install, configure, call and check. `@fingertrackid/server` is the FingerTrackID client for a Node.js backend. It builds each request with the same rules the API applies, signs it with the [reference signer](/api/authentication/#reference-signer-nodejs-2416-or-newer), retries only what the published Problem catalog marks retryable, and returns every outcome as a result. It never throws after startup, so your product keeps its own decision when FingerTrackID is slow or unavailable. The SDK is optional. Any backend that follows the [authentication](/api/authentication/) rules and reproduces the [public test vector](/api/authentication/#public-test-vector) integrates the same way; the published contracts win over the SDK, as over any example. ## Install [Section titled “Install”](#install) Node.js 24.16 or newer on the 24 line, or 26.1 or newer. Pin the exact version. `self-check` reproduces the public signature vector on the Node.js that runs it and needs no credential. The package has no install-time dependency and no install script: it is one unminified bundle you can read, with the open-source licenses it carries in `THIRD-PARTY-NOTICES.txt`. Compare the `integrity` in your lockfile with `npm view @fingertrackid/server@0.1.2 dist.integrity`. Terminal ```bash npm install --save-exact @fingertrackid/server@0.1.2 npx --no fingertrackid-server self-check ``` ## Configure [Section titled “Configure”](#configure) The client takes its values as options; the examples read them from these variables, which the `check` command also reads: | Variable | Value | | ---------------------- | ------------------------------------------------------------------------------------------------ | | `FTID_KEY_ID` | The `key_id` of the server credential, from the console or from `fingertrackid.integration.json` | | `FTID_HMAC_SECRET_HEX` | The 64 hexadecimal characters shown once in the console, read from your secret manager | The API origin defaults to `https://api.fingertrackid.com`; leave `apiOrigin` unset in production. The secret stays in the secret manager Never commit, print or log `FTID_HMAC_SECRET_HEX`, never pass it as a command argument and never give it to an AI assistant. Your code reads it by name from the environment; no FingerTrackID tool asks for its value. Create one client when the backend starts. A missing or malformed value throws `FtidConfigurationError` with a fixed `code`, and never echoes the value. ftid.ts ```ts import { createFtidServerClient, type FtidServerClient } from '@fingertrackid/server' let ftid: FtidServerClient | null = null try { ftid = createFtidServerClient({ keyId: process.env.FTID_KEY_ID ?? '', secretHex: process.env.FTID_HMAC_SECRET_HEX ?? '', }) } catch (error) { // Alert on it, and keep the product running without FingerTrackID. logger.error('ftid.configuration_error', { code: (error as { code?: string }).code }) } ``` ## Assess an action [Section titled “Assess an action”](#assess-an-action) Call `assess` after your own checks, with the tag you chose for the moment ([journeys and tags](/api/journeys/)) and the token the [Web SDK](/web-sdk/collection/) produced for this action. login.ts ```ts // `ftid` is null after a configuration error: `result` is then undefined, and the action goes on. const result = await ftid?.assess({ tag: 'login', subjectRef: pseudonymOf(user), // a keyed pseudonym, never an email or tax ID eventContextToken: body.ftidToken ?? null, eventContextExpiresAt: body.ftidTokenExpiresAt ?? null, }) ``` | `result.kind` | Meaning | What your backend does | | --------------------- | --------------------------------------------------------------- | ---------------------------------------------------- | | `ASSESSED` | `result.assessment` is the [response](/api/response/) | Record it; your own policy decides | | `INVALID_INPUT` | The request never left: `result.issues` names each field | Fix the call; nothing was sent | | `PROBLEM` | The API answered a catalogued [Problem](/api/errors/) | Follow `result.problem.category` | | `UNAVAILABLE` | Rate limit, outage, clock or timeout, after the allowed retries | Keep your decision; the request may have been stored | | `UNEXPECTED_RESPONSE` | An answer outside the contract | Keep your decision and report the `correlationId` | Every result carries `severity` (`info`, `warn` or `error`) for your logs, and every sent result carries `correlationId` for support. Log identifiers and verdicts only: never the secret, the token, the signature or the request body. ## What the client does for you [Section titled “What the client does for you”](#what-the-client-does-for-you) * Validates the request with the same schema the API applies, and serializes the body once. * Sends at most 3 attempts within 10 seconds, each with a 6.5-second timeout, above the 5-second server budget. The budget runs on a clock that never goes back, so a change of the system clock cannot extend it, and the first attempt always starts. A retry resends the same bytes with a new `X-FTID-Request-Id`, the current timestamp and a new signature. * Retries only Problems the published catalog marks retryable and attempts whose outcome is unknown, waiting `Retry-After` (at least 1 second) after a `429` or `503`. A `429`, `502`, `503` or `504` without a Problem body, as a proxy may send, counts as an unknown outcome. * Drops an event context token that would expire before the first attempt ends, and assesses without it. * When the API answers `event-context-invalid` to a request that carried a token, assesses the event once more as a new operation without the token, with a new external event ID and idempotency key that `result.operation` carries. That second operation keeps the instant of the action and spends only what is left of the call’s attempts and budget; without enough left, the refusal is the result. Set `retryWithoutEventContext: false` to turn this off. * Returns `INVALID_INPUT` instead of throwing for an argument outside the documented types, such as an ISO string where `occurredAt` expects a `Date`. * Reports each attempt, retry and result to an optional `onEvent` callback, without the secret, the token, the signature or the body. | Option | Default | Range | | -------------------------- | ------------------------------- | ------------------------------------------------------------------------- | | `apiOrigin` | `https://api.fingertrackid.com` | An `https:` origin | | `timeoutMs` | 6500 | Per attempt | | `budgetMs` | 10000 | Up to 60000 | | `maxAttempts` | 3 | 1 to 5 | | `retryWithoutEventContext` | `true` | | | `onEvent` | none | Exceptions it throws, and rejections of a promise it returns, are ignored | ## Reconcile an unknown outcome [Section titled “Reconcile an unknown outcome”](#reconcile-an-unknown-outcome) A sent result carries `result.operation`: the path, the exact body, its external ID and its idempotency key. Store it with your own record when the outcome matters, and send it again with the method of its kind: `ftid.resend(operation)` for an assessment, `ftid.resendCustomerAction(operation)` or `ftid.resendOutcome(operation)` for feedback. Each sends the same bytes with a new signature, as [idempotency and retry](/api/idempotency-and-retry/) requires; never build a new body for the same action. `operation.body` holds the event context token and `subject_ref` as sent: keep it with the record, out of logs. ## Report actions and outcomes [Section titled “Report actions and outcomes”](#report-actions-and-outcomes) They need the `action:write` and `outcome:write` capabilities and return the same result kinds, with `RECORDED` and the receipt instead of `ASSESSED`. See [feedback](/api/feedback/). A `LEGITIMATE` or `FRAUD_CONFIRMED` outcome also needs the evidence fields: `evidenceSource`, `evidenceReference` and `evidenceConfidence`. feedback.ts ```ts await ftid?.recordCustomerAction(assessmentId, { action: 'ALLOWED', basis: 'CLIENT_POLICY', reasonCode: 'CLIENT_POLICY_RULE', }) await ftid?.recordOutcome(assessmentId, { truthStatus: 'PENDING', labelMaturity: 'PROVISIONAL', reasonCode: 'INITIAL_OBSERVATION', }) ``` ## Check the real credential [Section titled “Check the real credential”](#check-the-real-credential) `check` sends one assessment in shadow mode, tagged `integration_check` and without a browser token, and prints the result. The `key_id` comes from `FTID_KEY_ID` or, when that is unset, from the [integration file](/getting-started/ai-agents/#the-integration-file) (`--manifest `, or `fingertrackid.integration.json` in the current directory) when it lists exactly one credential that may assess; with several, set `FTID_KEY_ID`. The secret comes only from `FTID_HMAC_SECRET_HEX` or standard input (at most 4,096 bytes, within 30 seconds), never from an argument. `FTID_API_ORIGIN`, when set for `check`, may name only the production API or a loopback test double. `--no` makes npx run only the copy your project installed: it never downloads a package of the same name from the registry, which matters when the secret is on standard input. Terminal ```bash your-secret-manager read ftid-hmac-secret | FTID_KEY_ID= npx --no fingertrackid-server check ``` ## Other languages [Section titled “Other languages”](#other-languages) Implement the [canonical string](/api/authentication/#the-canonical-string) and check it against the [public test vector](/api/authentication/#public-test-vector) before the first real call. An AI coding agent can compare its signer with the vector through the [MCP server](/getting-started/ai-agents/#the-mcp-server). # Journey templates > Ten common moments to assess with the v1.1 request — when to call, which references to send and what to read on the console. Guidance only; templates never change the assessment. A journey template is guidance for one common moment of your product: when your backend calls `POST /v1/assessments`, which pseudonymous references to send and what to look at on the console afterwards. Templates exist only in this documentation and in the console’s integration guide. Nothing of them is stored, and they have **no effect on the assessment**: every v1.1 request runs the same internal behaviour, so the same evidence gives the same score under any tag ([journeys and tags](/api/journeys/#what-the-tag-changes-and-what-it-does-not)). The suggested tag is only a suggestion. You may send any valid tag instead, for example `saque` instead of `withdrawal`; the console shows the tag you send. The v1.1 request Templates describe the v1.1 request, which production accepts. An integration that stays on v1 keeps its three fixed journeys, unchanged. ## The ten templates [Section titled “The ten templates”](#the-ten-templates) | Template | Suggested tag | When your backend calls | `subject_ref` | `account_ref` | | ----------------------------------------------- | ------------------- | ------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | ------------------------------ | | [Account creation](#account-creation) | `signup` | After your backend validates the form, **before** it activates the account | Recommended (a new pseudonym) | Recommended (a new pseudonym) | | [Login](#login) | `login` | After your backend checks the credential, **before** it issues the session | Recommended when the user is identified | Recommended with `subject_ref` | | [Password recovery](#password-recovery) | `password_recovery` | When the password is **actually changed**, not when the reset link is requested | Recommended only if your backend already resolved the user; never the email | `null` | | [Two-step verification](#two-step-verification) | `mfa_challenge` | When your backend validates the code | Recommended | Recommended | | [Profile change](#profile-change) | `profile_change` | When your backend saves a new email, phone number or address | Recommended | Recommended | | [Deposit](#deposit) | `deposit` | When your backend issues the charge (QR code, boleto) | Required | Required | | [Withdrawal](#withdrawal) | `withdrawal` | When the user confirms, **before** your backend settles | Required | Required | | [Transfer or Pix](#transfer-or-pix) | `pix_transfer` | When the user confirms, **before** your backend settles | Required | Required | | [Order](#order) | `trade` | When your backend accepts the order, **before** it routes it | Required | Required | | [Checkout](#checkout) | `checkout` | When the user confirms the payment, before your backend captures it | Recommended | Optional | `account_ref` requires `subject_ref`: when a template marks the account reference as required, the subject reference is required too, and a template that recommends `account_ref` recommends it together with `subject_ref`. Both are pseudonyms your backend creates; never send an email, tax ID, phone number, name or account number. Send the same `account_ref` with the same `subject_ref` on every call: an account stays bound to the person it was first sent with, and another `subject_ref` returns `409 binding-conflict` ([person and account references](/api/overview/#person-and-account-references)). The console items about an account (a new device for the account, other accounts on the same devices, events with the same tag on the same account) need `account_ref`: without it they show **Conta não informada pelo sistema da empresa**. The account’s history starts at its first access: with a completed browser collection, that access shows **Primeira vez: ainda sem histórico** (first time: no history yet), and without one **Sem coleta concluída do navegador** (no completed browser collection). A later access evaluates **Dispositivo novo para esta conta** only when an earlier access of the same account in the last 90 days also had a browser collection whose browser installation was linked; after more than 90 days without one, it shows **Primeira vez** again ([person and account references](/api/overview/#person-and-account-references)). ## Rules every template repeats [Section titled “Rules every template repeats”](#rules-every-template-repeats) * The tag carries no personal data: no email, tax ID, name, user ID or Pix key. It has at most ten digits in total. * `recommended_action` is advice, not an action taken: your system decides and acts. * The same evidence gives the same score under every template and every tag. * A retry repeats the **whole** body, tag included. Changing any field except `deadline_ms` under the same `external_event_id` or `idempotency_key` is `409 idempotency-conflict`; a retry that changes only `deadline_ms` is the same operation and answers `200`. A template never carries an amount, currency, order, beneficiary, deadline of its own, fallback, policy, weight, provider, signal applicability, quota, price or internal class. The v1.1 body has no field for any of them. ## Template by template [Section titled “Template by template”](#template-by-template) Each example is a synthetic v1.1 body, formatted for reading. In the real call, send the compact JSON that `JSON.stringify(object)` produces, and replace the token with the one the Web SDK returned for that action, or `null` when there is none. ### Account creation [Section titled “Account creation”](#account-creation) * **Suggested tag:** `signup` * **Call:** after your backend validates the form, **before** it activates the account. * **References:** `subject_ref` recommended (a new pseudonym); `account_ref` recommended (a new pseudonym). * **Look at on the console:** `DEVICE_MULTI_USER`, `BOT_AUTOMATION` and `EMULATED_ENVIRONMENT`; other accounts seen on the same device (needs `account_ref`); events with the same tag on the same device (many sign-ups from one device). ```json { "schema_version": "event-assessment-request.v1.1", "external_event_id": "evt_signup_0001", "idempotency_key": "idem_evt_signup_0001", "occurred_at": "2026-11-03T14:05:22.481Z", "subject_ref": "usr_5c0e", "account_ref": "acc_77d2", "event_context_token": "ect_signup_0001", "tag": "signup", "deadline_ms": null } ``` ### Login [Section titled “Login”](#login) * **Suggested tag:** `login` * **Call:** after your backend checks the credential, **before** it issues the session. * **References:** `subject_ref` recommended when the user is identified; `account_ref` recommended, always together with `subject_ref`. * **Look at on the console:** `BOT_AUTOMATION` and `BROWSER_SPOOFING`; `NETWORK_TOR`, `NETWORK_VPN` and `NETWORK_HOSTING`; a new device for the account (needs `account_ref`). ```json { "schema_version": "event-assessment-request.v1.1", "external_event_id": "evt_login_0001", "idempotency_key": "idem_evt_login_0001", "occurred_at": "2026-11-03T14:05:22.481Z", "subject_ref": "usr_5c0e", "account_ref": "acc_77d2", "event_context_token": "ect_login_0001", "tag": "login", "deadline_ms": null } ``` ### Password recovery [Section titled “Password recovery”](#password-recovery) * **Suggested tag:** `password_recovery` * **Call:** when the password is **actually changed**, not when the reset link is requested. * **References:** `subject_ref` recommended only if your backend already resolved the user; never the email; `account_ref` `null`. * **Look at on the console:** `NETWORK_TOR`, `NETWORK_VPN` and `NETWORK_HOSTING`; `PRIVATE_BROWSING`; devices seen for the person. ```json { "schema_version": "event-assessment-request.v1.1", "external_event_id": "evt_password_recovery_0001", "idempotency_key": "idem_evt_password_recovery_0001", "occurred_at": "2026-11-03T14:05:22.481Z", "subject_ref": "usr_5c0e", "account_ref": null, "event_context_token": "ect_password_recovery_0001", "tag": "password_recovery", "deadline_ms": null } ``` ### Two-step verification [Section titled “Two-step verification”](#two-step-verification) * **Suggested tag:** `mfa_challenge` * **Call:** when your backend validates the code. * **References:** `subject_ref` recommended; `account_ref` recommended. * **Look at on the console:** continuity with the login device: a new device for the account (needs `account_ref`), or a lost device cookie. ```json { "schema_version": "event-assessment-request.v1.1", "external_event_id": "evt_mfa_challenge_0001", "idempotency_key": "idem_evt_mfa_challenge_0001", "occurred_at": "2026-11-03T14:05:22.481Z", "subject_ref": "usr_5c0e", "account_ref": "acc_77d2", "event_context_token": "ect_mfa_challenge_0001", "tag": "mfa_challenge", "deadline_ms": null } ``` ### Profile change [Section titled “Profile change”](#profile-change) * **Suggested tag:** `profile_change` * **Call:** when your backend saves a new email, phone number or address. * **References:** `subject_ref` recommended; `account_ref` recommended. * **Look at on the console:** a new device for the account (needs `account_ref`); `NETWORK_TOR`, `NETWORK_VPN` and `NETWORK_HOSTING`. ```json { "schema_version": "event-assessment-request.v1.1", "external_event_id": "evt_profile_change_0001", "idempotency_key": "idem_evt_profile_change_0001", "occurred_at": "2026-11-03T14:05:22.481Z", "subject_ref": "usr_5c0e", "account_ref": "acc_77d2", "event_context_token": "ect_profile_change_0001", "tag": "profile_change", "deadline_ms": null } ``` ### Deposit [Section titled “Deposit”](#deposit) * **Suggested tag:** `deposit` * **Call:** when your backend issues the charge (QR code, boleto). * **References:** `subject_ref` required; `account_ref` required. * **Look at on the console:** a new device for the account; `NETWORK_TOR`, `NETWORK_VPN` and `NETWORK_HOSTING`; events with the same tag on the same account. The account items need `account_ref`, which this template requires. ```json { "schema_version": "event-assessment-request.v1.1", "external_event_id": "evt_deposit_0001", "idempotency_key": "idem_evt_deposit_0001", "occurred_at": "2026-11-03T14:05:22.481Z", "subject_ref": "usr_5c0e", "account_ref": "acc_77d2", "event_context_token": "ect_deposit_0001", "tag": "deposit", "deadline_ms": null } ``` ### Withdrawal [Section titled “Withdrawal”](#withdrawal) * **Suggested tag:** `withdrawal` * **Call:** when the user confirms, **before** your backend settles. * **References:** `subject_ref` required; `account_ref` required. * **Look at on the console:** a new device for the account; `NETWORK_TOR`, `NETWORK_VPN` and `NETWORK_HOSTING`; events with the same tag on the same account and on the same device. What a v1.1 withdrawal does not show The v1.1 request carries no amount and no beneficiary, so the context items that depend on them (shared beneficiaries, beneficiaries per device, accounts per beneficiary and the amount withdrawn in 24 hours) come back not evaluated, with a reason that says the request does not carry them. If you need them today, integrate the withdrawal with the v1 `WITHDRAWAL` journey, which keeps `journey_context` unchanged. ```json { "schema_version": "event-assessment-request.v1.1", "external_event_id": "evt_withdrawal_0001", "idempotency_key": "idem_evt_withdrawal_0001", "occurred_at": "2026-11-03T14:05:22.481Z", "subject_ref": "usr_5c0e", "account_ref": "acc_77d2", "event_context_token": "ect_withdrawal_0001", "tag": "withdrawal", "deadline_ms": null } ``` ### Transfer or Pix [Section titled “Transfer or Pix”](#transfer-or-pix) * **Suggested tag:** `pix_transfer` * **Call:** when the user confirms, **before** your backend settles. * **References:** `subject_ref` required; `account_ref` required. * **Look at on the console:** the same as for a withdrawal. What a v1.1 withdrawal does not show The v1.1 request carries no amount and no beneficiary, so the context items that depend on them (shared beneficiaries, beneficiaries per device, accounts per beneficiary and the amount withdrawn in 24 hours) come back not evaluated, with a reason that says the request does not carry them. If you need them today, integrate the withdrawal with the v1 `WITHDRAWAL` journey, which keeps `journey_context` unchanged. ```json { "schema_version": "event-assessment-request.v1.1", "external_event_id": "evt_pix_transfer_0001", "idempotency_key": "idem_evt_pix_transfer_0001", "occurred_at": "2026-11-03T14:05:22.481Z", "subject_ref": "usr_5c0e", "account_ref": "acc_77d2", "event_context_token": "ect_pix_transfer_0001", "tag": "pix_transfer", "deadline_ms": null } ``` ### Order [Section titled “Order”](#order) * **Suggested tag:** `trade` * **Call:** when your backend accepts the order, **before** it routes it. * **References:** `subject_ref` required; `account_ref` required. * **Look at on the console:** see [orders, one assessment each](/api/trade/). One assessment per order, with no order data. The full flow, cost and capacity notes are on [orders, one assessment each](/api/trade/). ```json { "schema_version": "event-assessment-request.v1.1", "external_event_id": "evt_trade_0001", "idempotency_key": "idem_evt_trade_0001", "occurred_at": "2026-11-03T14:05:22.481Z", "subject_ref": "usr_5c0e", "account_ref": "acc_77d2", "event_context_token": "ect_trade_0001", "tag": "trade", "deadline_ms": null } ``` ### Checkout [Section titled “Checkout”](#checkout) * **Suggested tag:** `checkout` * **Call:** when the user confirms the payment, before your backend captures it. * **References:** `subject_ref` recommended; `account_ref` optional. * **Look at on the console:** `BOT_AUTOMATION`, `BROWSER_SPOOFING` and `EMULATED_ENVIRONMENT`; events with the same tag on the same device. ```json { "schema_version": "event-assessment-request.v1.1", "external_event_id": "evt_checkout_0001", "idempotency_key": "idem_evt_checkout_0001", "occurred_at": "2026-11-03T14:05:22.481Z", "subject_ref": "usr_5c0e", "account_ref": null, "event_context_token": "ect_checkout_0001", "tag": "checkout", "deadline_ms": null } ``` ## Choosing your own tags [Section titled “Choosing your own tags”](#choosing-your-own-tags) * Name the moment, not the outcome: `withdrawal`, not `withdrawal_blocked`. * Keep one tag per moment. The console never merges two tags, so `saque` and `withdrawal` are two lines in every report. * Keep the tag stable across releases of your product: a renamed tag starts a new line on the console. # Orders, one assessment each > How to assess every order with the v1.1 request and the tag trade, reusing the session's fresh browser collection, with no order data and no external lookup per order. To follow every order, assess **each order once**, with the v1.1 request and the tag `trade`. The body carries no order data: no instrument, side, quantity, price or amount. FingerTrackID assesses the device and the access behind the order; your system already has the order. Sizing A high-frequency order flow needs a capacity review with FingerTrackID before go-live: request rate, retention and console volume are sized for your flow, not assumed. ## Flow [Section titled “Flow”](#flow) 1. **Prepare in the browser.** With the [collection recipe](/web-sdk/collection/#the-recipe), call `prepareNextAction()` on activity in the order form (its `focusin` and `pointerdown`), never when the trading screen loads. The helper keeps the collection fresh inside your application’s evidence window (60 or 300 seconds, [the evidence window](/web-sdk/collection/#the-evidence-window)) and collects again only when it would expire. 2. **For each order, take an event context.** Call `takeActionContext()` once per order, at its submit; it waits at most your own ceiling ([the SDK lifecycle](/web-sdk/collection/#the-lifecycle)). Each order gets its own event from the latest collection, with a budget of 5000 ms per lookup since SDK 0.2.0. If no context arrives in time, continue with `null`: the assessment comes back without a score, and a missing context is never a reason to hold the order. One SDK session issues at most 128 event contexts; the helper collects again before that. 3. **Assess from your backend.** Send `POST /v1/assessments` with the v1.1 body and `"tag": "trade"`. Derive `external_event_id` and `idempotency_key` from your order ID, so a retry of the same order is the same operation. Both references are required. 4. **Report what you did.** Send the action and the outcome by `assessment_id`, as for any other assessment ([feedback](/api/feedback/)). No order triggers an external lookup or a new collection: every order reuses the session’s fresh evidence. A synthetic body for one order, formatted for reading: ```json { "schema_version": "event-assessment-request.v1.1", "external_event_id": "evt_trade_0001", "idempotency_key": "idem_evt_trade_0001", "occurred_at": "2026-11-03T14:05:22.481Z", "subject_ref": "usr_5c0e", "account_ref": "acc_77d2", "event_context_token": "ect_trade_0001", "tag": "trade", "deadline_ms": null } ``` ## What to look at on the console [Section titled “What to look at on the console”](#what-to-look-at-on-the-console) | Reading | What it shows | | --------------------------------------- | --------------------------------------------------------------------------------------- | | Events with the same tag on the account | The burst of `trade` events from one account in the last minute, five minutes and hour | | Events with the same tag on the device | The burst of `trade` events from one device, across accounts, in the same windows | | Device indicators | `BOT_AUTOMATION`, `EMULATED_ENVIRONMENT`, `BROWSER_SPOOFING` and the network indicators | These readings are context for your analysts. They do not enter the score and are not returned by the API. The order readings of the v1 `TRADE` journey count only v1 orders; v1.1 orders are counted by tag. ## Usage [Section titled “Usage”](#usage) The usage unit is a completed browser collection, not an order. Orders that reuse the same collection add no usage, so usage follows the time your users actively trade divided by the evidence window, not the number of orders. Usage figures on the console are estimates, not invoices ([production status](/reference/availability/)). # Signing in to the console > Invitation and first access, password policy, mandatory authenticator, password recovery, lost authenticator and session security. The customer console is **[dash.fingertrackid.com](https://dash.fingertrackid.com/)**. Each person uses a named account. The server checks identity, email, authenticator and current access; a correct password alone does not open the console. The console, including sign-in, recovery and authenticator screens, is in Portuguese (pt-BR). Labels below are quoted exactly as shown, with an English gloss. ## First access [Section titled “First access”](#first-access) When your account is created, FingerTrackID sends you an **invitation email** (in Portuguese) with an **Ativar acesso** (activate access) button. The email is sent on a best-effort basis. If the activation link cannot be generated, the invitation comes instead with an **Abrir o console** (open the console) button and tells you to use **Esqueceu sua senha?**: in that case start at step 2. 1. Open the invitation and choose **Ativar acesso** (activate access). On the **Ativação de acesso** page, enter **Nova senha** (new password) and **Repita a nova senha** (repeat it), then choose **Ativar acesso**. This sets your password and confirms your email in one step. The link is personal and works only once; opening the page does not use it up. 2. If the email did not arrive or the page says the link is invalid, expired or already used, open the console and choose **Esqueceu sua senha?** (forgot your password?). Enter your **E-mail**, complete the anti-bot check if shown and choose **Enviar link** (send link). FingerTrackID emails you a link from `no-reply@fingertrackid.com`. The confirmation is the same whether or not the email exists. At most one email per address is sent in each fixed 15-minute window (for example, 10:00–10:14), so a second request in the same window sends nothing; wait for the next window. 3. If you used **Esqueceu sua senha?**, open the emailed link. On **Escolha uma nova senha** (choose a new password), enter **Nova senha** and **Repita a nova senha**, then choose **Trocar a senha** (change password). This only sets your password; it does not sign you in. Return to **Entrar no console** (sign in) and enter your **E-mail** and **Senha** (password). 4. If **Confirme seu e-mail** (confirm your email) appears, choose **Enviar e-mail de confirmação**, open the link it sends, then return and choose **Já confirmei meu e-mail** (I already confirmed my email). 5. On **Configure o autenticador** (set up the authenticator), scan the QR code with your authenticator app (or type the key shown). Enter the six-digit code in **Código do aplicativo autenticador** and choose **Cadastrar e entrar** (enroll and sign in). An anti-bot check may be required. Older accounts that were issued an initial password see **Troque a senha inicial** (change the initial password) before any data opens: enter the current password, a different new password twice and your authenticator code. The change ends the session; sign in again with the new password. Keep enrollment private The QR code and key are generated for your current enrollment. Do not send them to support, an AI, analytics or a screenshot-sharing service. Store the key in a password manager. ## Password policy [Section titled “Password policy”](#password-policy) The password must have **15 to 4096 characters**, including a lowercase letter, an uppercase letter, a digit and a special character. When a password is refused, the console lists only the missing requirements. ## Later sign-ins [Section titled “Later sign-ins”](#later-sign-ins) * Enter email and password, then the six-digit code from your authenticator. * If you enrolled more than one authenticator, select the one you are using before entering its code. * Codes are accepted within a 90-second window. Repeated refusals usually mean the wrong authenticator or a device clock that is off. * Sending a request (applications, collection setup, members, integration clients) or changing your own settings uses the session you signed in with; it does not ask for your password or code again. A request is normally approved when you send it, recorded in the audit trail and applied by the managed process; FingerTrackID can cancel it before the executor takes it. * Every other action (policy, lists, exports, reviews, outcomes) also uses the session you signed in with: it does not ask for your password or code again. Your role still decides what you can do, and every action is recorded in the audit trail. * A console session ends after 30 minutes without activity or 12 hours after sign-in. * A revoked, suspended or expired membership is refused even if your password and code are correct. ## Password recovery [Section titled “Password recovery”](#password-recovery) Use **Esqueceu sua senha?** on the sign-in screen, as in step 2 above. Recovery changes your password and **keeps your authenticator**: an email link never replaces the second factor. Expired or already-used links are refused; request a new one. Do not share the link. ## Lost authenticator [Section titled “Lost authenticator”](#lost-authenticator) Contact **** for a separate, identity-verified recovery. There is no self-service removal of the second factor by email. An authorized operator blocks the account’s existing access before resetting it; you then set a new password, verify your email and enroll a new authenticator. Never send passwords, keys or codes to support. To avoid this, add a second authenticator in **Conta e segurança** (account and security) with **Autenticador reserva** (backup authenticator). ## Account and session [Section titled “Account and session”](#account-and-session) **Conta e segurança** shows your email, display name and time zone, session expiry and security settings. There you can: * **Editar preferências** (edit preferences): change **Nome de exibição** (display name) and **Fuso** (time zone); both are saved together. Preferences never change a role. * **Trocar a senha** (change password): current password, new password twice and your authenticator code. The change ends the session. * **Autenticador reserva**: add a backup authenticator after confirming your current password and authenticator. The sign-out button in the top bar, **Sair do console**, ends this session and every other session of your account once the server confirms it. If the console says the server did not confirm the sign-out, only this browser was signed out; on a shared computer, close the browser and sign out again later. If you belong to several companies, choose the company in the top bar before reading its data. Switching company or application clears the previous view. Access granted while you are signed in appears after you sign out and sign in again. ## Internal backoffice [Section titled “Internal backoffice”](#internal-backoffice) FingerTrackID’s own operators use a separate internal backoffice, protected by Cloudflare Access and the same password and authenticator checks. Customer accounts cannot enter it. In the backoffice, an operator only creates a company and its first administrator. Everything else — applications, members, server clients, the initial setup and browser collection — is done by your company in the console. Operators follow your progress and intervene only when needed, for example to cancel a request, revoke access, recover an account, or suspend or archive a company. Backoffice actions use the session established at sign-in with password and authenticator; they do not ask for the password or code again, within the same session limits. A request the operator submits in the backoffice is approved by the same operator in the same operation and recorded in the audit trail. See [production status](/reference/availability/). # Roles and access boundaries > The seven customer roles, what each can see and do in the console, and how member changes work. Each person has **one role per company**. The server checks the current role on every read and every request. Every role sees the whole menu; a screen your role cannot use shows a notice instead of the data or form. Hiding or showing a menu item never grants access. ## Customer roles [Section titled “Customer roles”](#customer-roles) The role names below are the ones shown in **Usuários e acessos** (users and access). The top bar currently shows the same role in English (for example, *Tenant administrator*). The table lists company actions; every role, including **Leitor**, can manage its own preferences, password and backup authenticator in **Conta e segurança**. | Role on screen | Can read | Can do in the console | | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Administrador da empresa** (company administrator) | Everything every other customer role reads: events, assessments, accounts, devices, approximate location, policy, lists, review queue, integration, usage and audit | Full access within the company: everything every other customer role does, plus inviting and managing members. Reveals identifiers and decides privacy requests, always with a recorded reason | | **Analista de risco** (risk analyst) | Assessments, accounts, devices, approximate location, policy, lists and the review queue | Search accesses by your company’s ID, run policy simulations, work on review-queue items assigned to them | | **Supervisor de risco** (risk supervisor) | Everything the analyst reads, plus usage | Analyst actions plus reassign and reopen review items, set the queue profile, import and approve list versions, request exports, publish the company’s risk rules and use **Tudo em fantasma** | | **Gestor de integração** (integration manager) | Event metadata and integration health | Create applications and server credentials, configure collection and SDK. Does not see assessment content or policy | | **Auditor** | Assessments, accounts, review queue, policy, integration, usage and audit | Request exports only | | **Encarregado de dados (DPO)** (data protection officer) | Audit | Privacy decisions (retention, legal hold) through the control plane; identifier reveal | | **Leitor** (viewer) | Events, assessments, accounts, review queue and policy | Nothing | **Company risk rules.** **Administrador da empresa** and **Supervisor de risco** publish the application’s risk rules and use **Tudo em fantasma** in [**Configuração de risco**](/console/screens/#configura%C3%A7%C3%A3o-de-risco), each time with a recent authenticator check. Publishing is direct: no second approver and no request to FingerTrackID, and every publication is recorded in the audit trail; measuring the impact first is optional. Every role that reads policy sees the rules and, if measured, their impact. FingerTrackID’s operators never publish or activate a rule for your company; they can only put every rule of an application in ghost, with a recorded reason that your console shows as coming from FingerTrackID support (see [internal administration](#internal-administration)). The managed policy versions are still proposed, approved and published by FingerTrackID’s operations team, with the approval recorded. **Search by your company’s ID.** **Administrador da empresa**, **Analista de risco** and **Supervisor de risco** use **Buscar titular ou conta pelo ID da sua empresa** in the top bar ([Busca por ID](/console/screens/#busca-por-id)). The match is exact, each search is recorded in **Auditoria**, and the ID never goes into the page address. For the other roles the field stays disabled and says why. Customer actions and outcomes are reported by your backend through the [feedback API](/api/feedback/), not recorded in the console. Some consequences worth knowing: * **Administrador da empresa** has the maximum access within the company: the union of every other customer role. A one-person company needs no second account to reach policy, lists, the review queue or exports. Rules that require a second person still apply: the administrator never approves a request or list version they proposed themselves. * Only **Administrador da empresa** reads the member list and invites people. * **Exportações** (exports): the administrator and the supervisor can export assessments and device calls; the auditor can export assessments only. * No customer role sees raw collection payloads, the administrator included. Integration access is not assessment access **Gestor de integração** sees integration health and event metadata, but the server withholds assessment status, score, coverage and recommendation from that role. Copying another role’s link does not change that. ## Application scope [Section titled “Application scope”](#application-scope) Most screens read one application at a time, chosen in the top bar. A permission for one dataset does not open the others. A hidden or unavailable field is not an empty fact, a zero score or proof that no data exists. The permissions of a server credential are separate from a person’s console role. ## Member changes [Section titled “Member changes”](#member-changes) Only **Administrador da empresa** manages members, in **Usuários e acessos**: * **Conceder acesso** (grant access) invites a person by **E-mail**, **Nome de exibição** and **Papel na empresa**. When the request completes, a person without a console account receives an invitation email on a best-effort basis; a person who already has an account just signs out and in again. * **Reenviar convite** (resend invitation), in the **Convite** column of the member list, sends a new invitation with a new link while it is **Pendente**: at most 3 times per hour for each person, and not while another one is on its way. Once the person has set a password, the invitation shows **Aceito** (accepted). Fallback: **Esqueceu sua senha?** on the sign-in screen. * **Gerenciar o acesso** (manage access) offers **Trocar o papel**, **Suspender o acesso**, **Restaurar o acesso** and **Revogar o acesso**. Revoking is permanent. Each change is a request, normally approved as soon as it is sent and recorded in the audit trail; access changes only after the request is **Concluída** (done). The company’s last active administrator is protected. Internal FingerTrackID roles cannot be granted to customer members. ## Review and approval [Section titled “Review and approval”](#review-and-approval) List versions keep author and approver separate: the person who imported a version cannot approve it (an authorized supervisor, including the author, may later revoke it). Two accounts belonging to the same person are not two reviewers. An approved version and an active version are different states; approval alone never publishes a policy or enables collection. Company risk rules are the exception to the second-person rule: an administrator or risk supervisor publishes them alone, with a recent authenticator check, and the audit trail records who published what. ## Internal administration [Section titled “Internal administration”](#internal-administration) FingerTrackID’s operators use a separate internal backoffice behind Cloudflare Access and the same password and authenticator checks. Their global authority is separate from customer membership and does not let them read your assessments without a specific authorization. For company risk rules, the only action support can take is to put every rule of an application in ghost, with a recorded reason. Your **Configuração de risco** screen then shows a notice that FingerTrackID support did it, when and why, without naming the individual operator. Support can never activate a rule or change a weight, band or ruler: **Administrador da empresa** or **Supervisor de risco** reactivates the rules by publishing again, and the notice disappears. FingerTrackID’s operators also maintain the default context ruler, the starting point of every company’s context rules, publishing each version with a recorded reason; a new version never changes the rules your company already published. See [console screens](/console/screens/) and [production status](/reference/availability/). # Console screens > What each screen of the customer console shows today, with its exact Portuguese name, and what it cannot do yet. The console is in Portuguese (pt-BR). Screens are listed here by their menu name, exactly as shown, with an English gloss. Every role sees the whole menu; a screen your role cannot use shows a notice instead. See [roles](/console/roles/). | Menu group | Screens | | ----------------- | ---------------------------------------------------------------------------------------------- | | **Principal** | **Início**, **Visão geral**, **Acessos**, **Fila de revisão** | | **Detecção** | **Configuração de risco**, **Simulações de política**, **Listas**, **Catálogo de indicadores** | | **Operação** | **Integração e saúde**, **Uso**, **Exportações**, **Relatórios** | | **Administração** | **Usuários e acessos**, **Auditoria**, **Ciclo de vida do cliente**, **Conta e segurança** | | **Configurações** | **Configuração inicial** (only once your company is connected; see [Início](#in%C3%ADcio)) | Every screen reads your company’s real data; screens that read records carry a **Dados reais** (real data) mark in their own header. **Ciclo de vida do cliente** and **Catálogo de indicadores** are the exceptions that read no records: the first does not send requests yet, and the second is a fixed reference. The top bar has the **Aplicação** (application) selector and, on screens that use it, the **Período** (period): **Últimas 24 horas**, **Últimos 7 dias** or **Últimos 30 dias**, measured on the server clock, in UTC. If you belong to more than one company, an **Empresa** (company) selector appears there too. The search field in the top bar, **Buscar titular ou conta pelo ID da sua empresa** (find a person or an account by your company’s ID), finds the accesses your backend sent with one exact `subject_ref` or `account_ref` ([Busca por ID](#busca-por-id)). There is no search by device. While your company has no active application, **Visão geral** shows a notice pointing to **Início**. Recommendations do not execute actions FingerTrackID assesses and recommends. No screen, score, rule or list blocks a login, holds a withdrawal or changes an order in your product; your backend executes every action, including a block recommended by your own active rules. ## Início [Section titled “Início”](#início) (Start). The five setup steps of your company: **Criar a aplicação**, **Configurar a coleta web**, **Credencial servidor a servidor**, **Configuração do SDK** and **Primeiro evento recebido**, plus **Membros da equipe** for inviting people. The first four steps are requests, normally approved as soon as they are sent and then applied automatically; the fifth needs no request and completes when the first assessment arrives. A step is shown as **Concluída** only with confirmation from the server. **Visão geral** remains the opening screen. Step by step: [onboarding](/getting-started/onboarding/#3-follow-the-steps-in-in%C3%ADcio). Once your company has received its first access, Início leaves **Principal** and moves to the end of the menu as **Configuração inicial** (initial setup), the only item of **Configurações** (settings). The screen and its address stay the same. ## Visão geral [Section titled “Visão geral”](#visão-geral) (Overview). The opening screen. For the selected application and period it shows the cards **Acessos a conferir** (accesses to check), **Acessos avaliados** (assessed accesses), **Com nota** (with a score), **Sem nota** (without a score) and **Saques avaliados** (assessed withdrawals); the chart **Acessos avaliados e parte a conferir**; **Destaques do período** (period highlights); **Checagens no dispositivo** (device checks); and **Saques a conferir** (withdrawals to check). **Saques avaliados** and **Saques a conferir** count only the fixed v1 withdrawal journey. When the period has tagged accesses and no v1 withdrawal, the card shows **—** and points to **Acessos por etiqueta**: FingerTrackID does not infer which of your tags are withdrawals. **Acessos por etiqueta** (accesses by tag) lists the [tags](/api/journeys/#the-v11-tag) of the period with **Acessos**, **Com nota**, **Sem nota** and **Participação** (share), from the highest volume down, counted by when FingerTrackID received each access. Accesses without a tag (v1 requests) are not in this table; when there are many tags, it shows the ones with the most accesses and says so. These totals are not HTTP traffic, service health, precision/recall or fraud probability. Response time, collection availability and recommendation accuracy are not measured on this screen yet. ## Acessos [Section titled “Acessos”](#acessos) (Accesses). The latest assessed accesses, 25 per page, newest first. The list shows when it happened, the journey (for a v1.1 access, its tag exactly as sent), assessment and session, score, state and recommendation, and the network country when available. Risk indicators, browser and system, city and network, and the customer’s action show **indisponível na lista** (not available in the list): open the access to see them. **Ver só o contrato** switches to a compact view with the event and application. Times are shown in the America/Sao_Paulo time zone. A single period covers at most 31 days. The views **Todos os acessos**, **Logins**, **Saques** and **Ordens** work; tagged accesses are not in Logins, Saques and Ordens. After them come up to four views **Etiqueta** *tag*, one for each of the period’s tags with the most accesses; choosing one filters the list by that tag. The other saved views are not available yet. **Filtros** opens **Filtros de investigação** (investigation filters); with filters applied the button reads **Filtros (n)**. All filters combine (AND): * **Etiqueta da jornada** (journey tag): an exact v1.1 tag, as sent. * **Provedor de internet** (the network’s autonomous system number) and **País da rede** (network country). * **Instalação do dispositivo** (device installation). * **Motivo de “não avaliado”** (the reason an indicator was not evaluated). * **Indicador 1** and **Indicador 2**, each with a state. There is no IP address filter. Some filters need the risk roles’ read permissions. With filters applied, the list shows the newest matches first, read in batches of up to 1000. ### Access detail [Section titled “Access detail”](#access-detail) Opening an access shows what was actually recorded: **Nota de confiança** (trust score), **Cobertura de evidência** (evidence coverage), **Continuidade do dispositivo** (device continuity), **Recomendação e ação** (recommendation and action), the signals, limitations and reasons, session and device, device and account links, freshness and versions, and **Localização aproximada** (approximate location). A v1.1 access is titled **Acesso com etiqueta** and shows its tag. Later policy changes never recompute a past recommendation. **Recomendação e ação** also shows the latest action your backend recorded for the access, whether it followed the recommendation, and the latest outcome you reported, for roles that read customer feedback. **Sessão e dispositivo** shows **Navegador e sistema** (browser and system) as the browser declared them; that is never a verification of the device. The access detail also shows each of your company’s rules that fired on that access, with the points an active rule deducted or a ghost rule would have deducted; ghost rules never change the access’s score or recommendation. * **Contexto do acesso** (access context) adds context normally computed during the assessment; it changes the score and the recommendation only through context rules your company made active. See [Contexto do acesso](#contexto-do-acesso) below. * **Localização aproximada** shows a position derived from the observed IP address by the IP2Location LITE DB11 database: the coordinates of the city, not of the access, inside a fixed 15 km circle labelled as conventional, because the database states no precision. It is not GPS, an address or a live position, and only the risk roles see it. * **Registrar ação do cliente** (record customer action) is not available in the console yet; your backend reports actions and outcomes through the [feedback API](/api/feedback/). **Ver dados brutos** (view raw data) is not available either. * The device and session buttons open **Vínculos** (links), which shows what was recorded in each assessed access, not the state now; from there you can open a linked account. Accounts appear there only for accesses your backend sent with `account_ref`. A link is not proof of civil identity. #### Contexto do acesso [Section titled “Contexto do acesso”](#contexto-do-acesso) (Access context). Computed during the assessment, from your company’s own history, within a short time budget. It changes the score and the recommendation only through context rules your company made active in [Configuração de risco](#configura%C3%A7%C3%A3o-de-risco); such a rule that fires appears in the `reason_codes` of a scored API response under the item’s code, and the items themselves are not part of the API response. No item alone indicates fraud. The items are grouped under **Conta e dispositivo** (account and device), **Saques e ordens** (withdrawals and orders), **Rede e localização** (network and location), **Navegador e integridade do SDK** (browser and SDK integrity), **Velocidade** (velocity) and **Ligações entre contas** (links between accounts). An item that could not be computed shows **não avaliado** with its reason; **Passou do limite de cálculo (volume ou tempo)** means it did not fit in the assessment’s time budget, and such an item never deducts. If the step does not run inside the assessment at all (it failed as a whole, or FingerTrackID switched it off), the card is computed after the assessment instead: those items were not inputs to the delivered score or recommendation, add nothing to `reason_codes`, and the access’s rules say so with **Os itens de contexto deste acesso não ficaram prontos dentro do prazo da avaliação** (the access context items were not ready within the assessment’s time limit). **Visualização** (view) chooses how to read the card, and the browser remembers the choice: **Grupos** (groups) or **Tabela** (table). In **Grupos**, each group opens and closes: a closed group shows only its summary, and a group with an item read as **Incomum** (unusual) or **Muito incomum** (very unusual) opens by itself; **Abrir todos os grupos** opens them all. **Tabela** shows one row per computed item, with its value, its band and the reading, grouped by **Tema** (the groups above) or **Gravidade** (severity, with the group under each item’s name). In the table a count’s bar carries its value on the pointer, without the band bounds written under it. The **Foco** (focus) switch keeps only the unusual items, and the items without data follow the table, by reason. Each computed item carries a reference band, **Esperado** (expected), **Incomum**, **Muito incomum** or **Informativo** (informative), drawn as a bar whose zones have the same width, with the value’s zone filled and a pointer under it. The bands come from the ruler that applied to that access, and the legend says which: your company’s own ruler, from the rules it published, or FingerTrackID’s default ruler in force when the access arrived (version 1, not calibrated with real outcomes yet). A band is a reading aid and not a fraud label; it changes the score only when your company has activated that item’s rule, which then fires at **Incomum** or **Muito incomum**. In FingerTrackID’s default ruler, the privacy context (iCloud Private Relay, the VPN’s country, guest mode, a browser inside an app) is **Informativo**; your company can grade it in its own rules. A yes/no item says **Sim** or **Não** before its detail, so **Não · macOS** under **Sistema operacional novo para esta conta** means the system is not new. The ⓘ beside each item’s name says, in plain words, what the item measures: the same explanation as the [indicator catalog](#cat%C3%A1logo-de-indicadores). The account items need the `account_ref` your backend sends ([person and account references](/api/overview/#person-and-account-references)); without it they show **Conta não informada pelo sistema da empresa** (account not sent by the company’s system): * **Dispositivo novo para esta conta**, **País novo para esta conta**, **Provedor de internet novo para esta conta** and **Sistema operacional novo para esta conta**; * **Viagem impossível: velocidade entre este acesso e o anterior** (impossible travel); * **Quantidade de acessos desta conta** and **Repetições da mesma ação nesta conta**; * **Dispositivos que esta conta usou (24 horas)**, **Países de onde esta conta acessou (24 horas)**, **Provedores de internet usados por esta conta (24 horas)** and **IPs diferentes usados por esta conta (última hora)**; * **Outras contas que usaram os mesmos dispositivos (30 dias)**, **Contas ligadas por dispositivo em que houve robô ou navegador falsificado (30 dias)** and **Contas ligadas por dispositivo que a sua empresa marcou como fraude**; the last one also reads your outcomes from the [feedback API](/api/feedback/): while the application has never sent any outcome, it shows **Sem dado: a empresa não envia resultados** (no data: the company does not send outcomes); once it has sent any outcome, even one that is not a confirmed fraud, the item is computed and shows 0 when no neighbouring account has a confirmed fraud; * most withdrawal and order items of **Saques e ordens**. An account’s history starts with the first event that carries its `account_ref`: earlier events are never linked to it afterwards. With a completed browser collection, the first access of each account shows **Primeira vez: ainda sem histórico** (first time: no history yet) on the items that compare with the account’s past, never a clean history; without one they show **Sem coleta concluída do navegador** (no completed browser collection). A later access evaluates **Dispositivo novo para esta conta** only when an earlier access of the same account in the last 90 days also had a browser collection whose browser installation was linked; an account idle for more than 90 days shows **Primeira vez** again. **Contas que usaram este dispositivo (30 dias)**, **Contas de um mesmo titular neste dispositivo (30 dias)** and **Contas que usaram este mesmo IP (última hora)** are computed even without `account_ref`, but they count only the accounts your backend sent, so without it they show 0. **Viagem impossível** (impossible travel) needs an earlier location of the same account in the last 24 hours. Its location is the city of the connection as FingerTrackID’s network edge sees it, rounded to a coarse cell, a different source from the **Localização aproximada** card. The checks run in this order: without a record of this access from the network edge it shows **Dados de rede da borda indisponíveis** (network edge data unavailable); with that record but no location cell, it shows **Localização aproximada indisponível**, which here refers to that edge location. Only then, when this access, or an access of the same account in the last 24 hours, was detected behind a VPN, a proxy, a hosting network or Tor, or with Safari’s reinforced privacy, it is not evaluated (**Rede anonimizada: localização não confiável**). Under **Velocidade**, **Repetições da mesma ação nesta conta** and **Repetições da mesma ação neste dispositivo, em qualquer conta** count the events with the same [tag](/api/journeys/#the-v11-tag) in the last minute, five minutes and hour. On an access sent with a tag, the card names them by that tag, as your integration wrote it: **Repetições de `saque_pix` nesta conta**. As the screen says, the thresholds and windows are those of the calculation version and are not calibrated with real outcomes yet: a high count of accounts or people on one device is not evidence of fraud. The **Listas da empresa** card says whether the device, the account or the IP of the access is on one of your [lists](#listas), who put it there and why, and adds one of them without typing its value. The card shows a listed IP only up to its network (`203.0.113.***`) for every role; the whole address appears on **Listas** to company administrators and risk supervisors. When a list decided the response of this access, the card names the policy’s recommendation, the one returned, and the entries that decided it as they were at that moment. ### Busca por ID [Section titled “Busca por ID”](#busca-por-id) (Search by ID). Opened from the top bar field **Buscar titular ou conta pelo ID da sua empresa**, which ⌘K (on Apple platforms) or Ctrl+K (elsewhere) focuses. On the page, choose **Titular** (person) or **Conta** (account) and type the **ID enviado pela sua empresa** (the ID your company sent): the exact `subject_ref` or `account_ref` your backend sends, never a tax ID, email or phone number. The match is exact, uppercase and lowercase included, with no prefix or approximate match, inside the selected application and period. The top bar field has no **Titular** or **Conta** choice of its own: pressing Enter there runs the search at once, with the kind of your last search in this session, **Titular** by default, and opens **Busca por ID** with its result. An account ID typed there in a new session therefore first runs an audited search as **Titular**, which usually finds nothing; choose **Conta** on **Busca por ID** and search again. * The result, **Acessos encontrados para o ID buscado** (accesses found for the searched ID), uses the rows of **Acessos**. **Abrir o acesso** opens the access detail, and **Ver conta** or **Ver dispositivo** open **Vínculos**. There is no page for the person or the account. A search keeps at most its 500 most recent accesses (narrow the period to see older ones) and expires after a while; then search again. * **Administrador da empresa**, **Analista de risco** and **Supervisor de risco** can search. For the other roles, or while the search is not enabled for your company, the field stays disabled and says why. * Each search that runs is recorded in **Auditoria** as **Busca por titular** or **Busca por conta**, without the ID and without saying whether it found anything, and so is every page of its result you read, under the same label; a search refused before it runs leaves no record. The ID travels only in the body of the console’s request: never in the page address, the browser history, the page title or browser storage. * A search by account finds only the accesses your backend sent with `account_ref`. When nothing matches, the screen says that no retained access in the period carries that ID, and that your integration may not send that kind of ID yet. ## Fila de revisão [Section titled “Fila de revisão”](#fila-de-revisão) (Review queue). Withdrawals that need human review, with state **Aberto**, **Em revisão** or **Encerrado**, priority and deadline, up to 50 per page. Risk analysts and supervisors use **Atribuir a mim**, **Iniciar triagem** and **Concluir triagem**; only the assigned person starts or completes a triage. Supervisors can reassign, use **Reabrir item** and set the **Perfil da fila** (priority and deadline for new items). Without a profile the queue shows **Fila não configurada**. The queue organizes review only. Holding, challenging or blocking a withdrawal is always your company’s decision, outside FingerTrackID. ## Configuração de risco [Section titled “Configuração de risco”](#configuração-de-risco) (Risk configuration). Your company’s risk rules for the selected application, one configuration for every journey and tag of that application. Any rule can be published as active directly, but only your company can activate it; while no rule is active, every recommendation stays `MONITOR` ([availability](/reference/availability/)). A company that never published its own rules opens the editor with FingerTrackID’s recommendation, marked as not published yet: points for each indicator, five score bands and thresholds for the context items. Nothing changes until you publish it, and **Voltar à recomendação** brings it back after you discard it. * **Indicators.** Each of the 13 Web indicators has a mode, **Desligada** (off), **Fantasma** (ghost) or **Ativa** (active), and a weight from 1 to 100 points. A ghost rule is computed and recorded on every assessment but never changes the score or the response. An active rule that fires deducts its weight from the score. * **Context items.** Each of the 54 items of [Contexto do acesso](#contexto-do-acesso), computed during the assessment, has the same three modes, a ruler and two point values, one for **Incomum** and one for **Muito incomum** (at least as many as **Incomum**). The ruler sets where the item’s reading turns **Incomum** and **Muito incomum**; it starts from FingerTrackID’s default ruler, maintained in the backoffice, and your company adjusts it. An active item that fires deducts the points of the band it reached; an item read as **Informativo** never fires. An active item without data never deducts; if the score then falls into your allow band, the assessment stays without a score (`INCONCLUSIVE`, `MONITOR`). When FingerTrackID publishes a new default ruler, your published rules do not change: the screen shows a notice, and adopting it is a publication of your own. * **Bands.** You set the score bands and the action of each one (block, review, extra verification, monitor, allow). The score starts at 100, each active rule that fires deducts its weight, the score never goes below 0, and the final score falls into one of your bands. Until your company publishes its own rules, the managed configuration stays in force, with every band on monitor. FingerTrackID’s recommendation that opens in the editor has five bands instead: block from 0 to 19, review from 20 to 39, extra verification from 40 to 59, monitor from 60 to 80 and allow from 81 to 100, so publishing it unchanged can already return `BLOCK_RECOMMENDED`, `REVIEW`, `STEP_UP` or `ALLOW`. * **Impact.** Optional. When you ask for it, the impact card replays your draft over the assessments already recorded in the last 1, 7 or 30 days and shows how many would have had each action as delivered, with your draft, and with your ghost rules counting, for example how many accesses would have been blocked. **Publicar** never waits for a measurement; when it stays disabled, the reason is written beside it. * **Publish.** A confirmation dialog shows the impact summary when this exact draft was measured, and says so when it was not. Publishing creates a new version for the application, recorded in the audit trail, and affects only future assessments; past assessments are never recomputed. * **Tudo em fantasma** (everything to ghost) publishes, in one step, a new version where every active rule becomes ghost with its weight kept. From the next assessment on, none of your rules deducts from the score and every response is `MONITOR`. Use it to stop your rules at once. * **Put in ghost by support.** FingerTrackID’s support can also put every rule of an application in ghost, with a recorded reason, for example when an administrator session may be compromised. The screen then shows a notice that FingerTrackID support did it, when, and the reason; it names the support team, not the individual operator. Support never activates a rule or changes a weight, band or context item. To reactivate, your administrator or risk supervisor switches the rules you want back to **Ativa** and publishes; the notice disappears once you publish again. Publishing and **Tudo em fantasma** need **Administrador da empresa** or **Supervisor de risco** and a recent authenticator check; every role that reads policy sees the configuration and the impact, and every rule hit, active or ghost, including the context items, per access and in the impact. Apart from putting every rule in ghost, FingerTrackID’s operators never publish or activate a rule for your company. A `BLOCK_RECOMMENDED` produced by your rules is still a recommendation: your backend executes it. Missing evidence, a timeout or a failure never recommend blocking. ## Simulações de política [Section titled “Simulações de política”](#simulações-de-política) (Policy simulations). Re-evaluates **1 to 10** recorded assessments against a policy version already saved on the server, and compares original and simulated state, score and recommendation. Local drafts cannot be simulated. Choose **Capturar simulação** (capture simulation); past simulations open by their identifier. Simulating never changes the policy in force or past assessments. ## Listas [Section titled “Listas”](#listas) (Lists). Your company’s own **Lista de bloqueio** (block list) and **Lista de permissão** (allow list), kept one entry at a time. An entry names one identifier of your traffic: the FingerTrackID **device** (the `dev_…` id the API returns, or the id the console shows), the **account** reference your backend sends, or **one IP address** (IPv4 or IPv6; ranges are not accepted). A block-list entry carries the action **Bloquear** (block) or **Revisar** (review); an allow-list entry carries **Liberar** (allow). An entry may carry a comment of up to 500 characters, and the list shows who added it and when. An identifier sits on one list at a time, and a company keeps at most 10,000 active entries across both lists. Add an entry by typing it on this page (**Adicionar**), or from the detail of an access (**Adicionar à lista**), where the value comes from the access itself and is never typed. Taking an entry off (**Tirar da lista**) keeps its history: who removed it and when. Company administrators and risk supervisors change the lists; risk analysts read them and see an IP only up to its network (`203.0.113.***`). **Effect on the response.** When an active entry names the device, the account or the IP of an access that FingerTrackID scored (`COMPLETED`), the response carries the entry’s action whatever the score: **Bloquear** returns `BLOCK_RECOMMENDED`, **Revisar** returns `REVIEW` and **Liberar** returns `ALLOW`. With several entries, block wins over review, which wins over allow. The score stays the policy’s, and `reason_codes` starts with the deciding entries ([`recommended_action`](/api/response/#recommended_action)). An access without a score keeps the policy’s recommendation. The lists apply to production only. FingerTrackID never creates, imports or publishes an entry, your backend executes the action, and an entry is your company’s decision, not proof of fraud. The device and the IP of an access are the ones its FingerTrackID collection recorded: the IP is the address the collection edge saw, which a whole office or mobile network may share, so allowing an IP allows every scored access coming from it. An access sent without the collection’s token can match by its account only. An entry applies to the accesses answered after it was added. If FingerTrackID later merges a listed device into another, the entry stops matching; add the device the access detail shows. In **Simulações de política**, the original answer of an access is the one returned, a list’s decision included, while the simulated one is the candidate policy’s alone. ## Catálogo de indicadores [Section titled “Catálogo de indicadores”](#catálogo-de-indicadores) (Indicator catalog). A fixed reference of what FingerTrackID can report, marked **Catálogo versão 3** and **Referência fixa**: the 13 browser (Web) indicators, the 18 app indicators (which need the mobile SDK, not available today) and the context information, which changes the score only through context rules your company made active. It reads no records and has no application or period selector. A listed indicator is not proof of an active detector. ## Integração e saúde [Section titled “Integração e saúde”](#integração-e-saúde) (Integration and health). For the selected application, the last 24 recorded hours: integration stage, the path from collection to assessment, observation and the first-party collection domain, the **Credenciais de servidor** (server credentials) table with client, state, `key_id`, version and credential state, and integration governance. Numbers are not live. * **Segredo da credencial de servidor**: a pending credential’s secret appears exactly once, when a company administrator or integration manager chooses **Receber segredo**; it is never stored by the console or shown again. * **Trocar segredo ou revogar acesso**: **Trocar segredo** generates a new secret for an active client (the old one stays valid for up to 24 hours), delivered once in the card above. This is also how a lost secret is replaced. * **Limite de requisições**: the rate limit in force, with **Pedir aumento de limite** ([limits](/api/limits/)). * **Pacote do SDK Web**: download the private SDK package and see its SHA-256 and SHA-512, with the commands to check and install it for **macOS e Linux** and for **Windows (PowerShell)** ([installation](/web-sdk/installation/)). * **Origens das páginas** (page origins), for the company administrator and integration manager: the website origins the SDK collects from, up to 10, all under the collection domain. Add one with **Adicionar origem** or remove one with **Remover**; the change takes effect at once, with no request, and is recorded in the audit trail. The origin used in the collection setup is marked **Principal** and cannot be removed here, and at least one origin stays. Device continuity is per origin. * **Modelos de jornada** (journey templates): ten suggested tags with when to call, where to collect in the browser, which pseudonyms to send and what to look at in the console; the same content as [journey templates](/api/templates/). They are guidance only. **Executar teste de integração** (run integration test) is not available yet. Applications and credentials are requested in [Início](#in%C3%ADcio), not here. ## Uso [Section titled “Uso”](#uso) (Usage). A page of up to 50 usage records of your whole company, each counted as eligible or not, with a reason. Only a qualified SDK collection counts as one unit; everything else counts zero. The screen is labelled **Estimativa não faturável** (non-billable estimate): it is not an invoice, a price or a month’s consumption. It has no application or period selector. Records appear after the SDK completes its first collection. ## Exportações [Section titled “Exportações”](#exportações) (Exports). Request a CSV of **Avaliações** (assessments) or **Chamadas de dispositivo** (device calls) for the selected application, with a time window up to 31 days, 1 to 1000 rows and a reason. The **Avaliações** file gains a sixth column, `tag`, when at least one exported access has a tag; there is no tag filter on exports. Each company can make 20 requests per hour. A request moves through **Gerando**, **Pronta**, **Cancelada** or **Expirada**; download with **Baixar** while it is ready. You see only your own requests. Treat the CSV as untrusted data in spreadsheets. ## Relatórios [Section titled “Relatórios”](#relatórios) (Reports). A monthly recount for the selected application, for the open month and the five before it, in UTC calendar months: eligible units, device calls and assessments. The numbers are recounted on every read and can change; no period is closed yet and nothing here is billable. ## Usuários e acessos [Section titled “Usuários e acessos”](#usuários-e-acessos) (Users and access). Your own access and role, the role and permission matrix and, for the company administrator, the member list with **Conceder acesso** (grant access) and **Gerenciar o acesso** (manage access). Changes are requests and take effect only when completed. The **Convite** column shows whether each invitation is **Pendente** or **Aceito**; while it is pending, **Reenviar convite** sends a new invitation email (at most 3 per hour for each person). See [member changes](/console/roles/#member-changes). ## Auditoria [Section titled “Auditoria”](#auditoria) (Audit). The recorded actions and sensitive reads of your company, 50 per page: when, kind, operation, source, result, actor and environment. Search by person, period or operation is not available yet. Audit never shows secrets or raw collection. ## Ciclo de vida do cliente [Section titled “Ciclo de vida do cliente”](#ciclo-de-vida-do-cliente) (Customer lifecycle). Retention, offboarding and legal-hold operations. The screen shows your company and role, but the console does not send or read these requests yet: every operation is disabled and points to FingerTrackID support, and the history is not available in the console. ## Conta e segurança [Section titled “Conta e segurança”](#conta-e-segurança) (Account and security). Your display name and time zone (the saved time zone does not change how times are shown yet), session and security settings, with **Trocar a senha** (change password) and your authenticators. See [signing in](/console/access/#account-and-session). # Integrating with an AI coding agent > A step-by-step start and a prompt to paste into your AI coding agent, the documentation files it reads best, the rules for your repository, and what never goes to an AI. Many teams integrate FingerTrackID with an AI coding agent. This page gives the agent the documentation in the format it reads best and the rules it must not break. The [implementation handoff](/getting-started/implementation-handoff/) remains the brief for the integration itself. ## Quick start [Section titled “Quick start”](#quick-start) 1. **Download the integration file.** In the console, **Integração e saúde** › **Arquivo da integração** downloads `fingertrackid.integration.json`. A company administrator or integration manager generates it. Save it at the root of the repository that integrates FingerTrackID ([the integration file](#the-integration-file)). 2. **Store the secret yourself.** Whoever received the HMAC secret once in the console puts it straight into the backend’s secret manager, read by the backend as `FTID_HMAC_SECRET_HEX`. It never goes to the agent ([what never goes to an AI](#what-never-goes-to-an-ai)). 3. **Add the MCP server to your agent.** In Claude Code, from the repository root: ```bash claude mcp add --scope project fingertrackid -- npx -y @fingertrackid/mcp@0.1.3 ``` Other clients are in [the MCP server](#the-mcp-server). An agent without MCP reads [`/llms-full.txt`](https://docs.fingertrackid.com/llms-full.txt) and the integration file instead. 4. **Give the agent the rules.** Paste the [rules block](#rules-for-your-repository) into `AGENTS.md`, `CLAUDE.md` or your tool’s equivalent. 5. **Ask the agent to integrate.** In Claude Code, run the server’s prompt with your tags, comma-separated: ```text /mcp__fingertrackid__integrate_fingertrackid login,withdrawal ``` In another agent, or without MCP, paste [the integration prompt](#the-integration-prompt). The agent reads the integration file and adds one fail-open call per tag with the [server SDK](/api/server-sdk/). It then wires the [Web SDK](/web-sdk/collection/) token into your existing request and validates a synthetic request body. 6. **Check the real credential yourself.** Pipe the secret from your secret manager into `npx --no fingertrackid-server check` ([how](/api/server-sdk/#check-the-real-credential)). It sends one real assessment in shadow mode and prints the verdict. Then [review the agent’s work](#checking-the-agents-work) before real traffic. ## The integration prompt [Section titled “The integration prompt”](#the-integration-prompt) Copy this prompt into your AI coding agent, from the root of the repository that holds your site and backend. It works with or without the MCP server. The agent stops for your confirmation before it edits anything, and it never needs the secret. Integration prompt Paste it into your AI coding agent, or open it in one. Copy Open in * [CursorOpens with the prompt filled in](https://cursor.com/link/prompt) * [ClaudeOpens with the prompt filled in](https://claude.ai/new) * [ChatGPTOpens with the prompt filled in](https://chatgpt.com/) * [GeminiCopies the prompt to paste there](https://gemini.google.com/app) * [CodexCopies the prompt to paste there](https://chatgpt.com/codex) ```text Integrate FingerTrackID into this project, in shadow mode: FingerTrackID recommends, and our system keeps deciding and acting as it does today. Sources, in this order: - The fingertrackid MCP server, when it is connected: start with ftid_read_manifest. - fingertrackid.integration.json at the repository root: the issued values (API origin, application public key, allowed origins, collection origin, key_id, Web SDK package). Never invent them or copy them from an example. - The documentation, https://docs.fingertrackid.com/llms-full.txt. The published contracts win over any prose or example. Never ask for, print, log, commit or store the HMAC secret. The backend reads it at runtime, by name, as FTID_HMAC_SECRET_HEX from our secret manager. If a step needs its value, stop and tell me. Show me your plan before you edit anything, then work in this order: 1. Inventory. Find where users sign in (including MFA and redirects such as Google sign-in), sign up, recover a password and take sensitive actions (withdrawals, transfers, changes of email, phone or password). Propose one lowercase tag per moment, such as login, signup, password_recovery or withdrawal, using the FingerTrackID templates. Wait for my confirmation. 2. Missing inputs. Check the integration file. If the collection origin, a server credential with assessment:write or the Web SDK package is missing, tell me what to do in the console, and continue only with the work that does not depend on it. 3. Backend. In Node.js, install the server SDK with the exact version: npm install --save-exact @fingertrackid/server@0.1.2 Create one module with one function per tag (the MCP tool ftid_server_recipe generates it). In another language, implement the signature from https://docs.fingertrackid.com/api/authentication/ and match the public test vector first. - Call each function after our own checks and before our product acts: for login, after the real authentication and MFA. Keep our own decision. - Fail open: when FingerTrackID is slow, unavailable or misconfigured, the product works as today, and the failure is logged without secrets. - subject_ref and account_ref are keyed pseudonyms the backend derives, for example HMAC-SHA-256 of our internal ID with a key only the backend holds. Never an email, tax ID, phone number or name. - Log only identifiers and verdicts: tag, result kind, assessment_id, recommended action and correlation ID. Never the token, the signature or the request body. - When a result is UNAVAILABLE or UNEXPECTED_RESPONSE, keep result.operation with our record so it can be resent. 4. Browser. Install the private Web SDK archive named in the integration file's sdk_package. A person downloads it from the console. Verify its SHA-256 and SHA-512 against the file, then run npm install --save-exact --ignore-scripts ./fingertrackid-sdk-web.tgz. Never install a public package with a similar name. Then follow https://docs.fingertrackid.com/web-sdk/collection/#the-recipe: - one module with initializeSdk, only in browser code, with the publicKey and collectionOrigin from the integration file, and createActionContextHelper; - prefetch() on the first focus or pointer-down of each assessed form, never at page load, and only after any consent our privacy rules require; - take() once per submit; - the token and its expiresAt, or null, sent inside the existing authenticated, CSRF-protected request; - the helper closed on logout or account switch. The SDK never reads form values or passwords. 5. Site configuration. Add the collection origin to connect-src in our Content-Security-Policy. Keep that origin out of tracing header propagation (Sentry tracePropagationTargets, Datadog allowedTracingUrls, OpenTelemetry propagateTraceHeaderCorsUrls). 6. Checks. - Run npx --no fingertrackid-server self-check. - Validate one synthetic request body (ftid_validate_request in the MCP server). - Add tests for the fail-open paths: FingerTrackID unavailable, and a null token. - Run our test suite. 7. Hand-off. - List every file you changed, and what I must still do in the console. - Ask me to run npx --no fingertrackid-server check with the secret piped from our secret manager. It sends one real assessment in shadow mode. ``` Show all ## Documentation for the agent [Section titled “Documentation for the agent”](#documentation-for-the-agent) | File | Content | Use it for | | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ | -------------------------- | | [`/llms.txt`](https://docs.fingertrackid.com/llms.txt) | A short summary of the platform, its rules and a link to every documentation set | The agent’s starting point | | [`/llms-full.txt`](https://docs.fingertrackid.com/llms-full.txt) | Every page of this site in one plain-text file | Complete integration work | | [`/llms-small.txt`](https://docs.fingertrackid.com/llms-small.txt) | The same pages with whitespace removed; every note, caution and expandable section is kept | Smaller context windows | The three files are generated from these pages at every publication, so they say nothing the pages do not. The published contracts (OpenAPI and JSON Schema, linked from the [API overview](/api/overview/)) win over any prose or example, in these files and on every page. ## The integration file [Section titled “The integration file”](#the-integration-file) In the console, **Integração e saúde** › **Arquivo da integração** (also in **Início**, in the SDK step) downloads `fingertrackid.integration.json`. Save it at the root of the repository that integrates FingerTrackID: the agent reads the issued values from it instead of copying them by hand. | Field | Content | | ------------------------------------------------ | --------------------------------------------------------------------------------------------------------- | | `api_origin`, `documentation`, `request_profile` | The production API origin, this documentation for agents and the request profile to send | | `secret` | Only the name of the variable the backend reads the secret from, `FTID_HMAC_SECRET_HEX`. Never the secret | | `application` | The application ID, slug, name, environment, browser public key and allowed origins | | `collection` | The confirmed first-party collection origin, or `null` until the collection domain is active | | `server_credentials` | Each current server credential’s `key_id`, capabilities and approved rate | | `sdk_package` | The approved Web SDK archive’s version, SHA-256 and SHA-512, or `null` | The file holds public identifiers only, so it can be committed. Download it again when a key, the SDK package or the origins change: it does not update itself. Its contract is the [integration manifest schema](/downloads/contracts/json-schema/integration-manifest.v1.schema.json). ## The MCP server [Section titled “The MCP server”](#the-mcp-server) `@fingertrackid/mcp` is a local [Model Context Protocol](https://modelcontextprotocol.io/) server for coding agents that support MCP. It runs on the developer’s machine over stdio, reads the integration file and public values, and changes nothing. No tool accepts the HMAC secret. Add it with the exact version. In Claude Code, for this project: ```bash claude mcp add --scope project fingertrackid -- npx -y @fingertrackid/mcp@0.1.3 ``` Other clients that read an `mcpServers` file, such as `.mcp.json`: ```json { "mcpServers": { "fingertrackid": { "type": "stdio", "command": "npx", "args": ["-y", "@fingertrackid/mcp@0.1.3"] } } } ``` Start the agent from the repository root, where the integration file is. Then ask it to run the `integrate_fingertrackid` prompt, or to use the tools directly: | Tool | What it does | | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `ftid_read_manifest` | Reads the integration file and lists what is still missing in the console | | `ftid_list_templates` | The ten common moments to assess, with the suggested tag and references ([templates](/api/templates/)) | | `ftid_server_recipe` | A complete TypeScript module for the [server SDK](/api/server-sdk/), one fail-open function per tag | | `ftid_validate_request` | Checks a request body exactly as the API reads it | | `ftid_explain_signature` | The ten canonical lines and the MAC for a body, under the public test secret | | `ftid_check_vector_mac` | Compares a signer in any language with the [public test vector](/api/authentication/#public-test-vector) | | `ftid_interpret_response` | Explains an assessment or a Problem, and whether to retry | | `ftid_check_collection_domain` | Reads public DNS and TLS for your [collection domain](/getting-started/collection-domain/), only for a domain of the application’s origins in the integration file | The server also serves the public contracts as resources. Every tool is read-only; the first real call still runs from your backend, with the secret it reads from your secret manager. ## Rules for your repository [Section titled “Rules for your repository”](#rules-for-your-repository) Paste this block into the instructions file your agent reads (`AGENTS.md`, `CLAUDE.md` or your tool’s equivalent) in the repository that calls FingerTrackID. Repository rules Paste them into AGENTS.md, CLAUDE.md or your tool's equivalent. Copy ```markdown ## FingerTrackID integration Documentation: https://docs.fingertrackid.com/llms-full.txt. The published contracts win over any prose or example. - Shadow mode. FingerTrackID recommends; this system decides and executes every action. Never block, allow, hold or settle anything because of a recommendation unless the product team decided it explicitly. - Fail open. If FingerTrackID is slow, unavailable or misconfigured, the product keeps working with its own decision, and the failure is reported, not hidden. - HMAC secret. Read it at runtime from the server-side secret manager, by name. Never ask for its value, and never print, log, commit or store it, or put it in a test, fixture, example or browser-exposed variable. - Issued values. The key_id, the browser public key, the collection origin and the DNS records come from the FingerTrackID console. Never take them from a documentation example or from another application. - Signing. FTID-HMAC-SHA256-V1 over the decoded secret bytes, never the hexadecimal text. Reproduce the published test vector byte for byte before the first real call. - Body. Serialize it once. A retry resends the same bytes with a new X-FTID-Request-Id (UUIDv7), the current X-FTID-Timestamp and a new signature. Content-Type is exactly application/json, with no Content-Encoding. - References. subject_ref and account_ref are keyed pseudonyms created by this backend (for example HMAC-SHA-256 of the internal ID with a backend key). Never send an email, tax ID, phone number, name or account number. account_ref requires subject_ref. - Browser token. The event_context_token goes from the browser SDK to this backend inside the existing authenticated, CSRF-protected request. It is not a result; never log it. - Response. trust_score null means no score, never 0 or 100, and INCONCLUSIVE is a normal outcome. Keep X-Correlation-Id for support. - Logs. Never log the secret, the token, the signature or the full request body. ``` Show all ## What never goes to an AI [Section titled “What never goes to an AI”](#what-never-goes-to-an-ai) * **The HMAC secret.** A company administrator or integration manager [receives it once in the console](/getting-started/onboarding/#5-receive-the-hmac-secret-once-in-the-console) and puts it straight into the backend’s secret manager. The agent writes code that reads it by name; it never needs the value. * **Real user data.** Production logs, request bodies with real references, event context tokens and console exports stay out of prompts and agent context. * **Console access.** Your password, authenticator codes and console session are personal. An agent that asks for the secret If an agent asks for the secret value to test a call, the answer is no. It checks its signing code with the public [test vector and self-check](/api/authentication/#public-test-vector), which use a synthetic secret that authenticates nothing. The first real call runs from your backend, with the secret it reads from your secret manager. ## Checking the agent’s work [Section titled “Checking the agent’s work”](#checking-the-agents-work) * The [signature self-check](/api/authentication/#check-your-environment) prints the expected MAC on the Node.js that runs your backend. * The [integration guidance](/getting-started/integration-guidance/) covers failing open, the time budget and testing in production without a sandbox. * The [integration checklist](/reference/checklist/) is the review list before real traffic. # Collection domain (DNS) > The DNS records you publish for collect. to enable first-party collection by the Web SDK, and how to check each one. The Web SDK sends its collection through a subdomain of **yours**, `collect.`. The SDK itself is delivered as an approved private package bundled into your frontend. This is *first-party* collection. It is required for SDK collection: there is no shared domain as an automatic fallback, and without an active collection domain the SDK is not enabled for your application. Preparing DNS does not activate it. Each request must match your current verified collection host, one of your application’s allowed website origins under that domain, and the application key. There is no routing fallback to the API host or another company’s collector. ## Request the domain preparation [Section titled “Request the domain preparation”](#request-the-domain-preparation) Production status Web SDK collection is deployed in production, in shadow mode, only through your company’s registered collection domain; its end-to-end operation from a customer page is still being measured. Your collection is accepted once this domain is verified and active for your application; publishing the records alone does not activate it. See [production status](/reference/availability/). In the console, open **Início** and complete **Criar a aplicação** (create the application) with your exact HTTPS website origins. Then, in **Configurar a coleta web** (configure web collection), choose the **Origem registrada da aplicação** (one of those origins) and enter your **Domínio-base de coleta da sua empresa** (owned base domain), such as `yourcompany.com`. The resulting collector is `collect.yourcompany.com`. The SDK then collects from every allowed origin of the application whose host is `yourcompany.com` or one of its subdomains, such as `https://yourcompany.com`, `https://www.yourcompany.com` and `https://app.yourcompany.com`; you edit that list later in **Integração e saúde** › **Origens das páginas**. See [onboarding](/getting-started/onboarding/#collection-domain). The platform then prepares the application-bound certificate, routing and ownership challenge by itself, with no request to FingerTrackID and no operator step. Your only action is to publish the three public DNS records it shows you and configure the approved collector in the application. Private infrastructure plans, cloud credentials and secret-manager access are not integration inputs. ## Who acts: “Depende de você” and “Automático” [Section titled “Who acts: “Depende de você” and “Automático””](#who-acts-depende-de-você-and-automático) Every step of the setup progress in **Início** carries one of two labels: * **Depende de você** (depends on you): the setup waits for something only you can do, such as publishing the DNS records or adjusting your CAA policy. * **Automático** (automatic): the platform is doing the work; there is nothing for you to do. The step moves on by itself. The step **Confirmar o domínio de coleta** (confirm the collection domain) goes through these states: | On screen | Label | What it means | | -------------------------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Preparing the certificate and records | Automático | The platform is preparing the certificate and the DNS records for you. | | Publish the three records | Depende de você | Publish the three records shown, exactly as they appear. | | Records found, verifying | Automático | Your records were found; the platform is verifying the certificate and the collection route. | | Records not found yet | Depende de você | Your records were not seen yet. Check your DNS and choose **Verificar agora** (check now); the values stay the same, so you do not change what you published. | | New verification being prepared | Automático | The platform is preparing a new verification, with the same records. Nothing to change in your DNS. | | CAA does not allow the certificate authority | Depende de você | Your domain’s CAA policy blocks the certificate. See [CAA](#the-records-you-publish). | | Re-checking the domain | Automático | The platform is checking the domain again. Nothing to do. | | All records found, finishing | Automático | Every record shows **Encontrado**; the platform is finishing the verification and activating the domain. Nothing to do. | | Paused by support | — | FingerTrackID support paused this step. Contact support to resume it. | | Concluído | — | The collection domain is confirmed. | The step **Definir a guarda dos dados** (define data custody) is always **Automático**: the platform applies the managed retention to your application by itself: the data is kept for at least 10 years and none of it is deleted ([retention](/reference/data-and-processing/#retention)). You never act on it. If an automatic step does not move for 30 minutes, it shows **Algo travou** (something got stuck): contact FingerTrackID support with the request reference shown in **Início**. Waiting for your DNS records is never shown as stuck. ## The records you publish [Section titled “The records you publish”](#the-records-you-publish) You publish **two CNAME records** and **one TXT** ownership record, all under `collect.`. The exact values appear in the setup progress of your application in the console as soon as they are ready, each with a copy button and whether the platform has already found it (**Encontrado**, **Ainda não encontrado** or **Ainda não conferido**). They are generated for your application and must not be guessed or copied from another customer. | Shown as | Type | Name | Value | Purpose | | ---------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------- | ------------------------------------------------------------------ | | **Endereço de coleta** | `CNAME` | `collect.` | The FingerTrackID collection host, provided to you | Routes the SDK traffic to FingerTrackID | | **Validação do certificado** | `CNAME` | `_acme-challenge.collect.` or `_acme-challenge_.collect.` (exact name provided to you) | The certificate authorization target, provided to you | Authorizes issuing the TLS certificate for `collect.` | | **Prova de propriedade** | `TXT` | `_ftid-ownership.collect.` | `ftid-domain-verification-v1=…`, provided to you | Proves that your company controls the domain | Example with a fictitious domain, `yourcompany.com` — the target values are placeholders: ```text collect.yourcompany.com. CNAME . _acme-challenge_.collect.yourcompany.com. CNAME . _ftid-ownership.collect.yourcompany.com. TXT "ftid-domain-verification-v1=" ``` Rules: * **Exactly one record per CNAME name.** Exactly one CNAME at `collect.` and exactly one CNAME at the certificate authorization name; the verification fails if the name answers with more than one value or a different one. Do not create any other record type (A, AAAA, TXT) at the same name as a CNAME. * **DNS only, no proxy in front.** Publish the CNAMEs as plain DNS records, without a proxy, CDN or WAF of yours between the browser and FingerTrackID. A proxied record does not answer as the expected CNAME, so the verification does not pass. * **Publish the records, then choose Verificar agora.** There is no deadline for you to meet: the platform keeps checking by itself, and the button **Verificar agora** (check now) checks the records right away. If the platform’s verification needs renewing, the same click renews it with the **same TXT value**, so you never change your DNS. A value you reconstruct yourself is never accepted as ownership proof. * **CAA.** If your domain publishes CAA records, they must allow the certificate authorities Google uses for managed certificates: add `pki.goog` and `letsencrypt.org` (for example `0 issue "pki.goog"` and `0 issue "letsencrypt.org"`). The platform retries by itself once the policy allows them. If no CAA record applies to `collect.`, there is nothing to do. ## Cookies the collection host receives [Section titled “Cookies the collection host receives”](#cookies-the-collection-host-receives) `collect.` is a host under your own domain, so the browser sends it every cookie of your site whose scope covers it. A session cookie created with `Domain=.` (or for a parent of it) reaches the collection host on every SDK request, because the SDK calls it with `credentials: "include"`. * **What FingerTrackID does with them:** the collector reads the `Cookie` header only in memory, during the request, and uses only its own installation cookie (`__Host-ftid_i`). Every other cookie is discarded with the request: it is not written to logs, error monitoring, the stored collection or the database. * **Why `credentials: "include"`:** it is what lets the browser return the installation cookie, which is `HttpOnly` and restricted to the collection host, and which keeps device continuity between visits. Without it, collection still works but loses that continuity. * **What we recommend:** keep your session cookies restricted to the host of your application (no `Domain` attribute, ideally with the `__Host-` prefix), so they never reach `collect.` or any other subdomain. ## How to check [Section titled “How to check”](#how-to-check) After publishing, check from any machine with `dig` (replace `yourcompany.com` with your domain): ```sh dig +short CNAME collect.yourcompany.com dig +short CNAME _acme-challenge_.collect.yourcompany.com dig +short TXT _ftid-ownership.collect.yourcompany.com dig +short CAA collect.yourcompany.com dig +short CAA yourcompany.com ``` Each CNAME command must return **exactly one** line, equal to the value you received (`dig` may add a trailing dot to the name). The TXT answer must contain the value you received, in quotes. An empty answer means the record has not propagated yet or was created under a different name. The CAA commands are an initial diagnostic. They do not prove that certificate issuance is authorized: the platform also checks aliases and the applicable parent-domain chain before activation, and shows a CAA problem in the setup progress. Tip Many DNS panels append the domain automatically. If the panel asks only for the “name”, type `collect` — not `collect.yourcompany.com`, which would become `collect.yourcompany.com.yourcompany.com`. ## What happens next [Section titled “What happens next”](#what-happens-next) 1. The platform checks the three records by itself, from time to time. To check right after publishing, choose **Verificar agora** (check now): the button shows **Verificando…** and the page refreshes by itself until the check comes back, usually in under a minute. Each record then shows **Encontrado** (found) or **Ainda não encontrado** (not found yet). If something is still missing, fix it in your DNS and choose **Verificar agora** again (at most once a minute). When everything is found, the step moves to **Automático** while the certificate and the collection route are verified and the domain is activated. 2. With the domain confirmed and the data custody preset applied, the platform completes the remaining automatic steps and activates collection for the application, with no request to FingerTrackID. 3. **Configurar a coleta web** in Início then shows **Concluída** (done), and **Integração e saúde** (integration and health) shows the first-party domain as active. Past collection counts do not prove that today’s domain verification remains active ([see the screen](/console/screens/#integra%C3%A7%C3%A3o-e-sa%C3%BAde)). Once collection is active, continue with [installing the Web SDK](/web-sdk/installation/). # How the integration works > The path of an assessment, from the browser to your backend and back, and what lives in each system. ## The path of an assessment [Section titled “The path of an assessment”](#the-path-of-an-assessment) 1. The **approved Web SDK**, bundled into your frontend, collects browser signals through your active collection domain and returns to your frontend an opaque `event_context_token` for each business action (a login, a withdrawal, a trade order). The platform prepares the collection domain’s certificate, routing and ownership challenge by itself; you publish the DNS records it shows, and it verifies them and activates the domain ([collection domain](/getting-started/collection-domain/)). 2. Your **frontend** sends that token to **your** backend, together with the business request it already makes. 3. Your **backend** calls `POST /v1/assessments`, signed with HMAC, with the token (or `null` when there was no collection) and pseudonymous references for the person and the account. 4. **FingerTrackID** assesses, stores and answers in the same call with the assessment status, the score (`trust_score`) when there is one, the indicators and a recommendation (`recommended_action`). 5. **Your system decides and acts.** During shadow validation, observe the recommendation and compare it with your current decision. When your company configures its own active risk rules, a `BLOCK_RECOMMENDED` comes from your rules and your bands; your backend still executes it ([recommended action](/api/response/#recommended_action)). When the SDK cannot provide a context in time, send `event_context_token: null`. That means no browser evidence was supplied for the action; it does not guarantee an HTTP success or a particular assessment status. Read the actual [response or Problem](/api/response/) under your issued policy, and continue your product’s normal authentication and fallback behavior. Prepare browser and backend together Prepare the approved private SDK package and the signed backend call together. The documentation URL does not issue credentials, an approved archive or a collection domain. Follow the [implementation handoff](/getting-started/implementation-handoff/) and [availability](/reference/availability/). ## What lives in each system [Section titled “What lives in each system”](#what-lives-in-each-system) | System | Responsibility | Never put here | | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | | Your customer’s browser | Load the approved SDK, collect before the assessed action’s submit, obtain one opaque context per business action within the application’s evidence window (60 or 300 seconds) | The HMAC secret, authority over the account, blocking based on the score | | Your backend | Authenticate your user, resolve the pseudonymous references, create the event IDs, request the assessment and execute its own decision | Trust in an identity, score or flag coming from the browser | | FingerTrackID | Authenticate the integration, assess the available evidence, apply the managed policy or your company’s own rules, return an explainable recommendation | Approving a login, releasing a withdrawal, executing an order or blocking an account | The HMAC secret lives **only in your backend**. Apart from its single reveal in the console, where you copy it straight into your secret vault, it never goes to a browser, an app, a log, analytics, a support ticket, a code repository or a frontend environment variable. ## Public identifiers and secrets [Section titled “Public identifiers and secrets”](#public-identifiers-and-secrets) | Item | Public? | Where it is used | | ------------------------------------ | ----------------------------------- | ----------------------------------------------------------- | | Application public key (`pub_app_…`) | Yes — it is a locator, not a secret | Web SDK initialization | | Credential `key_id` | Yes | `X-FTID-Signature` header | | HMAC secret | **No** | Only in your backend’s secret vault | | `event_context_token` | Must not go to logs or analytics | From the browser to your backend, and from there to the API | Next step: [onboarding](/getting-started/onboarding/). # Implementation handoff for your developer or AI > Integrate the production browser and backend flows using actual application inputs, exact public contracts and a verified activation record. Use this page as the implementation brief for the team or AI integrating your product. [Register your company and application](/getting-started/onboarding/), then prepare the browser and backend together. Every company follows this same workflow; no preconfigured demonstration customer or site-specific integration is required. When an AI coding agent does the work, also give it the [documentation files and repository rules for AI agents](/getting-started/ai-agents/). Start in `SHADOW`: FingerTrackID assesses and recommends while your product continues to authenticate users and execute its own decisions. Preparation and activation The signed assessment API is live in production in shadow mode, and Web SDK collection is deployed there; its end-to-end operation from a customer page is still being measured, and its first real-browser latency figures, from one application, are [published](/web-sdk/collection/#measured-in-production-2026-10-15) ([production status](/reference/availability/)). Recommendations only, your product decides. They work for your application once its own inputs are active: the server credential installed with active admission, and the collection domain active for browser collection. Real data needs the applicable security, authentication-isolation, first-party, retention/disposal and operational/recovery proofs. A documentation URL, registered workspace or public key alone provides none of these proofs. ## Issued inputs [Section titled “Issued inputs”](#issued-inputs) Obtain the following from actual configuration and the verified onboarding handoff. Mark a missing input as a blocker; do not infer it from an example, another company’s setup or the docs URL. | Input | Where it belongs | | --------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Company/application IDs and environment | Integration configuration and support references | | Approved private SDK archive, exact version, SHA-256 and SHA-512 | Downloaded in the dash (**Início** › **Configuração do SDK**, or **Integração e saúde** › **Pacote do SDK Web**); verify both hashes against the ones the dash shows before installation | | Application public key (`pub_app_…`) | Browser SDK configuration; a public locator | | Exact HTTPS collection origin and allowed site origins | Browser configuration and CSP; the active first-party domain is required | | Two exact CNAME names/targets and fresh ownership TXT value | Your DNS provider; publish the issued records and wait for verification | | API origin: `https://api.fingertrackid.com` (also shown in the dash as **Endereço da API de risco**, step **Credencial servidor a servidor**) | Backend configuration; origin only, exactly as written, not the OpenAPI example server or a route | | UUIDv7 `key_id`, enabled capabilities, rate and burst limits | Backend configuration; use the issued credential scope | | HMAC secret received once in the console, `hex` encoding | Backend secret manager, straight from the console; never provide the value to an AI | | Active policy, qualified release and the request profile in use (v1.1, or v1 and its journeys) | Expected response interpretation and the production feature gate | | Named console access and activation confirmation | Human access, mandatory email verification/authenticator and operational record | The standard Web SDK delivery is an approved private archive, downloaded in the dash with the SHA-256 and SHA-512 FingerTrackID recorded for it. Those digests do not imply an independent signature. There is no script installation: script distribution is not enabled ([installation](/web-sdk/installation/#no-script-installation)). HMAC material never belongs in this brief or a generated configuration file. FingerTrackID generates the secret and a company administrator or integration manager [receives it once in the console](/getting-started/onboarding/#5-receive-the-hmac-secret-once-in-the-console) and stores it directly in the backend’s secret manager; it is never shown again. While the console shows the credential request as **Aguardando você receber o segredo** (waiting for you to receive the secret), live signing is blocked; prepare references and synthetic vector tests only. ## Managed setup before the implementation [Section titled “Managed setup before the implementation”](#managed-setup-before-the-implementation) 1. In the console’s **Início**, complete **Criar a aplicação** with exact site origins such as `https://app.yourcompany.com`, rather than individual `/login` or `/register` URLs. 2. Complete **Configurar a coleta web** with one of the application’s origins and the owned base domain `yourcompany.com`. The collector is `collect.yourcompany.com`, and the SDK collects through it from every allowed origin of the application under that domain (for example `https://yourcompany.com` and `https://www.yourcompany.com`); the list is edited later in **Integração e saúde** › **Origens das páginas**. The standard cooperative web profile (`COOPERATIVE_WEB_SHADOW_V1`) produces observation-only `MONITOR` recommendations; its presence does not demonstrate calibrated fraud detection. 3. Publish the [issued DNS records](/getting-started/collection-domain/) once the platform has prepared the exact first-party binding. Keep both CNAMEs DNS-only. The ownership TXT value does not change: if the platform’s verification window has lapsed, **Verificar agora** (check now) renews it with the same value, with no request to FingerTrackID and no DNS change. 4. Follow the same request while the platform runs its automatic steps (release, policy, admission and domain); the setup progress says when a step depends on you. **Aguardando revisão** (waiting for review) is not completed setup; the step is done only when Início shows it as **Concluída**. If waiting work is cancelled, artifacts already created remain; a cancelled request is not a rollback or a completed integration. 5. Complete **Credencial servidor a servidor**, receive the secret once with **Receber segredo** and store it in the backend’s secret manager, then confirm the public `key_id`, permissions and active production admission. Managed new clients start at **5 requests per second with a burst of 10**; the console shows the limit in force and how to raise it ([limits](/api/limits/)). 6. Complete **Configuração do SDK** once the collection domain is active, and obtain the exact private SDK archive and integration inputs. Prepare code with real issued identifiers and synthetic signing vectors; enable real collection and calls only after the application’s explicit activation confirmation and applicable technical proofs. Browser SDK activation and signed API activation are confirmed separately. An enabled collector does not waive receiving the credential secret, admission or authentication-isolation/runtime verification for your backend calls. Keep this brief limited to public IDs, DNS records, hashes, origins and activation references. Do not include private infrastructure plans, cloud credential files, secret locators or HMAC bytes. ## Public machine-readable contracts [Section titled “Public machine-readable contracts”](#public-machine-readable-contracts) Download these exact files and preserve their relative directories: * [OpenAPI: assessment.v1.1.yaml](/downloads/contracts/openapi/assessment.v1.1.yaml) (the v1.1 request, accepted in production) * [Request: event-assessment-request.v1.1.schema.json](/downloads/contracts/json-schema/event-assessment-request.v1.1.schema.json) * [Response: event-assessment.v1.1.schema.json](/downloads/contracts/json-schema/event-assessment.v1.1.schema.json) * [Synthetic v1.1 requests and responses](/downloads/contracts/examples/event-assessment-v1.1.examples.json) * [OpenAPI: assessment.v1.yaml](/downloads/contracts/openapi/assessment.v1.yaml) * [Request: event-assessment-request.v1.schema.json](/downloads/contracts/json-schema/event-assessment-request.v1.schema.json) * [Response: event-assessment.v1.schema.json](/downloads/contracts/json-schema/event-assessment.v1.schema.json) * [Errors: assessment-problem.v1.schema.json](/downloads/contracts/json-schema/assessment-problem.v1.schema.json) * [Synthetic requests, responses and Problems](/downloads/contracts/examples/event-assessment-v1.examples.json) * [Reference HMAC signer](/downloads/ftid-hmac-v1.mjs) * [Standalone signature self-check](/downloads/ftid-hmac-v1-self-check.mjs) * [Normative assessment HMAC vector](/downloads/assessment-hmac-v1-test-vector.json) The assessment OpenAPI document retains its historical example server, schema identifiers and planned implementation annotation. Those are not issued environment values or proof of deployment; the production API origin is `https://api.fingertrackid.com`. The public schemas/examples remain the exact contract sources, rather than an approximation. Read [authentication](/api/authentication/), [request](/api/overview/), [response](/api/response/), [errors](/api/errors/) and [retry rules](/api/idempotency-and-retry/) together. ## Browser integration [Section titled “Browser integration”](#browser-integration) 1. Verify and install the [private SDK package](/web-sdk/installation/#installation-private-package). Initialize it only in browser code, with one SDK copy and one client for the current document/account lifecycle. 2. After any applicable consent, prepare the context on the first intent of the assessed action’s form (focus or pointer-down, never at page load) with `createActionContextHelper`; it collects again only when the collection would leave the [evidence window](/web-sdk/collection/#the-evidence-window) (60 or 300 seconds) before your margin, when its session ended, after repeated refused lookups, or once per page after a transient limitation; while event contexts are unavailable it adds at most one collection per 300 seconds ([stages and reasons](/web-sdk/collection/#stages-and-reasons)); follow [the SDK lifecycle](/web-sdk/collection/#the-lifecycle). For a login with MFA, the context goes with the MFA submission. If the login redirects to Google or another provider, follow [login with Google, OAuth and other redirects](/web-sdk/login-redirects/). Login, registration and password-recovery pages can be collection locations. In v1.1 each moment your backend assesses gets its own tag (`signup`, `password_recovery`…); in v1 they are not additional journeys. Do not read or send form values, passwords, recovery codes or browser-selected identity references to the SDK. 3. For each new business action the backend will assess, take one retained [action context](/web-sdk/collection/#the-recipe) at its submit, waiting at most your own ceiling and never aborting the SDK; with SDK 0.3.0 the helper’s `take()` does this and never rejects. SDK/network/context failure must leave your normal business operation functional. 4. Pass only the captured token and its `expiresAt`, or `null`, in your existing authenticated, CSRF-protected business request; the backend sends `null` if the token is about to expire. Retry the same action with the same handle and captured result, **including `null`**. Do not recollect or replace a token to change an existing assessment. 5. Keep the document’s client alive until your backend has answered: closing it before the assessment is stored invalidates the context. Keep it out of page components that unmount at submit, and hold a classic form’s navigation until the context is settled or your ceiling ends; preserve the original deadlines. 6. On logout/account switch, close the client and discard handles before reinitialization. Never delay logout to finish optional collection or persist SDK handles/tokens in storage. 7. Keep your collection origin out of page instrumentation’s trace propagation: an extra request header makes every SDK request fail ([tracing headers](/web-sdk/installation/#tracing-and-other-request-headers)). The browser never signs S2S requests or chooses trusted person/account identity. Use the approved SDK interface rather than implementing its transport endpoints yourself. ## Backend integration [Section titled “Backend integration”](#backend-integration) New integrations use the **v1.1** request (`event-assessment-request.v1.1`): you name each assessed moment with your own [tag](/api/journeys/#the-v11-tag) (`login`, `saque`, `pix_transfer`…), and every tag is assessed the same way. The [journey templates](/api/templates/) suggest tags and call sites for common moments. Production accepts the v1.1 request with your `tag`. The **v1** request remains supported, unchanged. It has exactly `LOGIN`, `WITHDRAWAL` and `TRADE`, with their [actions and closed contexts](/api/journeys/#the-v1-profile); do not invent `REGISTER` or `PASSWORD_RECOVERY`, or label an unrelated operation `LOGIN`. An integration already on v1 moves call by call ([moving from v1 to v1.1](/api/migrating-to-v1-1/)). For a first login integration, use the tag `login` (in v1: `LOGIN` with `action: "LOGIN_ATTEMPT"`) after the backend has completed its real authentication and required MFA, and derives the person’s `subject_ref` and the account’s `account_ref` as stable keyed pseudonyms ([person and account references](/api/overview/#person-and-account-references)). Send it right away: the browser context of that login must be stored before its `expiresAt`. A provisional session or OTP request is not completed authentication. Preserve the existing login response even if FingerTrackID is unavailable. For every logical assessment: 1. Derive `occurred_at`, identity references (the same `subject_ref` and `account_ref` pair for the same person and account on every call), `external_event_id` and `idempotency_key` on the backend. Never accept browser values as authority for those fields. 2. Validate the closed request and serialize it once. Retain the exact original bytes and token, including `null`, with the logical operation. 3. For each transport attempt, generate a **new UUIDv7 `request_id`**, current timestamp and signature over those same bytes. Decode the 64-character hex key to its 32 bytes before HMAC. 4. Validate the returned assessment or Problem. Check the expected event and its `tag` (v1.1) or `journey` (v1), and preserve the returned policy/release/recommendation fields. 5. Follow the [retry matrix](/api/idempotency-and-retry/). An unknown result is reconciled by the same POST and original bytes; a definitive `410` never triggers regenerated operation IDs. A missing context means missing browser evidence. It does not guarantee `INCONCLUSIVE`, `201`, a score or success. `trust_score: null` is no score, never a default of 0 or 100. The customer decides and executes the action; FingerTrackID never applies a block in your product. ## Feedback and console verification [Section titled “Feedback and console verification”](#feedback-and-console-verification) When your credential has the required active capabilities, use the [customer-action and outcome contracts](/api/feedback/) to report your actual decision and later evidence. A block, cancellation or score is not fraud confirmation. Do not declare alignment that FingerTrackID must derive, or overwrite historical truth with the latest policy. Use the console to inspect **Acessos** (accesses), **Integração e saúde** (integration and health), **Configuração de risco** (risk configuration), **Auditoria** (audit) and **Uso** (usage) according to your role. Usage is an estimate from immutable records, not an invoice or a fraud-accuracy metric. ## Review and activation [Section titled “Review and activation”](#review-and-activation) Show your integration diff and synthetic checks for the signature vector, schema validation, duplicate/retry behavior, unknown/expired results, SDK initialization/synchronous-context failure, unavailable network/context, logout/account switch and correct server authentication placement. Demonstrate that your product remains functional when the integration is disabled or unavailable. FingerTrackID confirms the actual inputs, domain, credential installation, active admission, policy/release and applicable proofs before enabling real traffic. Keep request/operation IDs, correlation IDs, timestamps and public error codes for support. Never log tokens, secrets, cookies, personal data or full payloads. Before going live, read the [integration guidance](/getting-started/integration-guidance/): always send the assessment (with `null` when there is no context), fail open with visible errors, evaluate the shadow period with feedback and test in production without a sandbox. For your privacy policy, see [data collected and processing](/reference/data-and-processing/). Finish with the [integration checklist](/reference/checklist/). # Integration guidance > Always assess, fail open with visible errors, evaluate the shadow period with feedback, and test safely in production without a sandbox. This page complements the [implementation handoff](/getting-started/implementation-handoff/) with the operational rules that matter once your integration runs: what to send, how to stay independent of FingerTrackID, how to judge the shadow period and how to test without a sandbox. ## The production API origin [Section titled “The production API origin”](#the-production-api-origin) Your backend sends every signed call to the production assessment API origin: ```text https://api.fingertrackid.com ``` For example, `POST https://api.fingertrackid.com/v1/assessments`. Configure the origin exactly as written: `https://`, lowercase host, no trailing slash and no path. The dash shows the same value, with a copy button, as **Endereço da API de risco** (risk API address) in the **Credencial servidor a servidor** step of **Início**, once your credential exists. The downloadable OpenAPI file keeps a historical example server; do not use that one. ## The request profile [Section titled “The request profile”](#the-request-profile) New integrations send the **v1.1** request (`event-assessment-request.v1.1`) and name each assessed moment with their own [tag](/api/journeys/#the-v11-tag): `login` for a login, and one tag per other moment you integrate (the [journey templates](/api/templates/) suggest ten). The examples on this page use v1.1. v1.1 and v1 in production Production accepts the v1.1 request with your `tag`. The v1 request is unchanged and remains supported: it uses the fixed journeys `LOGIN` (`action: "LOGIN_ATTEMPT"`), `WITHDRAWAL` and `TRADE`, and its response carries `journey` where v1.1 carries `tag`. Everything else on this page applies to both profiles ([moving from v1 to v1.1](/api/migrating-to-v1-1/)). ## 1. Send an assessment for every completed login [Section titled “1. Send an assessment for every completed login”](#1-send-an-assessment-for-every-completed-login) Send one assessment with the tag `login` (in v1: `LOGIN`, `action: "LOGIN_ATTEMPT"`) every time your backend completes a login, including the required MFA, whatever the browser produced. Do the same for every other moment you integrate. Skipping events makes the shadow period impossible to evaluate, because the missing ones are exactly the cases you cannot see. Each login carries the authenticated user’s `subject_ref` and `account_ref`, built as stable keyed pseudonyms ([person and account references](/api/overview/#person-and-account-references)): without `account_ref`, the console’s account items stay unevaluated. Browser evidence for that login has to be fresh: under the managed production profile the event context must be used within your application’s [evidence window](/web-sdk/collection/#the-evidence-window) after the collection: 60 seconds for applications provisioned before the 300-second window was introduced (October 2026), 300 seconds for those provisioned since. In the browser, collect before the submit (at the first focus of the login form), keep that collection fresh inside the window and send the context with the request your backend assesses: the MFA step when your login has one ([the SDK lifecycle](/web-sdk/collection/#the-lifecycle)). ### When the browser has no event context [Section titled “When the browser has no event context”](#when-the-browser-has-no-event-context) `event_context_token` is a required field that accepts `null`; the [request schema](/downloads/contracts/json-schema/event-assessment-request.v1.1.schema.json) (and its [v1 counterpart](/downloads/contracts/json-schema/event-assessment-request.v1.schema.json)) defines `null` as “no collection supplied”. Send `null` whenever the browser could not produce a token: SDK not loaded, blocked or failed, page on an origin that is not registered, a timeout, a token whose `expiresAt` leaves too little time to store the assessment, or a login that never passed through your browser code. Never invent, reuse or decode a token. Without browser evidence the server stores and returns a normal assessment rather than an error. Under the current production profile, a login without a context comes back as `201` (or `200` on a retry of the same operation) with: | Field | Value | | ------------------------- | ------------------------------------------------------------------------------------------------------------------ | | `assessment_status` | `INCONCLUSIVE` | | `trust_score` | `null` (no score; not 0, not 100) | | `recommendation_basis` | `INSUFFICIENT_EVIDENCE` | | `recommended_action` | `MONITOR`, the fallback for every no-score assessment: every v1.1 tag, and `LOGIN`, `WITHDRAWAL` and `TRADE` in v1 | | `signals` | All 13 indicators `NOT_EVALUATED` | | `limitation_codes` | The reason evidence is missing, for example `COLLECTOR_NOT_LOADED` | | `fallback_policy_version` | The fallback that produced the recommendation | A synthetic excerpt of that response: INCONCLUSIVE — no browser context (excerpt) ```json { "assessment_id": "01996f2a-0000-7000-8000-000000000401", "tag": "login", "assessment_status": "INCONCLUSIVE", "retryable": false, "trust_score": null, "evidence_coverage": 0, "limitation_codes": [ "COLLECTOR_NOT_LOADED", "REQUIRED_EVIDENCE_MISSING" ], "recommended_action": "MONITOR", "recommendation_basis": "INSUFFICIENT_EVIDENCE", "fallback_policy_version": "production-observation-fallback-v2" } ``` A fallback MONITOR is not a favourable result The `MONITOR` on a no-score assessment is the fallback for missing evidence: neither a finding about the person nor an approval. Assessments created before this fallback changed may carry `STEP_UP` on a no-score `LOGIN` or `REVIEW` on a no-score `WITHDRAWAL`; treat those the same way and do not challenge users because of them. Code against the fields you receive rather than this table; another no-score state or an operational Problem (for example `503`) remains possible. ### Social and OAuth logins [Section titled “Social and OAuth logins”](#social-and-oauth-logins) A login completed through Google sign-in or another OAuth/OIDC provider is still a login (tag `login`; in v1, `LOGIN`): assess it once your backend has accepted the provider’s result and finished any MFA of its own. * `subject_ref` and `account_ref` are your own pseudonyms for the person and account, the same ones you use for password logins. Do not send the provider’s subject identifier or the email. * With a redirect flow, the browser page that could create a context navigates away before your callback runs on the server, and the SDK keeps its state only in page memory. Collect before the redirect or on a page after the callback, as described in [login with Google, OAuth and other redirects](/web-sdk/login-redirects/). If no request of yours carries a context to the backend after authentication completes, send `event_context_token: null`. * Never place a token in the OAuth `state`, a redirect URL or a query string. If your flow finishes with a request from your own page to your backend (a one-tap credential posted with `fetch`, for example), that request can carry the token like any other business request. ## 2. Fail open, and make misconfiguration visible [Section titled “2. Fail open, and make misconfiguration visible”](#2-fail-open-and-make-misconfiguration-visible) Your users’ login must never depend on FingerTrackID. At the same time, an integration that fails silently looks exactly like one that works. Both rules hold together: * **Never block the business action** because of the integration. Every FingerTrackID failure — timeout, network error, `429`, `503`, a thrown signer error, a disabled feature flag — ends with your product’s normal path. * **Never fail silently.** A configuration error must produce a clear, searchable log entry and, ideally, an alert on your side. ### Validate configuration at startup [Section titled “Validate configuration at startup”](#validate-configuration-at-startup) Check the configuration once, when your backend starts, and log a clear error if it is wrong instead of quietly skipping assessments: * The API origin is exactly `https://api.fingertrackid.com`. * The runtime has `crypto.randomUUIDv7()`: Node.js 24.16.0 or later on the 24 line (or 26.1.0 or later), as the [wrapper below](#a-minimal-fail-open-wrapper) explains. * `key_id` is a lowercase UUIDv7. * The secret decodes: the [reference signer](/api/authentication/#reference-signer-nodejs-2416-or-newer)’s `decodeSecret(text, 'hex')` throws `FTID_SECRET_ENCODING` for text that is not the 64-character hex value, and `FTID_SECRET` when the decoded key is too short. * If your own feature flag disables the integration, log that state once at startup so a disabled integration is a visible decision, not a mystery. Never log the secret, the token, cookies or the request body. ### Classify outcomes [Section titled “Classify outcomes”](#classify-outcomes) | Result | Meaning | Log level | Login | | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ | --------------------------------------------------------- | | `201` / `200` | Assessment stored | info, with `assessment_id` and `correlation_id` | Continue | | `401 authentication-failed`, `403 forbidden`, `400 validation-failed`, `415` | Your configuration or code is wrong; retrying will not help | **error**, alert | Continue | | `400 event-context-invalid` | The token expired or was already used before the assessment was stored; nothing was stored | warn; error if frequent (collect closer to the action) | Continue; assess once more as a new operation with `null` | | `401 timestamp-out-of-window`, `401 request-replayed` | Clock or `request_id` generator problem | warn; error if repeated | Continue | | `409 binding-conflict` | A reference mapping changed: an `account_ref` arrived with another `subject_ref` than the first time. Nothing was stored, and retrying will not help | **error**, alert | Continue | | `409 idempotency-conflict` | The same operation IDs arrived with different content; nothing new was stored | **error** | Continue; fix the code that reuses operation IDs | | `429`, `503`, network error, timeout, `FTID_INDETERMINATE` | Unavailable or unknown state | warn | Continue | Retry rules are on [idempotency and retry](/api/idempotency-and-retry/); each type is described on [errors](/api/errors/). ### Time budget [Section titled “Time budget”](#time-budget) FingerTrackID does not publish an end-to-end latency SLA. In production on 2026-10-15, on one application and a small sample (not an SLA), `getEventContext` on an accepted collection took 0.15 s at the median and 0.17 s at p95, while a collection started at the click took 2.6 s at the median, 3.9 s at p95 and at most 4.4 s from session open to finished evaluation ([measured in production](/web-sdk/collection/#measured-in-production-2026-10-15)): prepare at the first intent, not at the click. These are the documented budgets to plan your own timeout with: | Where | Budget | Source | | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------- | | Browser, SDK 0.2.x and 0.3.0 | Session open 3000 ms per attempt, collection 4000 ms, event context 5000 ms per action including renewal; your own submit ceiling (the helper’s `waitMs`) bounds what the user waits | [Timing and latency](/web-sdk/collection/#timing-and-latency) | | Browser evidence and token | 60 s or 300 s, per application, from when the collection reaches FingerTrackID; store the assessment before the token’s `expiresAt` | [The evidence window](/web-sdk/collection/#the-evidence-window) | | Server, v1.1 (every tag) | `deadline_ms` 5000 ms by default, 250–5000 ms allowed | [Journeys and tags](/api/journeys/) | | Server, v1 `LOGIN` | `deadline_ms` 5000 ms by default, 250–5000 ms allowed | [Journeys and tags](/api/journeys/#the-v1-profile) | | Server, v1 `TRADE` | `deadline_ms` 750 ms by default, 250–750 ms allowed | [Journeys and tags](/api/journeys/#the-v1-profile) | | Server, v1 `WITHDRAWAL` | `deadline_ms` 1000 ms by default, 500–1000 ms allowed | [Journeys and tags](/api/journeys/#the-v1-profile) | | Your HTTP client | The server budget plus your network latency: above about 6 s for a `null` `LOGIN` or v1.1 body; the reference example uses 6500 ms | [Authentication](/api/authentication/#use-in-your-backend) | `deadline_ms` is an internal budget that starts when the request reaches the service; it is not an SLA. Since 2026-10-14 the `LOGIN` and v1.1 default is 5000 ms (it was 1000 ms), so a client timeout that must not cut a `null` body has to exceed about 6 s. A body that signs `deadline_ms` explicitly may send any value up to 5000, or `null`; one already signed with 750 or 1000 stays valid and keeps exactly that budget. Pick an HTTP timeout your login can afford, and when it expires continue the login and, if you want the result, reconcile later with the same body (see [timeout without a response](/api/idempotency-and-retry/#timeout-without-a-response)). Reconciling with the same body returns the stored assessment (`200`) whenever the first attempt was stored, even after the token expired. If it was not stored and the body carries a token, the same body succeeds only before that token’s `expiresAt`; after it, it returns `400 event-context-invalid` and those operation IDs cannot be completed any more. Then assess the event as a new operation (new `external_event_id` and `idempotency_key`) with `event_context_token: null`, which a new operation accepts only while its `occurred_at` is within ±300 s of the server clock. ### A minimal fail-open wrapper [Section titled “A minimal fail-open wrapper”](#a-minimal-fail-open-wrapper) A sketch for a Node.js backend (for example a Next.js route handler or server action), built on the [reference signer](/api/authentication/#use-in-your-backend). `log` stands for your own structured logger. The signer needs `crypto.randomUUIDv7()`, which Node.js has from **24.16.0** on the 24 line and from **26.1.0**; Node.js 22 and 25 do not have it, and there the signer fails as soon as it is imported. Run the backend on Node.js 24 (24.16.0 or later; on Vercel, set the project’s Node.js version to **24.x**) and check `process.version` in your startup validation. The wrapper loads the signer with `await import(...)` inside its `try`, so a runtime without it degrades to “no assessment” instead of breaking the login route. ```js const API_ORIGIN = 'https://api.fingertrackid.com' // Above the 5000 ms default budget of a `null` LOGIN/v1.1 body plus the network. const HTTP_TIMEOUT_MS = 6500 // Send the token only if it stays valid longer than this: your HTTP timeout plus an // allowance for clock skew between your backend and FingerTrackID. Choose your own value, and // keep the browser helper's minRemainingMs (15000 ms by default) above it plus the trip from // the browser to this backend. const CONTEXT_MARGIN_MS = HTTP_TIMEOUT_MS + 1000 const EVENT_CONTEXT_INVALID = 'https://fingertrackid.dev/problems/event-context-invalid' // Problems that mean your configuration or integration code is wrong: logged as errors. const CONFIG_PROBLEMS = new Set([ 'https://fingertrackid.dev/problems/authentication-failed', 'https://fingertrackid.dev/problems/forbidden', 'https://fingertrackid.dev/problems/validation-failed', 'https://fingertrackid.dev/problems/unsupported-media-type', 'https://fingertrackid.dev/problems/binding-conflict', 'https://fingertrackid.dev/problems/idempotency-conflict', ]) // Signer codes that mean the configuration itself is wrong. const CONFIG_CODES = new Set([ 'FTID_API_ORIGIN', 'FTID_KEY_ID', 'FTID_SECRET', 'FTID_SECRET_ENCODING', 'FTID_TIMEOUT', ]) // Returns the assessment or null. It never throws, so the login cannot depend on it. // `request` is the first send of a new operation, with the browser's token or null. // `contextExpiresAt` is the expiresAt the browser sent with the token (untrusted: it only // decides whether to drop the token). `newOperationIds()` returns a fresh // { external_event_id, idempotency_key } and records it with your operation. // `isAssessment(body)` is your validator for the response schema of the profile you send // (event-assessment.v1.1 for a v1.1 request, event-assessment.v1 for v1), for example compiled // once at startup with a JSON Schema library; it returns true only for a valid body. export async function assessLoginFailOpen({ enabled, secret, keyId, request, contextExpiresAt, newOperationIds, isAssessment, log, }) { if (!enabled) return null // The disabled state was logged once at startup. try { // Inside the try: without crypto.randomUUIDv7 the import fails and the login continues. const { postAttempt, prepareAttempt } = await import('./ftid-hmac-v1.mjs') const send = async (operation) => { const body = Buffer.from(JSON.stringify(operation), 'utf8') // Keep for reconciliation. const attempt = prepareAttempt({ secret, keyId, body }) const result = await postAttempt(API_ORIGIN, attempt, HTTP_TIMEOUT_MS) return { result, parsed: JSON.parse(result.body) } } let operation = request const remainingMs = Date.parse(contextExpiresAt) - Date.now() if (operation.event_context_token !== null && !(remainingMs > CONTEXT_MARGIN_MS)) { operation = { ...operation, event_context_token: null } // Expired or about to expire. } let { result, parsed } = await send(operation) if ( result.status === 400 && parsed.type === EVENT_CONTEXT_INVALID && operation.event_context_token !== null ) { // Expired or already used: nothing was stored, but these operation IDs stay bound to // that token. Assess once more as a new operation, without a context. log.warn('ftid.event_context_invalid', { correlation_id: result.correlationId }) operation = { ...operation, ...newOperationIds(), event_context_token: null } ;({ result, parsed } = await send(operation)) } if (result.status === 201 || result.status === 200) { if (!isAssessment(parsed)) { // A 2xx whose body breaks the response schema is not an assessment: continue the login // and keep the stored request bytes to reconcile later. log.warn('ftid.assessment_invalid_body', { http_status: result.status, correlation_id: result.correlationId, }) return null } log.info('ftid.assessment', { assessment_id: parsed.assessment_id, status: parsed.assessment_status, recommended_action: parsed.recommended_action, correlation_id: result.correlationId, }) return parsed } log[CONFIG_PROBLEMS.has(parsed.type) ? 'error' : 'warn']('ftid.assessment_problem', { http_status: result.status, type: parsed.type, correlation_id: result.correlationId, }) return null } catch (error) { // FTID_INDETERMINATE (timeout, network, unreadable response), a signer error, a failed // signer import or a parse failure: continue the login. Log only the fixed signer code, // never an arbitrary message. const message = error instanceof Error ? error.message : '' const code = /^FTID_[A-Z_]+$/.test(message) ? message : 'UNEXPECTED' log[CONFIG_CODES.has(code) ? 'error' : 'warn']('ftid.assessment_unavailable', { code }) return null } } ``` The token check applies only before the **first** send of an operation. A retry or a later reconciliation of an operation already sent resends its stored bytes unchanged, token included. The wrapper validates every `200`/`201` body against the response schema of the profile you sent ([v1.1](/downloads/contracts/json-schema/event-assessment.v1.1.schema.json) or [v1](/downloads/contracts/json-schema/event-assessment.v1.schema.json)) before it reads a field. Validate Problem bodies against the [Problem](/downloads/contracts/json-schema/assessment-problem.v1.schema.json) schema the same way before relying on anything beyond `type`, and adapt the retry policy to your login’s deadline. ## 3. Evaluating the shadow period [Section titled “3. Evaluating the shadow period”](#3-evaluating-the-shadow-period) In `SHADOW`, FingerTrackID assesses and recommends; nothing is enforced and your product keeps making every decision. The point of the period is to compare recommendations with what your product decided and with what later turned out to be true. ### Record, for every assessment [Section titled “Record, for every assessment”](#record-for-every-assessment) * Your own operation IDs: `external_event_id` and `idempotency_key`. * `assessment_id`, `assessment_status`, `trust_score`, `recommended_action`, `recommendation_basis`, `limitation_codes`, `evidence_coverage` and the `*_version` fields, exactly as received. * The decision your product actually took, and why (your own rule, a manual review, a fallback). * Whether the event had a browser context (`event_context_token` was a token or `null`). * Whether the event carried `subject_ref` and `account_ref`. ### Report actions and outcomes [Section titled “Report actions and outcomes”](#report-actions-and-outcomes) Send what happened through the [feedback API](/api/feedback/), against the `assessment_id`. It needs the credential permissions `action:write` and `outcome:write`. * **Customer action**: the action your product executed. In shadow your own policy decided, so the usual pair is `basis: "CLIENT_POLICY"` with `reason_code: "CLIENT_POLICY_RULE"`; a human decision is `MANUAL_REVIEW`; a decision taken under your fallback because the result was not available in time is `FALLBACK` with `CLIENT_FALLBACK_APPLIED`. Use `ASSESSMENT` only when you actually followed the recommendation. * **Outcome**: later evidence, such as `LEGITIMATE` or `FRAUD_CONFIRMED` with its evidence, or `PENDING` while you do not know yet. Corrections append; they never overwrite. Each customer-action receipt carries `recommendation_alignment` (`ALIGNED`, `NOT_ALIGNED` or `NOT_COMPARABLE`), derived by FingerTrackID. Keep the receipts: the console does not yet show the customer action or outcome on an access. ### What to look at [Section titled “What to look at”](#what-to-look-at) * **Agreement rate**: the share of `ALIGNED` among comparable actions. In shadow, `NOT_ALIGNED` means your policy and the recommendation differ; it does not say which one is right. * **Known-good accounts**: the recommendations for accounts you trust, such as internal staff or long-standing customers with no incidents. Frequent `STEP_UP`, `REVIEW` or `BLOCK_RECOMMENDED` among completed assessments there is a false-positive signal. * **Known-bad cases**: the recommendations on events you later confirmed as fraud. * **Coverage**: the share of `COMPLETED` against `INCONCLUSIVE`, and the `limitation_codes` behind them. Analyze no-score assessments separately: their recommendation is the fixed fallback, not an evaluation. What the numbers are not `trust_score` is an ordered index, not a calibrated fraud probability. The managed `COOPERATIVE_WEB_SHADOW_V1` setup makes no calibrated fraud-detection claim, and a listed signal is not proof of an active detector. Agreement with your current decisions is not accuracy, and a recommendation is never fraud confirmation. FingerTrackID has not defined a threshold that ends the shadow period; any move beyond shadow is a separate decision with FingerTrackID. ## 4. Testing before production [Section titled “4. Testing before production”](#4-testing-before-production) There is no sandbox or test environment: production, in shadow mode, is where you test. Because nothing is enforced, a test assessment cannot block anyone, but it is real production data. It is stored under your company, appears in the console and may count in usage estimates. * **Use your production inputs only.** The public [signature self-check](/api/authentication/#check-your-environment) and the synthetic [v1.1 examples](/downloads/contracts/examples/event-assessment-v1.1.examples.json) (and the [v1 examples](/downloads/contracts/examples/event-assessment-v1.examples.json)) let you test signing and parsing without any credential. * **Gate the integration behind your own feature flag** and turn it on first for internal or test accounts, then for a small share of logins, then for everyone. Keep the flag as your kill switch. * **Only the allowed origins under the collection domain.** Collection through your collection domain is accepted from the application’s allowed origins whose host is that domain or one of its subdomains (exact HTTPS, no path, port or wildcard; up to 10, listed in **Integração e saúde** › **Origens das páginas**). Every other origin, including local development hosts and preview deployments, is refused without CORS approval, so the browser hides the response and the SDK returns `UNAVAILABLE` with reason `TRANSPORT_ERROR`. Your helper continues with `null`, and the backend assessment still works and returns a no-score result. Test browser collection on an allowed origin. A removed origin stops collecting at once (a browser may still hold a cached CORS preflight for a short time). * **Keep the secret server-side.** Store it only in the server-side environment of the deployment that calls the API, never in a variable exposed to the browser (in Next.js, never in a `NEXT_PUBLIC_` variable), and only in environments you are willing to send production data from. Questions: . # Onboarding your company > Request a company workspace, activate the administrator, follow the five steps of Início, invite members, install the server credential and prepare activation. Every company follows the same operator-assisted onboarding workflow. Your company owns its product’s identity, site configuration and business decisions; FingerTrackID operates the assessment platform. A workspace can be registered before any customer data or traffic exists. The console is in Portuguese (pt-BR). This page quotes screen names and buttons **exactly as they appear**, with an English gloss in parentheses the first time. Production status The console and the signed assessment API are **live in production**, in shadow mode: FingerTrackID recommends and your product decides. Web SDK collection is deployed there too; its end-to-end operation from a customer page is still being measured. Assessments and collection for your application are accepted once the configuration below is complete: the server credential installed with active admission, and the collection domain active. See [production status](/reference/availability/). ## 1. Request your company workspace [Section titled “1. Request your company workspace”](#1-request-your-company-workspace) Contact FingerTrackID and provide the company name, a lowercase identifier such as `your-company`, and the name and email of the person who will administer the company. A FingerTrackID operator creates the company and its first administrator in the protected internal backoffice. There is no public sign-up form or self-service company creation. Signing in to the console requires an account that FingerTrackID or your company administrator has requested; an account cannot create its own company. ## 2. Activate the administrator account [Section titled “2. Activate the administrator account”](#2-activate-the-administrator-account) When the account is created, FingerTrackID sends an **invitation email** (in Portuguese) to the administrator’s address, with an **Ativar acesso** (activate access) button. The email is sent on a best-effort basis: if it does not arrive, the account still exists. 1. Open the invitation and choose **Ativar acesso** (activate access). On the **Ativação de acesso** page, enter **Nova senha** (new password) and **Repita a nova senha** (repeat it), then choose **Ativar acesso**. This sets your password and confirms your email in one step. The link is personal and works only once; opening the page does not use it up. If the email did not arrive or the link no longer works, ask FingerTrackID to resend the invitation (a new email with a new link, at most 3 times per hour), or open **[dash.fingertrackid.com](https://dash.fingertrackid.com/)**, choose **Esqueceu sua senha?** and enter the administrator email to receive a new link. 2. Sign in with your email and password. Verify your email if asked, then enroll your authenticator app and sign in with the six-digit code. The complete sequence and the password policy are in [signing in](/console/access/). The administrator now holds the **Administrador da empresa** (company administrator) role. ## 3. Follow the steps in Início [Section titled “3. Follow the steps in Início”](#3-follow-the-steps-in-início) Open **Início** (start), at the top of the menu. It lists the five steps your company needs to start receiving real assessments and shows **N de 5 etapas concluídas** (N of 5 steps done). **Visão geral** (overview) remains the opening screen; while your company has no active application, it shows a notice pointing to Início. Once your company has received its first access, Início leaves the top of the menu and stays available as **Configuração inicial** (initial setup) under **Configurações** (settings), at the end of the menu. Each of the first four steps becomes a **request**, normally approved as you send it and recorded in the audit trail, then applied by FingerTrackID’s managed process; the fifth, **Primeiro evento recebido**, needs no request and completes on its own when the first assessment arrives. A step is shown as **Concluída** (done) only after the server confirms its effect, never just because the request was sent. Use **Recarregar o progresso** (reload progress) to refresh. | Step on screen | What you provide | Done when | | ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | | **Criar a aplicação** (create the application) | **Nome da aplicação**, **Identificador da aplicação** (lowercase letters, digits and hyphen, such as `web-app`) and **Origens permitidas no navegador** (one HTTPS origin per line, up to 32) | The application is active | | **Configurar a coleta web** (configure web collection) | **Origem registrada da aplicação** (one of the origins above, used to set up the collection) and **Domínio-base de coleta da sua empresa** (your owned base domain, such as `yourcompany.com`). The other origins of the application under the same domain also collect | The first-party collection domain is active | | **Credencial servidor a servidor** (server credential) | **Identificador do cliente de servidor** (8–128 letters, digits, hyphen or underscore) and **Permissões** (permissions). **Requisições por segundo** is read-only and comes from the application’s managed tier: 5 per second with a burst of 10 on the standard tier ([limits](/api/limits/)) | A current credential exists and its production admission is active | | **Configuração do SDK** (SDK configuration) | **Combinação de coleta qualificada** (qualified collection combination), available only after the collection domain is active. The step also shows the **Chave pública do navegador** (browser public key) | Browser collection is configured | | **Primeiro evento recebido** (first event received) | Nothing: it reads integration health | At least one assessment arrived in the last 24 hours | Steps 2 to 5 open only after the application is active. The forms appear only for roles that may submit them: **Administrador da empresa** and **Gestor de integração** (integration manager). ### Website origins [Section titled “Website origins”](#website-origins) An origin has HTTPS, no path, port, query or trailing slash. `https://app.yourcompany.com/login` is a page URL; the allowed origin is `https://app.yourcompany.com`. Do not use wildcard origins or another company’s domain. The SDK collects from every allowed origin whose host is your collection’s base domain or one of its subdomains, such as `https://yourcompany.com` and `https://www.yourcompany.com`. After the setup, the company administrator or integration manager edits the list in **Integração e saúde** › **Origens das páginas** (page origins), with no request: at most 10 origins and at least one; the origin used in the collection setup is marked **Principal** and cannot be removed there. A removed origin stops collecting at once. Device continuity is per origin: the same browser on two origins counts as two installations. ### Collection domain [Section titled “Collection domain”](#collection-domain) The collection hostname for the base domain `yourcompany.com` is `collect.yourcompany.com`; do not enter the collector hostname as the base domain. The origin you choose must be the base domain or one of its subdomains. The standard web collection observes the browser and recommends monitoring (`MONITOR`); it does not block anything on its own and makes no calibrated fraud-detection claim. After you send it, the setup runs by itself. The setup progress of the application in the console lists each step and who moves it: | Label on screen | Meaning | | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Automático** | The platform advances the step by itself: detection, evaluation rules, the collection domain’s certificate, routing and verification, the data custody preset and the final activation. Most steps take a few minutes; the domain also waits for DNS propagation | | **Depende de você** | Publish the DNS records of the collection domain exactly as shown, then choose **Verificar agora** (check now), or act on what the step says (for example, your CAA policy) | | **Pausado pelo suporte** | FingerTrackID support paused the collection domain; contact support to resume it | | **Algo travou** | An automatic step has not moved for 30 minutes; contact support | Publish the DNS records as described in the [collection-domain workflow](/getting-started/collection-domain/); verifying the records (also on **Verificar agora**), renewing the verification, applying the data custody preset and activating the collection need no request to FingerTrackID. ### Server credential [Section titled “Server credential”](#server-credential) Under **Permissões**, choose **Só avaliações** (assessments only) or **Avaliações, ações e resultados** (assessments, actions and outcomes) if your backend will also [report feedback](/api/feedback/). Permissions cannot change on an existing credential; changing them needs a new request. The request rate (standard tier: 5 per second, burst of 10) is a configured admission limit, not a throughput guarantee; **Integração e saúde → Limite de requisições** shows the limit in force and raises it ([limits](/api/limits/)). When the credential exists, the step shows only its public `key_id`. If it says the credential **aguarda a aprovação de produção** (awaits production approval), assessments are refused until that approval; creating another credential does not help. Your backend authenticates with the `key_id`, never with the client identifier or the browser public key. ### Request states [Section titled “Request states”](#request-states) After you choose **Enviar solicitação** (send request), the step shows **Estado da solicitação** (request state) with **Recarregar o estado** (reload state): | State on screen | Meaning | | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | | **Aguardando aprovação** | Received and waiting for an explicit FingerTrackID approval (only when automatic approval is unavailable); nothing is created yet | | **Aprovada** | Approved; the operational effect is still pending | | **Em execução** | FingerTrackID is applying it | | **Aguardando você receber o segredo** | The server credential’s secret is waiting for an administrator or integration manager to receive it once; it is not usable yet | | **Aguardando revisão** | Collection setup is running its automatic steps; the setup progress says whether a step depends on you | | **Concluída** | Done; the step shows the result | | **Rejeitada**, **Cancelada**, **Falhou** | Not completed; read the reason before trying again | If the response is lost, use **Repetir a mesma solicitação** (repeat the same request) rather than preparing a new one. While a request is **Aguardando aprovação**, **Aprovada**, **Aguardando você receber o segredo** or **Aguardando revisão**, you can tick the confirmation and choose **Cancelar a solicitação pendente** (cancel the pending request). Anything already created stays recorded; cancelling is not an undo. FingerTrackID can also cancel a request before the executor takes it, and afterwards suspend or revoke a member or revoke or rotate an integration client; a created application is not undone. Sending a request uses the session you signed in with; it does not ask for your password or code again. The session ends after 30 minutes without activity or 12 hours after sign-in. ## 4. Invite your team [Section titled “4. Invite your team”](#4-invite-your-team) The administrator invites people from **Membros da equipe** (team members) in Início, or with **Conceder acesso** (grant access) in **Usuários e acessos** (users and access). Enter **E-mail**, **Nome de exibição** (display name) and **Papel na empresa** (role). Each invitation is a request. When it completes, a person who did not have a console account yet receives the same invitation email, on a best-effort basis. A person who already has a console account (for example, in another company) receives no invitation: they sign out and sign in again to see the new company. If the invitation did not arrive or the link no longer works, ask for it to be sent again: the company administrator uses **Reenviar convite** (resend invitation) in **Usuários e acessos**, and FingerTrackID can resend the first administrator’s invitation. A resend sends a new email with a new link, at most 3 times per hour for each person, and only while the invitation is **Pendente** (pending). The person can also use **Esqueceu sua senha?** on the sign-in screen. In **Usuários e acessos**, **Gerenciar o acesso** (manage access) offers **Trocar o papel** (change role), **Suspender o acesso**, **Restaurar o acesso** and **Revogar o acesso** (revoke). Revoking is permanent. The company’s last active administrator is protected. See the [role boundaries](/console/roles/). ## 5. Receive the HMAC secret once, in the console [Section titled “5. Receive the HMAC secret once, in the console”](#5-receive-the-hmac-secret-once-in-the-console) The signing key is **32 cryptographically random bytes**, represented as canonical hexadecimal text with 64 characters. The HMAC signer uses the **decoded bytes**, not that text. FingerTrackID generates the secret for you. A few minutes after the credential request, the **Segredo da credencial de servidor** card (in Início, at the credential step, and in **Integração e saúde**) shows **Pronto para receber**. A company administrator or integration manager, signed in with the authenticator code, clicks **Receber segredo**, reads the warning and confirms. The console shows the `key_id` and the 64-character hexadecimal secret **once**, with a copy button. Put the secret straight into your backend’s secret manager before leaving the screen. * The secret is shown a single time. It is not stored by the console, not sent by email and not shown again; if it is lost, choose **Trocar segredo** in **Integração e saúde** once the credential is active: a new secret is generated and delivered once. * After you receive it, the card shows **Entregue, ativando a credencial** and the credential becomes usable within a few minutes, with no further action. * Never paste the value into chat, email, a support ticket, a form, a command argument, terminal output, a local file, a repository or an AI-readable environment. Who receives the secret Only a **TENANT_ADMIN** or **INTEGRATION_MANAGER** of the company receives it, on a session that completed the second factor. Each delivery is recorded in the audit trail with who and when, never the value. While the request shows **Aguardando você receber o segredo**, the credential cannot be used; knowing a `key_id` does not make it usable. ## 6. Receive the integration inputs [Section titled “6. Receive the integration inputs”](#6-receive-the-integration-inputs) | Input | Where you use it | | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | | API origin `https://api.fingertrackid.com`, shown as **Endereço da API de risco** in **Credencial servidor a servidor** | Backend configuration, exactly as written, with no path or trailing slash | | Application ID, browser public key and allowed origins (**Integração e saúde** › **Origens das páginas**) | Application selection and browser configuration | | Exact active collection origin and issued DNS records | Browser configuration, CSP and your DNS provider | | Credential `key_id`, encoding and permissions | Backend signing configuration; the value stays in secret custody | | Approved rate and burst | Bounded backend retries and capacity configuration | | Approved private SDK archive, exact version, SHA-256 and SHA-512 | Your frontend dependency process; download it and see both digests in the dash, in **Início** › **Configuração do SDK** | | The request profile you send (v1.1 with your own tags, recommended and accepted in production; or the v1 journeys, unchanged) and the effective policy/release versions | Request validation and response interpretation | | Retention configuration and activation confirmation | Your operational readiness record | Values come from your actual configuration and the verified handoff. Documentation examples and another company’s settings are not issued values. The Web SDK is delivered as an approved private archive; it has no public npm package or CDN URL. The optional [server SDK for Node.js](/api/server-sdk/) is a public npm package. ## 7. Verify and activate the application [Section titled “7. Verify and activate the application”](#7-verify-and-activate-the-application) Before real data, FingerTrackID confirms security testing, independent authentication-isolation proof, applied technical retention, and operational qualification including recovery. Your application also needs current first-party ownership/DNS/TLS/health verification, an installed credential, approved admission and an active qualified release and policy. Registering a company does not waive them. FingerTrackID keeps the data it records for at least 10 years, until further notice, and deletes none of it ([retention](/reference/data-and-processing/#retention)). The managed setup applies this permanent retention to your company by itself. Prepare browser collection and the signed backend integration with the [implementation handoff](/getting-started/implementation-handoff/). Validate synthetic requests, normal authentication and failure behavior, then finish the [integration checklist](/reference/checklist/). Enable real traffic only after FingerTrackID confirms your application’s activation. Start in `SHADOW` and keep your product’s existing decisions. Browser SDK activation and signed API activation are confirmed separately. # Your first assessment > Assess a login from a Node.js backend with the server SDK, read the answer and keep your own decision, in shadow mode. Your backend asks FingerTrackID about one action, here a login, and gets the answer in the same call. In shadow mode nothing changes in your product: you store the answer next to the decision your system already makes. Before you start, you need: * the `key_id` of a **server credential**, and its secret already in your secret manager ([onboarding](/getting-started/onboarding/)); * Node.js 24.16 or newer on the 24 line, or 26.1 or newer. Another language works too, through the [signing rules](/api/authentication/). ## 1. Install and check [Section titled “1. Install and check”](#1-install-and-check) Pin the exact version. `self-check` reproduces the [public signature vector](/api/authentication/#public-test-vector) on the Node.js that runs it, so you know signing works before you touch a credential. It needs none. The package has no install-time dependency and no install script. Compare the `integrity` in your lockfile with the registry’s. Terminal ```bash npm install --save-exact @fingertrackid/server@0.1.2 npx --no fingertrackid-server self-check ``` Compare with your lockfile ```bash npm view @fingertrackid/server@0.1.2 dist.integrity ``` ## 2. Create the client once [Section titled “2. Create the client once”](#2-create-the-client-once) Create one client when the backend starts, reading both values by name from the environment. The secret comes from your secret manager: never from a file in the repository, a command argument or an AI assistant. A missing or malformed value throws `FtidConfigurationError` with a fixed `code` and never echoes the value. Alert on it and keep your product running without FingerTrackID. ftid.ts ```ts import { createFtidServerClient, type FtidServerClient } from '@fingertrackid/server' export let ftid: FtidServerClient | null = null try { ftid = createFtidServerClient({ keyId: process.env.FTID_KEY_ID ?? '', secretHex: process.env.FTID_HMAC_SECRET_HEX ?? '', }) } catch (error) { // Alert on it, and keep the product running without FingerTrackID. logger.error('ftid.configuration_error', { code: (error as { code?: string }).code }) } ``` ## 3. Assess the login [Section titled “3. Assess the login”](#3-assess-the-login) After your own checks, call `assess` with the [tag](/api/journeys/) you chose for this moment, a keyed pseudonym of the person and the token the [Web SDK](/web-sdk/collection/) produced for this action. Until the Web SDK is in your frontend, send `null` for the token: the call works, and the assessment comes back without a score. `assess` never throws after startup. Every outcome is a result, and none of them should stop your login: | `result.kind` | What your backend does | | --------------------- | ---------------------------------------------------- | | `ASSESSED` | Store it; your own policy decides | | `INVALID_INPUT` | Fix the call; nothing was sent | | `PROBLEM` | Follow `result.problem.category` | | `UNAVAILABLE` | Keep your decision; the request may have been stored | | `UNEXPECTED_RESPONSE` | Keep your decision; report the `correlationId` | login.ts ```ts // After your own checks. `ftid` is null after a configuration error: the login goes on. const result = await ftid?.assess({ tag: 'login', subjectRef: pseudonymOf(user), // a keyed pseudonym, never an email or tax ID eventContextToken: body.ftidToken ?? null, eventContextExpiresAt: body.ftidTokenExpiresAt ?? null, }) if (result?.kind === 'ASSESSED') { await storeBesideYourDecision(result.assessment) } else if (result !== undefined) { // Log identifiers and verdicts only: never the secret, the token or the body. logger.warn('ftid.assess', { kind: result.kind, severity: result.severity }) } ``` ## 4. Read the answer [Section titled “4. Read the answer”](#4-read-the-answer) Three fields carry the answer: * `assessment_status` is `COMPLETED` only when fresh browser evidence was present. Without a token, under the current managed production profile, it is `INCONCLUSIVE`. * `trust_score` goes from 0 to 100, higher is better, and exists only in `COMPLETED`. `null` means **no score**: never 0. * `recommended_action` is a recommendation. It never blocks or allows anything by itself. Check that `external_event_id` and `tag` are the ones you sent, and store the response as received. Every field is on the [response](/api/response/) page. * Without a token 201 · INCONCLUSIVE ```json { "schema_version": "event-assessment.v1.1", "assessment_id": "01996f2a-0000-7000-8000-000000000401", "assessment_version": 1, "supersedes_assessment_id": null, "business_event_id": "01996f2a-0000-7000-8000-000000000402", "external_event_id": "login_20260904_000002", "tag": "login", "assessment_status": "INCONCLUSIVE", "retryable": false, "trust_score": null, "device_intelligence_snapshot_id": null, "device_intelligence_snapshot_version": null, "device_id": null, "device_match_confidence": null, "evidence_coverage": 0, "data_freshness": { "evidence_cutoff_at": "2026-09-04T12:00:00.105Z", "oldest_required_observed_at": null, "maximum_required_age_ms": 300000, "all_required_fresh": false }, "signals": [ { "signal_code": "ANTI_FINGERPRINTING", "channel": "WEB", "signal_state": "NOT_EVALUATED", "applicability": "APPLICABLE", "source": "DERIVED", "detector_version": "unavailable-web-detectors.v1", "detector_confidence": null, "observed_at": null, "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": null, "not_evaluated_reason_code": "COLLECTOR_NOT_LOADED", "applicability_reason_code": null }, { "signal_code": "BOT_AUTOMATION", "channel": "WEB", "signal_state": "NOT_EVALUATED", "applicability": "APPLICABLE", "source": "DERIVED", "detector_version": "unavailable-web-detectors.v1", "detector_confidence": null, "observed_at": null, "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": null, "not_evaluated_reason_code": "COLLECTOR_NOT_LOADED", "applicability_reason_code": null }, { "signal_code": "BROWSER_PRIVACY_BRAVE", "channel": "WEB", "signal_state": "NOT_EVALUATED", "applicability": "APPLICABLE", "source": "DERIVED", "detector_version": "unavailable-web-detectors.v1", "detector_confidence": null, "observed_at": null, "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": null, "not_evaluated_reason_code": "COLLECTOR_NOT_LOADED", "applicability_reason_code": null }, { "signal_code": "BROWSER_PRIVACY_SAFARI", "channel": "WEB", "signal_state": "NOT_EVALUATED", "applicability": "APPLICABLE", "source": "DERIVED", "detector_version": "unavailable-web-detectors.v1", "detector_confidence": null, "observed_at": null, "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": null, "not_evaluated_reason_code": "COLLECTOR_NOT_LOADED", "applicability_reason_code": null }, { "signal_code": "BROWSER_PRIVACY_SAMSUNG_INTERNET", "channel": "WEB", "signal_state": "NOT_EVALUATED", "applicability": "APPLICABLE", "source": "DERIVED", "detector_version": "unavailable-web-detectors.v1", "detector_confidence": null, "observed_at": null, "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": null, "not_evaluated_reason_code": "COLLECTOR_NOT_LOADED", "applicability_reason_code": null }, { "signal_code": "BROWSER_SPOOFING", "channel": "WEB", "signal_state": "NOT_EVALUATED", "applicability": "APPLICABLE", "source": "DERIVED", "detector_version": "unavailable-web-detectors.v1", "detector_confidence": null, "observed_at": null, "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": null, "not_evaluated_reason_code": "COLLECTOR_NOT_LOADED", "applicability_reason_code": null }, { "signal_code": "DEVICE_MULTI_USER", "channel": "WEB", "signal_state": "NOT_EVALUATED", "applicability": "APPLICABLE", "source": "DERIVED", "detector_version": "unavailable-web-detectors.v1", "detector_confidence": null, "observed_at": null, "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": null, "not_evaluated_reason_code": "COLLECTOR_NOT_LOADED", "applicability_reason_code": null }, { "signal_code": "EMULATED_ENVIRONMENT", "channel": "WEB", "signal_state": "NOT_EVALUATED", "applicability": "APPLICABLE", "source": "DERIVED", "detector_version": "unavailable-web-detectors.v1", "detector_confidence": null, "observed_at": null, "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": null, "not_evaluated_reason_code": "COLLECTOR_NOT_LOADED", "applicability_reason_code": null }, { "signal_code": "NETWORK_HOSTING", "channel": "WEB", "signal_state": "NOT_EVALUATED", "applicability": "APPLICABLE", "source": "DERIVED", "detector_version": "unavailable-web-detectors.v1", "detector_confidence": null, "observed_at": null, "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": null, "not_evaluated_reason_code": "COLLECTOR_NOT_LOADED", "applicability_reason_code": null }, { "signal_code": "NETWORK_PROXY", "channel": "WEB", "signal_state": "NOT_EVALUATED", "applicability": "APPLICABLE", "source": "DERIVED", "detector_version": "unavailable-web-detectors.v1", "detector_confidence": null, "observed_at": null, "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": null, "not_evaluated_reason_code": "COLLECTOR_NOT_LOADED", "applicability_reason_code": null }, { "signal_code": "NETWORK_TOR", "channel": "WEB", "signal_state": "NOT_EVALUATED", "applicability": "APPLICABLE", "source": "DERIVED", "detector_version": "unavailable-web-detectors.v1", "detector_confidence": null, "observed_at": null, "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": null, "not_evaluated_reason_code": "COLLECTOR_NOT_LOADED", "applicability_reason_code": null }, { "signal_code": "NETWORK_VPN", "channel": "WEB", "signal_state": "NOT_EVALUATED", "applicability": "APPLICABLE", "source": "DERIVED", "detector_version": "unavailable-web-detectors.v1", "detector_confidence": null, "observed_at": null, "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": null, "not_evaluated_reason_code": "COLLECTOR_NOT_LOADED", "applicability_reason_code": null }, { "signal_code": "PRIVATE_BROWSING", "channel": "WEB", "signal_state": "NOT_EVALUATED", "applicability": "APPLICABLE", "source": "DERIVED", "detector_version": "unavailable-web-detectors.v1", "detector_confidence": null, "observed_at": null, "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": null, "not_evaluated_reason_code": "COLLECTOR_NOT_LOADED", "applicability_reason_code": null } ], "limitation_codes": [ "COLLECTOR_NOT_LOADED", "REQUIRED_EVIDENCE_MISSING" ], "reason_codes": [ "REQUIRED_EVIDENCE_MISSING" ], "recommended_action": "MONITOR", "recommendation_basis": "INSUFFICIENT_EVIDENCE", "policy_version": "production-detectors-01996f29-4d2c-7b18-9e6a-3f0c8d1b5a72-login", "fallback_policy_version": "production-observation-fallback-v2", "ruleset_version": "d95-detectors.v1", "signal_catalog_version": "web-detectors-v3", "feature_schema_version": "configured-assessment-features.v1", "resolver_version": "first-party-installation-v1", "sdk_version": null, "provider_versions": [], "evidence_cutoff_at": "2026-09-04T12:00:00.105Z", "evaluated_at": "2026-09-04T12:00:00.143Z", "expires_at": "2026-09-04T12:01:00.143Z", "correlation_id": "01996f2a-1111-7000-8000-000000000405" } ``` * With the Web SDK 201 · COMPLETED ```json { "schema_version": "event-assessment.v1.1", "assessment_id": "01996f2a-0000-7000-8000-000000000101", "assessment_version": 1, "supersedes_assessment_id": null, "business_event_id": "01996f2a-0000-7000-8000-000000000102", "external_event_id": "login_20260904_000001", "tag": "login", "assessment_status": "COMPLETED", "retryable": false, "trust_score": 100, "device_intelligence_snapshot_id": "01996f2a-0000-7000-8000-000000000103", "device_intelligence_snapshot_version": 2, "device_id": "dev_AZlvKYwefUqy8GqePF17GA", "device_match_confidence": null, "evidence_coverage": 100, "data_freshness": { "evidence_cutoff_at": "2026-09-04T12:00:00.105Z", "oldest_required_observed_at": "2026-09-04T11:59:57.318Z", "maximum_required_age_ms": 300000, "all_required_fresh": true }, "signals": [ { "signal_code": "ANTI_FINGERPRINTING", "channel": "WEB", "signal_state": "NOT_DETECTED", "applicability": "APPLICABLE", "source": "SDK", "detector_version": "ftid-anti-fingerprinting.v2", "detector_confidence": 0.4, "observed_at": "2026-09-04T11:59:57.318Z", "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": 2825, "not_evaluated_reason_code": null, "applicability_reason_code": null }, { "signal_code": "BOT_AUTOMATION", "channel": "WEB", "signal_state": "NOT_DETECTED", "applicability": "APPLICABLE", "source": "SDK", "detector_version": "ftid-bot-automation.probe.v3", "detector_confidence": 0.6, "observed_at": "2026-09-04T11:59:57.318Z", "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": 2825, "not_evaluated_reason_code": null, "applicability_reason_code": null }, { "signal_code": "BROWSER_PRIVACY_BRAVE", "channel": "WEB", "signal_state": "NOT_DETECTED", "applicability": "APPLICABLE", "source": "SDK", "detector_version": "ftid-browser-privacy-brave.v1", "detector_confidence": 0.8, "observed_at": "2026-09-04T11:59:57.318Z", "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": 2825, "not_evaluated_reason_code": null, "applicability_reason_code": null }, { "signal_code": "BROWSER_PRIVACY_SAFARI", "channel": "WEB", "signal_state": "NOT_DETECTED", "applicability": "APPLICABLE", "source": "SDK", "detector_version": "ftid-browser-privacy-safari.v1", "detector_confidence": 0.5, "observed_at": "2026-09-04T11:59:57.318Z", "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": 2825, "not_evaluated_reason_code": null, "applicability_reason_code": null }, { "signal_code": "BROWSER_PRIVACY_SAMSUNG_INTERNET", "channel": "WEB", "signal_state": "NOT_DETECTED", "applicability": "APPLICABLE", "source": "SDK", "detector_version": "ftid-browser-privacy-samsung-internet.v1", "detector_confidence": 0.6, "observed_at": "2026-09-04T11:59:57.318Z", "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": 2825, "not_evaluated_reason_code": null, "applicability_reason_code": null }, { "signal_code": "BROWSER_SPOOFING", "channel": "WEB", "signal_state": "NOT_DETECTED", "applicability": "APPLICABLE", "source": "SDK", "detector_version": "browser-spoofing-coherence.v2", "detector_confidence": 0.5, "observed_at": "2026-09-04T11:59:57.318Z", "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": 2825, "not_evaluated_reason_code": null, "applicability_reason_code": null }, { "signal_code": "DEVICE_MULTI_USER", "channel": "WEB", "signal_state": "NOT_DETECTED", "applicability": "APPLICABLE", "source": "SERVER_CONTEXT", "detector_version": "ftid-device-multi-user.v1", "detector_confidence": 0.5, "observed_at": "2026-09-04T11:59:57.318Z", "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": 2825, "not_evaluated_reason_code": null, "applicability_reason_code": null }, { "signal_code": "EMULATED_ENVIRONMENT", "channel": "WEB", "signal_state": "NOT_DETECTED", "applicability": "APPLICABLE", "source": "SDK", "detector_version": "emulated-environment-coherence.v2", "detector_confidence": 0.3, "observed_at": "2026-09-04T11:59:57.318Z", "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": 2825, "not_evaluated_reason_code": null, "applicability_reason_code": null }, { "signal_code": "NETWORK_HOSTING", "channel": "WEB", "signal_state": "NOT_DETECTED", "applicability": "APPLICABLE", "source": "PROVIDER", "detector_version": "ftid-network-hosting.v1", "detector_confidence": 0.6, "observed_at": "2026-09-04T11:59:57.318Z", "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": 2825, "not_evaluated_reason_code": null, "applicability_reason_code": null }, { "signal_code": "NETWORK_PROXY", "channel": "WEB", "signal_state": "NOT_DETECTED", "applicability": "APPLICABLE", "source": "PROVIDER", "detector_version": "ftid-network-proxy.v1", "detector_confidence": 0.4, "observed_at": "2026-09-04T11:59:57.318Z", "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": 2825, "not_evaluated_reason_code": null, "applicability_reason_code": null }, { "signal_code": "NETWORK_TOR", "channel": "WEB", "signal_state": "NOT_DETECTED", "applicability": "APPLICABLE", "source": "PROVIDER", "detector_version": "ftid-network-tor.v1", "detector_confidence": 0.9, "observed_at": "2026-09-04T11:59:57.318Z", "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": 2825, "not_evaluated_reason_code": null, "applicability_reason_code": null }, { "signal_code": "NETWORK_VPN", "channel": "WEB", "signal_state": "NOT_DETECTED", "applicability": "APPLICABLE", "source": "PROVIDER", "detector_version": "ftid-network-vpn.v1", "detector_confidence": 0.4, "observed_at": "2026-09-04T11:59:57.318Z", "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": 2825, "not_evaluated_reason_code": null, "applicability_reason_code": null }, { "signal_code": "PRIVATE_BROWSING", "channel": "WEB", "signal_state": "NOT_DETECTED", "applicability": "APPLICABLE", "source": "SDK", "detector_version": "ftid-private-browsing.storage-mode.v2", "detector_confidence": 0.5, "observed_at": "2026-09-04T11:59:57.318Z", "evaluated_at": "2026-09-04T12:00:00.143Z", "freshness_ms": 2825, "not_evaluated_reason_code": null, "applicability_reason_code": null } ], "limitation_codes": [], "reason_codes": [ "NO_ADVERSE_INDICATOR_MATCH" ], "recommended_action": "MONITOR", "recommendation_basis": "EVALUATION", "policy_version": "production-detectors-01996f29-4d2c-7b18-9e6a-3f0c8d1b5a72-login", "fallback_policy_version": "production-observation-fallback-v2", "ruleset_version": "d95-detectors.v1", "signal_catalog_version": "web-detectors-v3", "feature_schema_version": "configured-assessment-features.v1", "resolver_version": "first-party-installation-v1", "sdk_version": "0.2.1", "provider_versions": [ "asn-reputation.v1", "cloud-ranges.v1", "icloud-private-relay.v1", "ip2proxy-lite-px2.v1", "tor-exit.v1", "x4b-vpn.v1" ], "evidence_cutoff_at": "2026-09-04T12:00:00.105Z", "evaluated_at": "2026-09-04T12:00:00.143Z", "expires_at": "2026-09-04T12:01:00.143Z", "correlation_id": "01996f2a-1111-7000-8000-000000000105" } ``` ## 5. Check the real credential [Section titled “5. Check the real credential”](#5-check-the-real-credential) `check` sends one assessment in shadow mode, tagged `integration_check` and without a browser token, and prints the result. The secret comes only from `FTID_HMAC_SECRET_HEX` or standard input, never from an argument. `--no` makes npx run only the copy your project installed, never a package of the same name from the registry. Terminal ```bash your-secret-manager read ftid-hmac-secret | FTID_KEY_ID= npx --no fingertrackid-server check ``` ## Next [Section titled “Next”](#next) * [Install the Web SDK](/web-sdk/installation/) so assessments carry browser evidence and a score. * [Reconcile an unknown outcome](/api/server-sdk/#reconcile-an-unknown-outcome) with `result.operation`, sending the same bytes again. * [Report your actions and outcomes](/api/feedback/), so the console can compare them with the recommendation. * [Hand the integration to an AI coding agent](/getting-started/ai-agents/), with the integration file and the MCP server. # What FingerTrackID is > Web device intelligence and access risk, in shadow mode, with an explainable recommendation and the decision always left to you. FingerTrackID is a B2B **device intelligence and access risk** service for Web products. At the critical moments of your product — a login, a withdrawal request, a trade order — your backend asks FingerTrackID for an assessment and receives, in the same call, an explainable answer: the assessment status, a trust score when there is evidence for one, the observed indicators and a **recommendation**. ## What FingerTrackID does [Section titled “What FingerTrackID does”](#what-fingertrackid-does) * **Collects browser signals** through the approved Web SDK, bundled into your frontend and sending collection to a domain that belongs to you (`collect.`). * **Assesses the access** at the moment your backend asks, with a versioned policy. * **Recommends** an action: `ALLOW`, `MONITOR`, `STEP_UP`, `REVIEW` or `BLOCK_RECOMMENDED`. * **Shows the history** in the console, so your team can understand each assessment. ## What FingerTrackID does not do [Section titled “What FingerTrackID does not do”](#what-fingertrackid-does-not-do) * **It does not decide or execute anything in your product.** It does not block a login, hold a withdrawal, cancel an order or move money. The recommendation reaches your system, and your system decides and acts. * **It does not turn a score into a decision.** The trust score (`trust_score`) is an index from 0 to 100 where higher is better, and it is only returned when the assessment is `COMPLETED`. It is not a probability of fraud and not a reputation of the person. * **It does not treat an event as fraud.** An assessed access is a fact; fraud is only confirmed by evidence on your side. * **It does not recommend blocking on privacy alone.** Under FingerTrackID’s managed policy, private browsing, Tor, a proxy, a VPN or a shared device, on their own, never lead to `BLOCK_RECOMMENDED`. Your company’s own risk rules are the exception. Any rule, including the access context items, can be active, but only your company can activate it. Your administrator chooses each rule’s points, the context rulers and the blocking band, and a `BLOCK_RECOMMENDED` produced by them is still a recommendation your backend executes. Missing evidence never recommends blocking ([recommended action](/api/response/#recommended_action)). ## Shadow mode [Section titled “Shadow mode”](#shadow-mode) Start your integration in **shadow mode** (`SHADOW`): FingerTrackID assesses and recommends, and your team compares the recommendation with the decision your system already makes. Keep your existing decisions during this validation. Production is the deployed environment; shadow mode describes how your application uses recommendations, not a temporary environment or an automatic blocking feature. BLOCK_RECOMMENDED does not block anything `BLOCK_RECOMMENDED` means FingerTrackID **recommends** blocking, under the managed policy or under your company’s own active rules. Nothing has been blocked. No FingerTrackID response executes an action in your product. ## Privacy by design [Section titled “Privacy by design”](#privacy-by-design) * Your backend identifies the person and the account through **pseudonymous references** that you create. Do not send national ID numbers (such as a CPF), e-mail addresses, phone numbers, names, account numbers or any reversible identifier. * The Web SDK sends canvas, WebGL, audio and font characteristics only as derived values (hashes, a GPU family, counts), never pixels, audio samples or font names. Its code does not read or write cookies, `localStorage` or `sessionStorage`; it uses a temporary IndexedDB database only to check for a private window, and deletes it. Continuity between visits uses an `HttpOnly` cookie on your collection domain, which the browser manages and the script cannot read. * Each customer company’s data is isolated; there is no correlation across customers. ## Registration and activation [Section titled “Registration and activation”](#registration-and-activation) Each company has its own workspace, applications, members and integration credentials. Registration uses the same [onboarding workflow](/getting-started/onboarding/) for every company. A completed registration does not by itself enable collection or signed calls: the application also needs its verified domain, installed credential and active policy. Today the console and the signed assessment API are live in production, in shadow mode: FingerTrackID recommends and your system decides. Web SDK collection is deployed there too, and its end-to-end operation from a customer page is still being measured. See [production status](/reference/availability/). Next step: [how the integration works](/getting-started/how-it-works/). # Supported capabilities and activation > What is live in production today, in shadow mode, what is not enabled, and the activation each company application still needs. Every company uses the same registration and integration workflow. A live production environment and an active customer application are separate facts, and publishing this documentation creates neither. ## Production status on 2026-10-17 [Section titled “Production status on 2026-10-17”](#production-status-on-2026-10-17) | Surface | Status | | ------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Customer console, [dash.fingertrackid.com](https://dash.fingertrackid.com/) | **Live.** Named sign-in with mandatory authenticator. Screens read your company’s real data; **Ciclo de vida do cliente** does not send or read requests yet (contact support) ([console screens](/console/screens/)) | | Invitation email | **Live.** Sent on a best-effort basis when an account is created; the sign-in page’s password recovery is the fallback | | Password recovery email | **Live.** Sent by FingerTrackID from `no-reply@fingertrackid.com`, at most one email per address in each fixed 15-minute window | | Managed configuration requests (application, collection setup, server credential, SDK configuration, members) | **Live.** Submitted from **Início**, normally approved as soon as they are sent (recorded in the audit trail, and visible to FingerTrackID, which can cancel or reverse them) and then applied automatically | | Internal backoffice | **Live**, for FingerTrackID operators only | | Signed assessment API, `POST https://api.fingertrackid.com/v1/assessments` | **Live, in shadow mode** (since 2026-10-08). Responses are recommendations only; FingerTrackID never blocks, allows or executes anything. Under the current managed production profile, an assessment stored without a browser `event_context_token` is `INCONCLUSIVE` with `trust_score: null` and the managed fallback recommendation, never `COMPLETED`; the call can still end in an operational Problem such as `503`. Your application needs its installed server credential and active admission | | Tagged request `event-assessment-request.v1.1` | **Live** (since 2026-10-13). It is the recommended profile for new integrations ([journeys and tags](/api/journeys/)): a v1.1 body with your `tag` is accepted and answered with `event-assessment.v1.1`. The v1 request is unchanged and stays accepted | | Action and outcome feedback API | **Live with the signed assessment API**, on the same signed runtime. It needs the credential permissions `action:write` and `outcome:write` | | Web SDK collection | **Deployed**, only through your company’s registered first-party collection domain, `collect.`, once that domain is active for your application. Its end-to-end operation from a customer page (a collection that becomes a `COMPLETED` event context attached to an assessment) is still being measured. The first latency figures, from one application and a small sample (not an SLA): `getEventContext` on an accepted collection 0.15 s at the median, a collection started at the click 2.6 s at the median ([measured in production](/web-sdk/collection/#measured-in-production-2026-10-15)). The private SDK package and its SHA-256/SHA-512 digests are downloaded in the dash ([installation](/web-sdk/installation/)) | | Public SDK script distribution (signed script assets served by FingerTrackID) | **Not enabled.** The private package bundled into your frontend is the only delivery | | Outbound assessment webhooks | **Not enabled.** They need their own endpoint verification, outbound credential and release | | Access context in the console (**Contexto do acesso**) | **Live** (computed during the assessment since 2026-10-17). The 54 context items are computed inside the assessment, within a short time budget: an item that does not fit stays without data and never deducts, and if the whole step fails, the context is computed after the assessment instead, as before, without an error, and then changes nothing in that access’s score. Context changes the score, the recommendation and the response only through context rules your company made active; otherwise it is informative. The account items need `account_ref`, and an account’s history starts with the first event that carries it ([console screens](/console/screens/#contexto-do-acesso)) | | Search by your company’s ID in the console (**Buscar titular ou conta pelo ID da sua empresa**) | **Live** for **Administrador da empresa**, **Analista de risco** and **Supervisor de risco**: an exact, audited lookup of the accesses your backend sent with one `subject_ref` or `account_ref`. The ID never goes into the page address ([console screens](/console/screens/#busca-por-id)) | | Company risk rules (off, ghost or active, edited in **Configuração de risco**) | **Live** (since 2026-10-13; context rules can be active since 2026-10-17). Any rule, including the 54 context items, can be published as active directly; a ghost rule is computed and recorded but never changes the score. Each context item fires on a ruler your company adjusts, starting from FingerTrackID’s default. Measuring the impact before publishing is optional. Only your company activates a rule; FingerTrackID never does, and while no rule is active every recommendation stays `MONITOR`. Missing evidence, a timeout or a failure never recommend blocking, and a block recommended by your own active rules is still a recommendation your backend executes ([console screens](/console/screens/#configura%C3%A7%C3%A3o-de-risco)) | Shadow mode FingerTrackID assesses and recommends; it never enforces. A recommendation such as `BLOCK_RECOMMENDED` does not block anything: your product keeps its existing decisions and executes every action itself. The trust score is an ordered index, not a calibrated fraud probability, and a listed signal is not proof of an active detector. Test your integration with the public contracts, the synthetic examples and the signature self-check before sending production traffic. ## What each application still needs [Section titled “What each application still needs”](#what-each-application-still-needs) A capability being live in production does not activate your application; each application is activated separately: | Capability | Activation requirement for your application | | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Console access | Provisioned named account, verified email, password, authenticator and current membership. FingerTrackID creates each company and its first administrator; there is no self-service company registration | | Configuration requests | The request must reach its completed state; a submitted or approved request is not a created resource | | Collection setup | The managed cooperative web observation profile, its detector/policy/admission artifacts applied by the automatic setup steps, your exact owned domain, DNS/TLS proof and native completion | | Assessment API | A v1.1 request with your own `tag` (recommended) or a v1 request with one of the journeys `LOGIN`, `WITHDRAWAL`, `TRADE`; installed credential, active admission and an active qualified release/policy | | Browser collection | Approved private SDK archive and the exact active first-party host, origin and public key | | Missing browser context | `event_context_token: null` is a supported input, not a promise of success, a score or a particular status | | Feedback | Credential capabilities `action:write` and `outcome:write`, actually active | | Exports and Usage | Current dataset permission; exports are redacted and expire, usage figures are estimates, not invoices | | Password recovery | Provider-backed reset; your existing authenticator remains required | | Lost authenticator | Separate identity-verified operator recovery; access is blocked first and restored only after a new setup | A credential still waiting for secure installation is not usable. Setup waiting for review is not ready. Valid DNS is not active collection, and an approved policy is not an active policy. Security, independent verifier isolation, technical retention/disposal, restoration and first-party proofs remain required before real data. ## Contract and measurement boundaries [Section titled “Contract and measurement boundaries”](#contract-and-measurement-boundaries) * Two request profiles share the same route and signature. v1.1 names the moment with your own `tag` and is the recommended profile for new integrations; v1 keeps exactly `LOGIN`, `WITHDRAWAL` and `TRADE` and remains supported, unchanged. Production accepts both. Every tag is assessed the same way ([journeys and tags](/api/journeys/)). * The Web SDK’s private archive delivery does not imply a public npm package or CDN. Script distribution requires a separately approved exact release, path and SRI and is not the standard installation. * There is no assessment retrieval `GET`; reconciliation uses the same original assessment POST. * A listed signal is not proof of an active detector. Missing, stale or failed evaluation is `NOT_EVALUATED`, never `NOT_DETECTED`. * Trust score is an ordered index, not a measured fraud probability. No recommendation executes a customer action. Start in `SHADOW` and keep your existing decisions during validation. * The managed `COOPERATIVE_WEB_SHADOW_V1` setup recommends `MONITOR` and makes no calibrated fraud-detection claim. New managed server clients are admitted at the standard tier, 5 requests per second with a burst of 10; higher tiers are requested in the console ([limits](/api/limits/)), and larger schema ranges do not promise higher throughput. * Usage estimates are not commercial acceptance, a price, quota, SLA or invoice. * The downloadable OpenAPI contract keeps its historical `planned` implementation annotation and example server. They are not evidence of live availability or an issued API origin. Start with [onboarding](/getting-started/onboarding/) and the [developer handoff](/getting-started/implementation-handoff/). Questions: . # Integration checklist > Verify managed onboarding, secure credential installation, browser/backend behavior and real application activation. ## Company, people and application [Section titled “Company, people and application”](#company-people-and-application) * [ ] Named administrator account activated through the invitation (or **Esqueceu sua senha?**); verified email and authenticator completed. * [ ] Each Início step is actually **Concluída**, not merely sent or **Aprovada**. * [ ] Current membership/role and exact application/environment match the integration. * [ ] Allowed website origins are exact HTTPS origins, without paths, ports or wildcards. * [ ] **Configurar a coleta web** uses one of the application’s origins and the owned base domain; its generated collector is `collect.`. * [ ] **Integração e saúde** › **Origens das páginas** lists every page origin that collects (for example the apex and `www`), all under the collection domain, at most 10. * [ ] The automatic release/policy/admission and technical retention steps are complete in Início; **Aguardando revisão** is not done. * [ ] Other members use named accounts and the least role required for their work. * [ ] [Issued inputs](/getting-started/implementation-handoff/#issued-inputs) are complete and come from actual configuration/verified handoff. * [ ] DNS/ownership/certificate/health proof for `collect.` is current; preparing records is not activation. * [ ] The HMAC secret was received once in the console and stored in the backend’s secret manager; no credential request remains **Aguardando você receber o segredo**. * [ ] Actual client permissions and the server client’s rate limit in force (standard tier: 5 requests per second, burst 10; see [limits](/api/limits/)) match the backend configuration. * [ ] Your server credential is installed with active admission and your collection domain is active; the signed API is live in production in shadow mode and Web SDK collection is deployed there ([status](/reference/availability/)). * [ ] Applicable security/isolation, applied technical retention/disposal and operational/recovery proofs are confirmed. * [ ] Active qualified release, policy, admission and explicit application activation are recorded before real data. ## Backend [Section titled “Backend”](#backend) * [ ] HMAC uses the 32 decoded bytes from the backend secret manager, never the 64-character hex text. * [ ] Apart from its single reveal in the console, no actual secret appears in chat, browser, email, command arguments/output, local files or repository. * [ ] The [signature self-check](/api/authentication/#check-your-environment) reproduces its public synthetic vector on the backend’s Node.js (24.16.0 or later on the 24 line; on Vercel, Node.js 24.x). * [ ] Backend clock is synchronized; `X-FTID-Timestamp` is in seconds. * [ ] Every attempt has a new UUIDv7 `request_id`, timestamp and signature, including after `429`/`503`. * [ ] Closed request serialized once with `JSON.stringify`; retries use the exact same bytes and token, including `null`. * [ ] Logical `idempotency_key`/`external_event_id` are unique, retained and never regenerated after an uncertain result. * [ ] A definitive `410 assessment-result-expired` stops reconciliation and never creates a replacement historical operation. * [ ] Exact `Content-Type`, route and headers; no `Content-Encoding`, `Expect`, tenant/client selector or nonce alias. * [ ] Trusted identity/event time come from the backend; references are opaque, stable keyed pseudonyms with no personal data, `account_ref` always travels with the same `subject_ref`, and the HMAC key and prefix never change silently ([person and account references](/api/overview/#person-and-account-references)). * [ ] New integrations send the v1.1 request, accepted in production, with one [tag](/api/journeys/#the-v11-tag) per call site; an integration that stays on v1 uses only `LOGIN`, `WITHDRAWAL` and `TRADE`, unchanged. * [ ] Tags contain no personal data and at most ten digits; a retry repeats the same tag. * [ ] Login placement follows completed server authentication/required MFA; on v1, registration/recovery do not invent journeys. * [ ] Response/Problem validated against the downloaded contract of the profile sent; expected event and tag (v1.1) or journey (v1) checked. * [ ] Null score handled as no score; returned recommendation, basis and effective versions preserved. * [ ] Your system executes its own decision/fallback; initial `SHADOW` does not automatically block users. * [ ] Rotation can hold two credentials, switch to the new one and retire the old one within the approved overlap. * [ ] Ordinary rotation preserves current permissions/rate and never exceeds a 24-hour overlap; changing permissions requires a separate reviewed configuration. * [ ] Support correlations retained without tokens, cookies, personal data or full payloads in logs. * [ ] Backend origin configured as `https://api.fingertrackid.com`, exactly as written. * [ ] Every completed login is assessed, including with `event_context_token: null` and for social/OAuth logins ([integration guidance](/getting-started/integration-guidance/)). * [ ] Integration failures never block the login, and configuration errors (`authentication-failed`, `forbidden`, `validation-failed`, invalid secret encoding) produce a clear error log or alert. * [ ] A `409 binding-conflict` logs an error and alerts, and the login continues. * [ ] The integration is behind your own feature flag, enabled first for internal accounts. ## Browser [Section titled “Browser”](#browser) * [ ] Exact approved private SDK version downloaded in the dash, and its SHA-256/SHA-512 checked against the dash before installation. * [ ] One SDK copy, initialized only in browser code for the current document/account lifecycle. * [ ] Exact active collection origin in CSP `connect-src`; no assumed script URL or fallback collector. * [ ] No page instrumentation (Sentry, Datadog, OpenTelemetry or another `fetch` wrapper) adds headers to requests to the collection origin; its `OPTIONS` requests ask only for the SDK’s own headers and get `204` ([tracing headers](/web-sdk/installation/#tracing-and-other-request-headers)). * [ ] Both issued CNAMEs remain DNS-only and the ownership TXT keeps its issued value; if the verification lapsed, **Verificar agora** renewed it. * [ ] The page uses `createActionContextHelper` ([recipe](/web-sdk/collection/#the-recipe)): `prefetch()` on the first intent of the assessed action’s form (`focusin`/`pointerdown`, never at page load) after applicable consent, one `take()` per action, and no form values/passwords/recovery codes reach the SDK; `minRemainingMs` stays above your backend’s margin ([lifecycle](/web-sdk/collection/#the-lifecycle)). * [ ] A login that redirects to Google or another OAuth provider collects before the redirect or on a page after the callback ([login with redirects](/web-sdk/login-redirects/)); the backend logs when a login arrives without a token, and why. * [ ] Every collecting page is on one of those allowed origins; any other origin gets `TRANSPORT_ERROR`. Device continuity is per origin: the same browser on `www` and on the apex counts as two installations. * [ ] The browser sends the token with its `expiresAt`; the backend sends `null` when too little time is left, and on `400 event-context-invalid` assesses once more as a new operation with `null`. * [ ] At submit the page waits for the context with `take()`, which resolves within its `waitMs` ceiling and never aborts the SDK; a classic form holds its navigation until then, and a single-page app keeps the SDK out of components that unmount at submit. * [ ] The SDK prepares only on pages with an assessed action, and `close()` follows the backend’s business response, so unclosed sessions do not hold the 1000-session limit. * [ ] The installed SDK is the version the dash offers (0.3.0 or later for the helper), and your application’s collection is set up for that version; with 0.1.1, a new collection uses a new client, closed only when no token of the old one is pending ([SDK 0.1.1](/web-sdk/collection/#if-you-still-run-sdk-011)). * [ ] Your privacy policy describes the collection ([data collected and processing](/reference/data-and-processing/)), validated by your DPO. * [ ] One retained handle/context per new assessed action; retries keep the captured result including `null`. * [ ] Initialization, collection, synchronous context and network failures leave normal authentication functional. * [ ] Client remains alive through the bounded first assessment attempt; a pending page teardown does not extend token validity. * [ ] Logout/account switch calls `close()` and discards handles without delaying normal logout. ## Feedback and verification [Section titled “Feedback and verification”](#feedback-and-verification) * [ ] Requested feedback capabilities are actually active before action/outcome calls. * [ ] Feedback signs the exact assessment path/ID and validates its own request/receipt/Problem contract. * [ ] Reported action reflects what your product actually did; a block/score is not fraud truth. * [ ] Shadow evaluation records assessment IDs, recommendations and your own decisions, and keeps the feedback receipts’ alignment. * [ ] Outcome correction references the current leaf and appends a fact without rewriting history. * [ ] Console **Acessos** and **Integração e saúde** reflect the intended scope; unavailable, truncated or expired results stay explicit. * [ ] With `account_ref` sent, an account’s first login with a completed browser collection shows **Primeira vez: ainda sem histórico** in **Contexto do acesso** (without one, **Sem coleta concluída do navegador**), and a later login of it with browser context, within 90 days of an earlier login that also had a browser collection with a linked browser installation, shows **Dispositivo novo para esta conta** evaluated (**Sim** or **Não**) instead of **Conta não informada pelo sistema da empresa**; no `409 binding-conflict` appears in your logs. * [ ] Revoked membership, crossed origin/key and unknown request state have been exercised with synthetic inputs. * [ ] A pending request can be cancelled with **Cancelar a solicitação pendente**; existing artifacts remain and an uncertain result is checked with **Recarregar o estado** on the same request. * [ ] **Uso** interpreted as estimates and exports as scoped/redacted/expiring data, not invoices or raw collection. # Data collected and processing > Factual description of what the Web SDK and your backend send to FingerTrackID, the first-party cookie, retention, region and purpose, for your privacy policy and DPO. This page lists, as facts, what FingerTrackID receives and keeps when you integrate it, so your privacy team and DPO can describe it accurately. It describes the current production behavior of the Web SDK and the v1 assessment API. Your contract and data processing terms with FingerTrackID prevail over this page. Not legal advice This page is technical information, not legal advice. The legal basis, consent requirements, wording of your privacy policy and any international transfer assessment are decisions for your DPO and legal counsel. ## Purpose [Section titled “Purpose”](#purpose) FingerTrackID assesses the risk of an access or business event (a login, withdrawal or order) for fraud prevention and account security, and returns a **recommendation**. It never executes a decision: your product decides and acts, and nothing is enforced by FingerTrackID, including when the recommendation comes from your company’s own risk rules ([production status](/reference/availability/)). The trust score is an ordered index for this event, not a reputation of the person and not a fraud probability. Data is kept separately per customer company. Correlation of data across customer companies is disabled. ## What the Web SDK collects in the browser [Section titled “What the Web SDK collects in the browser”](#what-the-web-sdk-collects-in-the-browser) The current managed production collection policy enables the **browser profile** and the canvas, audio and font options (canvas also covers WebGL); pointer and keystroke behaviour stays off and cannot be turned on. When the page calls `collect()`, the SDK reads these values from the browser and sends them to your first-party collection domain, `collect.`: | Group | Values | | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Browser | User-agent string; number of logical processors (`hardwareConcurrency`); approximate device memory (`deviceMemory`); maximum touch points; whether cookies are enabled | | Low-entropy client hints | Browser brand list, platform name and the mobile flag (`navigator.userAgentData`) | | Screen | Width, height, available width and height, color depth and device pixel ratio | | Locale | Time zone, preferred languages and the browser language | | Automation | The `navigator.webdriver` flag, and closed codes for page-visible marks that known automation tools or embedding runtimes leave behind (never the property names found) | | Privacy context and consistency | Closed values, booleans and small counts: whether the page is top-level and in a secure context, browser privacy features (Brave, Safari, Samsung Internet), timer precision, and whether values read in the page agree with the same values read in a hidden frame of the same page (relations only, never the values) | | Private-window check | The outcome of the storage check described [below](#browser-storage), and its raw measurement on Chromium-based browsers (a ratio, a quota comparison and one browser capability bit) | | Rendering (derived values only) | Canvas: a status, the winding-rule bit and a hash of two drawn images. WebGL: a status, a closed GPU family (for example `APPLE`, `NVIDIA` or `SWIFTSHADER`), whether rendering is in software, and a hash of the extension and parameter listing. Offline audio: a status and a hash of the rendered sum. Fonts: a status, how many of a fixed list of 52 fonts are present, and a 52-bit presence mask | The SDK also sends your application’s public key and technical values it generates itself: random session, observation and event identifiers, the browser’s clock time of the collection and the SDK version. ### What the Web SDK does not collect [Section titled “What the Web SDK does not collect”](#what-the-web-sdk-does-not-collect) * **No raw rendering output.** Canvas pixels, rendered audio samples, WebGL vendor and renderer strings, WebGL parameter values and font names stay in the browser; only the derived values above leave it. These derived values together can recognise the same browser again, which is why they are treated as device data, not as anonymous data. * **No pointer or keystroke behaviour.** The collection policy contract fixes this option to `false`; it cannot be turned on for an application. * **No high-entropy client hints** (such as full browser version, device model or architecture). * **No third-party fingerprinting provider.** The production collection policy has no provider plugin. * **No form content.** The SDK does not read form fields, passwords, recovery codes, email, names or any page content, and it does not read the DOM. * **No stored data about the user.** The SDK code does not read or write cookies, `localStorage` or `sessionStorage`. Its only storage use is the private-window check: on Chromium-based browsers it writes zero-filled blocks to a temporary IndexedDB database named `__ftid_pp_…`, deletes it at the end of the check (a database left by a page closed mid-check is deleted by a later check) and reads the storage quota estimate; on Safari and Firefox it opens the origin’s private file system once, which may create that origin’s empty root. * **No page URL.** Collection requests are sent with `referrerPolicy: 'no-referrer'`, so the page address is not sent. The browser does send the page’s origin (for example `https://app.yourcompany.com`), which is checked against your application’s allowed origins. ### Network data at the collection endpoint [Section titled “Network data at the collection endpoint”](#network-data-at-the-collection-endpoint) Like any web request, each collection request reaches FingerTrackID with the browser’s **IP address**. FingerTrackID’s load balancer also derives connection metadata from the request, such as TLS parameters, the network operator (ASN) and an approximate location of the IP address. The IP address appears in two kinds of record: * **Network evidence in FingerTrackID’s database.** Where FingerTrackID records the IP address as network evidence for an assessment, it stores the address as observed, together with the connection facts and an approximate location cell, and keeps them for at least 10 years (see [Retention](#retention)). The evidence is used for an assessment only while it is fresh; keeping the record does not make an old address count in a new assessment. * **Infrastructure request logs.** The load balancer and the Cloud Run services log every request they receive, including requests from your backend and from the console. Each entry contains the client IP address, the requested URL (including its path and query string), the user agent, the referrer when one is sent, the response status and the latency. These logs are kept for **30 days** in Google Cloud Logging (see [where data is processed](#where-data-is-processed)). For the console’s static web pages, only entries for the console’s own page and asset addresses are kept. An approximate location derived from the IP address, with a stated radius, can be shown in the console to your administrators and risk roles. It is not GPS, a street address or a live position. ## The first-party cookie [Section titled “The first-party cookie”](#the-first-party-cookie) To recognize the same browser across visits, the collection domain sets one cookie: | Attribute | Value | | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Name | `__Host-ftid_i` | | Set by | Your collection domain, `collect.`, only (host-only cookie, `Path=/`) | | Flags | `Secure`, `HttpOnly`, `SameSite=Lax` | | Content | A value signed by FingerTrackID that binds a random installation identifier to your company, application, environment and origins, with its issue and expiry times | | Lifetime | Set by FingerTrackID, never longer than 30 days; it can be renewed on later visits | The cookie is not readable by JavaScript, including the SDK, and it is not shared with other FingerTrackID customers. FingerTrackID stores a hash of the cookie value and its verified contents, never the cookie value itself. If the cookie is missing or rejected, collection still works; only continuity across visits is reduced. ## What your backend sends [Section titled “What your backend sends”](#what-your-backend-sends) Your backend sends the assessment request described in [the request](/api/overview/): * **Pseudonymous references** you create: `subject_ref` (the person), `account_ref` (the account) and, for v1 withdrawals, `beneficiary_ref`. FingerTrackID does not need, and you must not send, tax IDs, email, phone, name, account numbers or any other reversible identifier. Create them as keyed pseudonyms, for example an HMAC with a secret kept in your backend, never as a plain hash of a tax ID or an email, which anyone can recompute by guessing ([person and account references](/api/overview/#person-and-account-references)). In the console, **Administrador da empresa**, **Analista de risco** and **Supervisor de risco** can look up the accesses of one reference by its exact value; each lookup is recorded in the audit trail, and the reference never goes into a page address. * **Event data**: your event and idempotency identifiers, the event time and the moment being assessed. In the [v1.1 request](/api/journeys/#the-v11-tag) that moment is your own `tag` (for example `login` or `pix_transfer`), a label of your product that must never carry personal data; v1.1 sends no amount, currency or beneficiary. In the v1 request it is one of the fixed journeys (`LOGIN`, `WITHDRAWAL` or `TRADE`) and, for withdrawals only, the amount and currency. * **The browser’s event context token**, or `null`. The token is single-use and links the assessment to the browser collection above. If you use the [feedback API](/api/feedback/), your backend also sends the action your product took and later outcomes (for example legitimate or confirmed fraud), with an evidence reference of your choice that must contain no personal data or secrets. ## What FingerTrackID derives and returns [Section titled “What FingerTrackID derives and returns”](#what-fingertrackid-derives-and-returns) For each assessment FingerTrackID stores and returns a status, the trust score (when there is one), a recommendation, the state of 13 browser and network indicators, reasons, limitations and the versions used. `device_id` is an opaque alias (`dev_…`) for device continuity within your company; it does not identify a physical device. See [the response](/api/response/). ### Third-party reference lists [Section titled “Third-party reference lists”](#third-party-reference-lists) Four network indicators (Tor, hosting, VPN and public proxy) compare the address that reached the collection endpoint with public reference lists. FingerTrackID refreshes these lists on its own infrastructure and never sends your users’ addresses to the list publishers. Matching an address only adds context: it never leads to a block recommendation on its own. * FingerTrackID uses the IP2Proxy LITE database for [IP proxy detection](https://lite.ip2location.com). * Tor exit addresses published by the Tor Project (CC0 1.0). * Cloud provider address ranges compiled in [rezmoss/cloud-provider-ip-addresses](https://github.com/rezmoss/cloud-provider-ip-addresses) (CC0 1.0). * VPN provider ranges from [X4BNet/lists_vpn](https://github.com/X4BNet/lists_vpn) (MIT). * Hosting networks from [brianhama/bad-asn-list](https://github.com/brianhama/bad-asn-list) (MIT), with routing data from [iptoasn.com](https://iptoasn.com) (PDDL 1.0). * The iCloud Private Relay egress ranges Apple publishes, used so that Private Relay is never reported as a VPN or hosting network. ### Approximate location in the dashboard [Section titled “Approximate location in the dashboard”](#approximate-location-in-the-dashboard) The access detail shows roughly where the collecting address is: the centre of the city that the IP2Location LITE DB11 database associates with it, drawn with a fixed 15 km circle because the database states no accuracy. It is not GPS, not a street address and not where the person is now. FingerTrackID keeps the city-level place (country, region, city, postal code, the database’s time zone offset and the city centre at 0.01 degree), never the address, and the place does not feed the trust score. The map tiles come from [OpenFreeMap](https://openfreemap.org); a tile request carries no data about the access. * FingerTrackID uses the IP2Location LITE database for [IP geolocation](https://lite.ip2location.com). ## Retention [Section titled “Retention”](#retention) FingerTrackID keeps the data it records for **at least 10 years, until further notice**, and does not delete it. This covers: | Data | Kept for | | --------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | | Event and assessment history | At least 10 years | | Raw browser collection | At least 10 years (moved to colder storage classes after 30, 90 and 365 days) | | Browser uploads that were never used | At least 10 years | | IP address, connection facts and approximate location cell recorded as network evidence | At least 10 years | | Device context records | At least 10 years | | Security and control-plane audit records | At least 10 years | | Anti-replay and idempotency markers | At least 10 years (moved to an archive once they expire, not deleted) | This applies in production from the FingerTrackID release of October 2026. Before it, earlier releases deleted some of these records after shorter periods (for example, the IP address recorded as network evidence after at most one hour); records deleted then are not recovered. Some copies and records outside FingerTrackID’s database and storage have their own periods: | Data | Kept for | | ------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | First-party cookie in the browser | Never longer than 30 days | | Infrastructure request logs (load balancer and Cloud Run), including IP address, requested URL and user agent | 30 days | | Redacted exports | Downloadable for at most 24 hours | | Database backups | The latest 7 daily automatic backups, plus 7 days of transaction logs for point-in-time recovery; backups taken on demand before maintenance are kept until FingerTrackID deletes them | Ask FingerTrackID for the exact technical policy of your application if your DPO needs it. ## Where data is processed [Section titled “Where data is processed”](#where-data-is-processed) FingerTrackID runs on Google Cloud: * **Services, database and storage.** The services that handle collection, assessment, feedback and console requests, the database and the object storage run in the `us-central1` region (Iowa, United States). * **Entry through Google’s edge network.** Requests from browsers, your backend and the console first reach Google’s global external Application Load Balancer. The encrypted (TLS) connection is terminated at the Google edge location closest to the sender, which can be outside the United States, and the request is then forwarded over Google’s network to `us-central1`. Nothing is cached at the edge: Cloud CDN is not enabled. * **Request logs.** The infrastructure request logs described [above](#network-data-at-the-collection-endpoint) are stored in Google Cloud Logging in the `global` location, which Google describes as storage in any of its data centers worldwide, and are kept for 30 days. * **Database backups.** Cloud SQL stores the database backups in the `us` multi-region (United States). Error reports from FingerTrackID’s own services, console and websites are sent to an error-monitoring provider (Sentry) after a scrubber removes user data, cookies, request bodies, query strings, request headers other than the user agent, IP addresses, email addresses and tokens. They describe software failures, not your users. The Web SDK does not send error reports. Ask FingerTrackID for its current list of subprocessors. ### The console’s AI assistant [Section titled “The console’s AI assistant”](#the-consoles-ai-assistant) The customer console has an AI assistant that explains its screens, reads your company’s data to answer about it, and diagnoses your integration. Each question typed in its panel is sent by FingerTrackID’s console service to Anthropic’s Claude API, where a Claude model answers it: a Haiku model for most questions, and a Sonnet model for questions about an integration problem. The model receives the question and up to eight earlier turns of the same conversation, after email addresses, internal identifiers, IP addresses, CPF and CNPJ numbers, card numbers, phone numbers, long numbers and long tokens are replaced by placeholders; the console’s language, the names of the page and the menu, and where the user is (the page, the selected application and period, the open item and the setup checklist); the text of the screen the user has open when asking, as that user reads it; FingerTrackID’s public documentation; a summary of your company’s integration state (applications, origins, collection domain, SDK, credentials, recent collections and assessments, and counts of refused requests by reason); and whatever the model reads, during that question, from your company’s console data: accesses, devices, sessions, account links, rules, lists, the review queue, usage, members and the audit log. Each of those reads goes through the same console routes and checks as the screens, with the asking user’s own access: only your company, and only what that user’s role already reads. So the model can receive personal data your company’s console shows (an IP address, an account reference, an approximate location, a browser, a member’s email); card numbers and long tokens are masked in it, and fields the console treats as secrets, such as a revealed integration secret or a two-factor setup code, are never read. The model only answers questions about FingerTrackID and your company, never about another company, and text in screens or data is treated as data, never as an instruction. Anthropic handles the question under its commercial terms and, by its published policy, does not use the data it retains to train models without permission. When the Claude API does not answer, the same question, with the same content, goes through OpenRouter to a backup model (GPT-6 Luna), under a setting that admits only providers that do not keep prompts. FingerTrackID does not store questions or answers, and the conversation lives only in that browser tab, until the tab is closed or the user signs out: FingerTrackID records one audit entry per question, which is also the user’s daily question count; one audit entry per read the model makes, like any console read; the user’s rating of an answer and, when it did not help, the reason chosen from a fixed list; and, per question, counts without content (which model answered, tokens, estimated cost and time). ## Example disclosure text [Section titled “Example disclosure text”](#example-disclosure-text) Example only — validate before use The paragraphs below are a neutral starting point that reflects the facts on this page. They are not legal advice and do not choose a legal basis for you. Your DPO or legal counsel must adapt and validate them, including the legal basis, the controller/processor roles, the international transfers (to the United States and to the other locations described under [where data is processed](#where-data-is-processed)) and your own retention commitments. ### Português (exemplo) [Section titled “Português (exemplo)”](#português-exemplo) > **Prevenção a fraudes e segurança da conta.** Para proteger sua conta e prevenir fraudes, usamos o FingerTrackID, um serviço de avaliação de risco de acesso. Quando você usa nosso site, coletamos informações técnicas do seu navegador e dispositivo — como tipo e versão do navegador, sistema operacional, resolução de tela, fuso horário, idioma e endereço IP — e as enviamos ao FingerTrackID por um subdomínio nosso. Também é usado um cookie técnico, que não pode ser lido por scripts, para reconhecer o mesmo navegador em visitas futuras, com validade de até 30 dias. Nos acessos e operações avaliados, enviamos apenas identificadores pseudonimizados da sua conta, nunca seu nome, e-mail ou documento. O FingerTrackID devolve uma recomendação de risco; a decisão é sempre nossa. Os dados são processados no Google Cloud, nos Estados Unidos. A conexão criptografada é recebida no ponto da rede do Google mais próximo de você, que pode ficar fora dos Estados Unidos, sem armazenamento em cache, e encaminhada aos servidores nos Estados Unidos. Os registros técnicos de acesso, que incluem o endereço IP, o endereço solicitado ao FingerTrackID e o tipo de navegador, ficam em infraestrutura do Google que pode armazená-los em qualquer país, por até 30 dias. O FingerTrackID guarda os dados que registra, inclusive o endereço IP, por pelo menos 10 anos. \[Validar com o encarregado (DPO): base legal, papéis, transferência internacional e prazos.] ### English (example) [Section titled “English (example)”](#english-example) > **Fraud prevention and account security.** To protect your account and prevent fraud, we use FingerTrackID, an access risk assessment service. When you use our website, we collect technical information about your browser and device — such as browser type and version, operating system, screen resolution, time zone, language and IP address — and send it to FingerTrackID through a subdomain of ours. A technical cookie, which scripts cannot read, is used to recognize the same browser on later visits, for up to 30 days. For the logins and operations we assess, we send only pseudonymous identifiers of your account, never your name, email or ID number. FingerTrackID returns a risk recommendation; we always make the decision. The data is processed on Google Cloud in the United States. The encrypted connection is received at the Google network location closest to you, which may be outside the United States, without caching, and forwarded to servers in the United States. Technical access logs, which include the IP address, the address requested from FingerTrackID and the browser type, are kept in Google infrastructure that may store them in any country, for up to 30 days. FingerTrackID keeps the data it records, including the IP address, for at least 10 years. \[Validate with your DPO: legal basis, roles, international transfer and retention periods.] Questions about this page: . # Troubleshooting > What to check when the Web SDK collects nothing, assessments arrive without browser evidence, the API answers 401, 403 or 429, or many results are inconclusive. Start from the symptom you see. Each section names the likely causes, how to tell them apart and the fix, and links to the page that holds the full rule. FingerTrackID never blocks your product: while you fix any of these, your login and every other business action keep their normal path ([integration guidance](/getting-started/integration-guidance/)). ## Check the integration health first [Section titled “Check the integration health first”](#check-the-integration-health-first) In the console, open **Integração e saúde** (Integration and health) for the application ([the screen](/console/screens/#integra%C3%A7%C3%A3o-e-sa%C3%BAde)). It covers the last 24 recorded hours; its numbers are not live. * **Última coleta** (last collection) shows a recent time instead of **nenhuma coleta na janela** (no collection in the window). No collection at all points to the browser side: see [the Web SDK collects nothing](#the-web-sdk-collects-nothing). * In **Da coleta à avaliação** (from collection to assessment), **Coletas aceitas** (accepted collections) grows with your logins and **Correlacionadas** (assessments that found their browser collection) stays close to **Avaliações** (assessments). Collections without correlated assessments point to the hand-off from the browser to your backend: see [assessments arrive without browser evidence](#assessments-arrive-without-browser-evidence). * The health **Sem dados do SDK** (no SDK data) means assessments arrived in the window and none carried the SDK’s context, so none received a score. * **Credenciais de servidor** (server credentials) shows each credential’s `key_id` and state, **Origens das páginas** (page origins) the website origins the SDK may collect from, and **Limite de requisições** (request limit) the rate in force. For a single access, open it in **Acessos** (accesses): with browser evidence, **Cobertura de evidência** (evidence coverage) is above zero, the **Automação (robô)** indicator shows **Detectado** or **Não detectado** instead of **Não avaliado**, and the limitations do not include **Coletor não carregado** (collector not loaded). The full list of checks is in [check that SDK evidence arrives](/web-sdk/login-redirects/#check-that-sdk-evidence-arrives). ## The Web SDK collects nothing [Section titled “The Web SDK collects nothing”](#the-web-sdk-collects-nothing) Every SDK call that fails returns `UNAVAILABLE` with a `reason` (or, with the helper, a `failure` with its `stage` and `reason`); `initializeSdk()` and `createEvent()` throw instead. Log them: the reason tells most causes apart ([failure reasons](/web-sdk/collection/#failure-reasons)). | What you see | Likely cause | Fix | | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | No request reaches your collection domain, and the setup in **Início** is not done | The collection domain is not active yet. Without an active collection domain the SDK is not enabled for your application, and publishing DNS alone does not activate it | Follow the step **Confirmar o domínio de coleta** until it shows **Concluído**, and **Configurar a coleta web** until **Concluída** ([collection domain](/getting-started/collection-domain/)) | | The step waits on **Depende de você**, or a record shows **Ainda não encontrado** | A DNS record is missing, has another name, answers with more than one value, or sits behind a proxy; or your CAA policy does not allow the certificate | Publish the three records exactly as shown, DNS only, with nothing in front; allow `pki.goog` and `letsencrypt.org` in CAA; then choose **Verificar agora** ([the records](/getting-started/collection-domain/#the-records-you-publish), [how to check](/getting-started/collection-domain/#how-to-check)) | | An automatic step shows **Algo travou** | A platform step did not move for 30 minutes | Contact support with the request reference shown in **Início** | | `TRANSPORT_ERROR` on a page whose origin is not listed in **Origens das páginas** | The page origin is not one of the application’s allowed origins: the collection domain answers `403 origin-not-allowed` without CORS approval, so the browser hides that answer | Add the exact HTTPS origin in **Integração e saúde** › **Origens das páginas** (up to 10, all under the collection domain; the change takes effect at once) | | `TRANSPORT_ERROR`, and the browser console reports a CSP violation | Your `connect-src` does not list the exact collection origin | Add it to `connect-src`; the SDK needs no `script-src` entry ([your site’s CSP](/web-sdk/installation/#your-sites-csp)) | | `TRANSPORT_ERROR`, and the `OPTIONS` request to the collection domain answers `400` | Page instrumentation adds tracing headers to the SDK’s requests | Keep the collection origin out of trace propagation ([tracing headers](/web-sdk/installation/#tracing-and-other-request-headers)) | | `TRANSPORT_ERROR` only for some visitors | A blocking browser extension, or the network | Nothing to change in your setup: continue with a `null` context, and the assessment of that access is inconclusive | | `AUTHENTICATION_FAILED` on an allowed origin | The `publicKey` is not this application’s (`pub_app_…`), or the SDK version is not the one your application’s collection is set up for | Use the public key issued in the dash; install the version the dash offers, and contact support before changing it ([upgrading the SDK](/web-sdk/installation/#upgrading-the-sdk)) | | `INVALID_RESPONSE` | A proxy, CDN or WAF in front of the collection domain altered the response | Keep the collection CNAME DNS-only | | `TRANSPORT_ERROR` or `INVALID_RESPONSE` right at the session open, after a build change | A minifier option that breaks the SDK, such as `compress.unsafe_math` (measured on SDK 0.2.0) | Exclude the SDK package from that minification or turn the option off ([installation](/web-sdk/installation/)) | | `RATE_LIMITED` | For example, too many new sessions, or more than 1000 live sessions for the application | Prepare only on the first intent of an assessed form, never at page load, and call `close()` when done ([session limits](/web-sdk/collection/#session-limits)) | | `initializeSdk()` throws `INVALID_CONFIGURATION` | Its configuration is not exactly `publicKey` and `collectionOrigin`, an HTTPS origin with no path | Fix the two keys ([`initializeSdk`](/web-sdk/installation/#initializesdk)) | | `initializeSdk()` throws `UNSUPPORTED_RUNTIME` | The page is not HTTPS, or the browser lacks Fetch, cryptographic randomness or a monotonic clock | Continue with a `null` context | The SDK is delivered only as the private package bundled into your frontend. FingerTrackID serves no SDK script, so a page that loads it from a URL or a public CDN collects nothing. ## Assessments arrive without browser evidence [Section titled “Assessments arrive without browser evidence”](#assessments-arrive-without-browser-evidence) The response is `INCONCLUSIVE` with `trust_score: null`, `recommendation_basis` `INSUFFICIENT_EVIDENCE` and the fallback `MONITOR`; all 13 indicators are `NOT_EVALUATED` with `COLLECTOR_NOT_LOADED`, and `device_intelligence_snapshot_id` and `sdk_version` are `null`. Under the current managed production profile, an assessment stored without an `event_context_token` is always like this ([when the browser has no event context](/getting-started/integration-guidance/#when-the-browser-has-no-event-context)). * **Your backend sends `null`.** The browser sends the token and its `expiresAt` with the business request it already makes; your backend copies the token into `event_context_token`. Check that the field is forwarded, and that most logins in your backend logs carry a token. A login without one must have a logged reason, such as `WAIT_EXCEEDED` on a slow network, not one fixed code on every login. * **The browser had no token.** The SDK failed (see [above](#the-web-sdk-collects-nothing)), or the context was taken too late: a submit with nothing prepared needs at least three round trips before a token exists. Call `prefetch()` on the first focus or pointer-down of the form ([the lifecycle](/web-sdk/collection/#the-lifecycle)). * **Logins through a redirect** (social or OAuth providers) lose the context unless it is taken before the redirect or collected after the callback ([login redirects](/web-sdk/login-redirects/)). * **The client was closed too early.** Calling `close()` before your backend has answered terminates the session that issued the token, and a token whose assessment is not stored yet stops working. * **The API answers `400 event-context-invalid`.** The token expired, was already used, or is from another scope (for example, collected for another application). Nothing is stored. Do not retry the same body: assess the event once more as a new operation with `event_context_token: null` ([`event-context-invalid`](/api/errors/#event-context-invalid)). A token is single-use, one per business event, and the assessment that carries it must be stored before its `expiresAt`. The evidence window is 60 or 300 seconds from when the collection reaches FingerTrackID, depending on your application; every response shows it in `data_freshness.maximum_required_age_ms` ([the evidence window](/web-sdk/collection/#the-evidence-window)). If `event-context-invalid` is frequent, collect closer to the action. ## `401` on `POST /v1/assessments` [Section titled “401 on POST /v1/assessments”](#401-on-post-v1assessments) The `type` tells three cases apart. `timestamp-out-of-window` and `request-replayed` only answer a request whose signature was verified for a known credential: if you see them, your signature is right. ### `authentication-failed` [Section titled “authentication-failed”](#authentication-failed) The uniform failure, not retryable: the response never says which part was wrong. Treat it as a configuration error and check, in order: 1. **The secret.** The key is the decoded bytes of the secret, `decodeSecret(text, 'hex')`, never the hexadecimal text. Use the secret delivered with that `key_id`: after **Trocar segredo** the new credential has a new `key_id`, and the previous one is revoked at the end of an overlap of at most 24 hours. 2. **The bytes.** The body hash covers the exact bytes you send. Build the body with a single `JSON.stringify` call and send those bytes; re-serializing after signing gives `401`. 3. **The canonical string.** Exactly ten lines separated by LF, without a trailing LF, the ninth empty; the path is literally `/v1/assessments` ([the canonical string](/api/authentication/#the-canonical-string)). 4. **The headers.** `X-FTID-Signature: v1=:`, one signature, with a lowercase 64-character MAC; `key_id` and `request_id` are lowercase UUIDv7. `X-Client-Id`, `X-Signature`, `X-Timestamp` and `X-Nonce` are refused with `401`. 5. **The credential’s state.** In **Integração e saúde** › **Credenciais de servidor**, the credential must be active, not revoked or suspended. A credential still waiting for secure installation is not usable. A client that kept retrying `429` without waiting is blocked for at least 10 minutes, during which every request gets `401`. Run the reference self-check on the Node.js that runs your backend: `node ftid-hmac-v1-self-check.mjs` must print `OK` and the test vector’s MAC. If it does, the signer works, and the cause is the secret, the `key_id` or the bytes you send ([reference signer](/api/authentication/#reference-signer-nodejs-2416-or-newer)). ### `timestamp-out-of-window` [Section titled “timestamp-out-of-window”](#timestamp-out-of-window) Retryable. `X-FTID-Timestamp` is the second at which you sign the attempt, not the event’s `occurred_at`, and it must be within ±300 s of the server clock. Synchronize your backend clock (NTP), then send a new attempt. An `occurred_at` out of ±300 s is a different error, [`400 occurred-at-out-of-window`](/api/errors/#occurred-at-out-of-window). ### `request-replayed` [Section titled “request-replayed”](#request-replayed) Retryable. A `request_id` was reused. It is the anti-replay nonce: generate a new one for every attempt, including after `401`, `403`, `429` or `503`, with `crypto.randomUUIDv7()`, never `crypto.randomUUID()`, which makes version 4. Then fix the generator that repeated it. ## `403 forbidden` [Section titled “403 forbidden”](#403-forbidden) You are authenticated, but the credential lacks the route’s capability. A credential’s capabilities are frozen in its version, so you cannot add one to it: create a server credential with that permission in the console. The feedback routes need `action:write` and `outcome:write` ([`forbidden`](/api/errors/#forbidden)). ## `429 rate-limited` [Section titled “429 rate-limited”](#429-rate-limited) Your server client is above its rate: 5 requests per second with a burst of 10 on the standard tier ([limits](/api/limits/)). Wait for `Retry-After` (whole seconds, today `1`), then send a new attempt with the same body, a **new** `request_id`, the current timestamp and a new signature. Reusing the old `request_id` gives `401 request-replayed`, and retrying without waiting exhausts the security budget and blocks the credential for at least 10 minutes. To raise the rate, open **Integração e saúde** › **Limite de requisições** and choose **Pedir aumento de limite**, as *Administrador da empresa* or *Gestor de integração*; the console offers up to 10 per second, approved at once. For more, contact support at `contato@fingertrackid.com`. Momentary saturation of the service is `503 assessment-unavailable` instead, retried the same way. ## Many results are inconclusive [Section titled “Many results are inconclusive”](#many-results-are-inconclusive) `INCONCLUSIVE` is a statement about the evidence, not about the person: no limitation code is a reason against the access. * **Without a context** every assessment is `INCONCLUSIVE` ([above](#assessments-arrive-without-browser-evidence)). * **With a context**, the assessment is `COMPLETED` only when at least 30% of the 13 indicators were evaluated with fresh evidence (`evidence_coverage` ≥ 30) and both indicators that count toward the score, `BOT_AUTOMATION` and `BROWSER_SPOOFING`, were evaluated. * **Your company’s active rules** make every indicator you activate required: an active indicator without usable fresh evidence leaves the assessment without a score (`trust_score: null`, `MONITOR`), even above 30% coverage ([risk rules](/console/screens/#configura%C3%A7%C3%A3o-de-risco)). Read each indicator’s `not_evaluated_reason_code` to see what was missing ([limitation codes](/api/response/#limitation-codes)). For example: * `STALE_DEVICE_PROFILE`: the browser evidence was older than your application’s evidence window (collect closer to the action), or a server-side evidence source was older than its maximum age, which recollecting does not fix. * `UNSUPPORTED_RUNTIME` on the browser privacy and private browsing indicators: the SDK ran inside an iframe or an in-app browser, or the browser engine could not be identified consistently. * `REQUIRED_EVIDENCE_MISSING` on `DEVICE_MULTI_USER`: expected on the first login of a new browser installation; later logins evaluate it. ## Outbound webhooks [Section titled “Outbound webhooks”](#outbound-webhooks) Outbound assessment webhooks are not enabled ([production status](/reference/availability/)): there is no webhook to configure or debug. Your backend receives each assessment only in the response to its `POST /v1/assessments`. ## What to send to support [Section titled “What to send to support”](#what-to-send-to-support) Keep and send operation IDs, `request_id`, `correlation_id` (the `X-Correlation-Id` header of every response), the `assessment_id`, public error codes, timestamps and versions. Never send tokens, secrets, cookies, raw headers, personal data or full payloads. # Collection and event context > The Web SDK lifecycle — prepare on the first intent, take one context per business action within your own ceiling and send the event_context_token with the business request. Application activation Prepare this lifecycle and run it only after the [SDK and collection domain are activated](/web-sdk/installation/) for your application. Documentation publication does not activate collection. This page describes SDK **0.3.0**, the package the dash offers ([release notes](/web-sdk/installation/#release-notes)). Its API adds `createActionContextHelper`, which runs the whole lifecycle below for you; everything else keeps the API of 0.2.x. If your frontend still runs an earlier version, read [earlier versions](#earlier-versions) as well. ## The lifecycle [Section titled “The lifecycle”](#the-lifecycle) The SDK keeps its state in page memory and turns one accepted collection into an opaque, short-lived token per business action. Your backend sends that token with its assessment. For a login: 1. **Initialize once per page**, in browser code, and keep the client in a module that outlives your login components. `initializeSdk()` makes no request. 2. **Prepare on the first intent.** At the first focus or pointer-down on the login form, call `prefetch()`: it collects and starts the event context, so the token is usually ready by the submit. Not at page load: every load would open a session and a collection that counts as usage (the usage unit), also for visitors who never log in. 3. **Keep it fresh.** Browser evidence is usable only inside your application’s [evidence window](#the-evidence-window), 60 or 300 seconds from when the collection reaches FingerTrackID. The helper learns the window from the tokens’ own `expiresAt` and collects again only when it has to: the evidence would expire before your margin, the collection’s session ended, two lookups in a row were refused or expired on a collection that already gave a token, or a transient limitation came back (once per page). A collection that never gave a token is replaced only when its session ended (once in a row), for that one retry, or 300 seconds after it was sent, so an outage of event contexts alone costs at most one extra collection per 300 seconds ([stages and reasons](#stages-and-reasons)). Its clock counts time the device spent asleep. 4. **At submit, take this action’s context** with `take()`, which waits at most `waitMs` and never aborts the SDK. Send the token and its `expiresAt` with the business request, or `null` together with the reason, for your logs. 5. **Close only when you are done.** Call `close()` after your backend has answered the request that carried the token, on logout or on account switch. Closing earlier terminates the session that issued the token, and a token whose assessment is not stored yet stops working. In production on 2026-10-15 (one application, a small sample, not an SLA), `getEventContext` on an accepted collection took 0.15 s at the median, while a collection started at the click took 2.6 s ([measured in production](#measured-in-production-2026-10-15)): design for the token to be ready before the submit instead of fetching it at the submit. ## The recipe [Section titled “The recipe”](#the-recipe) One module per page. It uses only the public SDK API, never throws and never aborts the SDK. * **`prepareNextAction()`** runs at the first `focusin` or `pointerdown` on the form, and on every later one; for a sign-in button, at its `pointerenter`, `focus` and `pointerdown`. Never call it at page load: each load opens a collection that counts as usage (the usage unit). It never waits and never retries a failed collection by itself. * **`takeActionContext()`** runs once per business action, at its submit, and resolves within `SUBMIT_WAIT_MS` with `{ context, failure }`. Use the object it returns for that action and every retry of it, **including `null`**: do not take another context to change a request already sent. A token is never handed to two actions. * A click that is the first intent, or that follows a collection whose evidence ran out, waits up to `SUBMIT_WAIT_MS` while the helper collects; show progress during the wait. On touch devices `pointerdown` comes just before the click, so the first tap often waits. * Freshness comes from the token’s `expiresAt`, and the age of a collection counts time the device spent asleep. The helper never hands over a token with less than `minRemainingMs` left (15000 ms by default; option of `createActionContextHelper`). Keep it above the margin your backend needs to store the assessment ([integration guidance](/getting-started/integration-guidance/#a-minimal-fail-open-wrapper)). * With the helper, do not call `prepare()`, `collect()` or `getEventContext()` yourself, and do not call the client’s `close()` directly: use `closeSdk()`. A `collect()` of your own is adopted, but it costs a collection of its own. * A `null` context is never silent: `failure` gives the stage and the code ([stages and reasons](#stages-and-reasons)). Count these on your side; an integration where every login reaches the backend with `null` looks, from FingerTrackID’s side, exactly like one where the SDK never ran. The code is for your logs only: the assessment request has no field for it. * Send `expiresAt` with the token so your backend can drop a token that expired before it could be used. * Choose `SUBMIT_WAIT_MS` from your own measurements (0 to 10000 ms). With the prefetch the context has the time the user spends on the form to settle. A collection started at the click took 2.6 s at the median in production ([measured in production](#measured-in-production-2026-10-15)), above this recipe’s 2500 ms: preparing at the first intent, so that an accepted, fresh collection is waiting at the click, matters more than raising the ceiling. ```js import { createActionContextHelper, initializeSdk } from '@ftid/sdk-web' const configuration = { publicKey: 'REPLACE_WITH_ISSUED_PUBLIC_APPLICATION_KEY', collectionOrigin: 'https://collect.customer.example', } // Your choice, not an SDK value: the longest a submit waits for its context. const SUBMIT_WAIT_MS = 2_500 let helper = null let unavailable = null // Why there is no helper (initializeSdk threw), never a token. function actionContexts() { if (helper || unavailable) return helper try { helper = createActionContextHelper(initializeSdk(configuration), { waitMs: SUBMIT_WAIT_MS }) } catch (error) { // initializeSdk and createActionContextHelper throw only fixed codes. const reason = /^[A-Z_]{1,64}$/.test(error?.message) ? error.message : 'UNEXPECTED' const failure = Object.freeze({ stage: 'COLLECT', reason }) unavailable = Object.freeze({ context: null, failure }) } return helper } // First focus or pointer-down on the form, and every later one: cheap, never waits. // Not at page load. function prepareNextAction() { actionContexts()?.prefetch() } // At submit: this action's { context, failure } within SUBMIT_WAIT_MS. async function takeActionContext() { return (await actionContexts()?.take()) ?? unavailable } // After the backend answered and this page is done with the client, on logout or account switch. function closeSdk() { const closing = helper?.close() helper = null unavailable = null return closing } ``` ## Password login [Section titled “Password login”](#password-login) The recipe above serves both a classic form and a single-page application. In both, the token goes with the request your backend assesses: the password form, or the MFA code form when your login asks for one (call `prepareNextAction()` when that form gets focus or a pointer-down and `takeActionContext()` at its submit, as for the password form). Never send form values to the SDK. ### Classic form POST [Section titled “Classic form POST”](#classic-form-post) A submitted form unloads the page, and the unload cancels the SDK’s requests. Hold the submit until the context is settled or your ceiling ends, write it into hidden fields, then submit. * Keep `method="post"`: a `GET` form would put the token in the URL. * `submit()` does not send the clicked button’s name and value; if your backend reads them, put them in a hidden field. Calling it through `HTMLFormElement.prototype` keeps it working even if a field is named `submit`. * Do not call `close()` here: the page that answers has no SDK client. The session ends by itself after 30 minutes and holds one [session slot](#session-limits) until then. ```html
``` ```js // prepareNextAction and takeActionContext come from the recipe above. const loginForm = document.getElementById('login') loginForm.addEventListener('focusin', () => prepareNextAction()) loginForm.addEventListener('pointerdown', () => prepareNextAction()) if (loginForm.contains(document.activeElement)) prepareNextAction() // An autofocused field. let submitting = false loginForm.addEventListener('submit', async (event) => { event.preventDefault() // Hold the navigation until the context is settled or the ceiling ends. if (submitting) return submitting = true const { context, failure } = await takeActionContext() const fields = loginForm.elements fields.ftid_event_context_token.value = context?.token ?? '' fields.ftid_event_context_expires_at.value = context?.expiresAt ?? '' fields.ftid_context_failure.value = failure ? `${failure.stage}:${failure.reason}` : '' // Submits without firing this listener again; the page navigates only now. HTMLFormElement.prototype.submit.call(loginForm) }) // A page restored from the back/forward cache must be able to submit again. window.addEventListener('pageshow', (event) => { if (event.persisted) submitting = false }) ``` ### Single-page application (`fetch`) [Section titled “Single-page application (fetch)”](#single-page-application-fetch) * Call `prepareNextAction()` from the form’s focus and pointer-down handlers. In React, `onFocus` and `onPointerDown` on the `
` element receive those of every field. * Keep the SDK out of component state. A component that unmounts at submit (a route change, a spinner replacing the form) must not take the client or its pending work with it. * Do not call `closeSdk()` in an effect cleanup. React’s `StrictMode` runs cleanups an extra time in development, and a cleanup that closes the client terminates the session of a token not sent yet. * Do not pass an `AbortSignal` tied to the component or the route to SDK calls, and do not abort to stop waiting: `takeActionContext()` stops waiting by itself and leaves the SDK running. * Navigate after `fetch` resolves, never before. Logins that leave the page (Google sign-in, other OAuth providers) need one more step: [login with Google, OAuth and other redirects](/web-sdk/login-redirects/). ```js // takeActionContext and closeSdk come from the recipe above, kept in a module that outlives the // login component. csrfToken stands for your existing CSRF protection. async function submitLogin(credentials) { const { context, failure } = await takeActionContext() const response = await fetch('/api/login', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken }, body: JSON.stringify({ ...credentials, eventContextToken: context?.token ?? null, eventContextExpiresAt: context?.expiresAt ?? null, contextFailure: failure, }), }) // The backend has answered: a successful login is done with the client. A failed attempt keeps // it, and the next attempt prepares its own context. if (response.ok) void closeSdk() return response } ``` ## The evidence window [Section titled “The evidence window”](#the-evidence-window) Browser evidence is usable for a limited time, counted from the moment FingerTrackID receives the collection sent by `collect()`, not from page load or from `initializeSdk()`. The length is fixed **per application** by the managed production collection setup when the application is provisioned: | Application | Evidence window | | -------------------------------------------------------------------------------------------------------------- | --------------- | | Provisioned before the 300-second window was introduced (October 2026), including the first pilot applications | **60 seconds** | | Provisioned since then | **300 seconds** | An existing application keeps its window until FingerTrackID changes it with you; nothing changes it silently. To see yours: * Every assessment response carries it in `data_freshness.maximum_required_age_ms` (`60000` or `300000`), including assessments sent with `event_context_token: null`. * For a specific token, the limit that counts is always its `expiresAt`, described below. * If in doubt, ask FingerTrackID support for your application’s value. Code that respects `expiresAt` works with either window; only your collection margin needs to fit the shorter one. How the window applies: * `getEventContext` can return a token only inside the window. Once the collection’s window has ended, it returns `UNAVAILABLE` with reason `SERVER_UNAVAILABLE` (or `INCONCLUSIVE`, if the collection was only evaluated after its window). * The returned `expiresAt` is the earliest of: the window length after the token was issued, the end of the collection’s window, the end of the SDK session and the end of the SDK’s current capability. It is therefore never later than the window length after the collection reached FingerTrackID. Calling `getEventContext` again on the same handle after `expiresAt` returns `UNAVAILABLE` with reason `EVENT_CONTEXT_EXPIRED`. * The assessment that carries the token must be **stored before `expiresAt`**. A new assessment with an expired (or already used) token is refused with [`400 event-context-invalid`](/api/errors/#event-context-invalid): not retryable, nothing is stored, and the operation IDs of that request stay bound to that token. Assess the event as a new operation with `event_context_token: null` instead ([integration guidance](/getting-started/integration-guidance/#a-minimal-fail-open-wrapper)). * An assessment that was already stored is not affected: a retry with the same body returns the stored result (`200`) even after `expiresAt`. ## From the browser to the API [Section titled “From the browser to the API”](#from-the-browser-to-the-api) The browser sends **only the token and its `expiresAt`** (or `null`) to your backend, in the business request it already makes. Your backend puts the token in the `event_context_token` field of [`POST /v1/assessments`](/api/overview/), or `null` if `expiresAt` leaves too little time to store the assessment ([the evidence window](#the-evidence-window)). * The token is opaque and **single-use**: it is consumed once per business event. Do not decode it, write it to logs or analytics, put it in a URL, or use it as your own authorization token. * Your backend never accepts person and account references or `occurred_at` from the browser. * When there is no token, your backend still sends the assessment with `event_context_token: null` ([integration guidance](/getting-started/integration-guidance/#when-the-browser-has-no-event-context)). * A request already sent to FingerTrackID keeps its original token (or `null`) on every retry. * Keep the SDK client alive until your backend has answered. Until the assessment that carries the token is stored, terminating the session that issued it (`close()`) invalidates the token. Never extend the original context or request deadline to wait for completion. * Under the current managed production profile, a stored assessment without a context is `INCONCLUSIVE` with `trust_score: null`, never `COMPLETED`. With a context it is `COMPLETED` when at least 30% of the 13 indicators were evaluated with fresh evidence (`evidence_coverage` ≥ 30, counted across browser, network and device-history indicators together) and both indicators that count toward the score, `BOT_AUTOMATION` and `BROWSER_SPOOFING`, were evaluated; otherwise it is `INCONCLUSIVE`. With nothing adverse found the `trust_score` is `100`, and the managed recommendation is `MONITOR` in every band: the score informs, it never decides. Once your company activates its own [risk rules](/console/screens/#configura%C3%A7%C3%A3o-de-risco), the score and the bands follow them, and every indicator you activate becomes required: an active indicator without usable fresh evidence (for example a network indicator while network evidence is unavailable) leaves the assessment without a score (`trust_score: null`, `MONITOR`), even above 30% coverage. The context is what attaches the browser evidence (see [how to tell that SDK evidence arrived](/web-sdk/login-redirects/#check-that-sdk-evidence-arrives)). The call can still end in an operational Problem (for example `503`). Validate the actual [response or Problem](/api/response/) under your application’s issued policy. ## Session limits [Section titled “Session limits”](#session-limits) A client opens a browser session for your application only when it first needs server authority: at `prepare()` or the first `collect()`. `initializeSdk()` alone opens no session and consumes no quota. The limits are per application, the same for every application, and need no configuration: | Limit | Value | | -------------------------- | ------------------------------------------------------------------------------------ | | New sessions | 5 per second, with a burst of 20 | | Concurrent live sessions | Up to 1000 per application | | Session lifetime | 30 minutes from the operation that opens it, never extended | | Requests per session | One collection or event-context request per 250 ms, which the SDK respects by itself | | Event contexts per session | 128 | A session stops counting toward the concurrent limit when it ends: when it is terminated (`close()`, or the SDK retiring it, below) or when its 30 minutes run out. A session that is never closed holds its slot for the full 30 minutes, even after the visitor has left. When a limit is reached, a new session is refused and `collect()` returns `status: 'UNAVAILABLE'` with reason `RATE_LIMITED`, both above the new-session rate and at the concurrent limit; at the concurrent limit the `Retry-After` the SDK honours is the time until the first of the held sessions expires, at most 30 minutes. Continue the business action with a `null` context, as with any other collection failure, and never retry in a loop. See also the [API limits](/api/limits/). How the SDK uses sessions (since 0.2.0): * Each new collection after an accepted one opens a new session, at most one per second per client (a `collect()` sooner than that returns `RATE_LIMITED`, and the previous collection stays usable). The replaced session is terminated by the client once the last token it issued has expired (at most 300 seconds), at once if it issued none, or at `close()`. A client keeps at most three replaced sessions waiting; beyond that it terminates the oldest one. * After 30 minutes the client opens the next session by itself on its next `collect()`, `prepare()` or `renew()`; nothing reopens in the background. Handles from the old session return `CAPABILITY_EXPIRED` (`createEvent` and `getEventContext` on them open no session), and the helper collects again. Since 0.3.0 those 30 minutes count time the device spent asleep. To keep within 1000 concurrent sessions: * Prepare only on pages that have an assessed action (login, withdrawal, trade), never on every page view, and only on the **first intent** (focus or pointer-down) of its form: then sessions open only for visitors who start the action. * Call `close()` once your backend has answered and the page is done with the client, and on logout or account switching. ## Timing and latency [Section titled “Timing and latency”](#timing-and-latency) FingerTrackID publishes no latency SLA. The first figures measured in production are [below](#measured-in-production-2026-10-15); they come from one application and a small sample. Plan with the limits that follow, measure on your own page (from `collect()` to a `COMPLETED` context) and choose `SUBMIT_WAIT_MS` from what you see. These limits are protocol bounds, not expected latency or an SLA. Each request has a client deadline longer than the server’s budget for that route, because a browser request also spends DNS, TLS, the CORS preflight and the trip back: | Step | SDK 0.2.0 and later | Server budget | | --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | | Session open (`prepare()` or the first `collect()`) | 3000 ms per attempt; a timed-out, lost, `429` or `503` attempt is repeated once in the same call when the wait for it fits in one second | 1000 ms | | Collection | 4000 ms per attempt | 2000 ms | | Event context | 1500 ms per request, inside a budget of 5000 ms per event, at most 8 event-context requests per event | 500 ms per request | | Capability renewal and session termination | 1500 ms per attempt | 1000 ms | * The 5000 ms of an event start with its first `getEventContext` call and never reset. Inside them the SDK asks again 400 ms after each `PROCESSING` answer (or after a longer `Retry-After`) and repeats a timed-out, lost, `429` or `503` lookup by itself. No lookup starts with less than 1000 ms left. When the budget ends, the event keeps its last answer, which can be `PROCESSING`. * One session sends one collection or event-context request at a time, at least 250 ms after the previous one settled. * A submit with nothing prepared needs at least three round trips to your collection domain before a token exists (session open, collection, event context), plus FingerTrackID’s processing of the collection. That is why the recipe prefetches at the first intent. * The first request to each route may also need a CORS preflight. 0.2.0 lets the browser reuse it (the server allows caching it for 600 seconds); 0.1.1 sent a preflight before every request. * Request headers added by page instrumentation make every request fail ([tracing headers](/web-sdk/installation/#tracing-and-other-request-headers)). ### Measured in production (2026-10-15) [Section titled “Measured in production (2026-10-15)”](#measured-in-production-2026-10-15) Measured on 2026-10-15 in production, on one application: | Measurement | Sample | Median | p95 | Maximum | | ---------------------------------------------------------------------------------------------- | ------ | ------ | ------ | ------- | | `getEventContext` on an accepted collection, until the token | 10 | 0.15 s | 0.17 s | — | | A collection started at the click, from session open to the end of the collection’s evaluation | 13 | 2.6 s | 3.9 s | 4.4 s | This is a small sample from a single application: read it as indicative, not as an SLA, and measure your own page, network and users’ devices. The second row is roughly what a submit with nothing prepared waits before its token can exist; preparing at the first intent leaves only the first row at the submit. ## What throws and what returns a reason [Section titled “What throws and what returns a reason”](#what-throws-and-what-returns-a-reason) | Call | Throws | Otherwise returns | | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `initializeSdk(configuration)` | An `Error` whose `message` is `INVALID_CONFIGURATION`, `UNSUPPORTED_RUNTIME` or `CONFIGURATION_CONFLICT` (and `CLOCK_INVALID` in a browser whose monotonic clock misbehaves) | The client, synchronously | | `createEvent(observation)` | An `Error` whose `message` is `CLOSED`, `INVALID_HANDLE` or `CAPABILITY_EXPIRED` (and `CLOCK_INVALID` or `UNSUPPORTED_RUNTIME` in a broken browser) | The event handle, synchronously | | `prepare({ signal }?)` | Never; the promise never rejects | `READY`, or `UNAVAILABLE` with a `reason` | | `collect({ signal }?)` | Never | `ACCEPTED` with its `observation`, or `UNAVAILABLE` with a `reason` and the pending `observation` (or `null`) | | `retryCollection(observation, { signal }?)` | Never | The same as `collect()`; an already accepted observation answers `ACCEPTED` without a request | | `getEventContext(event, { signal }?)` | Never | `COMPLETED` with `eventContextToken` and `expiresAt`; `PROCESSING` when its budget ended first; `INCONCLUSIVE` or `FAILED` with a `limitationCode`; or `UNAVAILABLE` with a `reason` | | `renew({ signal }?)` | Never | `RENEWED`, `NOT_DUE`, or `UNAVAILABLE` with a `reason` | | `close()` | Never | `TERMINATED`, `UNCERTAIN` or `LOCAL_ONLY` | | `createActionContextHelper(client, { minRemainingMs, waitMs }?)` | An `Error` whose `message` is `INVALID_INPUT` (options other than those two integers, `minRemainingMs` 1000–30000 and `waitMs` 0–10000), `INVALID_HANDLE` (not a client of this SDK copy), `CLOSED` or `CONFIGURATION_CONFLICT` (the client already has a helper with other options) | The helper, synchronously; the same options return the same helper | | `helper.prefetch()` | Never | Nothing; it never waits | | `helper.take({ waitMs }?)` | Never; the promise never rejects | `{ context: { token, expiresAt }, failure: null }` or `{ context: null, failure: { stage, reason } }` | | `helper.close()` | Never | The client’s `close()` result | `UNAVAILABLE` carries `retryAfterMs` after a server `429` or `503` and when the SDK’s own back-off refuses a request. The SDK renews its short-lived capability by itself inside these calls; `renew()` only does it ahead of time and never extends the 30-minute session. `close()` returns `TERMINATED` when every session it held confirmed its termination; `UNCERTAIN` when any did not, when a session open was still in flight, or when it held no session after trying to open one; and `LOCAL_ONLY` when it never tried to open one. ## Failure reasons [Section titled “Failure reasons”](#failure-reasons) Every `UNAVAILABLE` result carries a `reason` (type `SdkFailureReason`), and `createEvent` throws an `Error` whose `message` is one of the same codes. In every case continue the business action with a `null` context. | `reason` | Likely cause | What to do | | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `TRANSPORT_ERROR` | The request did not complete, or the browser hid the answer: network failure, a CSP `connect-src` without the collection origin, a blocking extension, a page origin that is not one of the application’s allowed origins, a CORS preflight refused because page instrumentation added request headers, or the platform-wide new-session limit (100 per second shared by all applications) | Check the origin in **Integração e saúde** › **Origens das páginas**, your `connect-src` and your [tracing configuration](/web-sdk/installation/#tracing-and-other-request-headers); otherwise treat as transient | | `AUTHENTICATION_FAILED` | `401`/`403` from your collection domain on a correctly allowed origin: a `publicKey` that is not this application’s, or an SDK version your application’s collection is not set up for | Check the public key issued in the dash; install the SDK version the dash offers, and contact support before you change it | | `RATE_LIMITED` | Above the application’s new-session rate or its 1000 concurrent sessions, more than 128 event contexts in one session, a `collect()` within one second of the previous session open, or the SDK’s own back-off after a `Retry-After` | Do not retry in a loop; continue with `null`; free sessions with `close()` | | `SERVER_UNAVAILABLE` | `503` (or another unexpected status), including an event whose collection’s evidence window has ended | Continue with `null`; collect closer to the action | | `TIMEOUT` | A request ran past its client deadline (after the in-call retry), or the event’s 5000 ms budget ended | Continue with `null`; if frequent, prepare the context earlier | | `CAPABILITY_EXPIRED` | A handle from a session the client no longer uses. Since 0.2.0 a new `collect()` after an accepted one opens a new session and retires the earlier observation: `createEvent` on it throws this code and a new lookup for an event created from it is refused, while a token it already returned stays usable until its `expiresAt` and a lookup already started finishes. The end of the session’s 30 minutes has the same effect | Use the latest collection. With the helper, `take()` reports it, as `EVENT` or `CONTEXT`, only when that take cannot start the replacing collection: it already started its one collection, a collection back-off is running, or the limit on replacements a failure causes holds it (300 seconds since the previous one). While the helper’s last collection failed and the current one is stale, the `COLLECT` reason is reported instead ([stages and reasons](#stages-and-reasons)). A `collect()` of your own beside the helper makes it more likely | | `EVENT_CONTEXT_EXPIRED` | `getEventContext` called again for an event whose token passed `expiresAt` | Use `null` for that action; a new action creates a new event | | `CONFLICT` | A `409` conflict with the session or event state. With 0.1.1, also a second `collect()` on the same client | Continue with `null`; with 0.1.1, see [below](#if-you-still-run-sdk-011) | | `ABORTED` | The `signal` you passed was aborted | Expected after your own cancellation; never pass a signal tied to page teardown | | `CLOSED` | The client was closed | Initialize a new client for later actions | | `INVALID_HANDLE` | An observation or event handle that this client does not know (from another client, or from before `close()`), or an observation that was not accepted | Pass the handles this client returned | | `INVALID_INPUT` | An options argument other than `{ signal }` with an `AbortSignal` | Fix the call | | `INVALID_RESPONSE` | A response that failed the SDK’s checks, for example altered by a proxy, CDN or WAF in front of the collection domain | Keep the collection CNAME DNS-only, with nothing in front | | `CLOCK_INVALID` | The browser’s clock or monotonic timer behaved invalidly | Continue with `null` | | `BODY_TOO_LARGE` | An SDK request above its size limit | Report it to support with the SDK version | | `UNSUPPORTED_RUNTIME` | The browser lacks a required feature, such as cryptographic randomness | Continue with `null` | ## Stages and reasons [Section titled “Stages and reasons”](#stages-and-reasons) When `take()` has no context, `failure.stage` says where the action stopped and `failure.reason` why. The reasons are the [failure reasons](#failure-reasons) above plus these. Each `take()` starts at most one collection and two lookups. | `stage` | `reason` | What the helper does next | | --------- | ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `COLLECT` | `SERVER_UNAVAILABLE`, `RATE_LIMITED`, `TIMEOUT`, `TRANSPORT_ERROR`, `INVALID_RESPONSE`, `AUTHENTICATION_FAILED`… | The helper’s collection failed (`AUTHENTICATION_FAILED` when the session open is refused, for example because your application’s collection is not set up for this SDK version). `prefetch()` never retries it. Each `take()` retries it once, after a back-off of 1, 2, 4… seconds (at most 300, never below `Retry-After`); the retry resends the same observation, so it is not a new collection that counts as usage. Until a collection is accepted, a `take()` whose current collection is stale keeps reporting this reason, inside the back-off too, even when its lookup on the stale collection fails with `EVENT` or `CONTEXT`; a usable token from that lookup still wins | | `COLLECT` | `INVALID_INPUT`, `BODY_TOO_LARGE`, `UNSUPPORTED_RUNTIME` | The collection can never succeed on this page: the helper sends no more requests and keeps answering this reason | | `COLLECT` | `INVALID_CONFIGURATION`, `UNSUPPORTED_RUNTIME`, `CONFIGURATION_CONFLICT`, `UNEXPECTED` | `initializeSdk()` or `createActionContextHelper()` threw; the recipe keeps that code until `closeSdk()` | | `EVENT` | `CAPABILITY_EXPIRED`, `INVALID_HANDLE` | The collection’s session ended: the helper drops the collection and the next intent collects again, under the limit on replacements a failure causes (next row) | | `CONTEXT` | `AUTHENTICATION_FAILED`, `CAPABILITY_EXPIRED`, `INVALID_HANDLE` | The helper drops the collection. Under the limit on replacements a failure causes (this one, two failed events in a row, a transient limitation’s retry, or a collection that never gave a token, whether or not the collection gave a token before), each is sent no sooner than 300 seconds after the previous one was. The first may come at the next intent (`prefetch()` or `take()`); if the collection it brings fails too, only a `take()` replaces it. A token does not lift the limit. A collection replaced for its age or its 128 tokens, or one your page made with `collect()`, lets `prefetch()` replace again; the 300 seconds still apply | | `CONTEXT` | `SERVER_UNAVAILABLE`, `RATE_LIMITED` | The next lookup waits 1, 2, 4… seconds (at most 60, never below `Retry-After`). Two such events in a row, `EVENT_CONTEXT_EXPIRED` included, on a collection that already gave a token make the next intent collect again, under the limit above; until it can, the helper keeps looking the collection up and hands over its token if the events recover. A collection that never gave a token is replaced only 300 seconds after it was sent | | `CONTEXT` | `EVENT_CONTEXT_EXPIRED` | The lookup answered expired, which counts toward the two in a row above, with no wait; or the token came with less than `minRemainingMs` left, so it is never handed over | | `CONTEXT` | `TIMEOUT`, `TRANSPORT_ERROR`, `INVALID_RESPONSE` | The next lookup on the same collection waits, as above. These never count toward the two in a row and never start a new collection | | `CONTEXT` | `PROCESSING` | The collection was still processing when the lookup’s budget ended. The count of events in a row starts over, and the next decision looks it up again | | `CONTEXT` | `INCONCLUSIVE:`, `FAILED:` | The server’s technical limitation. `DETECTOR_TIMEOUT`, `DETECTOR_ERROR`, `PROVIDER_UNAVAILABLE` and `REQUIRED_EVIDENCE_MISSING` start one new collection per code per page, under the limit above; any other code stays the answer, with no request, until the collection can be replaced | | `WAIT` | `WAIT_EXCEEDED` | `waitMs` ended first; the work still running continues and serves the next action | | `WAIT` | `CLOSED`, `INVALID_INPUT`, `CLOCK_INVALID` | The client was closed, `take()` got an invalid option, or the browser’s clock misbehaved | ## Logout and account switching [Section titled “Logout and account switching”](#logout-and-account-switching) On logout or when the account changes, call `closeSdk()` from the recipe (it closes the client and discards its handles) before the next account’s first action. `close()` returns `TERMINATED`, `UNCERTAIN` or `LOCAL_ONLY`; closing in the browser does not replace your backend’s logout. Never send one user’s context under another user’s account. Do not delay logout or account switching for an optional assessment. If either interrupts an uncommitted context, preserve the normal error/fallback and the existing logical request. ## Earlier versions [Section titled “Earlier versions”](#earlier-versions) ### Page code written for SDK 0.2.x [Section titled “Page code written for SDK 0.2.x”](#page-code-written-for-sdk-02x) Page code written for 0.2.x runs unchanged on 0.3.0: every 0.2.x export and type is the same. Your application’s collection accepts one SDK version at a time, so contact FingerTrackID support before moving it ([upgrading](/web-sdk/installation/#upgrading-the-sdk)). Moving that code to the recipe above is recommended but can come in a later deploy of yours. ### If you still run SDK 0.1.1 [Section titled “If you still run SDK 0.1.1”](#if-you-still-run-sdk-011) Everything above applies, except the helper, with these differences: * **One collection per client.** A second `collect()` after an accepted one returns `UNAVAILABLE` with reason `CONFLICT`, and the client keeps its session. To collect again, `await close()` and call `initializeSdk()` for a new client, but only when no token from the old client is still pending, that is, sent or about to be sent and not yet stored by an assessment: closing terminates its session and that token stops working. While the accepted collection is fresh, a new action can instead create a new event from it. * **No `prepare()` and no `createActionContextHelper()`.** The first `collect()` opens the session. * `getEventContext` has a single 2000 ms budget per event and at most three event-context requests. * Every SDK request is preceded by a CORS preflight, and the [tracing headers](/web-sdk/installation/#tracing-and-other-request-headers) rule applies the same way. SDK 0.1.0 also never opens a second session: after its 30 minutes, `collect()` returns `CAPABILITY_EXPIRED` until you `await close()` and call `initializeSdk()` again. Install the package the dash offers. ## What the SDK collects [Section titled “What the SDK collects”](#what-the-sdk-collects) The full list, for your privacy policy, is on [data collected and processing](/reference/data-and-processing/). In short: a browser profile limited by your application’s collection policy: browser and user-agent data (including low-entropy client hints), screen, time zone, language, automation markers such as `navigator.webdriver`, privacy and consistency checks, and, under the current managed collection policy, rendering characteristics of canvas, WebGL, offline audio and installed fonts. Rendering characteristics leave the browser only as derived values: fixed-length hashes, a GPU family, booleans, a count and a presence mask over a fixed list of fonts. Pixels, audio samples, WebGL vendor and renderer strings and font names never leave the browser. The SDK does not collect high-entropy client hints or pointer and keystroke behaviour, and its code does not read or write cookies, `localStorage` or `sessionStorage`. To tell a private window from a normal one it uses browser storage briefly: on Chromium-based browsers it writes zero-filled blocks to a temporary IndexedDB database named `__ftid_pp_…` and deletes it at the end of the check (a database left by a page closed mid-check is deleted by a later check), and reads the storage quota estimate; on Safari and Firefox it opens the origin’s private file system once, which may create that origin’s empty root. None of this stores data about the user. The browser manages a signed `HttpOnly`, `Secure` cookie from your collection domain for continuity; the script never reads it. If the cookie is missing or rejected, continuity across visits is reduced, but collection still completes normally. ## Support [Section titled “Support”](#support) For support, keep operation IDs, `request_id`, `correlation_id`, public error codes, timestamps and versions. Never send tokens, secrets, cookies, raw headers, personal data or full payloads. # Installing the Web SDK > Verify and install the approved private SDK archive, initialize it in the browser and configure your site's CSP. Production status and activation Web SDK collection is deployed in production, in shadow mode ([production status](/reference/availability/)). Its end-to-end operation from a customer page is still being measured; the first latency figures from real browsers, on one application, are on the [collection page](/web-sdk/collection/#measured-in-production-2026-10-15). Prepare the frontend and backend together; collection is accepted only through your application’s active [collection domain](/getting-started/collection-domain/), with the approved package and issued inputs. An unavailable context becomes `null`; it must not interrupt your product’s normal login. See the [implementation handoff](/getting-started/implementation-handoff/). ## Your verified installation inputs [Section titled “Your verified installation inputs”](#your-verified-installation-inputs) * The **application public key** (`pub_app_…`). It is a locator, not a secret. * The active **collection domain**, for example `https://collect.yourcompany.com`. * The **approved version** of the private `fingertrackid-sdk-web.tgz` package with its SHA-256 and SHA-512 digests. Download the package and see both digests in the dash, signed in to your company: in **Início**, step **Configuração do SDK**, or in **Integração e saúde**, card **Pacote do SDK Web**. Your role must be allowed to read the integration (company administrator, integration manager or auditor). The SDK is not published to any public npm registry. Do not install a package from a public registry just because its name matches. ## Installation: private package [Section titled “Installation: private package”](#installation-private-package) Bundle the approved archive into your frontend. The dash shows the digests FingerTrackID recorded for the approved release; it does not compute them from the file you received, and it refuses to save a download that does not match the SHA-256. Still verify both digests on your own machine **before** installing; a digest found beside an untrusted archive is not approval. If the dash says the package is not available yet, contact FingerTrackID support. Each printed digest must equal the one shown in the dash. PowerShell prints the digest in uppercase; compare it without regard to case. The dash offers the same commands to copy, for both systems. The package declares its TypeScript types. Import and initialize in browser code, not during server-side rendering. Load exactly one copy; keep one client for the current browser document and account lifecycle, in a module that outlives your page components (the [collection recipe](/web-sdk/collection/#the-recipe) does this). Commit the lockfile and keep the approved archive in your dependency process. On logout or account switch, close this client and discard its action handles before initializing another. Your bundler may minify the package again with your production settings. Since 0.2.1 the SDK’s browser checks keep measuring under minifier options that treat built-in methods as free of side effects, such as terser’s `compress.unsafe`; with 0.2.0 such a build removed them (see the [release notes](#release-notes)). Before each release the package is checked re-minified by terser and SWC with `unsafe`, `unsafe_methods`, `pure_getters`, `passes: 3` and `toplevel`, and with the other `unsafe_*` options that keep ordinary code sound. Three compression options rewrite ordinary arithmetic and type checks. Measured on SDK 0.2.0, each one on its own breaks the SDK: the session open already fails, with `TRANSPORT_ERROR` or `INVALID_RESPONSE`, and no collection reaches FingerTrackID. The same measurement on 0.3.0 is still pending. Keep them off for the SDK package: | Minifier | Option that breaks the SDK | | -------- | ------------------------------------------------------------------- | | terser | `compress.unsafe_math`, `compress.booleans_as_integers` | | SWC | `compress.unsafe_math`, `compress.unsafe_hoist_static_method_alias` | If your build turns one of them on, exclude the SDK package from that minification or turn the option off, then check in the browser’s network panel that the session open and the collection complete. * macOS or Linux ```sh shasum -a 256 fingertrackid-sdk-web.tgz shasum -a 512 fingertrackid-sdk-web.tgz npm install --save-exact --ignore-scripts ./fingertrackid-sdk-web.tgz ``` * Windows ```powershell Get-FileHash -Algorithm SHA256 .\fingertrackid-sdk-web.tgz Get-FileHash -Algorithm SHA512 .\fingertrackid-sdk-web.tgz npm install --save-exact --ignore-scripts ./fingertrackid-sdk-web.tgz ``` ```js import { initializeSdk } from '@ftid/sdk-web' let sdk = null try { sdk = initializeSdk({ publicKey: 'REPLACE_WITH_ISSUED_PUBLIC_APPLICATION_KEY', collectionOrigin: 'https://collect.customer.example', }) } catch { // Optional collection must not interrupt the customer's normal business action. } ``` ## No script installation [Section titled “No script installation”](#no-script-installation) The private package bundled into your frontend is the only delivery. FingerTrackID does not serve the SDK as a script: script distribution is not enabled, and an active collection origin hosts no SDK file. Do not load the SDK from a URL or a public CDN. ## `initializeSdk` [Section titled “initializeSdk”](#initializesdk) * Takes **exactly** two keys: `publicKey` (format `pub_app_…`) and `collectionOrigin` (an HTTPS origin with no path, query, fragment, credentials or explicit default port). * It is synchronous and makes no network request. * On the same page, the same configuration returns the same client while it is open; a different configuration throws `CONFIGURATION_CONFLICT`. * An invalid configuration throws `INVALID_CONFIGURATION`; a browser without the required features throws `UNSUPPORTED_RUNTIME`. Every other SDK call reports failures in its result instead of throwing, except `createEvent` ([what throws](/web-sdk/collection/#what-throws-and-what-returns-a-reason)). The SDK requires a secure page (HTTPS), Fetch, native cryptographic randomness and a monotonic clock. Confirm the actually qualified browser matrix with your SDK release; browser features alone are not evidence of compatibility with every browser/version. ## Your site’s CSP [Section titled “Your site’s CSP”](#your-sites-csp) Add the exact active collection origin to your existing `connect-src`. The SDK is bundled into your own code, so it needs no `script-src` entry. This is a fragment, not a complete CSP. The SDK does not need `unsafe-inline`, `unsafe-eval`, host wildcards or inline handlers. Since SDK 0.1.1 it generates no code at run time, so a CSP without `unsafe-eval` produces no violation report from it. (SDK 0.1.0 produced one harmless `script-src` eval violation when its module was first evaluated.) Your backend’s calls to the assessment API, `https://api.fingertrackid.com`, are server-to-server and do **not** go in this browser list; the browser only talks to your collection domain. ```text connect-src 'self' https://collect.customer.example; ``` ## Tracing and other request headers [Section titled “Tracing and other request headers”](#tracing-and-other-request-headers) The collection domain accepts exactly the request headers the SDK sends: `Content-Type`, plus `X-Capability-Token` on collection and event-context requests or `X-SDK-Session-Token` on renewal and termination. The browser announces any other header in the CORS preflight, and the collection domain refuses that preflight (`400`, without CORS approval). The browser then hides the answer and the SDK request fails: every call returns `UNAVAILABLE` with reason `TRANSPORT_ERROR`, and no collection reaches FingerTrackID. Page instrumentation that adds tracing headers to outgoing `fetch` requests causes exactly this when one of its URL patterns also matches your collection origin, for example a pattern for every subdomain of your domain. Keep the collection origin out of trace propagation: | Tool | Option | What to check | | ---------------------------------------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Sentry browser SDKs | `tracePropagationTargets` | A string entry matches any URL that contains it, so `'yourcompany.com'` also matches `https://collect.yourcompany.com`. List your own API origins with anchored patterns, such as `/^https:\/\/api\.yourcompany\.com\//` | | Datadog Browser RUM | `allowedTracingUrls` | No entry, regular expression or function matches the collection origin | | OpenTelemetry `FetchInstrumentation` | `propagateTraceHeaderCorsUrls` | No entry matches the collection origin | | Any other `fetch` wrapper or HTTP client that adds headers | — | It adds nothing to requests whose URL starts with your collection origin | To verify, open the browser’s network panel on a page that collects: each `OPTIONS` request to your collection domain lists in `Access-Control-Request-Headers` only `content-type` (session open) or `content-type, x-capability-token` (collection and event context), and is answered with `204`. ## Upgrading the SDK [Section titled “Upgrading the SDK”](#upgrading-the-sdk) Your application’s collection accepts one SDK version at a time, and a request from another version is refused with `AUTHENTICATION_FAILED`. Install the version the dash offers. If your application already collects with an earlier version, contact FingerTrackID support before you deploy a new package: FingerTrackID moves the application to the new version and you deploy the new package at the same time, because from then on the earlier version is refused. What changed from 0.1.1 to 0.2.0 for your code is on the [collection page](/web-sdk/collection/#if-you-still-run-sdk-011). From 0.3.0 on, a version number names one package forever: a released number never comes back with another file. Any later change, even a single byte, gets a new number: a patch when nothing in the API or the behavior changes, a minor version (in 0.x) when something is added or behaves differently, with every breaking item listed below. When the dash offers a new version, read its release notes before you install it. ### Release notes [Section titled “Release notes”](#release-notes) * **0.3.0.** * *What changes.* A new export, `createActionContextHelper(client, options?)`, with its types (`SdkActionContextHelper`, `SdkActionContext` and the others on the [collection page](/web-sdk/collection/#the-recipe)): it runs the lifecycle for you, with `prefetch()` on the first intent of a form, one `take()` per business action and `close()`. The SDK’s credential clock now counts the time the device spent asleep. Every 0.2.x export, type, request and timing is unchanged. * *What breaks.* No API. After the device sleeps, the client stops using a credential, a session or a cached token that expired during the sleep: a cached token past its `expiresAt` answers `EVENT_CONTEXT_EXPIRED`, and the next collection after more than 30 minutes asleep opens a new session instead of reusing the old one. Your application’s collection accepts one SDK version at a time: once FingerTrackID moves it to 0.3.0, a browser still running 0.2.1 is refused with `AUTHENTICATION_FAILED`. * *Migration.* Contact support to schedule the move. Download 0.3.0 from the dash, verify both digests and install it with `npm install --save-exact --ignore-scripts`. Your 0.2.x page code runs unchanged; replacing it with the [recipe](/web-sdk/collection/#the-recipe) is recommended and can come later. Build and stage your frontend, and deploy it right after FingerTrackID confirms the move. Then sign in once and check that the login reached your backend with a token. * **0.2.1.** Nothing changes for your code: the API, the lifecycle, the requests and the timing are those of 0.2.0. It fixes 0.2.0 under bundlers that minify the package with options that treat built-in methods as free of side effects (for example terser’s `compress.unsafe`): in such a build 0.2.0 lost its JavaScript-engine checks, so the browser privacy and private-window signals of every access came back as not evaluated. 0.2.1 keeps them measuring under terser and SWC with those options. If your application collects with 0.2.0, contact support to move it to 0.2.1 as described above. * **0.2.0.** `prepare()`, a new SDK session for each new collection, client deadlines above the server budgets with retries inside the same call, and reuse of the CORS preflight. What it changes for your code is on the [collection page](/web-sdk/collection/#if-you-still-run-sdk-011). Next step: [collect and obtain the event context](/web-sdk/collection/). # Login with Google, OAuth and other redirects > Keep browser evidence when a login leaves the page — collect before the redirect or on a page after the callback — and check that SDK evidence actually reaches your assessments. Application activation These patterns need the [SDK and collection domain activated](/web-sdk/installation/) for your application, and pages served from one of the application’s allowed origins. A password login stays on your page until its submit, so the [collection recipe](/web-sdk/collection/#password-login) covers it. A login through Google sign-in or another OAuth/OIDC provider usually does not: the browser leaves your page for the provider and comes back to a callback on your server. This page shows how to keep browser evidence in that flow and how to verify it. ## Why a redirect loses the context [Section titled “Why a redirect loses the context”](#why-a-redirect-loses-the-context) The SDK keeps its session, observations, event handles and the token **only in page memory**. It never writes them to storage, and it must not: the token is a single-use credential for one business event. A full-page navigation discards all of it. If the page starts the SDK and then navigates to the provider: * the SDK’s first request may be cut off by the navigation, so no collection reaches FingerTrackID; * even a completed collection is useless once its handle is gone, because only that page could have asked for the event context; * your callback runs on the server, where there is no SDK, so the login is assessed with `event_context_token: null`. Nothing fails visibly: the login works and the assessment is stored. What is missing is the browser evidence. ## What happens without a context [Section titled “What happens without a context”](#what-happens-without-a-context) Your backend still sends the login assessment (tag `login`; `LOGIN` in v1) with `event_context_token: null`, which the [request schema](/downloads/contracts/json-schema/event-assessment-request.v1.1.schema.json) accepts as “no collection supplied”. The stored assessment then has: | Field | Without browser evidence | | ----------------------------------------------------------------------------------------------------- | ---------------------------------- | | `assessment_status` | `INCONCLUSIVE` | | `trust_score` | `null` (no score; not 0, not 100) | | `device_intelligence_snapshot_id`, `device_intelligence_snapshot_version`, `sdk_version`, `device_id` | `null` | | `signals` | All 13 indicators `NOT_EVALUATED` | | `limitation_codes` | For example `COLLECTOR_NOT_LOADED` | | `evidence_coverage` | `0` | `device_intelligence_snapshot_id: null` is the reliable way to tell, from the response alone, that no browser evidence was attached. What a context changes today Under the current managed production profile an assessment **with** a context is `COMPLETED` when at least 30% of the 13 indicators (browser, network and device history together) were evaluated with fresh evidence and both scoring indicators (`BOT_AUTOMATION` and `BROWSER_SPOOFING`) were evaluated; with nothing adverse found its `trust_score` is `100` and the recommendation stays `MONITOR`. Otherwise it is `INCONCLUSIVE` with `trust_score: null`. The context is what attaches the browser evidence to the login — the snapshot, the device continuity alias and the evaluated indicators — and no score can ever exist for a login that arrives without one. ## Password login [Section titled “Password login”](#password-login) Use the recipes on the collection page, for a [classic form POST](/web-sdk/collection/#classic-form-post) or a [single-page application](/web-sdk/collection/#single-page-application-fetch). The context goes with the request your backend assesses: the password form, or the MFA code form when your login asks for one. The backend assesses after it has verified the password and any MFA, using the token only while its `expiresAt` leaves enough time ([fail-open wrapper](/getting-started/integration-guidance/#a-minimal-fail-open-wrapper)). ## Redirect logins: two patterns [Section titled “Redirect logins: two patterns”](#redirect-logins-two-patterns) Both patterns produce one context for the login and carry it to your backend without ever putting it in a URL, the OAuth `state` or browser storage. Pick by how long users spend at the provider compared with your application’s [evidence window](/web-sdk/collection/#the-evidence-window): | Pattern | Collects | Best when | Cost | | ---------------------- | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | ---------------------------------------------- | | A. Before the redirect | On the first intent toward “Continue with Google”, taken on its click | The round trip to the provider fits comfortably inside the window | Up to your ceiling before the redirect | | B. After the callback | On an intermediate page of yours, before the session is finished | Users may spend long at the provider (account choice, password, provider 2FA), or the window is 60 seconds | One extra page, which waits up to your ceiling | With a **60-second** window, time spent at the provider counts against the token, so pattern B is the more reliable choice. With **300 seconds**, pattern A covers most provider round trips. Popup, One Tap and FedCM sign-ins do not leave the page, and the user may never touch a button of yours: call `prepareNextAction()` on the first `focusin` or `pointerdown` of the login form or of the container that holds the sign-in, on the sign-in button’s `pointerenter`, `focus` or `pointerdown`, or when your code displays the One Tap or FedCM prompt after a user action. Never at page load: do not prepare when One Tap or FedCM displays itself automatically as the page loads, because a prompt displayed on every load would open a collection that counts as usage (the usage unit) on every load, also for visitors who never sign in. Take the context with `takeActionContext()` when the credential arrives and send it with the request that posts the credential, as in a password login. Without a prepare beforehand, the credential callback waits up to your ceiling while the collection starts, and often reaches your backend with `null`. ### Pattern A: take the context on the click, before redirecting [Section titled “Pattern A: take the context on the click, before redirecting”](#pattern-a-take-the-context-on-the-click-before-redirecting) 1. Call `prepareNextAction()` from the [collection recipe](/web-sdk/collection/#the-recipe) on the first intent toward the Google button (`pointerenter`, `focus`, `pointerdown`), never at page load, so the context can be ready by the click. On the click, take it with `takeActionContext()`, which waits at most your ceiling. On touch devices `pointerdown` comes just before the click, so the click may wait up to `SUBMIT_WAIT_MS`. 2. Send the context (or `null` with its reason) to your backend in a same-origin, CSRF-protected request. The backend keeps it **server-side**, in the pre-login session that already holds your OAuth `state` and PKCE verifier (or the encrypted, `HttpOnly`, short-lived cookie your framework uses for them), bound to that `state`. 3. Start the provider redirect as you do today. Do not call `close()` first: closing can invalidate the unconsumed context. 4. In the callback, after verifying the provider’s response and any MFA of your own, take the stored context and delete it (single use). Send the token if its `expiresAt` still leaves your margin; otherwise send `null` and log `EXPIRED_BEFORE_CALLBACK`. If your authentication library starts the provider flow from the browser (a `signIn('google')` style call), keep that call as the last step and send the context in its own request just before it, as above. The page is discarded at the redirect without `close()`; its SDK session keeps one of your [concurrent-session slots](/web-sdk/collection/#session-limits) until its 30 minutes run out. ```js // prepareNextAction and takeActionContext come from the collection recipe; csrfToken and // startGoogleSignIn stand for your existing CSRF token and provider redirect. // The first intent prepares the context; a page load alone opens no session and bills nothing. for (const intent of ['pointerenter', 'focus', 'pointerdown']) googleButton.addEventListener(intent, () => prepareNextAction()) googleButton.addEventListener('click', async (event) => { event.preventDefault() googleButton.disabled = true // Show progress; the wait is bounded by SUBMIT_WAIT_MS. const { context, failure } = await takeActionContext() try { // Your endpoint: stores { context, failure } server-side with the OAuth state. await fetch('/auth/login-context', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken }, body: JSON.stringify({ eventContext: context, contextFailure: failure }), }) } catch { // The login must not depend on this request; the callback then assesses with null. } startGoogleSignIn() // Your existing redirect to the provider, unchanged. }) ``` ### Pattern B: collect on a page after the callback [Section titled “Pattern B: collect on a page after the callback”](#pattern-b-collect-on-a-page-after-the-callback) 1. The callback verifies the provider’s response but does **not** finish the session yet. It stores a short-lived pending login server-side (bound to the browser, for example with the same pre-login session) and redirects to an intermediate page of yours, such as `/login/continue`, on one of the application’s allowed origins. 2. That page collects and obtains the context with `takeActionContext()`, which waits at most your ceiling, then posts it (or `null` with its reason) to your backend. 3. The backend verifies the pending login, runs any MFA of your own, assesses the login, then finishes the session as it does today and answers with where to go next. 4. The page calls `close()` after that answer and navigates on. The intermediate page must always finish the login: keep the bounded wait, and give it a `