Autenticación
Modelo de credenciales, la especificación de firma MD5 con tres cabeceras, vectores de firma conocidos, códigos de error del gateway e implementaciones de referencia en curl, Java, Node.js, Python y C#.
Todas las llamadas a la Open API de TF Fiscal (/openapi/**) se autentican por solicitud con un esquema de firma MD5. No hay sesión ni intercambio de token OAuth: cada solicitud se firma de forma independiente con el secreto de su aplicación (app_secret).
Modelo de credenciales
| Credencial | Finalidad |
|---|---|
| App Key | Identificador público de la aplicación (devuelto por el endpoint de echo) |
| App Secret | Credencial de llamada; se envía en la cabecera token y se usa para calcular sign |
- El App Secret se muestra una sola vez, al crear la aplicación o al rotar el secreto. No se puede recuperar después.
- Rotación: si el secreto se filtra, solicite una rotación. El secreto antiguo queda inválido de inmediato, así que planifique con operaciones una ventana de cambio antes de rotar.
- Suscripción: la plataforma suscribe su aplicación a los endpoints que necesite. Un endpoint fuera de la suscripción responde HTTP 403 con el código
10009005, incluso con una firma válida. - Mantenga el secreto solo en el servidor. Nunca lo incruste en aplicaciones móviles, código de front-end ni repositorios públicos.
Cabeceras de la solicitud
Toda solicitud debe llevar tres cabeceras:
| Cabecera | Valor | Notas |
|---|---|---|
token | app_secret | Credencial de la aplicación |
timestamp | Timestamp Unix en segundos | Debe estar dentro de ±300 segundos de la hora del servidor (protección contra replay) |
sign | Firma de la solicitud | Algoritmo abajo; 32 caracteres hexadecimales, en minúsculas |
Algoritmo de firma
sign = MD5( token + path + body + timestamp ) -> lowercase hex
Reglas de concatenación (orden fijo, concatenación simple de cadenas, sin separadores):
| Elemento | Regla |
|---|---|
token | El app_secret, tal cual |
path | Ruta de la solicitud incluyendo el prefijo /openapi, excluyendo la query string, sin esquema ni host. Las variables de ruta (empresaId, nfeId, cteId, dceId, chave, cnpj, cpf, nascimento) forman parte de la ruta y se firman |
body | Cuerpo bruto de la solicitud con todo CR (\r) y LF (\n) eliminados. Las solicitudes GET y DELETE sin cuerpo usan la cadena vacía; las solicitudes multipart (carga de certificado) usan la cadena vacía |
timestamp | Exactamente la misma cadena enviada en la cabecera timestamp |
Reglas del cuerpo en detalle:
- Cuerpos JSON: los bytes enviados deben ser idénticos byte a byte a la cadena firmada. Serialice una vez y use esa misma cadena tanto para firmar como para el cuerpo de la solicitud; nunca serialice dos veces. Esto aplica también a un
DELETEcon cuerpo (cancelación de CT-e y DC-e con motivo). - Cuerpos XML (endpoint de verificación por XML): el XML participa en la firma tras eliminar todo CR y LF, mientras que el cuerpo de la solicitud se envía sin cambios. Un archivo XML con formato de varias líneas se firma, por tanto, como una sola línea.
- Sin cuerpo (GET, DELETE) y multipart: concatene la cadena vacía
"", no"null","{}"ni ningún marcador. - Las cabeceras opcionales como
forceRevalidateoLanguageno forman parte de la firma.
Ejemplos de firma
Valores fijos que puede recalcular para validar su implementación antes de una llamada real.
appSecret = "sk_live_9f8e7d6c5b4a"timestamp = "1786843552"GET path = "/openapi/v2/empresas/1934811222334455/nf-e/NFe-000014553" body = ""sign = "24a450c3ca24d01700c534700c1b2343"POST path = "/openapi/v2/empresas/1934811222334455/nf-e" body = {"id":"NFe-000014553"}sign = "667b8127e211ff8c058f9640a9af672f"GET path = "/openapi/v3/consultas/cpf/40710536828/09011997" body = ""sign = "84a877ec34052db54cf41bb736f7c585"
Para los endpoints de verificación por XML y por chave aplica la misma regla:
POST path = "/openapi/v3/consultas/nf-e/xml"body = xml.replace("\r", "").replace("\n", "")sign = md5Hex(appSecret + path + body + timestamp)GET path = "/openapi/v3/consultas/nf-e/35260764962869000108550990001366171195929648"body = ""sign = md5Hex(appSecret + path + "" + timestamp)
Semántica de los estados HTTP
| Estado HTTP | Significado | Forma del cuerpo |
|---|---|---|
| 200 | Solicitud aceptada por el endpoint; inspeccione el cuerpo de la respuesta para el resultado de negocio | Específica del endpoint |
| 400 / 404 | Error de negocio o de solicitud generado por el endpoint | Forma de error específica del endpoint |
| 401 | Fallo de autenticación: cabeceras ausentes, timestamp inválido, token desconocido o firma no coincidente | Sobre de la plataforma |
| 403 | Fallo de autorización: aplicación deshabilitada o no vigente, integrador deshabilitado o endpoint no suscrito | Sobre de la plataforma |
| 429 | Límite de tasa excedido | Sobre de la plataforma |
Los errores de la capa de autenticación los produce el gateway de la plataforma antes de que la solicitud llegue al endpoint y siempre usan el sobre de la plataforma:
{ "success": false, "errorType": 1, "code": 10009003, "message": "Signature error" }
| HTTP | code | Significado | Acción |
|---|---|---|---|
| 401 | 10009000 | Faltan cabeceras de firma (token / sign / timestamp) | Envíe las tres cabeceras en cada solicitud |
| 401 | 10009001 | Timestamp inválido o desfase de reloj superior a ±300 s | Sincronice con NTP; genere uno nuevo por solicitud |
| 401 | 10009002 | Token inválido | Compruebe el app_secret; actualícelo tras una rotación |
| 401 | 10009003 | Firma no coincide | Vea Solución de problemas |
| 403 | 10009004 | Aplicación deshabilitada | Contacte a la plataforma |
| 403 | 10009015 | Aplicación no vigente (pendiente de aprobación o rechazada) | Espere la aprobación / contacte a la plataforma |
| 403 | 10009014 | Cuenta de integrador deshabilitada | Contacte a la plataforma |
| 403 | 10009005 | API no suscrita | Solicite la suscripción al endpoint |
| 429 | 10009006 | Límite de tasa excedido | Reintente con backoff exponencial (empiece en 1 s, duplique hasta 30 s, añada jitter) |
401 y 403 son errores de configuración: reintentar sin corregir es inútil y puede activar el límite de tasa. Las formas de error de negocio que devuelven los propios endpoints se describen en Convenciones generales.
Verificar la firma con echo
POST /openapi/demo/echo es la primera llamada recomendada de toda integración: valida toda la cadena de firma y devuelve la identidad de la aplicación que llama. A diferencia de los endpoints estándar, echo responde con el sobre de la plataforma. Compruebe una vez con un POST con cuerpo y una vez con un GET firmando el cuerpo vacío antes de continuar. Referencia de solicitud, respuesta y errores: Echo de prueba.
HOST="https://api.v2.tffiscal.com"APP_SECRET="<APP_SECRET>"API_PATH="/openapi/demo/echo"BODY='{"message":"hello tffiscal"}'TIMESTAMP=$(date +%s)SIGN=$(printf '%s%s%s%s' \"$APP_SECRET" "$API_PATH" "$(printf '%s' "$BODY" | tr -d '\r\n')" "$TIMESTAMP" \| md5sum | awk '{print $1}')curl -sS -X POST "$HOST$API_PATH" \-H "Content-Type: application/json" \-H "token: $APP_SECRET" \-H "timestamp: $TIMESTAMP" \-H "sign: $SIGN" \-d "$BODY"
{"success": true,"message": "OK","data": {"echo": "hello tffiscal","appKey": "tfapp_0123456789abcdef","appName": "My Integration","serverTime": "2026-07-18T16:33:54.450Z"}}
serverTime es el reloj del servidor en UTC; compárelo con su propio reloj para descartar desfases de timestamp.
Implementaciones de referencia
curl (bash), POST con cuerpo JSON
HOST="https://api.v2.tffiscal.com"APP_SECRET="<APP_SECRET>"API_PATH="/openapi/v2/empresas/1934811222334455/nf-e"BODY='{"id":"NFe-000014553","ambienteEmissao":"Homologacao"}'TIMESTAMP=$(date +%s)SIGN=$(printf '%s%s%s%s' \"$APP_SECRET" "$API_PATH" "$(printf '%s' "$BODY" | tr -d '\r\n')" "$TIMESTAMP" \| md5sum | awk '{print $1}')curl -sS -X POST "$HOST$API_PATH" \-H "Content-Type: application/json" \-H "token: $APP_SECRET" \-H "timestamp: $TIMESTAMP" \-H "sign: $SIGN" \-d "$BODY"
curl (bash), GET con cuerpo vacío
API_PATH="/openapi/v2/empresas/1934811222334455/nf-e/NFe-000014553"TIMESTAMP=$(date +%s)# body is the empty string: token + path + timestampSIGN=$(printf '%s%s%s' "$APP_SECRET" "$API_PATH" "$TIMESTAMP" | md5sum | awk '{print $1}')curl -sS -X GET "$HOST$API_PATH" \-H "token: $APP_SECRET" \-H "timestamp: $TIMESTAMP" \-H "sign: $SIGN"
Java
import java.nio.charset.StandardCharsets;import java.security.MessageDigest;public final class TffiscalSigner {/*** Computes the request signature.** @param appSecret application secret (also sent as the token header)* @param path request path including the /openapi prefix, without query string* @param body raw request body exactly as it will be sent; null or "" for GET / DELETE / multipart* @param timestamp unix time in seconds, same value as the timestamp header* @return lowercase hex MD5 signature for the sign header*/public static String sign(String appSecret, String path, String body, long timestamp) {String normalizedBody = body == null ? "" : body.replace("\r", "").replace("\n", "");String payload = appSecret + path + normalizedBody + timestamp;try {MessageDigest md5 = MessageDigest.getInstance("MD5");byte[] digest = md5.digest(payload.getBytes(StandardCharsets.UTF_8));StringBuilder hex = new StringBuilder(digest.length * 2);for (byte b : digest) {hex.append(String.format("%02x", b));}return hex.toString();} catch (Exception e) {throw new IllegalStateException("MD5 unavailable", e);}}}
String appSecret = "<APP_SECRET>";String path = "/openapi/v2/empresas/1934811222334455/nf-e";String body = "{\"id\":\"NFe-000014553\",\"ambienteEmissao\":\"Homologacao\"}";long timestamp = System.currentTimeMillis() / 1000;String sign = TffiscalSigner.sign(appSecret, path, body, timestamp);HttpRequest request = HttpRequest.newBuilder().uri(URI.create("https://api.v2.tffiscal.com" + path)).header("Content-Type", "application/json").header("token", appSecret).header("timestamp", String.valueOf(timestamp)).header("sign", sign).POST(HttpRequest.BodyPublishers.ofString(body)).build();
Node.js
const crypto = require('node:crypto');/*** Computes the request signature.* @param {string} appSecret application secret (also sent as the token header)* @param {string} path request path including the /openapi prefix, without query string* @param {string} body raw request body exactly as it will be sent; '' for GET / DELETE / multipart* @param {number} timestamp unix time in seconds* @returns {string} lowercase hex MD5 for the sign header*/function sign(appSecret, path, body, timestamp) {const normalizedBody = (body || '').replace(/[\r\n]/g, '');return crypto.createHash('md5').update(appSecret + path + normalizedBody + timestamp, 'utf8').digest('hex');}async function queryNfe(empresaId, nfeId) {const host = 'https://api.v2.tffiscal.com';const appSecret = '<APP_SECRET>';const path = `/openapi/v2/empresas/${empresaId}/nf-e/${nfeId}`;const timestamp = Math.floor(Date.now() / 1000);const response = await fetch(host + path, {method: 'GET',headers: {token: appSecret,timestamp: String(timestamp),sign: sign(appSecret, path, '', timestamp)}});console.log(await response.json());}queryNfe('1934811222334455', 'NFe-000014553');
Python
import hashlibdef sign(app_secret: str, path: str, body: str, timestamp: str) -> str:"""body is '' for GET / DELETE / multipart requests."""normalized = (body or "").replace("\r", "").replace("\n", "")return hashlib.md5((app_secret + path + normalized + timestamp).encode("utf-8")).hexdigest()
C#
var raw = appSecret + path + (body ?? "").Replace("\r", "").Replace("\n", "") + timestamp;var sign = Convert.ToHexString(MD5.HashData(Encoding.UTF8.GetBytes(raw))).ToLowerInvariant();
Solución de problemas
Firma no coincide (401, código 10009003)?
Compruebe, en orden de frecuencia:
- En POST, el JSON firmado difiere de los bytes realmente enviados (serializado dos veces, orden de campos o espacios cambiados).
- GET / DELETE / multipart no usó la cadena vacía
""como cuerpo (se usó"null", un objeto vacío o un texto de marcador). pathsin el prefijo/openapio sin un segmento de variable de ruta (en la consulta de CPF deben firmarse tanto el CPF como la fecha de nacimiento), o incluyendo la query string.signenviado en mayúsculas (debe ser hexadecimal en minúsculas).- El
timestampusado en la concatenación difiere del de la cabecera (regenerado entre ambos). - CR/LF no eliminados del cuerpo antes de la concatenación (típico con archivos XML).
- Bytes del cuerpo recodificados (calcule el hash exactamente de los bytes UTF-8 enviados por la red).
Recalcule primero los valores fijos de Ejemplos de firma; cuando su implementación local coincida, pase a inspeccionar los parámetros de la solicitud real.
Desfase de reloj (401, código 10009001)?
El reloj de su servidor difiere del nuestro en más de 300 segundos. Use NTP y nunca almacene en caché ni reutilice timestamps entre solicitudes. El serverTime devuelto por el endpoint de echo muestra el reloj de la plataforma.
