NF-e 验证
第三方 NF-e 的两级验证:XML 验证、chave 查询、经 webhook 推送的最终结论、幂等策略与错误模型。
能力概述
平台对第三方 NF-e(模型 55)执行两级验证:
| 级 | 内容 | 时机 |
|---|---|---|
| 一级(同步) | XML 本地校验:语法、NF-e 4.00 XSD 版式、数字签名与证书归属、44 位访问密钥(chave)结构及与票面一致性、授权协议自洽 | 提交请求即返回 |
| 二级(异步) | 向 SEFAZ(州税务局)实时核验真伪:授权状态、取消事件、协议号与摘要与官方记录比对 | 一级通过后执行;结论经 webhook 推送(见 接收最终结论) |
另提供独立的 chave 查询:按访问密钥查询发票票面数据,纯查询接口,不含验证结论。
两个端点都需要三个签名请求头,见 认证与签名。响应为裸结构(不带平台信封);请求级错误为裸 {code, message} 对象。
验证生命周期
提交 XML ──► 一级校验├─ 阻断错误 ─────────────────► REJECTED (终态)└─ 通过 ──► PENDING_SEFAZ ──► VALIDATING(二级)├──► VALIDATED (终态)├──► REJECTED (终态,带 reason)└──► VALIDATION_ERROR(可重试,带 reason)
| validationStatus | 含义 |
|---|---|
VALIDATED | SEFAZ 确认发票真实有效(已授权、无取消事件、协议一致) |
REJECTED | 验证不通过:一级阻断错误,或 SEFAZ 判定已取消 / 否决 / 作废 / 查无此票 / 协议不符,附 reason |
VALIDATION_ERROR | 验证未能完成(SEFAZ 限流或查询重试耗尽)。并非对发票本身的判定,可稍后重新提交 |
对接前提(一次性)
- 应用凭证:申请应用并领取
app_secret。仅回显一次,请妥善保存;如泄漏可申请轮换。 - 接口订阅:平台为你的应用开通
POST /openapi/v3/consultas/nf-e/xml与GET /openapi/v3/consultas/nf-e/{chave}。 - 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} | 按访问密钥纯查询;返回票面数据,不含验证结论 |
接收最终结论
二级 SEFAZ 核验落定后,平台向已注册的 webhook URL 发起 POST。这是最终结论的唯一推送通道;chave 查询可作轮询兜底。
请求头
| 请求头 | 说明 |
|---|---|
X-Tffiscal-Event | invoice.verify.completed |
X-Tffiscal-Event-Id | 事件 id,即幂等键:重试间不变,按此去重 |
X-Tffiscal-Delivery-Id | 投递 id,每次尝试唯一 |
X-Tffiscal-Timestamp | Unix 秒,每次尝试重新生成 |
X-Tffiscal-Signature | hex( HMAC-SHA256( secret, timestamp + "." + body ) ) 小写;secret = 你的 app_secret |
载荷
{"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"}}
信封字段:
| 字段 | 类型 | 出现性 | 说明 |
|---|---|---|---|
| version | string | 恒有 | 载荷 schema 版本,当前 1.0 |
| event_id | string | 恒有 | 事件 id,幂等键,重试间不变 |
| event_type | string | 恒有 | invoice.verify.completed |
| occurred_at | string | 恒有 | 事件时间,ISO-8601 UTC |
| data | object | 恒有 | 结论主体,见下 |
data 字段:
| 字段 | 类型 | 出现性 | 说明 |
|---|---|---|---|
| chaveAcesso | string | 恒有 | 被验证发票的 44 位访问密钥,回关联你提交记录的键 |
| validationStatus | string | 恒有 | 最终结论:VALIDATED / REJECTED / VALIDATION_ERROR(见 验证生命周期) |
| status | string | 恒有 | SEFAZ 税务状态:Autorizada / Cancelada / Denegada / Inutilizada / NaoEncontrada / Desconhecida |
| cStat | string | null | 可空 | SEFAZ 原始返回码(如 100 = 已授权、101 = 已取消);未触达 SEFAZ 时为 null |
| xMotivo | string | null | 可空 | SEFAZ 原始返回说明(葡语原文) |
| protocolo | object | null | 可空 | Protocol 对象(numero、digestValue),结构同 XML 验证 响应;取自官方记录 |
| dataAutorizacao | string | null | 可空 | SEFAZ 授权时间,ISO-8601 UTC |
| eventos[] | array | 恒有(可为空) | 该发票已登记的税务事件(取消、更正函);无则为空数组 |
| verifiedAt | string | 恒有 | 二级核验完成时间,ISO-8601 UTC |
| reason | string | 仅失败时 | 仅 REJECTED / VALIDATION_ERROR 出现;解释结论的英文稳定文案 |
注意: webhook 载荷只含语言无关的枚举值,无
*Description字段。展示文案由接收方自理。
接收端要求
- 验签:复算
HMAC-SHA256(secret, timestamp + "." + 原始body)与签名头比对。必须用收到的原始字节,不要先反序列化再重序列化(字段顺序变化会破坏签名)。 - 10 秒内返回 2xx。 其他情况(含超时)计为投递失败。
- 失败按 1m / 5m / 30m / 2h / 6h 退避重试 5 次,仍失败进死信队列(可联系平台人工重推)。
- 按
event_id幂等去重(重试与多目标扇出共用同一 event_id)。
结论处置
| validationStatus | 终态? | 处置 |
|---|---|---|
VALIDATED | 是 | 可放行(发货、结算等) |
REJECTED | 是 | 不可放行;reason 说明 SEFAZ 判定(已取消 / 否决 / 作废 / 查无 / 协议不符) |
VALIDATION_ERROR | 否 | 平台侧核验失败,非发票判定;稍后带 forceRevalidate: true 重新提交 |
幂等与重验
- XML 验证以 chave 为幂等键:验证进行中重复提交返回当前进度;终态结论 24 小时内复用(不重复消耗核验资源)。
- 强制重验:在 XML 验证 端点加请求头
forceRevalidate: true(不参与签名)。chave 查询为固定 GET 形状,无重验通道;需强制重验请走 XML 端点重新提交。 - 一级阻断的
REJECTED无二级对象,重验须重新提交 XML。
错误模型
两种错误形状
业务与请求级错误(HTTP 400)为裸对象:
{ "code": 10015004, "message": "查无此票" }
| 字段 | 类型 | 说明 |
|---|---|---|
| code | integer | 平台错误码 |
| message | string | 人类可读说明,本地化(见 响应语言) |
鉴权层错误(HTTP 401 / 403 / 429)由平台网关在请求到达接口前产出,为平台信封形状:
{ "success": false, "errorType": 1, "code": 10009003, "message": "签名错误" }
| 字段 | 类型 | 说明 |
|---|---|---|
| success | boolean | 所有错误恒为 false |
| errorType | integer | 错误分类:1 API 错误、2 SEFAZ 驳回、3 系统异常、4 字段校验失败 |
| code | integer | 平台错误码 |
| message | string | 人类可读说明,本地化 |
| data | object | null | 错误时不填充 |
错误处理须兼容两种形状。稳妥做法:把响应体按 JSON 解析,从任一形状读取 code,以 success=false 或 HTTP >= 400 判定失败。
鉴权与授权错误
| 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 | 超出限流额度 | 退避重试;平滑调用频率。限流按应用与接口组两级实施 |
重试指引: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) | 平台信封 |
| 403 | 应用停用 / 未生效 / 集成商停用 / 未订阅 | 平台信封 |
| 429 | 超出限流额度 | 平台信封 |
| 5xx | 平台侧故障 | 退避重试;持续失败请携带失败请求的 timestamp 与路径联系平台 |
响应语言
message 与 *Description 字段随请求语言返回:Language 头(zh / en / pt / es)优先,其次 Accept-Language(支持 pt-BR 与 q 值)。未提供语言头时默认葡语(pt)。
程序判断一律使用语言无关的枚举字段(validationStatus、status、errors[].code),永远不要匹配描述文案。
联调清单
- 领取
app_secret;用 sign-helper 与本地签名实现对拍(POST 带 XML body 与 GET 空 body 各一次)。 POST /openapi/v3/consultas/nf-e/xml提交一张真实授权 nfeProc:HTTP 200,五项校验全 true,validationStatus=PENDING_SEFAZ。- 接收
invoice.verify.completedwebhook:验签通过、按event_id去重、拿到VALIDATED。 - chave 查询:查已提交过的 chave 得到 200 票面数据;查不存在的 chave 得到 400 + 10015004;校验位错误的 chave 得到 400 + 10015104。
- 逆向用例:提交内容被篡改的 XML(
SIGNATURE_INVALID);提交无 protNFe 的 XML(仅 warning,照常受理)。 - 幂等验证:同 chave 重复提交得到复用结论;带
forceRevalidate: true触发重验,新结论经 webhook 推送。 - 失败路径:故意用错误
sign调用确认 401(code 10009003);调用未订阅端点确认 403(code 10009005)。
排障
签名始终对不上(401,code 10009003)?
按出现频率依次检查:
- body 拼接前未去除 CR/LF;
- GET 请求把 body 拼成了
"null"(应为空字符串); path缺/openapi前缀,或带上了查询串;sign用了大写(必须小写十六进制);- 拼接用的
timestamp与请求头不是同一个字符串(两次生成); - body 字节被重新编码(必须对线上实际发送的字节做哈希,UTF-8)。
HTTP 200 但发票是假的?
一级只判断 XML 本身是否自洽。真伪由二级对 SEFAZ 核验裁定并经 webhook 推送;业务放行动作(发货、结算)应挂在 webhook 的 VALIDATED 上,绝不能只看同步响应。
webhook 一直验签失败?
最常见原因是先反序列化载荷再重新序列化后参与 HMAC 计算,字段顺序或空白变了。必须对收到的原始字节做哈希。
VALIDATION_ERROR 是发票有问题吗?
不是。它表示平台核验通道故障(如 SEFAZ 限流),发票本身未被判定。稍后带 forceRevalidate: true 重新提交即可。
时钟偏差(401,code 10009001)?
你的服务器时钟与平台相差超过 300 秒。请用 NTP。永远不要跨请求缓存或复用时间戳。
