# Nfse Sem Gateway

> Use when automating Brazilian NFS-e — emitting, consulting, or cancelling service invoices against the SEFIN Nacional / Padrão Nacional API (nfse.gov.br) without a paid gateway. Covers replacing PlugNotas/Focus NFe/eNotas, mTLS with an ICP-Brasil A1 certificate, signing DPS XML (XMLDSig), rendering the DANFSe PDF locally (the national PDF API was discontinued on 2026-08-03), and rejection codes like E1229, E1235, E0166, E0625, E0120.

- Skill: `tarikbc/nfse-sem-gateway` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add tarikbc/nfse-sem-gateway`
- Raw SKILL.md: https://api.skillmd.com/api/skills/tarikbc/nfse-sem-gateway/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Docs & Writing
- Author: tarikbc (https://skillmd.com/u/tarikbc)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/tarikbc/nfse-sem-gateway

---


# 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 `regTrib` and 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.
- **`chaveAcesso` comes 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 `chaveAcesso` is **50 digits** (not 44 like NF-e). Inside the returned XML, the
  `infNFSe/@Id` is `"NFS" + chaveAcesso`, so strip the `NFS` prefix 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 in `references/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](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 `.pfx` bytes + 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.

```sql
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:

1. **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-`Id` rejection on retry.
2. **Seed it from your last real note.** If your previous provider issued up to `nDPS=42` on
   série 1, seed `last_ndps = 42` so 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](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](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).
- [ ] `.pfx` in a secret manager (base64) + password as a separate secret. Not in git.
- [ ] Certificate `notAfter` monitored, 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, and `tpAmb=1`.
- [ ] Guard: refuse to run if `tpAmb=1` and the base URL is not the produção host.
- [ ] `cTribNac` and 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 `PENDING` row.

## Rejection codes and other traps

Every rejection code encountered, what it actually means, and the fix:
**[references/gotchas.md](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.

