Generate a Brazilian invoice PDF in Go
Typed structs, a shared http.Client, context deadlines and no ioutil — plus why omitempty matters more than usual with this API.
Go is a good fit here: the whole integration is one POST, one status check and a byte slice. The
interesting decisions are about types — how to model optional fields so you do not send
"vencimento": "" by accident — and about not creating a new http.Client per call.
The Brazilian domain rules the API applies to your data — check-digit validation on CPF/CNPJ and BRL formatting with integer-cent arithmetic — are documented separately; this page is about the wire.
Requirements
- Go 1.21+ (nothing here is exotic;
log/slogin the examples is 1.21). No third-party dependency. - A key from the RapidAPI listing, in
RAPIDAPI_KEY.
Modelling the payload
The field names are Portuguese because they mirror the document vocabulary. Tag them and use
pointers or omitempty for optional fields:
package faturapdf
type Party struct {
Nome string `json:"nome"`
Documento string `json:"documento,omitempty"` // CPF or CNPJ; check digits verified
Endereco string `json:"endereco,omitempty"`
Email string `json:"email,omitempty"`
Telefone string `json:"telefone,omitempty"`
}
type Item struct {
Descricao string `json:"descricao"`
Quantidade float64 `json:"quantidade,omitempty"` // defaults to 1 server-side
ValorUnitario float64 `json:"valor_unitario"` // reais, NOT cents
}
type Request struct {
Tipo string `json:"tipo,omitempty"` // "fatura" (default) | "recibo"
Numero string `json:"numero,omitempty"`
Data string `json:"data,omitempty"` // "2006-01-02" or "02/01/2006"
Vencimento string `json:"vencimento,omitempty"`
Emitente Party `json:"emitente"`
Destinatario Party `json:"destinatario"`
Itens []Item `json:"itens"`
Desconto float64 `json:"desconto,omitempty"`
FormaPagamento string `json:"forma_pagamento,omitempty"`
Observacoes string `json:"observacoes,omitempty"`
PixCopiaCola string `json:"pix_copia_cola,omitempty"`
MostrarValorPorExtenso bool `json:"mostrar_valor_por_extenso,omitempty"`
}omitempty is load-bearing here
Without it, an unset Data serializes as "data": "". The API treats an empty string as
"not provided" and stamps today's date — so that one is harmless. But an unset
ValorUnitario with omitempty would disappear, and that field is required: you would
get a 400 instead of a zero-priced line. That is why ValorUnitario above deliberately has no
omitempty. Think about each field rather than tagging them all the same way.
The client
package faturapdf
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
const Host = "brazilian-invoice-receipt-pdf-api-cpf-cnpj.p.rapidapi.com"
// APIError carries the structured error body the API returns on 4xx/5xx.
type APIError struct {
Status int `json:"-"`
Code string `json:"error"`
Message string `json:"message"`
}
func (e *APIError) Error() string {
return fmt.Sprintf("faturapdf: [%d] %s: %s", e.Status, e.Code, e.Message)
}
// Problems splits the semicolon-separated validation message into one item per problem.
func (e *APIError) Problems() []string {
if e.Code != "invalid_params" {
return nil
}
return strings.Split(e.Message, "; ")
}
// Retryable reports whether re-sending the identical request could succeed.
func (e *APIError) Retryable() bool {
switch e.Status {
case http.StatusInternalServerError, http.StatusBadGateway,
http.StatusServiceUnavailable, http.StatusGatewayTimeout:
return true
}
return false
}
type Client struct {
key string
http *http.Client
}
func New(key string) *Client {
return &Client{
key: key,
// One client, reused. Creating one per call leaks connections and
// forces a TLS handshake every time.
http: &http.Client{
Timeout: 20 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 20,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
},
},
}
}
// Generate returns the raw PDF bytes.
func (c *Client) Generate(ctx context.Context, in Request) ([]byte, error) {
body, err := json.Marshal(in)
if err != nil {
return nil, fmt.Errorf("faturapdf: marshal: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
"https://"+Host+"/invoice", bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-RapidAPI-Key", c.key)
req.Header.Set("X-RapidAPI-Host", Host)
res, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("faturapdf: request: %w", err)
}
defer res.Body.Close()
// Cap the read: a PDF is tens of KB; anything enormous is a bug or an attack.
data, err := io.ReadAll(io.LimitReader(res.Body, 20<<20))
if err != nil {
return nil, fmt.Errorf("faturapdf: read body: %w", err)
}
if res.StatusCode != http.StatusOK {
apiErr := &APIError{Status: res.StatusCode}
if jsonErr := json.Unmarshal(data, apiErr); jsonErr != nil {
apiErr.Code, apiErr.Message = "http_error", strings.TrimSpace(string(data))
}
return nil, apiErr
}
return data, nil
}| Status | Meaning | Retry? |
|---|---|---|
| 200 | Raw PDF bytes, Content-Type: application/pdf | — |
400 invalid_params | Every validation failure at once, semicolon-separated (includes which CPF/CNPJ failed its check digit) | No — fix the payload |
401 unauthorized | Request did not come through the RapidAPI gateway | No |
| 405 / 413 | Wrong method / body over ~200 KB or 200 line items | No |
| 429 | Gateway quota exhausted | Only if quota can free up |
500 render_failed · 504 render_timeout | Transient server-side failure | Yes |
Calling it
package main
import (
"context"
"log"
"os"
"time"
"example.com/app/faturapdf"
)
func main() {
client := faturapdf.New(os.Getenv("RAPIDAPI_KEY"))
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
pdf, err := client.Generate(ctx, faturapdf.Request{
Tipo: "fatura",
Numero: "0001/2026",
Data: time.Now().Format("2006-01-02"),
Vencimento: time.Now().AddDate(0, 0, 15).Format("2006-01-02"),
Emitente: faturapdf.Party{
Nome: "Atlas Solucoes Digitais LTDA",
Documento: "11.222.333/0001-81",
Endereco: "Av. Paulista, 1000 - Sao Paulo/SP",
},
Destinatario: faturapdf.Party{
Nome: "Comercio Silva & Filhos ME",
Documento: "22.333.444/0001-81",
},
Itens: []faturapdf.Item{
{Descricao: "Consultoria de implementacao", Quantidade: 20, ValorUnitario: 250},
{Descricao: "Licenca mensal", Quantidade: 1, ValorUnitario: 349.90},
},
Desconto: 100,
FormaPagamento: "PIX",
})
if err != nil {
var apiErr *faturapdf.APIError
if errors.As(err, &apiErr) {
for _, p := range apiErr.Problems() {
log.Printf("field error: %s", p)
}
}
log.Fatal(err)
}
if err := os.WriteFile("fatura.pdf", pdf, 0o600); err != nil {
log.Fatal(err)
}
log.Printf("wrote fatura.pdf (%d bytes)", len(pdf))
}"2006-01-02" is Go's reference layout for ISO dates and "02/01/2006" is the Brazilian
one. The API accepts both on input and always prints DD/MM/YYYY, so send whichever your data
already has.
Serving it from an HTTP handler
func (s *Server) handleInvoicePDF(w http.ResponseWriter, r *http.Request) {
order, err := s.orders.Find(r.Context(), chi.URLParam(r, "id"))
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
// Bound the upstream call so a slow gateway cannot pin this handler open.
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
pdf, err := s.invoices.Generate(ctx, payloadFor(order))
if err != nil {
slog.Error("invoice generation failed", "order", order.ID, "err", err)
http.Error(w, "could not generate invoice", http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "application/pdf")
w.Header().Set("Content-Length", strconv.Itoa(len(pdf)))
w.Header().Set("Content-Disposition",
fmt.Sprintf("inline; filename=%q", "fatura-"+order.Number+".pdf"))
w.WriteHeader(http.StatusOK)
_, _ = w.Write(pdf)
}Set headers before WriteHeader; anything set afterwards is ignored, which is the
classic Go mistake that produces a PDF served as text/plain.
Retry with backoff
func (c *Client) GenerateWithRetry(ctx context.Context, in Request, tries int) ([]byte, error) {
var lastErr error
for attempt := 0; attempt < tries; attempt++ {
pdf, err := c.Generate(ctx, in)
if err == nil {
return pdf, nil
}
lastErr = err
var apiErr *APIError
if errors.As(err, &apiErr) && !apiErr.Retryable() {
return nil, err // 400/401/413 - retrying changes nothing
}
if ctx.Err() != nil {
return nil, ctx.Err() // caller gave up; stop immediately
}
// 400ms, 800ms, 1600ms with +/-30% jitter
base := time.Duration(400*(1<<attempt)) * time.Millisecond
jitter := time.Duration(rand.Int63n(int64(base) * 6 / 10))
select {
case <-time.After(base*7/10 + jitter):
case <-ctx.Done():
return nil, ctx.Err()
}
}
return nil, lastErr
}Batch generation with a worker pool
func GenerateAll(ctx context.Context, c *Client, reqs []Request, workers int) ([][]byte, error) {
out := make([][]byte, len(reqs))
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(workers) // errgroup handles the semaphore for you
for i, req := range reqs {
i, req := i, req
g.Go(func() error {
pdf, err := c.GenerateWithRetry(ctx, req, 3)
if err != nil {
return fmt.Errorf("invoice %d: %w", i, err)
}
out[i] = pdf
return nil
})
}
return out, g.Wait()
}errgroup cancels the shared context on the first error, so a 429 (quota exhausted) stops the
whole batch instead of hammering the gateway 400 more times.
CPF/CNPJ validation in Go
package taxid
import "strings"
func digitsOnly(s string) []int {
out := make([]int, 0, len(s))
for _, r := range s {
if r >= '0' && r <= '9' {
out = append(out, int(r-'0'))
}
}
return out
}
func allSame(n []int) bool {
for _, d := range n[1:] {
if d != n[0] {
return false
}
}
return true
}
func checkDigit(n []int, weights []int) int {
sum := 0
for i, w := range weights {
sum += n[i] * w
}
if rest := sum % 11; rest >= 2 {
return 11 - rest
}
return 0
}
func ValidCPF(s string) bool {
n := digitsOnly(s)
if len(n) != 11 || allSame(n) {
return false
}
w1 := []int{10, 9, 8, 7, 6, 5, 4, 3, 2}
w2 := []int{11, 10, 9, 8, 7, 6, 5, 4, 3, 2}
return checkDigit(n, w1) == n[9] && checkDigit(n, w2) == n[10]
}
func ValidCNPJ(s string) bool {
n := digitsOnly(s)
if len(n) != 14 || allSame(n) {
return false
}
w1 := []int{5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2}
w2 := append([]int{6}, w1...)
return checkDigit(n, w1) == n[12] && checkDigit(n, w2) == n[13]
}
func Valid(s string) bool {
if len(digitsOnly(s)) == 11 {
return ValidCPF(s)
}
return ValidCNPJ(s)
}
// Mask formats digits as 000.000.000-00 or 00.000.000/0000-00.
func Mask(s string) string {
n := digitsOnly(s)
var b strings.Builder
switch len(n) {
case 11:
for i, d := range n {
if i == 3 || i == 6 { b.WriteByte('.') } else if i == 9 { b.WriteByte('-') }
b.WriteByte(byte('0' + d))
}
case 14:
for i, d := range n {
switch i {
case 2, 5: b.WriteByte('.')
case 8: b.WriteByte('/')
case 12: b.WriteByte('-')
}
b.WriteByte(byte('0' + d))
}
default:
return s
}
return b.String()
}Note w2 := append([]int{6}, w1...) — this allocates a new slice rather than sharing
w1's backing array, which matters if you ever reuse the weights. The algorithm itself is derived
in the check-digit guide.
Go-specific notes
Do not defer inside a loop
In a batch, defer res.Body.Close() inside the loop keeps every response open until the
function returns. The client above closes inside Generate, which is called once per document —
correct by construction.
io.ReadAll vs streaming
A generated invoice is tens of kilobytes, so reading it fully is fine and makes error handling much
simpler (you need the body to parse the error JSON anyway). Streaming with io.Copy straight to
the ResponseWriter would save an allocation but leaves you unable to inspect a non-200 body.
ioutil is deprecated
ioutil.ReadAll and ioutil.WriteFile have been deprecated since Go 1.16 —
use io.ReadAll and os.WriteFile.
Frequently asked questions
Is there an official Go SDK?
No, and you do not need one — the client above is the entire integration, roughly 80 lines with no dependency. If you prefer generated code, the OpenAPI spec works with oapi-codegen.
How do I test without calling the API?
httptest.NewServer returning a fixture PDF, and inject its URL into the client. Make the host configurable rather than a constant if you want that; the example keeps it constant for clarity.
Can I run this from a Lambda or Cloud Run?
Yes. Keep the *Client in a package-level variable so connections survive between invocations — creating it inside the handler defeats connection pooling and adds a TLS handshake to every cold path.
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