TF Fiscal
开发文档

NF-e 验证

第三方 NF-e 的两级验证:XML 验证、chave 查询、经 webhook 推送的最终结论、幂等策略与错误模型。

能力概述

平台对第三方 NF-e(模型 55)执行两级验证:

级内容时机
一级(同步)XML 本地校验:语法、NF-e 4.00 XSD 版式、数字签名与证书归属、44 位访问密钥(chave)结构及与票面一致性、授权协议自洽提交请求即返回
二级(异步)向 SEFAZ(州税务局)实时核验真伪:授权状态、取消事件、协议号与摘要与官方记录比对一级通过后执行;结论经 webhook 推送(见 接收最终结论),chave 查询可读到当前状态作轮询兜底

另提供独立的 chave 查询:按访问密钥查询发票票面数据;对你经 XML 验证提交过的 chave,同时返回当前的 validation 块,webhook 未送达时可用它轮询结论。

两个端点都需要三个签名请求头,见 认证与签名。响应为无包装结构(不带平台统一响应体);请求级错误为无包装的 {code, message} 对象。

验证生命周期

text
提交 XML ──► 一级校验
├─ 阻断错误 ─────────────────► REJECTED (终态)
└─ 通过 ──► PENDING_SEFAZ ──► VALIDATING(二级)
├──► VALIDATED (终态)
├──► REJECTED (终态,带 reason)
└──► VALIDATION_ERROR(可重试,带 reason)
validationStatus含义
VALIDATEDSEFAZ 确认发票真实有效(已授权、无取消事件、协议一致)
REJECTED验证不通过:一级阻断错误,或 SEFAZ 判定已取消 / 否决 / 作废 / 查无此票 / 协议不符,附 reason
VALIDATION_ERROR验证未能完成(SEFAZ 限流或查询重试耗尽)。并非对发票本身的判定,可稍后带 forceRevalidate: true 重新提交

对接前提(一次性)

  1. 应用凭证:申请应用并领取 app_secret。仅回显一次,请妥善保存;如泄漏可申请轮换。
  2. 接口订阅:平台为你的应用开通 POST /openapi/v3/consultas/nf-e/xml 与 GET /openapi/v3/consultas/nf-e/{chave}。
  3. webhook 接收地址:通过 注册 Webhook 登记回调 URL,并订阅事件 invoice.verify.completed(二级结论的唯一推送通道)。保存地址时平台会立即发送一条 event_type=webhook.verify 的测试推送,接收端须返回 2xx 才能保存成功(该测试事件直接回 200 即可,无需处理)。

端点

端点用途
POST /openapi/v3/consultas/nf-e/xml提交 NF-e XML 原文;返回一级结论与票面数据,并启动二级
GET /openapi/v3/consultas/nf-e/{chave}按访问密钥查询;返回票面数据,提交过验证的 chave 另附当前的 validation 块

接收最终结论

二级 SEFAZ 核验落定后,平台向已注册的 webhook URL 发起 POST。这是最终结论的唯一推送通道;推送未送达时,chave 查询 返回当前的 validationStatus,可作轮询兜底。

请求头

请求头说明
X-Tffiscal-Eventinvoice.verify.completed
X-Tffiscal-Event-Id事件 id,即幂等键:重试间不变,按此去重
X-Tffiscal-Delivery-Id投递 id,每个目标地址一个,对该目标的重试间不变(去重请用事件 id,不要用它)
X-Tffiscal-TimestampUnix 秒,每次尝试重新生成
X-Tffiscal-Signaturehex( HMAC-SHA256( secret, timestamp + "." + body ) ) 小写;应用级 webhook 地址的 secret 即你的 app_secret(回落到租户级地址时,用该地址配置的 secret)

载荷

