Skip to Content
API Reference (English)Authentication & Signing

Authentication & Signing

All calls to the TF Fiscal Open API (/openapi/**) are authenticated per request with an MD5 signature scheme. There is no session or OAuth token exchange: each request is independently signed with your App Secret (app_secret).

Credential model

CredentialPurpose
App KeyPublic identifier of the application (shown in the console)
App SecretCalling credential; sent as the token header and used to compute sign
  • The App Secret is displayed only once — at application creation or at secret rotation. It cannot be retrieved later.
  • Rotation: if the secret leaks, request a rotation. The old secret becomes invalid immediately, so plan a switch-over window with your operations before rotating.
  • Keep the secret server-side only. Never embed it in mobile apps, front-end code or public repositories.

Request headers

Every request must carry three headers:

HeaderValueNotes
tokenapp_secretApplication credential
timestampUnix timestamp in secondsMust be within ±300 seconds of server time (replay protection)
signRequest signatureAlgorithm below; 32 hex characters, lowercase

Signature algorithm

sign = MD5( token + path + body + timestamp ) → lowercase hex

Concatenation rules (fixed order, plain string concatenation, no separators):

ElementRule
tokenThe app_secret, verbatim
pathRequest path including the /openapi prefix, excluding the query string, without scheme or host. Path variables (empresaId, nfeId, chave, cnpj, cpf, nascimento) are part of the path and are signed
bodyRaw request body with every CR (\r) and LF (\n) removed. GET and DELETE requests use the empty string; multipart requests (certificate upload) use the empty string
timestampThe exact same string sent in the timestamp header

Body rules in detail:

  • JSON bodies — the bytes you send must be byte-for-byte identical to the string you signed. Serialize once, then use that same string both for signing and as the request body; never serialize twice.
  • XML bodies (the XML verification endpoint) — the XML participates in the signature after stripping every CR and LF, while the request body itself is sent unchanged. A pretty-printed XML file therefore signs as a single line: md5Hex(app_secret + "/openapi/v3/consultas/nf-e/xml" + xmlWithoutCrLf + timestamp).
  • No body (GET, DELETE) and multipart — concatenate the empty string "", not "null", "{}" or any placeholder.

Signing examples

Fixed values you can recompute to verify your implementation before making a live call.

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"

For the XML and chave verification endpoints the same rule applies:

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)

HTTP status semantics

HTTP statusMeaningBody shape
200Request accepted by the endpoint; inspect the response body for the business resultEndpoint-specific
400 / 404Business or request-level error raised by the endpointEndpoint-specific error shape
401Authentication failed: missing headers, invalid timestamp, unknown token or signature mismatchPlatform envelope
403Authorization failed: application disabled or not effective, integrator disabled, or endpoint not subscribedPlatform envelope
410Endpoint retired; the message names the replacementPlatform envelope
429Rate limit exceededPlatform envelope

Authentication-layer errors are produced by the platform gateway before the request reaches the endpoint and always use the platform envelope:

{ "success": false, "errorType": 1, "code": 10009003, "message": "Signature error" }
HTTPcodeMeaningAction
40110009000Missing signature headers (token / sign / timestamp)Send all three headers on every request
40110009001Timestamp invalid or clock skew beyond ±300 sSync with NTP; regenerate per request
40110009002Invalid tokenCheck the app_secret; update it after a rotation
40110009003Signature mismatchSee Troubleshooting
40310009004Application disabledContact the platform
40310009015Application not effective (pending approval or rejected)Wait for approval / contact the platform
40310009014Integrator account disabledContact the platform
40310009005API not subscribedRequest a subscription for the endpoint
41010009034Endpoint retiredSwitch to the replacement named in message; see Deprecated Endpoints
42910009006Rate limit exceededRetry with exponential backoff (start at 1 s, double up to 30 s, add jitter)

401 and 403 are configuration errors — retrying without a fix is pointless and may trip rate limits. The business error shapes returned by the endpoints themselves are described in General Conventions.

Verify signing with echo

POST /openapi/demo/echo is the recommended first call of every integration: it validates the whole signing pipeline and returns the identity of the calling application. Unlike the standard endpoints, echo responds with the platform envelope.

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" } }

The platform also provides a sign helper during integration: give it the target path and the exact request body and it returns the token / timestamp / sign the server expects plus a ready-to-run curl command. Cross-check once for a POST with a body and once for a GET with an empty body before moving on.

Reference implementations

curl (bash) — POST with a JSON body

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 with an empty body

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

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 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#

var raw = appSecret + path + (body ?? "").Replace("\r", "").Replace("\n", "") + timestamp; var sign = Convert.ToHexString(MD5.HashData(Encoding.UTF8.GetBytes(raw))).ToLowerInvariant();

Troubleshooting

Signature mismatch (401, code 10009003) — in order of frequency:

  1. On POST, the JSON that was signed differs from the bytes actually sent (serialized twice, field order or whitespace changed).
  2. CR/LF not stripped from the body before concatenation (typical with XML files).
  3. GET / DELETE / multipart did not use the empty string "" as the body ("null", an empty object or placeholder text was used instead).
  4. path missing the /openapi prefix or a path-variable segment (for the CPF lookup, both the CPF and the date of birth must be signed), or including the query string.
  5. sign sent in uppercase (must be lowercase hex).
  6. The timestamp used in concatenation differs from the header (regenerated between the two).
  7. Body bytes re-encoded (hash the exact UTF-8 bytes sent on the wire).

Recompute the fixed values in Signing examples first — once your local implementation matches, move on to inspecting the live request parameters.

Clock skew (401, code 10009001) — your server clock differs from ours by more than 300 seconds. Use NTP, and never cache or reuse timestamps across requests.

Last updated on