TF Fiscal
开发文档

认证与签名

凭证模型、三请求头 MD5 签名规范、已知签名向量、网关错误码,以及 curl、Java、Node.js、Python、C# 参考实现。

TF Fiscal 开放 API(/openapi/**)的每个调用都按请求逐一鉴权,采用 MD5 签名方案。没有会话,也没有 OAuth 令牌交换:每个请求都用你的应用密钥(app_secret)独立签名。

凭证模型

凭证用途
App Key应用的公开标识(由联调回显端点返回)
App Secret调用凭证;作为 token 请求头发送,并用于计算 sign
  • App Secret 仅回显一次,在应用创建或密钥轮换时显示,之后无法再次获取。
  • 轮换:密钥泄露时请申请轮换。旧密钥立即失效,请先与运营约定切换窗口再轮换。
  • 订阅:平台为你的应用开通所需端点的订阅。未订阅的端点即使签名正确也返回 HTTP 403 与错误码 10009005
  • 密钥只保存在服务端。不要嵌入移动应用、前端代码或公开仓库。

请求头

每个请求都必须携带三个请求头:

请求头说明
tokenapp_secret应用调用凭证
timestampUnix 秒级时间戳与服务器时钟误差须在 ±300 秒内(防重放)
sign请求签名算法见下;32 位十六进制,小写

签名算法

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

拼接规则(顺序固定,纯字符串拼接,无分隔符):

要素规则
tokenapp_secret 原文
path请求路径,/openapi 前缀不含查询串,不含协议与域名。路径变量(empresaIdnfeIdcteIddceIdchavecnpjcpfnascimento)属于路径的一部分,参与签名
body请求体原文去除全部 CR(\r)与 LF(\n无请求体的 GET 与 DELETE 取空字符串;multipart(证书上传)取空字符串
timestamp与请求头 timestamp 完全相同的字符串

请求体规则详述:

  • JSON 请求体:发送的字节必须与参与签名的字符串逐字节相同。先序列化一次,再用同一份字符串既签名又作为请求体发送,不要二次序列化。带请求体的 DELETE(CT-e 与 DC-e 带原因的取消)同样适用。
  • XML 请求体(XML 验证端点):XML 去除全部 CR 与 LF 后参与签名,而请求体本身原样发送。带换行缩进的 XML 文件因此按单行签名。
  • 无请求体(GET、DELETE)与 multipart:拼接空字符串 "",而不是 "null""{}" 或任何占位符。
  • forceRevalidateLanguage 等可选请求头参与签名。

签名示例

以下为固定取值,可自行复算以在真实调用前校对你的实现。

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"

XML 验证与 chave 验证端点适用同样的规则:

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)

HTTP 状态语义

HTTP 状态含义响应形状
200端点已受理请求;业务结果看响应体端点自定义
400 / 404端点产生的业务或请求级错误端点自定义的错误形状
401鉴权失败:缺少请求头、时间戳非法、token 未知或签名不匹配平台信封
403授权失败:应用停用或未生效、集成商停用、端点未订阅平台信封
429超出限流额度平台信封

鉴权层错误由平台网关在请求到达端点之前产出,恒为平台信封形状:

json
{ "success": false, "errorType": 1, "code": 10009003, "message": "Signature error" }
HTTPcode含义处置
40110009000缺少签名请求头(token / sign / timestamp每次请求带全三个头
40110009001时间戳非法或时钟偏差超 ±300 秒NTP 校准;每次请求重新生成
40110009002token 无效核对 app_secret;轮换后更新配置
40110009003签名不匹配排障
40310009004应用已停用联系平台
40310009015应用未生效(待审批或已驳回)等待审批 / 联系平台
40310009014集成商账户已停用联系平台
40310009005接口未订阅为所调端点申请订阅
42910009006超出限流额度指数退避重试(初始 1 秒,倍增至 30 秒上限,加抖动)

401 与 403 属配置错误:不修复就重试没有意义,还可能触发限流。端点自身返回的业务错误形状见通用约定

用 echo 验证签名

POST /openapi/demo/echo 是每个集成推荐的第一个调用:它验证完整的签名链路,并返回调用方应用身份。与标准端点不同,echo 以平台信封响应。继续之前,先用带请求体的 POST 对一次,再用空请求体签名的 GET 对一次。请求、响应与错误参考:联调回显

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 是服务器的 UTC 时钟,可与本机时钟对照以排除时间戳偏差。

参考实现

curl(bash),带 JSON 请求体的 POST

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

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();

排障

签名不匹配(401,错误码 10009003)?

按出现频率依次检查:

  1. POST 时参与签名的 JSON 与实际发送的字节不一致(二次序列化、字段顺序或空格变化)。
  2. GET / DELETE / multipart 没把 body 当作空字符串 "" 拼接(用了 "null"、空对象或占位文本)。
  3. path/openapi 前缀或漏了路径变量段(CPF 查询时 CPF 与出生日期要参与签名),或者带上了查询串。
  4. sign 用了大写(须为十六进制小写)。
  5. timestamp 拼接值与请求头不是同一个(两者之间重新生成过)。
  6. 拼接前没有去除 body 中的 CR/LF(XML 文件尤其常见)。
  7. 请求体字节被重新编码(须对实际发送的 UTF-8 字节做哈希)。

先复算签名示例中的固定取值;本地实现对上之后,再检查真实请求的参数。

时钟偏差(401,错误码 10009001)?

你的服务器时钟与平台相差超过 300 秒。请使用 NTP,且不要缓存或跨请求复用时间戳。联调回显端点返回的 serverTime 即平台时钟。