json
{
"version": "1.0",
"event_id": "1950000000000001",
"event_type": "invoice.verify.completed",
"occurred_at": "2026-07-23T17:16:23Z",
"data": {
"chaveAcesso": "35260764962869000108550990001366171195929648",
"validationStatus": "VALIDATED",
"status": "Autorizada",
"cStat": "100",
"xMotivo": "Autorizado o uso da NF-e",
"protocolo": { "numero": "135262955451772", "digestValue": "oAEEuC3tGmb2W7ZJxWkNVWuBHwY=" },
"dataAutorizacao": "2026-07-23T14:30:09Z",
"eventos": [],
"verifiedAt": "2026-07-23T17:16:23Z"
}
}

统一事件体字段:

字段类型出现性说明
versionstring恒有载荷 schema 版本,当前 1.0
event_idstring恒有事件 id,幂等键,重试间不变
event_typestring恒有invoice.verify.completed
occurred_atstring恒有事件时间,ISO-8601 UTC
dataobject恒有结论主体,见下

data 字段:

字段类型出现性说明
chaveAcessostring恒有被验证发票的 44 位访问密钥,回关联你提交记录的键
validationStatusstring恒有最终结论:VALIDATED / REJECTED / VALIDATION_ERROR(见 验证生命周期)
statusstring | null可空SEFAZ 税务状态:Autorizada / Cancelada / Denegada / Inutilizada / NaoEncontrada / Desconhecida;VALIDATION_ERROR 时为 null(未拿到 SEFAZ 结论)
cStatstring | null可空SEFAZ 原始返回码(如 100 = 已授权、101 = 已取消);未触达 SEFAZ 时为 null
xMotivostring | null可空SEFAZ 原始返回说明(葡语原文)
protocoloobject | null可空Protocol 对象(numero、digestValue),结构同 XML 验证 响应;取自官方记录
dataAutorizacaostring | null可空SEFAZ 授权时间,ISO-8601 UTC
eventos[]array恒有(可为空)该发票已登记的税务事件(取消、更正函);无则为空数组
verifiedAtstring恒有二级核验完成时间,ISO-8601 UTC
reasonstring仅失败时仅 REJECTED / VALIDATION_ERROR 出现;解释结论的英文稳定文案

注意: webhook 载荷只含语言无关的枚举值,无 *Description 字段。展示文案由接收方自理。

接收端要求

  1. 验签:复算 HMAC-SHA256(secret, timestamp + "." + 原始body) 与签名头比对。必须用收到的原始字节,不要先反序列化再重序列化(字段顺序变化会破坏签名)。
  2. 10 秒内返回 2xx。 其他情况(含超时)计为投递失败。
  3. 失败按 1m / 5m / 30m / 2h / 6h 退避重试 5 次,仍失败可联系平台人工重推。
  4. 按 event_id 幂等去重(重试与多目标扇出共用同一 event_id)。
  5. 同一个 chave 可能先后产生多条事件:带 forceRevalidate: true 的重验、超过 24 小时后的重复提交、chave 查询触发的后台复核,每一次都会以新的 event_id 推送一条新事件。请以 occurred_at 最新的一条为准。

结论处置

validationStatus终态?处置
VALIDATED是可放行(发货、结算等)
REJECTED是不可放行;reason 说明 SEFAZ 判定(已取消 / 否决 / 作废 / 查无 / 协议不符)
VALIDATION_ERROR是,可重试平台侧核验失败,非发票判定。与其他结论一样 24 小时内复用,请稍后带 forceRevalidate: true 重新提交

