HMAC authentication
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”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.
FTID-HMAC-SHA256-V1<key_id>POST/v1/assessments<X-FTID-Timestamp>external_event_id:<external_event_id>request_id:<X-FTID-Request-Id>application/json
<lowercase hexadecimal SHA-256 of the exact body bytes>X-FTID-Signature: v1=<key_id>:<mac>Rules that break integrations most often
Section titled “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_idandrequest_idare 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”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). 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”X-FTID-Timestampis the second at which you sign the attempt, not the event’soccurred_at. Outside ±300 s of the server clock:401 timestamp-out-of-window.X-FTID-Request-Idis the nonce. Resending the samerequest_idgives401 request-replayed. Never reuse arequest_id, not even after401,403,429or503.- 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 usecrypto.randomUUID(): it generates version 4, which the API rejects. - Keep your backend clock synchronized (NTP).
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()X-FTID-Timestamp: 1788523200X-FTID-Request-Id: <a new lowercase UUIDv7>X-FTID-Signature: v1=<key_id>:<mac>The reference signer does both for you.
Credential, capabilities and rotation
Section titled “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 revokedkey_idnever 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”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.
| 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)”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 does.
- ftid-hmac-v1.mjs — the signer
- ftid-hmac-v1-self-check.mjs — the self-check
Check your environment
Section titled “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.
node ftid-hmac-v1-self-check.mjsOK df9016da863e7fc4949f1845fb9457d1e3f165d427d1c8efd47af44eff86851bUse in your backend
Section titled “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.
prepareAttemptonly signs a body whose text is exactly whatJSON.stringifywrites; any other text getsFTID_BODYwithout being signed. That is how it blocks duplicate keys at any level. Build the body with a singleJSON.stringifycall.postAttemptdoes not retry on its own, does not follow redirects and reads at most 65536 bytes of response; one byte more becomesFTID_INDETERMINATE. Validate the response against the schema before using it.- If you already have an HTTP client, use only
prepareAttempt, sendattempt.bodyandattempt.headersunchanged, and cap the response read at the same limit.
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.Full code
Section titled “Full code”// 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 = 65536export 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 = 2147483647const MIN_SECRET_BYTES = 32const 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, }}// 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 <mac>` 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}