Generate a Brazilian invoice PDF in Node.js
From npm init to a signed-off PDF on disk. Native fetch, no SDK, no headless Chrome — plus the two Node-specific traps that silently corrupt binary responses.
Generating a Brazilian invoice from Node is mostly easy and slightly annoying. Easy, because
the whole thing is one HTTP POST. Annoying, because Brazil expects things that a generic invoice
template does not do: the CPF/CNPJ has to be real (it carries two check digits), the money has to read
R$ 1.234,56 and not $1,234.56, dates are DD/MM/YYYY, and a formal recibo
traditionally spells the amount out in Portuguese words.
This guide covers the whole path: a minimal call, writing the PDF to disk, streaming it straight to a browser from Express, retrying safely, and validating documents locally before you spend an API call on a payload that is going to be rejected.
Requirements
- Node 18 or newer. Everything here uses the built-in
fetch,AbortSignal.timeoutandnode:fs/promises— zero dependencies. On Node 16 or older, addnode-fetchand swap the import. - An API key from the RapidAPI listing. The free tier is 20 documents/month and does not ask for a card, which is enough to finish this guide.
Read it from process.env.RAPIDAPI_KEY. This is a server-side key: if you ship it to a browser
bundle, anyone can spend your quota. There is no client-side mode.
The request body
Every request is the same JSON document. The field names are in Portuguese because they
mirror the vocabulary printed on the PDF — emitente is the issuer, destinatario
is the customer, itens are the line items:
{
"tipo": "fatura",
"numero": "0001/2026",
"data": "2026-08-07",
"vencimento": "2026-08-22",
"emitente": {
"nome": "Atlas Solucoes Digitais LTDA",
"documento": "11.222.333/0001-81",
"endereco": "Av. Paulista, 1000 - Sao Paulo/SP",
"email": "financeiro@exemplo.com.br"
},
"destinatario": {
"nome": "Comercio Silva & Filhos ME",
"documento": "22.333.444/0001-81"
},
"itens": [
{ "descricao": "Consultoria de implementacao", "quantidade": 20, "valor_unitario": 250 },
{ "descricao": "Licenca mensal", "quantidade": 1, "valor_unitario": 349.90 }
],
"desconto": 100,
"forma_pagamento": "PIX",
"observacoes": "Pagamento em ate 10 dias."
}| Field | Required | What it does |
|---|---|---|
tipo | no | "fatura" (invoice, default) or "recibo" (receipt). Changes the whole layout, not just a label. |
numero | no | Your document number, free text — e.g. "0001/2026". |
data | no | ISO YYYY-MM-DD or DD/MM/YYYY. Omitted = today. Always printed as DD/MM/YYYY. |
vencimento | no | Due date, same formats. Only makes sense on fatura. |
emitente.nome | yes | Issuer legal name. |
emitente.documento | yes | Issuer CPF or CNPJ. Check digits are verified — a wrong one returns 400, it is not silently printed. |
destinatario.nome | yes | Customer name. |
destinatario.documento | no | Customer CPF/CNPJ. Optional, but validated when present. |
itens[].descricao | yes | Line description, up to 200 chars. |
itens[].quantidade | no | Defaults to 1. |
itens[].valor_unitario | yes | Unit price as a number. Do not send totals — the API computes subtotal and total in integer cents. |
desconto | no | Flat discount subtracted from the subtotal. |
forma_pagamento | no | Free text, e.g. "PIX", "Boleto". |
observacoes | no | Notes block at the bottom, up to 2000 chars. |
pix_copia_cola | no | A PIX BR Code string you already generated; the API renders it as a scannable QR on the PDF. |
mostrar_valor_por_extenso | no | true prints the total spelled out in Portuguese. Automatic on receipts. |
Minimal working example
Thirty lines, no dependencies. It posts the payload, checks the status code before touching the
body, and writes the bytes to fatura.pdf:
import { writeFile } from "node:fs/promises";
const HOST = "brazilian-invoice-receipt-pdf-api-cpf-cnpj.p.rapidapi.com";
async function generateInvoice(payload) {
const res = await fetch(`https://${HOST}/invoice`, {
method: "POST",
headers: {
"content-type": "application/json",
"X-RapidAPI-Key": process.env.RAPIDAPI_KEY,
"X-RapidAPI-Host": HOST,
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(15_000),
});
// Status first, body second. On failure the body is JSON, not a PDF.
if (!res.ok) {
const detail = await res.json().catch(() => ({ message: res.statusText }));
throw new Error(`[${res.status}] ${detail.error ?? "http_error"}: ${detail.message}`);
}
// Buffer.from(ArrayBuffer) does NOT copy — it wraps. Fine here: nothing else
// holds a reference to it.
return Buffer.from(await res.arrayBuffer());
}
const pdf = await generateInvoice({
tipo: "fatura",
numero: "0001/2026",
emitente: { nome: "Atlas Solucoes Digitais LTDA", documento: "11222333000181" },
destinatario: { nome: "Comercio Silva & Filhos ME", documento: "22333444000181" },
itens: [{ descricao: "Consultoria de implementacao", quantidade: 20, valor_unitario: 250 }],
forma_pagamento: "PIX",
});
await writeFile("fatura.pdf", pdf);
console.log(`wrote fatura.pdf (${pdf.length} bytes)`);Run it with RAPIDAPI_KEY=... node invoice.mjs. You get a real PDF —
open it and you should see the CNPJ masked as 11.222.333/0001-81 and the total as
R$ 5.000,00.
A successful response is binary, an error response is JSON. If you decode the
response as text before checking the status code, a valid PDF becomes an unopenable file — and the real
error message gets thrown away. Always branch on the status code (or on Content-Type) first,
then read bytes or JSON accordingly. Every example below does this.
Reading the errors
The 400 response is unusually chatty on purpose: it reports all validation failures in one message instead of making you fix them one round-trip at a time.
// Sending a CNPJ with a broken check digit:
{ "error": "invalid_params",
"message": "emitente.documento \"11.222.333/0001-99\" tem formato de CNPJ mas digito verificador invalido; itens[0].valor_unitario e obrigatorio" }| Status | error | Cause & fix |
|---|---|---|
| 200 | — | Body is raw PDF bytes, Content-Type: application/pdf. |
| 400 | invalid_json | Body was not parseable JSON. Usually a serialization bug on your side. |
| 400 | invalid_params | The useful one. message lists every problem at once, semicolon-separated — including which CPF/CNPJ failed its check digit. |
| 401 | unauthorized | The request did not arrive through the RapidAPI gateway. Check X-RapidAPI-Key and X-RapidAPI-Host. |
| 405 | method_not_allowed | You sent GET. Both endpoints are POST-only. |
| 413 | payload_too_large | Body over ~200 KB, or more than 200 line items. |
| 429 | — | Returned by the RapidAPI gateway when your plan quota is exhausted, before the API is reached. |
| 500 / 504 | render_failed / render_timeout | Rendering failed or exceeded the limit. Safe to retry. |
Serving the PDF from Express
The common production shape: your backend builds the payload from the database and pipes the result to the browser. Do not write a temp file — pass the bytes through.
import express from "express";
const app = express();
app.use(express.json());
app.post("/invoices/:id/pdf", async (req, res, next) => {
try {
const order = await db.orders.findById(req.params.id); // your data
const pdf = await generateInvoice({
tipo: "fatura",
numero: order.number,
data: order.issuedAt.toISOString().slice(0, 10), // Date -> "YYYY-MM-DD"
emitente: { nome: COMPANY.name, documento: COMPANY.cnpj, endereco: COMPANY.address },
destinatario: { nome: order.customer.name, documento: order.customer.taxId },
itens: order.lines.map((l) => ({
descricao: l.title,
quantidade: l.qty,
valor_unitario: l.unitPriceCents / 100, // send reais, not cents
})),
forma_pagamento: order.paymentMethod,
});
res.status(200)
.type("application/pdf")
// "inline" opens in the browser's viewer; "attachment" forces download.
.set("Content-Disposition", `inline; filename="fatura-${order.number}.pdf"`)
.set("Content-Length", String(pdf.length))
.send(pdf);
} catch (err) {
next(err);
}
});
app.listen(3000);If you store prices as integer cents (you should), divide by 100 on the way out. Send
349.90, not 34990. The API converts back to integer cents internally before summing,
so 0.1 + 0.2 never shows up as R$ 0,30000000000000004 on the document.
Retrying without duplicating documents
Generation is a pure function of the payload: the same body always produces the same document, and nothing is stored on the server. That makes retries safe — but only for the failures that are actually transient. Retrying a 400 just burns quota.
const RETRYABLE = new Set([408, 429, 500, 502, 503, 504]);
async function generateWithRetry(payload, { tries = 3 } = {}) {
let lastError;
for (let attempt = 1; attempt <= tries; attempt++) {
try {
return await generateInvoice(payload);
} catch (err) {
lastError = err;
const status = Number(String(err.message).match(/^\[(\d{3})\]/)?.[1]);
const transient = !status || RETRYABLE.has(status); // no status = network/timeout
if (!transient || attempt === tries) throw err;
// Exponential backoff with jitter: 400ms, 800ms, 1600ms (+/- 30%).
const base = 400 * 2 ** (attempt - 1);
await new Promise((r) => setTimeout(r, base * (0.7 + Math.random() * 0.6)));
}
}
throw lastError;
}Note 429 in the retryable set: that is the gateway telling you the monthly quota is gone.
Retrying helps only if your quota resets or another worker freed capacity — in a batch job you usually
want to stop the whole run instead, so consider treating 429 as fatal there.
Validate CPF/CNPJ before you spend a call
The API rejects invalid documents, which is the right behaviour but a waste of a request if the bad data came from a form you control. Both are mod-11 checksums and take about 20 lines each. This is the same algorithm the API runs server-side:
const digits = (s) => String(s ?? "").replace(/\D/g, "");
export function isValidCPF(input) {
const d = digits(input);
if (d.length !== 11) return false;
if (/^(\d)\1{10}$/.test(d)) return false; // 111.111.111-11 passes mod-11 but is not a CPF
const n = d.split("").map(Number);
const check = (upTo, startWeight) => {
let sum = 0;
for (let i = 0; i < upTo; i++) sum += n[i] * (startWeight - i);
const rest = sum % 11;
return rest < 2 ? 0 : 11 - rest;
};
return check(9, 10) === n[9] && check(10, 11) === n[10];
}
export function isValidCNPJ(input) {
const d = digits(input);
if (d.length !== 14) return false;
if (/^(\d)\1{13}$/.test(d)) return false;
const n = d.split("").map(Number);
const W1 = [5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2];
const W2 = [6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2];
const check = (weights) => {
const sum = weights.reduce((acc, w, i) => acc + n[i] * w, 0);
const rest = sum % 11;
return rest < 2 ? 0 : 11 - rest;
};
return check(W1) === n[12] && check(W2) === n[13];
}
export const isValidTaxId = (v) =>
digits(v).length === 11 ? isValidCPF(v) : isValidCNPJ(v);Two details people get wrong: the repeated-digit guard (000.000.000-00 satisfies the
arithmetic and is still not a CPF) and the remainder rule — when sum % 11 is 0 or 1 the check
digit is 0, not 11 - rest. Skip either and you will accept garbage. The full derivation,
including the alphanumeric CNPJ that Receita Federal began issuing in 2026, is in
the CPF/CNPJ check-digit guide.
Adding a PIX QR code
Pass pix_copia_cola and the QR is drawn on the invoice. Important scoping detail: the API
renders the code, it does not mint it. The BR Code string comes from you — either from
your bank/PSP's API, or built locally from your own PIX key:
const payload = {
tipo: "fatura",
// ... issuer, customer, items ...
forma_pagamento: "PIX",
pix_copia_cola:
"00020101021126580014BR.GOV.BCB.PIX0136123e4567-e12b-12d1-a456-4266554400005204000053039865802BR5913FULANO DE TAL6008BRASILIA62070503***6304XXXX",
};That string is an EMV TLV structure with a CRC-16 at the end — if you want to generate it yourself in Node, the field-by-field breakdown is in the PIX BR Code format guide, and the free PIX tool builds one in the browser for testing.
Node-specific traps
1. res.text() destroys the PDF
If you log the response body with await res.text() for debugging and then also try to save it,
two things go wrong: the stream is already consumed (a second read throws), and the bytes you captured
went through UTF-8 decoding, which mangles anything above 0x7F. A PDF is full of such bytes. Use
arrayBuffer() and only decode as text when the status is not 2xx.
2. Missing Content-Length in Express
res.send(buffer) sets it for you, but res.write()/res.end() does not.
Browsers will still render the PDF, but the viewer cannot show a progress bar and some download managers
truncate. One line, worth setting.
3. Accents and JSON.stringify
This one is a non-issue in Node and it is worth saying explicitly, because it is an issue in other
languages: JSON.stringify emits UTF-8 and fetch sends it as UTF-8, so
"Soluções" arrives intact. Do not "helpfully" strip accents before sending —
the PDF renders them correctly.
4. AbortSignal.timeout vs. the gateway
A 15-second client timeout is generous: median render time is around 34 ms with a PIX QR and 3 ms without (measured over 200 local runs of the same render path, network excluded). If you are routinely hitting 15 s, the problem is network or gateway, not rendering — retry rather than raising the timeout.
Where to go from here
- Need a recibo instead? Set
tipo: "recibo"— you get the narrative "Recebemos de..." paragraph, the amount in words and a signature line, from the same payload. See the receipt guide. - Not sure whether you legally need an NF-e instead of a formatted invoice? Read Brazilian fiscal documents explained before you ship anything to real customers.
- The complete request/response contract lives in the OpenAPI spec — point your code generator at it if you prefer a typed client.
Frequently asked questions
Do I need any native dependency or headless browser?
No. The examples use only Node's built-in fetch and fs. On the server side the PDF is produced by a pure-JS engine (pdf-lib), not by printing HTML in Chromium — which is why cold starts are milliseconds rather than seconds.
Can I call the API directly from the browser?
You should not. The API key is a server-side credential and a browser call would expose it, plus CORS is not configured for arbitrary origins. Proxy through your own backend, as in the Express example. If you need a no-key, client-side option for one-off documents, the free browser generator runs the same rendering logic entirely on the client.
How do I generate a multi-page invoice?
Just send more line items. Pagination is automatic: the table header repeats on each page and page numbers are correct. The hard cap is 200 items or ~200 KB of JSON, whichever comes first, after which you get a 413.
Is the output deterministic enough to cache?
Yes, with one caveat: if you omit data, the API stamps the document with the current server date, so the same payload changes at midnight UTC. Send an explicit data and the output is stable, which makes hashing the payload a valid cache key.
Generate these PDFs from your own code
FaturaPDF turns a JSON payload into a ready-to-send Brazilian invoice or receipt PDF — validated CPF/CNPJ, R$ formatting, amount in words, optional PIX QR. Free tier: 20 documents/month, no credit card.
Get an API key on RapidAPI → Or try the free browser generator