Integration guide · Go

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.

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

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/slog in 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:

gotypes.go
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"`
}
Why 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

goclient.go
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
}
StatusMeaningRetry?
200Raw PDF bytes, Content-Type: application/pdf
400 invalid_paramsEvery validation failure at once, semicolon-separated (includes which CPF/CNPJ failed its check digit)No — fix the payload
401 unauthorizedRequest did not come through the RapidAPI gatewayNo
405 / 413Wrong method / body over ~200 KB or 200 line itemsNo
429Gateway quota exhaustedOnly if quota can free up
500 render_failed · 504 render_timeoutTransient server-side failureYes

Calling it

gomain.go
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))
}
Go's date layout, once

"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

gohandler.go
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

goretry.go
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

gobatch.go
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

gotaxid.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

Related guides