Ir al contenido
En producción, en modo shadow: FingerTrackID recomienda, su sistema decide.Estado →

Cada llamada se firma con FTID-HMAC-SHA256-V1: HMAC-SHA-256 con los bytes del secreto de su credencial como clave, sobre una cadena canónica.

Exactamente diez líneas en UTF-8, separadas por LF (\n), sin LF final.

La novena línea está vacía: es el Content-Encoding, que no existe en este protocolo. El MAC está en hexadecimal en minúsculas, tiene 64 caracteres y va en el encabezado X-FTID-Signature.

Cadena canónica
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>
Encabezado
X-FTID-Signature: v1=<key_id>:<mac>

Las reglas que más a menudo rompen las integraciones

Sección titulada «Las reglas que más a menudo rompen las integraciones»
  • La clave son los bytes decodificados del secreto, nunca el texto hexadecimal. La consola entrega el secreto de producción una sola vez, como 32 bytes aleatorios codificados en 64 caracteres hexadecimales. Léalos solo desde el gestor de secretos de su backend y llame a decodeSecret(text, 'hex') antes de firmar. El firmador de referencia también admite una codificación base64 canónica emitida de forma explícita; nunca adivine la codificación ni pegue el valor en un navegador, una IA o un archivo local.
  • El hash cubre los bytes que usted envía. Volver a serializar el JSON después de firmar (otro orden de claves, otro espaciado u otro escape de caracteres) produce 401.
  • key_id y request_id son UUIDv7 canónicos, en minúsculas.
  • La ruta firmada es /v1/assessments, literalmente, sin origen, cadena de consulta ni barra final.
  • No hay degradación: si cualquier parte del sobre v1 falla, el servidor no intenta otro protocolo.

Toda falla de autenticación —firma ausente o mal formada, key_id desconocido, MAC incorrecto, credencial revocada o suspendida, o una credencial fuera de su ventana de solapamiento— devuelve la misma respuesta: 401, type authentication-failed, título Autenticação falhou (el contrato fija este título en portugués), sin detail, con WWW-Authenticate: FTID-HMAC-V1 realm="assessments". La respuesta no revela si el key_id existe. Programe según status y type, no según ese encabezado ni el título.

Solo después de que la firma se haya verificado para una credencial conocida pueden aparecer otros dos tipos de 401, relativos al intento y no a la credencial: timestamp-out-of-window y request-replayed (más abajo). Nunca responden a una solicitud cuya firma o credencial haya fallado. Con una firma válida y una credencial que no tiene la capacidad de la ruta, la respuesta es 403 forbidden.

  • X-FTID-Timestamp es el segundo en que usted firma el intento, no el occurred_at del evento. Fuera de ±300 s respecto al reloj del servidor: 401 timestamp-out-of-window.
  • X-FTID-Request-Id es el nonce. Reenviar el mismo request_id da 401 request-replayed. Nunca reutilice un request_id, ni siquiera después de 401, 403, 429 o 503.
  • Genere UUIDv7 con crypto.randomUUIDv7(), disponible desde Node.js 24.16.0 en la serie 24 y desde 26.1.0; Node.js 22 y 25 no la tienen. No use crypto.randomUUID(): genera la versión 4, que la API rechaza.
  • Mantenga sincronizado el reloj de su backend (NTP).
Una vez por intento
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()
Encabezados de un intento
X-FTID-Timestamp: 1788523200
X-FTID-Request-Id: <a new lowercase UUIDv7>
X-FTID-Signature: v1=<key_id>:<mac>

El firmador de referencia hace ambas cosas por usted.

  • Una credencial tiene un conjunto cerrado de capacidades, congelado en su versión. Cambiar una capacidad requiere una credencial nueva.
  • La rotación cambia el key_id. El flujo gestionado crea una credencial nueva y exige la instalación segura de su nuevo material. La credencial anterior se revoca al final de un solapamiento de 24 horas como máximo. Un key_id revocado nunca vuelve.
  • Sospecha de filtración: avise a FingerTrackID de inmediato. La rotación de emergencia no tiene solapamiento.
  • Prepare su backend para mantener dos credenciales durante una rotación y para cambiar de una a otra sin un despliegue.

Un secreto sintético público —la secuencia de 32 bytes 00..1f— que no autentica nada, en ningún lugar. Firme el cuerpo del vector con él y debe obtener exactamente el MAC de la derecha.

El vector completo, con cuerpo, cadena canónica y encabezados: assessment-hmac-v1-test-vector.json.

Vector Ruta MAC esperado
Evaluación POST /v1/assessments df9016da863e7fc4949f1845fb9457d1e3f165d427d1c8efd47af44eff86851b

Firmador de referencia (Node.js 24.16 o posterior)

Sección titulada «Firmador de referencia (Node.js 24.16 o posterior)»

Dos archivos, sin otra dependencia que node:crypto, probados contra el vector anterior en cada cambio. Necesitan crypto.randomUUIDv7(): Node.js 24.16.0 o posterior en la serie 24, o 26.1.0 o posterior. En Node.js 22 o 25, el firmador falla en cuanto se importa (en Vercel, configure la versión de Node.js del proyecto en 24.x). Para que un inicio de sesión no dependa de eso, cárguelo con await import() dentro de su try fail-open, como hace el wrapper de la guía de integración.

Guarde ambos archivos en la misma carpeta y ejecute la autocomprobación con el mismo Node.js que ejecuta su backend. Imprime OK y el MAC del vector.

La autocomprobación lleva integrados los valores del vector; solo necesita el firmador a su lado.

Terminal
node ftid-hmac-v1-self-check.mjs
Salida esperada
OK df9016da863e7fc4949f1845fb9457d1e3f165d427d1c8efd47af44eff86851b

Un envío por intento, siempre sobre los mismos bytes.

readSecretFromYourVault, assessmentRequest, keyId y el tiempo de espera son suyos. En producción, apiOrigin es 'https://api.fingertrackid.com', exactamente como está escrito: https://, host en minúsculas, sin barra final, ruta, cadena de consulta ni credenciales. La consola muestra el mismo valor como Endereço da API de risco.

  • prepareAttempt solo firma un cuerpo cuyo texto es exactamente el que escribe JSON.stringify; cualquier otro texto recibe FTID_BODY sin ser firmado. Así es como bloquea claves duplicadas en cualquier nivel. Construya el cuerpo con una sola llamada a JSON.stringify.
  • postAttempt no reintenta por su cuenta, no sigue redirecciones y lee como máximo 65536 bytes de respuesta; un byte más se convierte en FTID_INDETERMINATE. Valide la respuesta contra el esquema antes de usarla.
  • Si ya tiene un cliente HTTP, use solo prepareAttempt, envíe attempt.body y attempt.headers sin cambios y limite la lectura de la respuesta al mismo tope.
server.mjs
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.
ftid-hmac-v1.mjs
// 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
// 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
}