认证与签名
凭证模型、三请求头 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。 - 密钥只保存在服务端。不要嵌入移动应用、前端代码或公开仓库。
请求头
每个请求都必须携带三个请求头:
| 请求头 | 值 | 说明 |
|---|---|---|
token | app_secret | 应用调用凭证 |
timestamp | Unix 秒级时间戳 | 与服务器时钟误差须在 ±300 秒内(防重放) |
sign | 请求签名 | 算法见下;32 位十六进制,小写 |
签名算法
text
sign = MD5( token + path + body + timestamp ) -> lowercase hex
拼接规则(顺序固定,纯字符串拼接,无分隔符):
| 要素 | 规则 |
|---|---|
token | app_secret 原文 |
path | 请求路径,含 /openapi 前缀、不含查询串,不含协议与域名。路径变量(empresaId、nfeId、cteId、dceId、chave、cnpj、cpf、nascimento)属于路径的一部分,参与签名 |
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"、"{}"或任何占位符。 forceRevalidate、Language等可选请求头不参与签名。
签名示例
以下为固定取值,可自行复算以在真实调用前校对你的实现。
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" }
| HTTP | code | 含义 | 处置 |
|---|---|---|---|
| 401 | 10009000 | 缺少签名请求头(token / sign / timestamp) | 每次请求带全三个头 |
| 401 | 10009001 | 时间戳非法或时钟偏差超 ±300 秒 | NTP 校准;每次请求重新生成 |
| 401 | 10009002 | token 无效 | 核对 app_secret;轮换后更新配置 |
| 401 | 10009003 | 签名不匹配 | 见排障 |
| 403 | 10009004 | 应用已停用 | 联系平台 |
| 403 | 10009015 | 应用未生效(待审批或已驳回) | 等待审批 / 联系平台 |
| 403 | 10009014 | 集成商账户已停用 | 联系平台 |
| 403 | 10009005 | 接口未订阅 | 为所调端点申请订阅 |
| 429 | 10009006 | 超出限流额度 | 指数退避重试(初始 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 + 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
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 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#
csharp
var raw = appSecret + path + (body ?? "").Replace("\r", "").Replace("\n", "") + timestamp;var sign = Convert.ToHexString(MD5.HashData(Encoding.UTF8.GetBytes(raw))).ToLowerInvariant();
排障
签名不匹配(401,错误码 10009003)?
按出现频率依次检查:
- POST 时参与签名的 JSON 与实际发送的字节不一致(二次序列化、字段顺序或空格变化)。
- GET / DELETE / multipart 没把 body 当作空字符串
""拼接(用了"null"、空对象或占位文本)。 path缺/openapi前缀或漏了路径变量段(CPF 查询时 CPF 与出生日期都要参与签名),或者带上了查询串。sign用了大写(须为十六进制小写)。timestamp拼接值与请求头不是同一个(两者之间重新生成过)。- 拼接前没有去除 body 中的 CR/LF(XML 文件尤其常见)。
- 请求体字节被重新编码(须对实际发送的 UTF-8 字节做哈希)。
先复算签名示例中的固定取值;本地实现对上之后,再检查真实请求的参数。
时钟偏差(401,错误码 10009001)?
你的服务器时钟与平台相差超过 300 秒。请使用 NTP,且不要缓存或跨请求复用时间戳。联调回显端点返回的 serverTime 即平台时钟。
