Integration guide · Python

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.

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

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 (or httpx for 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:

pythonpayload.py
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.",
}
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

pythoninvoice.py
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)")
.text will destroy your PDF

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

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.

The invalid_params message concatenates every failure with semicolons, so one round trip tells you everything that is wrong:

pythonerrors.py
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:
        raise

A 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:

pythonsession.py
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 session
allowed_methods is the line people forget

urllib3 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:

pythonbatch.py
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

pythonviews.py
# --- 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"'},
    )
Do not use FileResponse for bytes you already hold

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:

pythonmoney.py
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:

pythontaxid.py
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

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

Related guides