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
| Credential | Purpose |
|---|---|
| App Key | Public identifier of the application (shown in the console) |
| App Secret | Calling 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:
| Header | Value | Notes |
|---|---|---|
token | app_secret | Application credential |
timestamp | Unix timestamp in seconds | Must be within ±300 seconds of server time (replay protection) |
sign | Request signature | Algorithm below; 32 hex characters, lowercase |
Signature algorithm
sign = MD5( token + path + body + timestamp ) → lowercase hexConcatenation rules (fixed order, plain string concatenation, no separators):
| Element | Rule |
|---|---|
token | The app_secret, verbatim |
path | Request 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 |
body | Raw 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 |
timestamp | The 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 status | Meaning | Body shape |
|---|---|---|
| 200 | Request accepted by the endpoint; inspect the response body for the business result | Endpoint-specific |
| 400 / 404 | Business or request-level error raised by the endpoint | Endpoint-specific error shape |
| 401 | Authentication failed: missing headers, invalid timestamp, unknown token or signature mismatch | Platform envelope |
| 403 | Authorization failed: application disabled or not effective, integrator disabled, or endpoint not subscribed | Platform envelope |
| 410 | Endpoint retired; the message names the replacement | Platform envelope |
| 429 | Rate limit exceeded | Platform 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" }| HTTP | code | Meaning | Action |
|---|---|---|---|
| 401 | 10009000 | Missing signature headers (token / sign / timestamp) | Send all three headers on every request |
| 401 | 10009001 | Timestamp invalid or clock skew beyond ±300 s | Sync with NTP; regenerate per request |
| 401 | 10009002 | Invalid token | Check the app_secret; update it after a rotation |
| 401 | 10009003 | Signature mismatch | See Troubleshooting |
| 403 | 10009004 | Application disabled | Contact the platform |
| 403 | 10009015 | Application not effective (pending approval or rejected) | Wait for approval / contact the platform |
| 403 | 10009014 | Integrator account disabled | Contact the platform |
| 403 | 10009005 | API not subscribed | Request a subscription for the endpoint |
| 410 | 10009034 | Endpoint retired | Switch to the replacement named in message; see Deprecated Endpoints |
| 429 | 10009006 | Rate limit exceeded | Retry 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:
- On POST, the JSON that was signed differs from the bytes actually sent (serialized twice, field order or whitespace changed).
- CR/LF not stripped from the body before concatenation (typical with XML files).
- GET / DELETE / multipart did not use the empty string
""as the body ("null", an empty object or placeholder text was used instead). pathmissing the/openapiprefix 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.signsent in uppercase (must be lowercase hex).- The
timestampused in concatenation differs from the header (regenerated between the two). - 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.