幂等与重验

  • XML 验证以 chave(提交的 XML 里的访问密钥)为幂等键:
    • 二级进行中(PENDING_SEFAZ / VALIDATING)重复提交,返回当前进度,不会再发起一次核验。此时带 forceRevalidate: true 也不会让进行中的核验重新开始或提前完成。
    • 终态结论(VALIDATED / REJECTED / VALIDATION_ERROR)自得出之时起 24 小时内复用:窗口内重复提交直接返回该结论,不再访问 SEFAZ。超过 24 小时后,普通的重复提交会重新完整验证一遍(一级 + 二级)。
    • 复用的响应反映的是当初提交的那份 XML(票面解析数据、一级检查项、errors 与 warnings),validationStatus 则是当前值。窗口内为同一 chave 提交修正后的 XML,请带 forceRevalidate: true,否则拿到的仍是上一次的结果。
  • 强制重验:在 XML 验证 端点加请求头 forceRevalidate: true(不参与签名),跳过复用、重新执行一级和二级,新结论作为一条新事件经 webhook 推送。
  • chave 查询:无法强制复核;只在最近一次 SEFAZ 答复超过 24 小时时自动安排复核(见 chave 查询)。
  • 一级阻断的 REJECTED 无二级对象,重验须重新提交 XML(24 小时窗口内需带 forceRevalidate: true)。
  • 验证记录归属于你的集成商账户:复用、chave 查询与 webhook 只看本账户的提交记录。其他集成商验证同一张票互不影响,各自独立核验、各自出结论。

错误模型

两种错误格式

业务与请求级错误(HTTP 400)为无包装的 JSON 对象:

json
{ "code": 10015004, "message": "未查询到该发票:35260764962869000108550990001366171195929648" }
字段类型说明
codeinteger平台错误码
messagestring人类可读说明,本地化(见 响应语言)

鉴权层错误(HTTP 401 / 403 / 429)由平台网关在请求到达接口前产出,为平台统一响应体格式:

json
{ "success": false, "errorType": 1, "code": 10009003, "message": "签名错误" }
字段类型说明
successboolean所有错误恒为 false
errorTypeinteger错误分类:1 API 错误、2 SEFAZ 驳回、3 系统异常、4 字段校验失败
codeinteger平台错误码
messagestring人类可读说明,本地化
dataobject | null错误时不填充

错误处理须兼容两种格式。稳妥做法:把响应体按 JSON 解析,从任一格式读取 code,以 success=false 或 HTTP >= 400 判定失败。

鉴权与授权错误

HTTP错误码含义处置
40110009000缺少签名请求头(token / sign / timestamp)修客户端:每次请求带全三个头
40110009001时间戳非法或时钟偏差超 ±300 秒校准时钟(NTP);每次请求重新生成时间戳,勿复用
40110009002token 无效核对 app_secret;如已轮换请更新配置
40110009003签名不匹配重新推导签名;见 排障 的清单
40310009004应用已停用联系平台
40310009015应用未生效(待审批或已驳回)等待审批 / 联系平台
40310009014集成商账户已停用联系平台
40310009005接口未订阅为所调端点申请订阅
42910009006超出限流额度退避重试;平滑调用频率。限流按应用与接口组两级实施
41310009027请求体超过网关大小上限XML 验证最多接受 1 MB;确认每次只发送一张 NF-e

重试指引:401 与 403 属配置错误,不修复就重试没有意义,还可能触发限流。429 可重试,用指数退避(初始 1 秒,倍增至 30 秒上限,加抖动)。

请求级与校验错误

详见各端点页:

  • XML 验证:请求级错误 10015000 到 10015003(HTTP 400,无包装格式,请求未进入校验流程),以及一级校验错误目录 XML_MALFORMED 到 XML_VERSION_UNSUPPORTED(HTTP 200,位于 validation.errors[])。
  • chave 查询:10015104(chave 非法)与 10015004(查无此票),HTTP 400,无包装格式。
  • 二级结论不是 HTTP 错误,见 结论处置。

HTTP 状态速查

HTTP场景响应格式
200请求受理成功,含校验不通过(看 validation 块)业务数据(无包装)
400请求本身非法(空体 / 超限 / DTD / 编码 / chave 非法 / 查无此票){code, message} 对象
401鉴权失败(token / sign / timestamp)平台统一响应体
413请求体超过网关大小上限平台统一响应体
403应用停用 / 未生效 / 集成商停用 / 未订阅平台统一响应体
429超出限流额度平台统一响应体
5xx平台侧故障退避重试;持续失败请携带失败请求的 timestamp 与路径联系平台

