TF Fiscal
Documentación

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

CredencialFinalidad
App KeyIdentificador público de la aplicación (devuelto por el endpoint de echo)
App SecretCredencial 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:

CabeceraValorNotas
tokenapp_secretCredencial de la aplicación
timestampTimestamp Unix en segundosDebe estar dentro de ±300 segundos de la hora del servidor (protección contra replay)
signFirma de la solicitudAlgoritmo abajo; 32 caracteres hexadecimales, en minúsculas

Algoritmo de firma

text
sign = MD5( token + path + body + timestamp ) -> lowercase hex

Reglas de concatenación (orden fijo, concatenación simple de cadenas, sin separadores):

ElementoRegla
tokenEl app_secret, tal cual
pathRuta 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
bodyCuerpo 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
timestampExactamente 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 DELETE con 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 forceRevalidate o Language no forman parte de la firma.

Ejemplos de firma

Valores fijos que puede recalcular para validar su implementación antes de una llamada real.

text
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:

text
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 HTTPSignificadoForma del cuerpo
200Solicitud aceptada por el endpoint; inspeccione el cuerpo de la respuesta para el resultado de negocioEspecífica del endpoint
400 / 404Error de negocio o de solicitud generado por el endpointForma de error específica del endpoint
401Fallo de autenticación: cabeceras ausentes, timestamp inválido, token desconocido o firma no coincidenteSobre de la plataforma
403Fallo de autorización: aplicación deshabilitada o no vigente, integrador deshabilitado o endpoint no suscritoSobre de la plataforma
429Límite de tasa excedidoSobre 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:

json
{ "success": false, "errorType": 1, "code": 10009003, "message": "Signature error" }
HTTPcodeSignificadoAcción
40110009000Faltan cabeceras de firma (token / sign / timestamp)Envíe las tres cabeceras en cada solicitud
40110009001Timestamp inválido o desfase de reloj superior a ±300 sSincronice con NTP; genere uno nuevo por solicitud
40110009002Token inválidoCompruebe el app_secret; actualícelo tras una rotación
40110009003Firma no coincideVea Solución de problemas
40310009004Aplicación deshabilitadaContacte a la plataforma
40310009015Aplicación no vigente (pendiente de aprobación o rechazada)Espere la aprobación / contacte a la plataforma
40310009014Cuenta de integrador deshabilitadaContacte a la plataforma
40310009005API no suscritaSolicite la suscripción al endpoint
42910009006Límite de tasa excedidoReintente 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.

bash
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"
json
{
"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

bash
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

bash
API_PATH="/openapi/v2/empresas/1934811222334455/nf-e/NFe-000014553"
TIMESTAMP=$(date +%s)
# body is the empty string: token + path + timestamp
SIGN=$(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

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);
}
}
}
java
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

javascript
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

python
import hashlib
def 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#

csharp
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:

  1. En POST, el JSON firmado difiere de los bytes realmente enviados (serializado dos veces, orden de campos o espacios cambiados).
  2. GET / DELETE / multipart no usó la cadena vacía "" como cuerpo (se usó "null", un objeto vacío o un texto de marcador).
  3. path sin el prefijo /openapi o 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.
  4. sign enviado en mayúsculas (debe ser hexadecimal en minúsculas).
  5. El timestamp usado en la concatenación difiere del de la cabecera (regenerado entre ambos).
  6. CR/LF no eliminados del cuerpo antes de la concatenación (típico con archivos XML).
  7. 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.