Compartilhando os aprendizados da nossa migração de SaaS de NFS-e para integração direta com o gov.br/nfse. Foco em erros que matamos no soco (XSD, pattern, ordem de elementos, timezone, instabilidade do PDF) e as soluções definitivas — pra quem vai começar agora não perder dias.
Cobre: emissão, consulta de status, cancelamento (eventos), exemplos completos de request/response, e a saga do PDF.
TL;DR
- mTLS com certificado A1, sem OAuth/token. Quem tem o
.pfxda empresa emite — fim. - Endpoint principal:
POST https://sefin.nfse.gov.br/SefinNacional/nfsecom body{ dpsXmlGZipB64: "<XML assinado, gzipado, base64>" }. - Resposta síncrona com
chaveAcesso(50 pos) enfseXmlGZipB64(a NFS-e autorizada já volta pronta). - Cancelamento:
POST /SefinNacional/nfse/{chave}/eventoscom XML do eventoe101101(gzip+base64) — também síncrono. - Consulta:
GET /SefinNacional/nfse/{chave}ouGET /SefinNacional/dps/{idDps}. - O XML é o documento juridicamente válido, o PDF é só renderização. Salve o XML, é a sua cópia legal.
- O endpoint de PDF (
/danfse/{chave}) é instável crônico (502/503). Use retry agressivo + ofereça XML como fallback.
Sumário
- Stack e dependências
- Endpoints e ambientes
- Autenticação mTLS
- Fluxo de emissão (passo a passo)
- Erros do XSD que vão te pegar
- Exemplos de request/response
- Consulta de status
- Cancelamento (evento e101101)
- O drama do PDF DANFSE
- Idempotência
- Gotchas adicionais
- Como descobrir os tipos certos do XSD
- Checklist pra produção
- Recursos úteis
Stack e dependências
{
"axios": "^1.7",
"fast-xml-parser": "^5.7",
"luxon": "^3.6",
"node-forge": "^1",
"xml-crypto": "^6"
}
Nenhuma biblioteca específica de NFS-e. O troco é o XSD oficial v1.01.
Endpoints e ambientes
| Grupo | Produção Restrita (homologação) | Produção |
|---|---|---|
| ADN | adn.producaorestrita.nfse.gov.br | adn.nfse.gov.br |
| SEFIN | sefin.producaorestrita.nfse.gov.br | sefin.nfse.gov.br |
Endpoints essenciais
# EMISSÃO (síncrono)
POST /SefinNacional/nfse
Body: { "dpsXmlGZipB64": "<base64>" }
201 → { tipoAmbiente, idDps, chaveAcesso, nfseXmlGZipB64, alertas }
400/403/500 → { erros: [{ Codigo, Descricao, Complemento }] }
# CONSULTA por chave de acesso
GET /SefinNacional/nfse/{chaveAcesso}
200 → { ...dados completos da NFS-e... }
# CONSULTA por idDps (idempotência em caso de timeout)
GET /SefinNacional/dps/{idDps}
200 → { chaveAcesso, ... }
# EVENTOS (cancelar/substituir/etc)
POST /SefinNacional/nfse/{chaveAcesso}/eventos
Body: { "pedidoRegistroEventoXmlGZipB64": "<base64>" }
201 → { eventoXmlGZipB64, ... }
# CONSULTAR EVENTO específico
GET /SefinNacional/nfse/{chaveAcesso}/eventos/{tpEvento}/{nSeq}
# LISTAR eventos da NFS-e (acessível ao contribuinte)
GET /contribuintes/NFSe/{chaveAcesso}/Eventos
# PDF DANFSE (instável — ver seção dedicada)
GET /danfse/{chaveAcesso}
# CADASTRO Nacional do Contribuinte (one-shot por empresa)
POST /cnc/CNC
# VERIFICAR se cidade é conveniada
GET /parametrizacao/{codMun}/convenio
# CONSULTAR alíquota oficial
GET /parametrizacao/{codMun}/{codServ}/{competencia}/aliquota
Specs OpenAPI completos
Acessíveis com mTLS:
/SefinNacional/swagger/docs/v1
/adn/swagger/v1/swagger.json
/cnc/swagger/v1/swagger.json
/danfse/swagger/v1/swagger.json
/parametrizacao/swagger/v1/swagger.json
/contribuintes/swagger/v1/swagger.json
Autenticação mTLS
Sem OAuth, sem token, sem credenciais separadas. A autenticação é mútua TLS com certificado A1 da empresa (cada empresa que vai emitir precisa do seu).
Validar localmente
# Extrair PEM do .pfx
openssl pkcs12 -in cert.pfx -clcerts -nokeys -passin "pass:$CERT_PASS" -out cert.pem
openssl pkcs12 -in cert.pfx -nocerts -nodes -passin "pass:$CERT_PASS" -out key.pem
# Testar conexão (deve retornar 200)
curl --cert cert.pem --key key.pem \
https://adn.producaorestrita.nfse.gov.br/docs/index.html
# Verificar se município é conveniado (BH = 3106200)
curl --cert cert.pem --key key.pem \
https://adn.producaorestrita.nfse.gov.br/parametrizacao/3106200/convenio
No Node.js
import https from "node:https";
import forge from "node-forge";
import fs from "node:fs";
const pfxBuffer = fs.readFileSync("cert.pfx");
const p12Asn1 = forge.asn1.fromDer(pfxBuffer.toString("binary"));
const p12 = forge.pkcs12.pkcs12FromAsn1(p12Asn1, false, process.env.CERT_PASS);
const certBag = p12.getBags({ bagType: forge.pki.oids.certBag })[forge.pki.oids.certBag][0];
const keyBag = p12.getBags({ bagType: forge.pki.oids.pkcs8ShroudedKeyBag })[forge.pki.oids.pkcs8ShroudedKeyBag][0];
const certPem = forge.pki.certificateToPem(certBag.cert);
const keyPem = forge.pki.privateKeyToPem(keyBag.key);
const httpsAgent = new https.Agent({
cert: certPem,
key: keyPem,
rejectUnauthorized: true,
});
Fluxo de emissão (passo a passo)
1. Carregar cert A1 → PEM
2. Montar XML do DPS conforme XSD v1.01
3. Assinar com XMLDSig SHA-256 (action: "after")
4. Comprimir gzip + base64
5. POST /SefinNacional/nfse via mTLS
6. Decodificar resposta:
- chaveAcesso (50 pos)
- nfseXmlGZipB64 → descomprimir → XML autorizado (juridicamente válido!)
7. Salvar XML autorizado no banco
Erros do XSD que vão te pegar
Tudo abaixo retorna RNG6110: Falha Schema Xml. Cada item custou ~1h de debug
com nota real em produção.
1. Id na tag raiz <DPS> é proibido
<!-- ❌ vai dar "The 'Id' attribute is not declared" -->
<DPS xmlns="..." versao="1.00" Id="DPS...">
<!-- ✅ -->
<DPS xmlns="..." versao="1.00">
<infDPS Id="DPS...">
TCDPS no XSD só tem o atributo versao. O Id vai SÓ no <infDPS>.
2. Pattern do Id (TSIdDPS): 45 chars exatos
DPS + cMun(7) + tpInsc(1) + CNPJ(14) + serie(5) + nDPS(15)
Pattern oficial: <xs:pattern value="DPS[0-9]{42}"/>
Cuidado: o nDPS dentro do Id deve bater exatamente com o conteúdo do
elemento <nDPS>. Não dá pra ter zero-padding diferente.
3. Convenção tpInsc é diferente da NFe
Na NFS-e Nacional (SPED):
1 = CPF
2 = CNPJ
Se você vier de NFe e usar 1=CNPJ, vai dar E0004: Conteúdo do identificador informado na DPS difere. Cuidado!
4. <nDPS> não pode começar com 0
XSD TSNumDPS: [1-9]{1}[0-9]{0,14}
Mas o Id precisa de 15 dígitos. Conflito? Resolva gerando um nDPS que naturalmente tenha 15 dígitos começando com [1-9]:
const deriveNumericNDPS = (referencia: string) => {
const hex = referencia.match(/[a-fA-F0-9]+/g)!.join("").slice(-12);
const reduced = (BigInt(`0x${hex}`) % BigInt("100000000000000"))
.toString(10)
.padStart(14, "0");
return `1${reduced}`; // sempre 15 dígitos, sempre começa com '1'
};
Bônus: é determinístico (mesmo input → mesmo nDPS), bom pra idempotência.
5. Ordem estrita dos elementos
XSD é <xs:sequence>, não <xs:all>. Você TEM que respeitar a ordem.
<toma> (TCInfoPessoa):
CNPJ|CPF|NIF|cNaoNIF → CAEPF? → IM? → xNome → end? → fone? → email?
<prest> (TCInfoPrestador):
CNPJ|CPF|... → CAEPF? → IM? → xNome? → end? → fone? → email? → regTrib (obrigatório)
Quase todo mundo coloca email antes de fone (alfabético). Errado.
6. <tribMun> não tem vBC/vISSQN/vLiq
Quem migra de Nuvem Fiscal/Focus/etc costuma trazer essa estrutura. Aqui não:
<!-- ✅ correto -->
<tribMun>
<tribISSQN>1</tribISSQN> <!-- 1=tributável -->
<tpRetISSQN>1</tpRetISSQN> <!-- 1=Não Retido -->
<pAliq>2.00</pAliq> <!-- opcional -->
</tribMun>
Os valores monetários vivem só em <vServPrest><vServ>.
7. <Signature> é IRMÃ de <infDPS>, não filha
XSD TCDPS:
<xs:sequence>
<xs:element name="infDPS" type="TCInfDPS"/>
<xs:element ref="ds:Signature" minOccurs="0"/>
</xs:sequence>
No xml-crypto:
sig.computeSignature(opts.xml, {
location: { reference: `//*[@Id='${idDPS}']`, action: "after" }, // ← "after", não "append"
});
append coloca a Signature dentro do <infDPS> → schema invalid.
8. dhEmi com timezone errado
// ❌ fica 3h adiantado (UTC + label "-03:00")
new Date().toISOString().replace(/\.\d+Z$/, "-03:00")
// ✅ converte de verdade
import { DateTime } from "luxon";
DateTime.now()
.setZone("America/Sao_Paulo")
.minus({ seconds: 5 }) // margem contra clock drift do servidor
.toFormat("yyyy-LL-dd'T'HH:mm:ssZZ")
Erro relacionado: E0008: A data de emissão da DPS não pode ser posterior à data do seu processamento.
9. <indTotTrib> x <pTotTribSN>
Para Simples Nacional (opSimpNac=3):
<!-- ❌ E0712: ME/EPP não pode usar indTotTrib -->
<totTrib><indTotTrib>0</indTotTrib></totTrib>
<!-- ✅ -->
<totTrib><pTotTribSN>6.00</pTotTribSN></totTrib>
pTotTribSN é a alíquota efetiva do Simples para o serviço.
10. Mensagens de erro vêm em PascalCase
{ "erros": [{ "Codigo": "...", "Descricao": "...", "Complemento": "..." }] }
Não confiar só em codigo/descricao — alguns endpoints retornam camelCase.
Normalize:
const norm = (m: any) => ({
codigo: m?.codigo ?? m?.Codigo,
descricao: m?.descricao ?? m?.Descricao,
complemento: m?.complemento ?? m?.Complemento,
});
11. vServ deve ser STRING, não Number
XSD TSDec15V2 exige pattern 0|0\.NN|D+(\.NN)?. Em JavaScript:
Number(61.20) // 61.2 ❌ (perde o zero — rejeitado)
Number(61.2).toFixed(2) // "61.20" ✅ (string com 2 casas — aceito)
fast-xml-parser serializa o que você passar. Sempre passe string formatada:
const f2 = (v: number): string => v.toFixed(2);
// no objeto pra serializar:
valores: { vServPrest: { vServ: f2(invoice.amount / 100) } }
Se passar Number, vai falhar com:
The 'vServ' element is invalid - The value '61.2' is invalid
according to its datatype 'TSDec15V2' - The Pattern constraint failed.
Exemplos de request/response
Emissão — REQUEST
XML do DPS antes da assinatura (resumido):
<?xml version="1.0" encoding="UTF-8"?>
<DPS xmlns="http://www.sped.fazenda.gov.br/nfse" versao="1.00">
<infDPS Id="DPS31062002505167240001600000110000000000001">
<tpAmb>1</tpAmb>
<dhEmi>2026-04-28T19:34:48-03:00</dhEmi>
<verAplic>1.00</verAplic>
<serie>00001</serie>
<nDPS>100000000000001</nDPS>
<dCompet>2026-04-28</dCompet>
<tpEmit>1</tpEmit>
<cLocEmi>3106200</cLocEmi>
<prest>
<CNPJ>50516724000160</CNPJ>
<IM>14701490012</IM>
<regTrib>
<opSimpNac>3</opSimpNac>
<regApTribSN>1</regApTribSN>
<regEspTrib>0</regEspTrib>
</regTrib>
</prest>
<toma>
<CNPJ>19678493000141</CNPJ>
<xNome>Cliente da Silva</xNome>
<fone>1132051030</fone>
<email>[email protected]</email>
</toma>
<serv>
<locPrest><cLocPrestacao>3106200</cLocPrestacao></locPrest>
<cServ>
<cTribNac>010501</cTribNac>
<cTribMun>001</cTribMun>
<xDescServ>Assinatura SaaS — Plataforma Esthetis</xDescServ>
<cNBS>115062100</cNBS>
</cServ>
</serv>
<valores>
<vServPrest><vServ>169.00</vServ></vServPrest>
<trib>
<tribMun>
<tribISSQN>1</tribISSQN>
<tpRetISSQN>1</tpRetISSQN>
</tribMun>
<totTrib>
<pTotTribSN>6.00</pTotTribSN>
</totTrib>
</trib>
</valores>
</infDPS>
</DPS>
Após xml-crypto assinar (action: "after"), a <Signature> vai ser irmã
do <infDPS>, dentro do <DPS>:
<DPS ...>
<infDPS Id="DPS...">...</infDPS>
<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
<SignedInfo>...</SignedInfo>
<SignatureValue>...</SignatureValue>
<KeyInfo><X509Data><X509Certificate>...</X509Certificate></X509Data></KeyInfo>
</Signature>
</DPS>
Comprime + base64 + envia:
const gz = await gzip(Buffer.from(signedXml, "utf-8"));
const dpsXmlGZipB64 = gz.toString("base64");
const response = await axios.post(
"https://sefin.nfse.gov.br/SefinNacional/nfse",
{ dpsXmlGZipB64 },
{ httpsAgent, headers: { "Content-Type": "application/json" } },
);
Emissão — RESPONSE 201 (sucesso)
{
"tipoAmbiente": 1,
"versaoAplicativo": "SefinNacional_1.6.0",
"dataHoraProcessamento": "2026-04-28T19:34:51.6810658-03:00",
"idDps": "NFS31062002505167240001600000110000000000001",
"chaveAcesso": "31062002250516724000160000000000002126046985535602",
"nfseXmlGZipB64": "H4sIAAAA...AAA=",
"alertas": null
}
chaveAcesso é a chave de 50 posições que identifica a NFS-e na ADN.
nfseXmlGZipB64 é o XML autorizado (gzip+base64) — descompacte e salve:
import zlib from "node:zlib";
import { promisify } from "node:util";
const gunzip = promisify(zlib.gunzip);
const buf = Buffer.from(response.data.nfseXmlGZipB64, "base64");
const xmlAutorizado = (await gunzip(buf)).toString("utf-8");
Emissão — RESPONSE 400 (erro)
{
"tipoAmbiente": 1,
"versaoAplicativo": "SefinNacional_1.6.0",
"dataHoraProcessamento": "2026-04-28T19:33:05.1286368-03:00",
"idDPS": "DPS31062002...",
"erros": [
{
"Codigo": "E0712",
"Descricao": "Para ME/EPP o indicador de informação de valor total de tributos não pode ser informado.",
"Complemento": null
}
]
}
Consulta de status
Existem três formas de consultar:
1. Por chaveAcesso (você sabe a chave de 50 pos)
curl --cert cert.pem --key key.pem \
https://sefin.nfse.gov.br/SefinNacional/nfse/31062002250516724000160000000000002126046985535602
const response = await axios.get(
`https://sefin.nfse.gov.br/SefinNacional/nfse/${chaveAcesso}`,
{ httpsAgent },
);
// response.data tem TODOS os dados da NFS-e (status, valores, tomador, etc.)
2. Por idDps (você só tem o ID do DPS — usado em recovery após timeout)
curl --cert cert.pem --key key.pem \
https://sefin.nfse.gov.br/SefinNacional/dps/DPS31062002505167240001600000110000000000001
Resposta:
{
"idDps": "DPS...",
"chaveAcesso": "31062002...",
"status": "AUTORIZADA"
}
⭐ Útil quando o POST /nfse deu timeout mas o servidor processou. Você
gerou o idDps localmente (determinístico), então pode tentar recuperar a
chave em vez de reemitir e gerar duplicidade.
3. Listar eventos da nota (cancelamentos, substituições)
curl --cert cert.pem --key key.pem \
https://adn.nfse.gov.br/contribuintes/NFSe/{chaveAcesso}/Eventos
Retorna lote de eventos (até 100) vinculados à chave.
Cancelamento (evento e101101)
NFS-e cancelada via evento padronizado pelo MOC. O fluxo é parecido com a emissão: monta XML, assina, gzip+base64, envia.
Endpoint
POST /SefinNacional/nfse/{chaveAcesso}/eventos
Body: { "pedidoRegistroEventoXmlGZipB64": "<base64>" }
XSD do pedRegEvento (TCPedRegEvt)
<pedRegEvento versao="1.00" xmlns="http://www.sped.fazenda.gov.br/nfse">
<infPedReg Id="PRE...">
<tpAmb>1|2</tpAmb>
<verAplic>1.00</verAplic>
<dhEvento>2026-04-28T20:27:08-03:00</dhEvento>
<CNPJAutor>50516724000160</CNPJAutor> <!-- ou CPFAutor -->
<chNFSe>31062002250516724000160000000000002126046985535602</chNFSe>
<!-- Choice: e101101 (cancelamento), e105102 (substituição), etc -->
<e101101>
<xDesc>Cancelamento de NFS-e</xDesc> <!-- LITERAL fixo -->
<cMotivo>1</cMotivo> <!-- 1=Erro, 2=Serv não prestado, 9=Outros -->
<xMotivo>...descrição (15-255 chars)...</xMotivo>
</e101101>
</infPedReg>
<Signature>...</Signature> <!-- assinatura, irmã de infPedReg -->
</pedRegEvento>
Pattern do Id do evento (TSIdPedRegEvt)
PRE + chaveAcesso(50) + tpEvento(3) + nPedRegEvento(3) = 56 dígitos (59 chars total)
Pattern oficial: <xs:pattern value="PRE[0-9]{56}"/>
const idPedReg = `PRE${chaveAcesso}${"101"}${"001"}`; // 1º cancelamento
Builder completo
import { XMLBuilder } from "fast-xml-parser";
import { DateTime } from "luxon";
interface CancelInput {
ambiente: "homologacao" | "producao";
chaveAcesso: string;
cnpjAutor: string;
cMotivo: 1 | 2 | 9;
xMotivo: string; // 15-255 chars
nPedRegEvento?: number; // default 1
}
const xmlBuilder = new XMLBuilder({
ignoreAttributes: false,
attributeNamePrefix: "@_",
format: false,
suppressEmptyNode: true,
});
export const buildCancelEventoXml = (input: CancelInput) => {
const tpAmb = input.ambiente === "producao" ? 1 : 2;
const nPed = String(input.nPedRegEvento ?? 1).padStart(3, "0");
const idPedReg = `PRE${input.chaveAcesso}101${nPed}`;
const dhEvento = DateTime.now()
.setZone("America/Sao_Paulo")
.minus({ seconds: 5 })
.toFormat("yyyy-LL-dd'T'HH:mm:ssZZ");
const xMotivo = input.xMotivo.trim().slice(0, 255);
if (xMotivo.length < 15) {
throw new Error("xMotivo precisa de no mínimo 15 caracteres");
}
const obj = {
pedRegEvento: {
"@_xmlns": "http://www.sped.fazenda.gov.br/nfse",
"@_versao": "1.00",
infPedReg: {
"@_Id": idPedReg,
tpAmb,
verAplic: "1.00",
dhEvento,
CNPJAutor: input.cnpjAutor.replace(/\D/g, ""),
chNFSe: input.chaveAcesso,
e101101: {
xDesc: "Cancelamento de NFS-e",
cMotivo: input.cMotivo,
xMotivo,
},
},
},
};
const xml = `<?xml version="1.0" encoding="UTF-8"?>${xmlBuilder.build(obj)}`;
return { xml, idPedReg };
};
Cancelamento — REQUEST completo
const { xml, idPedReg } = buildCancelEventoXml({
ambiente: "producao",
chaveAcesso: "31062002250516724000160000000000002126046985535602",
cnpjAutor: "50516724000160",
cMotivo: 1,
xMotivo: "Nota emitida em duplicidade durante migração para o ADN.",
});
// Mesma assinatura usada na emissão (action: "after")
const signedXml = signXmlByElementId({ xml, elementId: idPedReg, certPem, keyPem });
const gz = await gzip(Buffer.from(signedXml, "utf-8"));
const pedidoRegistroEventoXmlGZipB64 = gz.toString("base64");
const response = await axios.post(
`https://sefin.nfse.gov.br/SefinNacional/nfse/${chaveAcesso}/eventos`,
{ pedidoRegistroEventoXmlGZipB64 },
{ httpsAgent, headers: { "Content-Type": "application/json" } },
);
Cancelamento — RESPONSE 201 (sucesso)
{
"tipoAmbiente": 1,
"versaoAplicativo": "SefinNacional_1.6.0",
"dataHoraProcessamento": "2026-04-28T20:27:14.2893049-03:00",
"eventoXmlGZipB64": "H4sIAAAA...AAA="
}
eventoXmlGZipB64 é o evento autorizado (com selo da SEFIN). Descompacte e
salve junto da nota original — tem o protocolo do cancelamento.
Cancelamento — RESPONSE 400 (erro)
Mesmo formato da emissão (erros: [{ Codigo, Descricao, Complemento }]).
Erros comuns:
E1102— NFS-e já canceladaE1101— Prazo de cancelamento expirado (depende do município, geralmente até o último dia do mês de competência)E0004— Id do pedido de evento inválido (pattern PRE+56 dígitos errou)
Outros eventos disponíveis
| Código | Tipo |
|---|---|
e101101 | Cancelamento normal |
e105102 | Cancelamento por substituição |
e101103 | Solicitação de análise fiscal para cancelamento |
e202201 | Confirmação do prestador |
e203202 | Confirmação do tomador |
e202205 | Rejeição do prestador |
e203206 | Rejeição do tomador |
e305101 | Cancelamento por ofício (uso da prefeitura) |
A estrutura de cada evento varia. Consulte tiposEventos_v1.01.xsd.
O drama do PDF DANFSE
Sintomas
502 Bad Gateway— gateway sem resposta503 Service Unavailable— sem servidor disponível504 Gateway Timeout429 Too Many Requests- ~60% das requests falham aleatoriamente
Diagnóstico oficial (do fórum gov.br)
"Tem sistemas robotizados buscando DANFEs para guardar, sendo que o DANFE não é documento válido juridicamente. Em razão dessas robotizações o serviço está intermitente."
Threads relevantes em forum.nfsebrasil.com.br:
- "Erro 502 no acesso a adn.nfse.gov.br para consultar .PDF da NFSe"
- "Geração de PDF das NFS-e não está funcionando" — 1.8k views, 29 usuários, thread aberta há 3+ meses sem resolução pelo gov.br
Solução definitiva: 2 camadas
1) Retry no PDF — com agent fresh sem keep-alive
Esse é o pulo do gato que ninguém fala: o gateway parece cachear o 502 na conexão TCP/TLS reusada. Conexão nova, request nova chance.
import https from "node:https";
const buildFreshAgent = () => new https.Agent({
cert: certPem,
key: keyPem,
keepAlive: false, // ← crítico
});
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));
async function baixarPdfDANFSE(chaveAcesso: string): Promise<Buffer> {
const waits = [200, 400, 600, 800, 1000, 1500, 2000, 2500, 3000];
for (let attempt = 1; attempt <= 10; attempt++) {
const freshAgent = buildFreshAgent();
const response = await axios.get(
`https://adn.nfse.gov.br/danfse/${chaveAcesso}`,
{
httpsAgent: freshAgent,
responseType: "arraybuffer",
timeout: 30_000,
validateStatus: () => true,
headers: {
Accept: "application/pdf",
Connection: "close",
},
},
);
freshAgent.destroy();
if (response.status === 200) return Buffer.from(response.data);
if (![429, 502, 503, 504].includes(response.status)) {
throw new Error(`HTTP ${response.status}`);
}
const wait = waits[Math.min(attempt - 1, waits.length - 1)] + Math.random() * 200;
await sleep(wait);
}
throw new Error("PDF indisponível após 10 tentativas");
}
Taxa de sucesso medida: ~95% em 10 tentativas (worst-case ~13s).
2) Salvar o XML autorizado e oferecer como download
A SEFIN devolve o XML autorizado junto com a resposta de emissão
(nfseXmlGZipB64). Salve no banco. É o documento legal.
// Após POST /SefinNacional/nfse com sucesso:
import zlib from "node:zlib";
import { promisify } from "node:util";
const gunzip = promisify(zlib.gunzip);
const data = response.data; // tem chaveAcesso + nfseXmlGZipB64
const buf = Buffer.from(data.nfseXmlGZipB64, "base64");
const xmlAutorizado = (await gunzip(buf)).toString("utf-8");
await prisma.invoice.update({
where: { id: invoiceId },
data: {
chaveAcesso: data.chaveAcesso,
authorizedXml: xmlAutorizado, // ← guarde isso
},
});
Endpoint pra cliente baixar:
// GET /api/nfse/xml/:invoiceId
res.setHeader("Content-Type", "application/xml; charset=utf-8");
res.setHeader("Content-Disposition", `attachment; filename="NFSe-${chave}.xml"`);
res.send(invoice.authorizedXml);
E na UI:
ℹ O XML é o documento juridicamente válido. O PDF é só representação visual. Se o servidor gov.br estiver instável, use o XML.
Idempotência
NFS-e duplicada gera dor fiscal séria. Camadas mínimas:
referenceKeyno DB com@unique(chave de idempotência local)- Antes de chamar SEFIN, verificar se já existe nota
AUTHORIZEDpara essa referência. Se sim, retornar resultado existente sem chamar API. - Em caso de timeout no
POST /nfse— recuperar comGET /SefinNacional/dps/{idDps}(devolve a chave se o DPS foi processado).
async function emitir(referenceKey: string) {
const existing = await prisma.invoice.findUnique({ where: { referenceKey } });
if (existing?.status === "AUTHORIZED") {
return { skipped: true, chave: existing.chaveAcesso };
}
try {
const result = await emitirNfseADN(...);
return result;
} catch (e) {
if (e.code === "ECONNABORTED" || e.code === "ETIMEDOUT") {
// Recovery: o DPS pode ter sido processado mesmo com timeout
const recovery = await axios.get(
`https://sefin.nfse.gov.br/SefinNacional/dps/${idDpsLocal}`,
{ httpsAgent }
).catch(() => null);
if (recovery?.data?.chaveAcesso) {
return { recovered: true, chave: recovery.data.chaveAcesso };
}
}
throw e;
}
}
Gotchas adicionais
Proxy Next.js corrompe binary/XML
Se você tem um BFF/proxy entre cliente e API (comum em Next.js / Nuxt / Remix), detecte resposta não-JSON e repasse os bytes brutos:
// ❌ ERRADO — converte tudo em JSON, corrompe PDF e XML
return NextResponse.json(response.data);
// ✅ CORRETO
const isBinary =
url.includes("/danfse-pdf/") ||
url.includes("/adn-nfse/pdf/") ||
url.includes("/adn-nfse/xml/");
if (isBinary) {
return new NextResponse(response.data, {
status: response.status,
headers: {
"Content-Type": response.headers["content-type"],
"Content-Disposition": response.headers["content-disposition"] || "",
},
});
}
return NextResponse.json(response.data);
E no axios da chamada interna no proxy: responseType: "arraybuffer".
Configurar XMLBuilder do fast-xml-parser
const xmlBuilder = new XMLBuilder({
ignoreAttributes: false,
attributeNamePrefix: "@_", // pra usar "@_Id", "@_versao", etc.
format: false, // sem indentação — o XSD é sensível em alguns pontos
suppressEmptyNode: true, // evita <tag/> vazias indesejadas
});
Logs com prefixo identificável
Facilita debug em produção. Use sempre o mesmo prefixo:
console.log(`[adn:nfse][emit] iniciando emissão ${JSON.stringify(meta)}`);
console.log(`[adn:nfse][http] → POST sefin /SefinNacional/nfse`);
console.log(`[adn:nfse][http] ✗ ← 400 ${JSON.stringify(erros)}`);
E no Kibana/Loki: grep "\[adn:nfse\].*✗" pra achar erros rapidinho.
Validade do certificado
Implemente um cron de aviso. A1 vence em 1 ano e o serviço cai 100% no D+0:
const daysLeft = Math.floor(
(cert.validity.notAfter.getTime() - Date.now()) / (24 * 3600 * 1000)
);
if (daysLeft < 30) sendAlertToOps(`Cert vence em ${daysLeft} dias`);
Município não conveniado
const r = await axios.get(`/parametrizacao/${codMun}/convenio`, { httpsAgent });
if (r.status === 404 || r.data?.parametrosConvenio?.aderenteAmbienteNacional !== 1) {
throw new Error(`Município ${codMun} ainda não emite NFS-e via ADN`);
}
5.482 de 5.571 municípios já estão (98,4% — abr/2026), mas alguns como Brasília-DF ainda não. Cheque antes de oferecer integração.
Como descobrir os tipos certos do XSD
Baixe o pacote oficial e leia os arquivos. Um grep bem feito economiza horas:
curl -L https://www.gov.br/nfse/pt-br/biblioteca/documentacao-tecnica/documentacao-atual/nfse-esquemas_xsd-v1-01-20260209.zip -o nfse-xsd.zip
unzip -d xsd nfse-xsd.zip
# Achar regra do Id
grep -nA8 "TSIdDPS" xsd/Schemas/1.01/tiposSimples_v1.01.xsd
# Achar ordem dos elementos do tomador
grep -nA50 'name="TCInfoPessoa"' xsd/Schemas/1.01/tiposComplexos_v1.01.xsd
# Estrutura de tribMun
grep -nA40 'name="TCTribMunicipal"' xsd/Schemas/1.01/tiposComplexos_v1.01.xsd
# Eventos disponíveis
grep -E 'name="e[0-9]+"' xsd/Schemas/1.01/tiposEventos_v1.01.xsd
# Pattern de qualquer tipo simples (substituir TIPO)
grep -nA5 'name="TIPO"' xsd/Schemas/1.01/tiposSimples_v1.01.xsd
Checklist pra produção
-
.pfxda empresa em local seguro + senha em.envseparado - mTLS funcionando (testar com
curl --cert cert.pem --key key.pem) - Verificar se município é conveniado:
GET /parametrizacao/{cMun}/convenio - XSD v1.01 validado nos pontos críticos do item "Erros" acima
- Salvar XML autorizado no banco (
authorizedXml) - Endpoint próprio de download de XML (independente do gov.br)
- Retry de PDF com agent fresh (10 tentativas)
-
referenceKeyúnica por nota + check antes de chamar SEFIN (idempotência) - Recovery por
idDpsem caso de timeout - Cancelamento via evento
e101101implementado e testado - Cron de monitoramento da validade do certificado (alerta D-30/D-15/D-7)
- Logs estruturados com prefixo identificável (facilita debug)
- Proxy/BFF detecta binary/XML e repassa bytes (não converte para JSON)
Recursos úteis
- Documentação técnica oficial: https://www.gov.br/nfse/pt-br/biblioteca/documentacao-tecnica
- Lista de municípios conveniados (XLSX): https://www.gov.br/nfse/pt-br/municipios/monitoramento-adesoes
- XSDs (v1.01): https://www.gov.br/nfse/pt-br/biblioteca/documentacao-tecnica/documentacao-atual/nfse-esquemas_xsd-v1-01-20260209.zip
- Swagger das APIs (precisa do .pfx):
https://adn.nfse.gov.br/docs/index.htmlhttps://sefin.nfse.gov.br/SefinNacional/docs/index
- Fórum oficial da comunidade: https://forum.nfsebrasil.com.br/