Generate a Brazilian invoice PDF in Python
requests, httpx, Django and FastAPI — with the .text versus .content trap that silently corrupts every binary download in Python.
Python makes HTTP easy and binary responses slightly treacherous. The library will happily hand you
a str where you wanted bytes, guess an encoding, and hand back a file that no PDF reader
will open. This guide gets it right, and covers the Brazilian specifics you cannot skip:
CPF/CNPJ check digits, R$ 1.234,56 formatting and DD/MM/YYYY dates.
Requirements
- Python 3.9+ and
pip install requests(orhttpxfor the async version). - A key from the RapidAPI listing — free tier is 20 documents/month, no card.
- Put the key in an environment variable, never in the repo:
export RAPIDAPI_KEY=...
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:
payload = {
"tipo": "fatura", # or "recibo"
"numero": "0001/2026",
"data": "2026-08-07", # ISO in, DD/MM/YYYY printed
"vencimento": "2026-08-22",
"emitente": {
"nome": "Atlas Solucoes Digitais LTDA",
"documento": "11.222.333/0001-81", # CNPJ - check digits are verified
"endereco": "Av. Paulista, 1000 - Sao Paulo/SP",
},
"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
import os
import requests
HOST = "brazilian-invoice-receipt-pdf-api-cpf-cnpj.p.rapidapi.com"
URL = f"https://{HOST}/invoice"
class InvoiceError(RuntimeError):
def __init__(self, status, code, message):
super().__init__(f"[{status}] {code}: {message}")
self.status, self.code, self.message = status, code, message
def generate_invoice(payload: dict, timeout: float = 15.0) -> bytes:
response = requests.post(
URL,
json=payload, # sets Content-Type and encodes UTF-8
headers={
"X-RapidAPI-Key": os.environ["RAPIDAPI_KEY"],
"X-RapidAPI-Host": HOST,
},
timeout=timeout, # ALWAYS pass this; requests has no default
)
if response.status_code != 200:
try:
detail = response.json()
except ValueError:
detail = {"error": "http_error", "message": response.text[:300]}
raise InvoiceError(response.status_code, detail.get("error"), detail.get("message"))
# .content = raw bytes. .text = decoded str, which WILL corrupt the PDF.
return response.content
if __name__ == "__main__":
pdf = generate_invoice(payload)
with open("fatura.pdf", "wb") as fh: # "wb", not "w"
fh.write(pdf)
print(f"wrote fatura.pdf ({len(pdf)} bytes)")response.text decodes the body into a str using a guessed charset. Every byte the
decoder cannot map is replaced or re-encoded, and a PDF is roughly half non-ASCII bytes. The file will
be written, will have a plausible size, and will not open. Use response.content and open the file
in "wb" mode. If you are on Windows and use "w", Python also rewrites every 0x0A
into 0x0D 0x0A — same broken result, different cause.
Reading the errors
| 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. |
The invalid_params message concatenates every failure with semicolons, so one round trip tells
you everything that is wrong:
try:
pdf = generate_invoice(payload)
except InvoiceError as err:
if err.code == "invalid_params":
# Split it back into a list for a form-level error display.
for problem in err.message.split("; "):
print("field error:", problem)
elif err.status == 429:
print("monthly quota exhausted - stop the batch, do not retry")
else:
raiseA session with retries and connection reuse
For anything that generates more than one document, build a Session. You get connection
pooling (a real latency win — no TLS handshake per invoice) and a declarative retry policy:
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
def build_session() -> requests.Session:
session = requests.Session()
retry = Retry(
total=3,
backoff_factor=0.5, # 0.5s, 1s, 2s
status_forcelist=[500, 502, 503, 504], # NOT 400 - that is your payload
allowed_methods=["POST"], # urllib3 excludes POST by default
raise_on_status=False,
)
session.mount("https://", HTTPAdapter(max_retries=retry, pool_maxsize=10))
session.headers.update({
"X-RapidAPI-Key": os.environ["RAPIDAPI_KEY"],
"X-RapidAPI-Host": HOST,
})
return sessionurllib3 treats POST as non-idempotent and will not retry it unless you say so. Here it is safe: generation has no side effects, nothing is stored, and the same payload always yields the same document — so a retried POST cannot create a duplicate record anywhere.
Async with httpx
Generating a batch — end-of-month invoices for every customer — is I/O bound, so concurrency pays off immediately. Bound it with a semaphore so you do not open 500 sockets at once:
import asyncio
import httpx
async def generate_many(payloads: list[dict], concurrency: int = 8) -> list[bytes]:
limits = httpx.Limits(max_connections=concurrency)
gate = asyncio.Semaphore(concurrency)
async with httpx.AsyncClient(
base_url=f"https://{HOST}",
headers={"X-RapidAPI-Key": os.environ["RAPIDAPI_KEY"], "X-RapidAPI-Host": HOST},
timeout=httpx.Timeout(15.0, connect=5.0),
limits=limits,
) as client:
async def one(body: dict) -> bytes:
async with gate:
r = await client.post("/invoice", json=body)
if r.status_code != 200:
raise InvoiceError(r.status_code, *_detail(r))
return r.content # httpx uses .content for bytes too
return await asyncio.gather(*(one(p) for p in payloads))
def _detail(r):
try:
d = r.json()
return d.get("error"), d.get("message")
except ValueError:
return "http_error", r.text[:300]Returning the PDF from Django and FastAPI
# --- Django ---
from django.http import HttpResponse, JsonResponse
def invoice_pdf(request, order_id):
order = Order.objects.select_related("customer").get(pk=order_id)
try:
pdf = generate_invoice(build_payload(order))
except InvoiceError as err:
return JsonResponse({"detail": err.message}, status=502)
response = HttpResponse(pdf, content_type="application/pdf")
# inline = open in the browser viewer; attachment = force a download
response["Content-Disposition"] = f'inline; filename="fatura-{order.number}.pdf"'
return response
# --- FastAPI ---
from fastapi import FastAPI, HTTPException
from fastapi.responses import Response
app = FastAPI()
@app.get("/orders/{order_id}/invoice.pdf")
async def invoice(order_id: int):
order = await get_order(order_id)
try:
pdf = await generate_invoice_async(build_payload(order))
except InvoiceError as err:
raise HTTPException(status_code=502, detail=err.message)
return Response(
content=pdf,
media_type="application/pdf",
headers={"Content-Disposition": f'inline; filename="fatura-{order.number}.pdf"'},
)FileResponse expects a path or a file-like object and is for streaming from disk. Writing a
temp file just to stream it back adds I/O, a cleanup problem and a race in multi-worker deployments.
Plain Response(content=pdf, media_type="application/pdf") is correct here.
Decimal, floats and cents
If your prices are Decimal (they should be, in any billing system), remember that
json.dumps cannot serialize Decimal and will raise TypeError. Convert
explicitly and deliberately:
from decimal import Decimal, ROUND_HALF_UP
def money(value: Decimal) -> float:
"""Decimal -> float with exactly 2 decimal places, rounded the way finance expects."""
return float(value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
itens = [
{"descricao": line.title, "quantidade": line.qty, "valor_unitario": money(line.unit_price)}
for line in order.lines
]The float round-trip is safe at this precision — the API immediately converts to integer cents and
sums there, so 0.1 + 0.2 never becomes 0.30000000000000004 on the printed total. What is
not safe is str(Decimal) into a JSON string field: the API expects a number and will reject
"349.90" as a quoted value in valor_unitario... actually it coerces it, but you lose the
type guarantee. Send a number.
Validating CPF/CNPJ in Python
Same mod-11 checksum the API applies, ~25 lines, no dependency. Run it on your form input so a typo never costs an API call:
import re
def _digits(value: str) -> str:
return re.sub(r"\D", "", value or "")
def _check_digit(nums: list[int], weights: list[int]) -> int:
total = sum(n * w for n, w in zip(nums, weights))
rest = total % 11
return 0 if rest < 2 else 11 - rest # the rule everyone gets wrong
def is_valid_cpf(value: str) -> bool:
d = _digits(value)
if len(d) != 11 or d == d[0] * 11: # 111.111.111-11 is arithmetically valid
return False
n = [int(c) for c in d]
return (_check_digit(n[:9], list(range(10, 1, -1))) == n[9]
and _check_digit(n[:10], list(range(11, 1, -1))) == n[10])
def is_valid_cnpj(value: str) -> bool:
d = _digits(value)
if len(d) != 14 or d == d[0] * 14:
return False
n = [int(c) for c in d]
w1 = [5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2]
w2 = [6] + w1
return _check_digit(n[:12], w1) == n[12] and _check_digit(n[:13], w2) == n[13]
def is_valid_tax_id(value: str) -> bool:
return is_valid_cpf(value) if len(_digits(value)) == 11 else is_valid_cnpj(value)Note w2 = [6] + w1 — the CNPJ second-digit weights are the first-digit weights shifted by one,
with a 6 prepended. That is not a coincidence; it falls out of the mod-11 construction, explained in
the check-digit guide along with the alphanumeric CNPJ
that Receita Federal started issuing in 2026.
Python-specific traps
1. json= versus data=
requests.post(url, json=payload) serializes and sets Content-Type: application/json.
data=payload form-encodes a dict and the API answers 400 invalid_json. If you must
pre-serialize, use data=json.dumps(payload) and set the header yourself.
2. Accents and ensure_ascii
json.dumps defaults to ensure_ascii=True, which escapes ç as
\u00e7. That is still valid JSON and decodes correctly on the server, so it works — but if you
are debugging by eye, set ensure_ascii=False so you can read your own payload. Do not strip
accents: "Soluções" renders correctly on the PDF.
3. No default timeout
requests waits forever if you omit timeout. In a web worker that is how a single
slow call takes down a whole process pool. Always pass it.
4. len(pdf) is bytes, not pages
Obvious once said, but a common assertion bug in tests. To assert on content, check the magic
header instead: assert pdf[:5] == b"%PDF-", which is a solid smoke test that you received a
real document and not a JSON error page.
Where to go from here
- Receipts: set
"tipo": "recibo"for the "Recebemos de..." layout with the amount in words — see receipt PDF generation. - Formatting BRL, dates and amounts in words covers what the API does to your numbers, and how to reproduce it in your own UI.
- NF-e, NFS-e, fatura or recibo? — read this before sending anything to a real customer.
Frequently asked questions
Why is my downloaded PDF corrupted in Python?
Almost always one of two things: you used response.text instead of response.content, or you opened the output file in text mode (open(path, 'w')) instead of binary mode ('wb'). Both silently re-encode bytes. A quick check: a healthy file starts with %PDF-.
Does it work with Django, Flask and FastAPI?
Yes — it is a plain HTTPS POST, so any framework works. This guide shows Django's HttpResponse and FastAPI's Response; Flask is send_file(BytesIO(pdf), mimetype='application/pdf') or the same explicit Response pattern.
How do I generate hundreds of invoices at month end?
Use the async httpx pattern with a semaphore of 8–16, and watch your plan quota — the gateway returns 429 once it is exhausted, and in a batch you generally want that to abort the run rather than retry. Rendering itself is milliseconds; your bottleneck is network round-trips, so concurrency is the whole win.
Can I send Decimal values?
Not directly — json.dumps raises TypeError on Decimal. Quantize to 2 places and cast to float, as shown above. The server converts to integer cents before summing, so no floating-point artefact reaches the printed total.
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