响应语言

message 与 *Description 字段随请求语言返回:Language 头(zh / en / pt / es)优先,其次 Accept-Language(支持 pt-BR 与 q 值)。未提供语言头时默认葡语(pt)。

程序判断一律使用语言无关的枚举字段(validationStatus、status、errors[].code),永远不要匹配描述文案。

联调清单

  1. 领取 app_secret;用 sign-helper 与本地签名实现对拍(POST 带 XML body 与 GET 空 body 各一次)。
  2. POST /openapi/v3/consultas/nf-e/xml 提交一张真实授权 nfeProc:HTTP 200,五项校验全 true,validationStatus=PENDING_SEFAZ。
  3. 接收 invoice.verify.completed webhook:验签通过、按 event_id 去重、拿到 VALIDATED。
  4. chave 查询:查已提交过的 chave 得到 200 票面数据与 validation 块(validationStatus 与 webhook 结论一致);查不存在的 chave 得到 400 + 10015004;校验位错误的 chave 得到 400 + 10015104。
  5. 逆向用例:提交内容被篡改的 XML(SIGNATURE_INVALID);提交无 protNFe 的 XML(仅 warning,照常受理)。
  6. 幂等验证:同 chave 重复提交得到复用结论;带 forceRevalidate: true 触发重验,新结论经 webhook 推送。
  7. 失败路径:故意用错误 sign 调用确认 401(code 10009003);调用未订阅端点确认 403(code 10009005)。

排障

签名始终对不上(401,code 10009003)?

按出现频率依次检查:

  1. body 拼接前未去除 CR/LF;
  2. GET 请求把 body 拼成了 "null"(应为空字符串);
  3. path 缺 /openapi 前缀,或带上了查询串;
  4. sign 用了大写(必须小写十六进制);
  5. 拼接用的 timestamp 与请求头不是同一个字符串(两次生成);
  6. body 字节被重新编码(必须对线上实际发送的字节做哈希,UTF-8)。

HTTP 200 但发票是假的?

一级只判断 XML 本身是否自洽。真伪由二级对 SEFAZ 核验裁定并经 webhook 推送;业务放行动作(发货、结算)应挂在 webhook 的 VALIDATED 上,绝不能只看同步响应。

同步响应里的 status 是 SEFAZ 的状态吗?

不是。首次响应的 status 取自你提交的 XML 自带的授权协议,此时还没有访问 SEFAZ。SEFAZ 核实过的状态随二级结论给出:webhook 的 status,或二级拿到答复后的 chave 查询。

REJECTED 且 status 为 NaoEncontrada,发票是假的吗?

不一定。这表示 SEFAZ 答复「查无此票」(cStat 217)。平台不会凭第一次 217 下结论:刚授权的发票需要一段时间才能在 SEFAZ 查到,平台会在几分钟内自动多次重查,仍是 217 才给出 REJECTED + NaoEncontrada。常见原因:

  1. 发票刚授权,SEFAZ 尚未同步完成:稍后带 forceRevalidate: true 重新验证;
  2. 发票是在 SEFAZ 测试环境开具的(ambienteEmissao=Homologacao):二级查询的是生产环境,生产环境里没有测试票;
  3. 该 chave 从未获得授权。

这与 chave 查询返回的 10015004 不同,后者表示平台的各数据源都不认识这个 chave。

webhook 一直验签失败?

最常见原因是先反序列化载荷再重新序列化后参与 HMAC 计算,字段顺序或空白变了。必须对收到的原始字节做哈希。

VALIDATION_ERROR 是发票有问题吗?

不是。它表示平台核验通道故障(如 SEFAZ 限流),发票本身未被判定。稍后带 forceRevalidate: true 重新提交即可。

时钟偏差(401,code 10009001)?

你的服务器时钟与平台相差超过 300 秒。请用 NTP。永远不要跨请求缓存或复用时间戳。