Integration guide · Node.js

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.

Updated 2026-08-07 · 9 min read · by the FaturaPDF team

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.timeout and node:fs/promises — zero dependencies. On Node 16 or older, add node-fetch and 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.
Keep the key out of your source

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:

jsonpayload.json
{
  "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."
}
FieldRequiredWhat it does
tipono"fatura" (invoice, default) or "recibo" (receipt). Changes the whole layout, not just a label.
numeronoYour document number, free text — e.g. "0001/2026".
datanoISO YYYY-MM-DD or DD/MM/YYYY. Omitted = today. Always printed as DD/MM/YYYY.
vencimentonoDue date, same formats. Only makes sense on fatura.
emitente.nomeyesIssuer legal name.
emitente.documentoyesIssuer CPF or CNPJ. Check digits are verified — a wrong one returns 400, it is not silently printed.
destinatario.nomeyesCustomer name.
destinatario.documentonoCustomer CPF/CNPJ. Optional, but validated when present.
itens[].descricaoyesLine description, up to 200 chars.
itens[].quantidadenoDefaults to 1.
itens[].valor_unitarioyesUnit price as a number. Do not send totals — the API computes subtotal and total in integer cents.
descontonoFlat discount subtracted from the subtotal.
forma_pagamentonoFree text, e.g. "PIX", "Boleto".
observacoesnoNotes block at the bottom, up to 2000 chars.
pix_copia_colanoA PIX BR Code string you already generated; the API renders it as a scannable QR on the PDF.
mostrar_valor_por_extensonotrue 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:

javascriptinvoice.mjs
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.

The one mistake everybody makes

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.

javascript400 response
// 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" }
StatuserrorCause & fix
200Body is raw PDF bytes, Content-Type: application/pdf.
400invalid_jsonBody was not parseable JSON. Usually a serialization bug on your side.
400invalid_paramsThe useful one. message lists every problem at once, semicolon-separated — including which CPF/CNPJ failed its check digit.
401unauthorizedThe request did not arrive through the RapidAPI gateway. Check X-RapidAPI-Key and X-RapidAPI-Host.
405method_not_allowedYou sent GET. Both endpoints are POST-only.
413payload_too_largeBody over ~200 KB, or more than 200 line items.
429Returned by the RapidAPI gateway when your plan quota is exhausted, before the API is reached.
500 / 504render_failed / render_timeoutRendering 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.

javascriptserver.mjs
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);
Money in, money out

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.

javascriptretry.mjs
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:

javascripttaxid.mjs
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:

javascriptpix.mjs
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

Related guides