NFS-e direct via SEFIN Nacional
Automate Brazilian service invoices (NFS-e) by talking straight to the government
(nfse.gov.br) instead of paying a gateway (PlugNotas, Focus NFe, WebmaniaBR, eNotas…).
The API is free, synchronous, and officially supported. There is no API key, no account, no OAuth: you authenticate with mutual TLS using your company's own ICP-Brasil A1 certificate, and you sign the fiscal document with that same certificate.
Everything in this guide is distilled from a production integration that ran the full
cycle — emit (201) → consult (200) → cancel (201) — first in the sandbox and then
against real produção with a real note. Where this guide states a value, that value was
accepted by gov.br. Where it warns, that warning is a rejection someone actually hit.
When to use this
- You are a Brazilian company issuing service invoices (NFS-e) and want to automate it.
- You are on Simples Nacional (ME/EPP) — the fiscal profile below is tuned for that.
Lucro Presumido/Real works too, but
regTriband the tax totals differ and you must get those from your accountant. - Your município has adopted the Padrão Nacional (most have; many are national-only since 2026 — Maringá/PR since 01/07/2026, and CGSN 189/2026 makes the national Emissor mandatory for every Simples Nacional optante from 01/09/2026).
Do not try to automate the gov.br Emissor Nacional web portal. It has a captcha and issues one DPS at a time. The API is the only supported automation path.
Prerequisites
| # | What | Notes |
|---|---|---|
| 1 | ICP-Brasil A1 certificate (.pfx/.p12 + password) for the emitting CNPJ |
e-CNPJ A1. A3 (token/smartcard) will not work for a server. Watch the expiry — A1 lasts 1 year. |
| 2 | The CNPJ is an active emitter in the national system | If you have ever issued a note through the national Emissor (portal or a gateway), you are already active — no credenciamento step is needed. If never, register once at www.nfse.gov.br. |
| 3 | IBGE município code of the emitter (7 digits) | e.g. 4115200 = Maringá/PR, 3550308 = São Paulo/SP. Also needed for each customer. |
| 4 | cTribNac — the 6-digit national service code |
Maps from your LC 116 item. e.g. LC 116 10.02 → 100201 (títulos) or 100202 (contratos quaisquer); 10.05 → 100501/100502. Confirm with your accountant — this drives the ISS rate. |
| 5 | A place to keep the .pfx |
Secret manager, base64-encoded, plus the password as a second secret. Never in the repo. |
Customer (tomador) data you need per note: CPF or CNPJ, name, IBGE code of their
município, CEP, street, number, neighbourhood, and optionally email. The IBGE code is
derivable from the CEP — ViaCEP (https://viacep.com.br/ws/{cep}/json/) returns it in the
ibge field.
The whole thing in seven steps
1. load A1 .pfx ──► { pfx bytes + passphrase } → mTLS
└─► { privateKeyPem, certificatePem } → XMLDSig
2. allocate nDPS (atomic, COMMIT BEFORE the POST)
3. build DPS XML (exact element order, no prolog yet)
4. sign infDPS (enveloped XMLDSig, RSA-SHA256), then PREPEND the UTF-8 prolog
5. gzip → base64
6. POST /SefinNacional/nfse { dpsXmlGZipB64 } over the mTLS agent
7. 201 → { chaveAcesso, nfseXmlGZipB64 }
gunzip the XML → read nNFSe (the human-facing note number)
persist ONE row, terminal status
Cancel is the same shape with a different document:
build pedRegEvento (e101101) ─► sign infPedReg ─► gzip+b64
└─► POST /SefinNacional/nfse/{chave}/eventos { pedidoRegistroEventoXmlGZipB64 }
201 → { eventoXmlGZipB64 }
Environments
Prove both emit and cancel in the sandbox before you point at produção.
| Sandbox ("produção restrita") | Produção | |
|---|---|---|
| SEFIN host | https://sefin.producaorestrita.nfse.gov.br |
https://sefin.nfse.gov.br |
DPS tpAmb |
2 |
1 |
There is no PDF endpoint any more. The national DANFSe API (adn.*/danfse/{chave}) was
discontinued on 2026-08-03 (NT SE/CGNFS-e 008/2026 v1.02); since then the issuing system
renders the DANFSe itself from the authorized XML. See the gotchas and references/danfse/.
Default your config to sandbox + tpAmb=2 so a missing env var can never emit a real
note. Add a guard that refuses to run if tpAmb=1 but the base URL is not the produção host.
API contract
All paths are under /SefinNacional. Content-Type: application/json. The fiscal XML
is always carried as a gzip + base64 string field, never as a raw body.
| Op | Method + path | Request body | Success |
|---|---|---|---|
| Emit | POST /SefinNacional/nfse |
{ "dpsXmlGZipB64": "..." } |
201 { chaveAcesso, nfseXmlGZipB64, … } |
| Consult | GET /SefinNacional/nfse/{chaveAcesso} |
— | 200 { nfseXmlGZipB64, … } |
| Cancel / event | POST /SefinNacional/nfse/{chaveAcesso}/eventos |
{ "pedidoRegistroEventoXmlGZipB64": "..." } |
201 { eventoXmlGZipB64, … } |
| DANFSe (PDF) | none since 2026-08-03: render it from the XML (NT 008/2026) | — | — |
Notes that cost time to learn:
- Emission is synchronous. There is no protocol/receipt to poll. 201 means the note exists and is authorized.
chaveAcessocomes back at the top level of the JSON.nNFSe— the note number a human reads — is only inside the returned XML. You must gunzip and parse it.- The
chaveAcessois 50 digits (not 44 like NF-e). Inside the returned XML, theinfNFSe/@Idis"NFS" + chaveAcesso, so strip theNFSprefix if you read it there. Treat the key as opaque; do not try to reconstruct it. (Eyeball check: it begins with the emitter's 7-digit IBGE code and contains the CNPJ.) - Business rejections arrive as 4xx with a JSON body, typically
{ "erros": [{ codigo, mensagem }] }. Configure your HTTP client to not throw on non-2xx so you can read them. - The DANFSe PDF is not served by any API since 2026-08-03. Render it yourself from the
authorized XML: one A4 page, Anexo I of NT 008/2026, Arial-style fonts in black, 5% grey
shading, the official NFS-e logo, and a QR code to
https://www.nfse.gov.br/ConsultaPublica/?tpc=1&chave={chaveAcesso}. A reference renderer ships inreferences/danfse/(pdfkit + qrcode + xmlbuilder2).
Architecture
Eight small modules, each independently testable. This split is worth copying — it lets you
unit-test the XML and the signature offline, with a throwaway self-signed cert, without ever
touching the network or the real .pfx.
a1-credential ── loads .pfx once, caches, exposes:
│ getMtls() → { pfx, passphrase } (for https.Agent)
│ getSigningPem()→ { privateKeyPem, certificatePem } (for XMLDSig)
│ getExpiry() → Date (wire this to an alert!)
▼
dps-builder ── DpsInput → { xml, id } (element order is load-bearing)
cancel-event ── CancelInput → { xml, id } (evento e101101)
▼
dps-signer ── enveloped XMLDSig over infDPS / infPedReg + UTF-8 prolog
▼
gzip-codec ── gzipB64 / gunzipB64
▼
sefin-client ── mTLS HTTP: postDps, consultNfse, postEvento
danfse/ ── authorized XML → DANFSe PDF (model, renderer)
▲
ndps-counter ── allocateNextNdps(cnpj, serie) — atomic, per (CNPJ, série)
▲
orchestrator ── aggregate amount → build → sign → gzip → POST → parse
→ store XML/PDF → persist ONE terminal-status row
Full working code for every module: references/code-patterns.md.
Step by step
1. Load the A1 certificate
One .pfx serves two purposes and you need it in two different forms:
- mTLS: the raw
.pfxbytes + passphrase, handed to the TLS layer (new https.Agent({ pfx, passphrase })in Node). - XMLDSig: the private key and the leaf certificate as PEM, for the signing library.
In Node, node-forge does the extraction: asn1.fromDer → pkcs12.pkcs12FromAsn1 → pull
the private key from the pkcs8ShroudedKeyBag (fall back to keyBag) and the leaf from
the certBag, then privateKeyToPem / certificateToPem. Cache the result — parsing a
PKCS#12 on every call is pure waste. Grab cert.validity.notAfter while you are in there
and alert on it; an expired A1 breaks emission and mTLS at the same time, silently, on a
date you did not pick.
Never log the passphrase or any key material, including inside error messages.
2. Allocate the nDPS
nDPS is a sequential number per (CNPJ, série) that identifies the DPS. If you used a
gateway before, the gateway owned this counter — now you do.
CREATE TABLE nfse_dps_counter (
cnpj varchar NOT NULL,
serie integer NOT NULL,
last_ndps bigint NOT NULL DEFAULT 0,
updated_at timestamp NOT NULL DEFAULT now(),
PRIMARY KEY (cnpj, serie)
);
-- atomic under a row lock; concurrent callers get distinct, monotonic numbers
UPDATE nfse_dps_counter SET last_ndps = last_ndps + 1, updated_at = now()
WHERE cnpj = $1 AND serie = $2
RETURNING last_ndps;
Two rules:
- Commit the allocation before the POST. A rejected DPS then simply burns its number.
Gaps are allowed. Reusing a number is not — you get a duplicate-
Idrejection on retry. - Seed it from your last real note. If your previous provider issued up to
nDPS=42on série 1, seedlast_ndps = 42so the next one is 43.
The série insight that saves you a collision: the gov.br web Emissor Nacional issues
under série 70000, with its own independent nDPS sequence that starts at 1. Your API
notes go under your own série (série 1 is the natural choice). The two sequences never
touch. So a human hand-issuing a note in the portal cannot collide with your automation, and
vice-versa — but it does mean you have two numbering streams and must track only your own.
3. Build the DPS XML
Namespace http://www.sped.fazenda.gov.br/nfse, root <DPS versao="1.01">, single child
<infDPS Id="...">. The element order is validated — get it wrong and you get a schema
rejection with a code that does not tell you which element moved.
Id layout (45 chars):
"DPS" + cLocEmi(7) + tpInsc(1) + CNPJ(14) + serie(5, zero-padded) + nDPS(15, zero-padded)
tpInsc: 2 = CNPJ
example: DPS + 4115200 + 2 + 11222333000181 + 00001 + 000000000000042
= DPS411520021122233300018100001000000000000042
Full element-by-element anatomy, an annotated example, and every field's meaning: references/dps-xml-reference.md.
4. Sign it
Enveloped XMLDSig, RSA-SHA256, C14N — over the infDPS element, <Signature> inserted
as its next sibling (inside <DPS>, after </infDPS>).
| Parameter | Value |
|---|---|
| Signature algorithm | http://www.w3.org/2001/04/xmldsig-more#rsa-sha256 |
| Digest algorithm | http://www.w3.org/2001/04/xmlenc#sha256 |
| Canonicalization | http://www.w3.org/TR/2001/REC-xml-c14n-20010315 |
| Reference URI | #<the Id attribute value> |
| Transforms | …#enveloped-signature, then C14N |
| KeyInfo | must carry <X509Certificate> (the leaf) |
Use SHA-256, not SHA-1. Old gateway-produced samples floating around use SHA-1; the current Nota Técnica wants SHA-256 and gov.br accepts it. Keeping the algorithm behind a config switch is cheap insurance while you nail it down in the sandbox.
Then prepend <?xml version="1.0" encoding="UTF-8"?> to the final string. Most signing
libraries re-serialize without the prolog, and SEFIN rejects a prolog-less document with
E1229 ("não está utilizando UTF-8"). Prepending after signing is safe — the prolog sits
outside the signed element, so the signature stays valid.
5. Transport-encode, POST, and read the answer
signedXml → gzip → base64 → { dpsXmlGZipB64 }. Response nfseXmlGZipB64 → base64-decode →
gunzip → UTF-8 XML.
On 201, read chaveAcesso from the JSON and nNFSe from the decoded XML. Store the
authorized XML — it is the legal artifact — and render the DANFSe PDF from it (there is no
PDF endpoint since 2026-08-03).
6. Persist exactly once, with a terminal status
Call the API first, then write your invoice row exactly once, with a terminal status:
ISSUED on 201, or a MANUAL/FAILED fallback otherwise so the note is never lost and a
human can pick it up.
Never pre-insert a PENDING row. Concretely: a stale PENDING primary occupies your
unique (customer, month, year) index but counts as zero in any "how much have we
already invoiced?" sum — so the next run sees an unbilled gap and issues a second
full-value note. That is double-billing a real customer. This is the single most expensive
mistake in this whole integration.
Store the raw response body too. When storage (S3/GCS) fails after a successful emission,
do not fail the operation — the note exists at the government; keep the row ISSUED and
reconcile the files later from the stored response or a consultNfse call.
7. Cancel
A cancellation is a signed "Pedido de Registro de Evento", event type 101101.
Id = "PRE" + chaveAcesso(50) + tipoEvento(6 = "101101") → pattern PRE[0-9]{56}
There is no nPedRegEvento in the Id. Root is <pedRegEvento versao="1.00"> (note:
1.00, not the DPS's 1.01), child <infPedReg Id="...">, and you sign infPedReg with
the exact same enveloped RSA-SHA256 recipe. Fields: tpAmb, verAplic, dhEvento,
CNPJAutor, chNFSe, then e101101 { xDesc, cMotivo, xMotivo } where cMotivo ∈ {1, 2, 9}
and xMotivo must be 15–255 characters (validate this yourself before posting — it is a
pointless round-trip otherwise). Layout in
references/dps-xml-reference.md.
There are hard deadlines on cancellation by event (municipal rules); past them you need a substitution or an administrative process. Check your município's rules.
Fiscal profile — Simples Nacional (ME/EPP), ISS not withheld
These are the values that were accepted. Each one has a rejection code attached to getting it wrong.
| Field | Value | Why |
|---|---|---|
tpEmit |
1 |
Emitter is the service provider |
opSimpNac |
3 |
Optante pelo Simples Nacional (ME/EPP) |
regApTribSN |
1 |
Federal + municipal apurados pelo SN — required, or E0166 |
regEspTrib |
0 |
No special regime |
tribISSQN |
1 |
Taxable operation |
tpRetISSQN |
1 |
ISS not withheld (you pay it via DAS) |
ISS aliquota |
omit entirely | Sending it → E0625 for SN without withholding |
Prestador IM |
omit entirely | Sending it → E0120 where the município has no CNC complementary info |
vTotTribFed/Est/Mun |
0.00 each |
Formatted with 2 decimals |
dCompet |
last calendar day of the service month | Competência is the month the service happened, not the emission date |
dhEmi/dhEvento are ISO 8601 with the -03:00 offset (Brazil has no DST since 2019).
Go-live checklist
- Sandbox: emit → consult → cancel all succeed (201/200/201) with the real A1.
- Signature algorithm confirmed as RSA-SHA256 against the current XSD/NT.
- DANFSe rendered locally per NT 008/2026 (the PDF API is gone since 2026-08-03).
-
.pfxin a secret manager (base64) + password as a separate secret. Not in git. - Certificate
notAftermonitored, alert at 30 days. A1 expires yearly. - nDPS counter seeded from your last real note on that série.
- Your série is not 70000 (that belongs to the web portal).
-
NFSE_SEFIN_BASE_URL→ produção host, andtpAmb=1. - Guard: refuse to run if
tpAmb=1and the base URL is not the produção host. -
cTribNacand the Simples Nacional flags confirmed with your accountant. - Canary: one real note for one customer, verified end to end (row, XML, PDF, and the note visible in the gov.br portal) before the full batch.
- Rollback is a feature flag, not a code revert — one env var returns you to manual.
- Persist-once rule implemented; no code path writes a
PENDINGrow.
Rejection codes and other traps
Every rejection code encountered, what it actually means, and the fix: references/gotchas.md. Read it before your first sandbox run — it will save you several hours.
Quick index: E1229 UTF-8 prolog · E1235 tomador address nesting · E0166 missing
regApTribSN · E0625 alíquota present · E0120 prestador IM present · E0310 wrong
service code · duplicate Id · série 70000 collision.
Node.js dependencies
The reference implementation uses, all mainstream:
| Package | Role |
|---|---|
node-forge |
Parse the .pfx, extract key + cert as PEM |
xmlbuilder2 |
Build the DPS / event XML with explicit element order |
xml-crypto |
Enveloped XMLDSig (pulls @xmldom/xmldom + xpath) |
fast-xml-parser |
Read chaveAcesso / nNFSe out of the authorized XML |
axios + node:https |
HTTP with a client-cert https.Agent |
node:zlib |
gzip / gunzip (built in) |
Porting to another stack is mechanical — the only genuinely fiddly part is XMLDSig. Known
good equivalents: Python cryptography + signxml + requests (cert= for mTLS, from
a PEM pair converted with openssl pkcs12); PHP openssl_pkcs12_read + robrichards/ xmlseclibs + cURL CURLOPT_SSLCERT; Java/.NET have XMLDSig in the standard library.
Whatever you use, verify your own signature locally before the first POST.