Generate a Brazilian invoice PDF in Ruby on Rails
No gem required: Net::HTTP, send_data, an ActiveJob with a sane retry_on, and an ActiveModel validator for CPF/CNPJ.
Ruby's standard library is enough for this integration — no httparty, no
faraday. What deserves care in a Rails app is where the call lives (not in the controller),
how a failure surfaces to the user, and where the resulting bytes end up.
Background reading that will save you debugging time: the CPF/CNPJ check-digit algorithm (the validator at the bottom of this page implements it), BRL and DD/MM/YYYY formatting, and which Brazilian document you are actually generating — a fatura is not a nota fiscal.
A plain Net::HTTP client
# app/clients/fatura_pdf_client.rb
require "net/http"
require "json"
require "uri"
class FaturaPdfClient
HOST = "brazilian-invoice-receipt-pdf-api-cpf-cnpj.p.rapidapi.com".freeze
ENDPOINT = URI("https://#{HOST}/invoice").freeze
class Error < StandardError
attr_reader :status, :code, :problems
def initialize(status:, code:, message:)
@status = status
@code = code
# invalid_params packs every failure into one semicolon-separated message
@problems = code == "invalid_params" ? message.split("; ") : []
super("[#{status}] #{code}: #{message}")
end
def retryable?
[500, 502, 503, 504].include?(status)
end
end
def initialize(api_key: ENV.fetch("RAPIDAPI_KEY"), open_timeout: 5, read_timeout: 15)
@api_key = api_key
@open_timeout = open_timeout
@read_timeout = read_timeout
end
# Returns the raw PDF bytes as a binary-encoded String.
def generate(payload)
request = Net::HTTP::Post.new(ENDPOINT)
request["Content-Type"] = "application/json"
request["X-RapidAPI-Key"] = @api_key
request["X-RapidAPI-Host"] = HOST
request.body = JSON.generate(payload)
response = http.request(request)
unless response.code == "200"
detail = begin
JSON.parse(response.body)
rescue JSON::ParserError
{ "error" => "http_error", "message" => response.body.to_s[0, 300] }
end
raise Error.new(status: response.code.to_i,
code: detail["error"],
message: detail["message"])
end
# force_encoding matters: Net::HTTP may hand back ASCII-8BIT already, but
# being explicit stops anything downstream from re-encoding the bytes.
response.body.dup.force_encoding(Encoding::BINARY)
end
private
def http
@http ||= Net::HTTP.new(ENDPOINT.host, ENDPOINT.port).tap do |h|
h.use_ssl = true
h.open_timeout = @open_timeout
h.read_timeout = @read_timeout
h.keep_alive_timeout = 30 # reuse the TLS connection across calls
end
end
end| 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 |
Building the payload from a model
# app/services/invoice_payload.rb
class InvoicePayload
def self.call(order)
{
tipo: "fatura",
numero: order.number,
data: order.issued_on.iso8601, # "2026-08-07"
vencimento: order.due_on&.iso8601,
emitente: {
nome: Rails.configuration.x.company.legal_name,
documento: Rails.configuration.x.company.cnpj,
endereco: Rails.configuration.x.company.address
},
destinatario: {
nome: order.customer.name,
documento: order.customer.tax_id.presence
}.compact,
itens: order.lines.map do |line|
{
descricao: line.title,
quantidade: line.quantity,
# Money stored as integer cents -> send reais as a Float with 2 places.
valor_unitario: (line.unit_price_cents / 100.0).round(2)
}
end,
desconto: (order.discount_cents / 100.0).round(2),
forma_pagamento: order.payment_method,
observacoes: order.notes
}.compact
end
end.compact twice, deliberately
Rails' Hash#compact drops nil values. Dropping them at the top level keeps
observacoes: nil out of the JSON, and dropping them inside destinatario keeps
documento: nil out — which matters because an empty-string document would be treated as
supplied-but-invalid in some client libraries. Using .presence converts "" to
nil first.
The controller
# app/controllers/invoices_controller.rb
class InvoicesController < ApplicationController
before_action :set_order
def show
pdf = FaturaPdfClient.new.generate(InvoicePayload.call(@order))
send_data pdf,
filename: "fatura-#{@order.number}.pdf",
type: "application/pdf",
disposition: "inline" # "attachment" forces a download
rescue FaturaPdfClient::Error => e
Rails.logger.error("invoice generation failed: #{e.message}")
Sentry.capture_exception(e) if defined?(Sentry)
head :bad_gateway
end
private
def set_order
@order = current_account.orders.find(params[:id])
end
endsend_data, not render plain:
render plain: pdf runs the bytes through Rails' text response path and can apply an
encoding conversion, producing a file that downloads but will not open. send_data is the
binary-safe path and sets Content-Length for you. Also: never send_file a Tempfile you
just wrote — you do not need the disk round-trip.
An ActiveJob for batches
# app/jobs/generate_invoice_pdf_job.rb
class GenerateInvoicePdfJob < ApplicationJob
queue_as :documents
# Retry only transient failures. A 400 means the data is wrong - retrying
# three times just burns quota and delays the alert.
retry_on FaturaPdfClient::Error,
wait: :polynomially_longer,
attempts: 3 do |job, error|
raise error unless error.retryable?
end
discard_on ActiveRecord::RecordNotFound
def perform(order_id)
order = Order.find(order_id)
pdf = FaturaPdfClient.new.generate(InvoicePayload.call(order))
order.invoice_pdf.attach(
io: StringIO.new(pdf),
filename: "fatura-#{order.number}.pdf",
content_type: "application/pdf"
)
order.update!(invoice_generated_at: Time.current)
end
end
# Enqueue the whole month, spaced out so you do not burst the API quota:
Order.ready_to_invoice.find_each.with_index do |order, i|
GenerateInvoicePdfJob.set(wait: i.seconds).perform_later(order.id)
endActiveStorage takes an IO, so StringIO.new(pdf) attaches the bytes
without touching the filesystem. On Ruby 3.x StringIO preserves the binary encoding you set in
the client.
A CPF/CNPJ validator
# app/models/concerns/tax_id.rb
module TaxId
module_function
def digits(value)
value.to_s.gsub(/\D/, "")
end
def check_digit(nums, weights)
sum = weights.each_with_index.sum { |w, i| nums[i] * w }
rest = sum % 11
rest < 2 ? 0 : 11 - rest
end
def valid_cpf?(value)
d = digits(value)
return false unless d.length == 11
return false if d.chars.uniq.size == 1 # 111.111.111-11
n = d.chars.map(&:to_i)
check_digit(n, (2..10).to_a.reverse) == n[9] &&
check_digit(n, (2..11).to_a.reverse) == n[10]
end
def valid_cnpj?(value)
d = digits(value)
return false unless d.length == 14
return false if d.chars.uniq.size == 1
n = d.chars.map(&:to_i)
w1 = [5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2]
check_digit(n, w1) == n[12] && check_digit(n, [6] + w1) == n[13]
end
def valid?(value)
digits(value).length == 11 ? valid_cpf?(value) : valid_cnpj?(value)
end
def mask(value)
d = digits(value)
case d.length
when 11 then d.sub(/(\d{3})(\d{3})(\d{3})(\d{2})/, '\1.\2.\3-\4')
when 14 then d.sub(/(\d{2})(\d{3})(\d{3})(\d{4})(\d{2})/, '\1.\2.\3/\4-\5')
else value.to_s
end
end
end
# app/validators/cpf_or_cnpj_validator.rb
class CpfOrCnpjValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
return if value.blank?
return if TaxId.valid?(value)
record.errors.add(attribute, options[:message] || :invalid_tax_id)
end
end
# app/models/customer.rb
class Customer < ApplicationRecord
validates :tax_id, cpf_or_cnpj: true, allow_blank: true
before_save { self.tax_id = TaxId.digits(tax_id).presence } # store digits only
endPersisting "11222333000181" and masking on render avoids the classic duplicate-record bug
where 11.222.333/0001-81 and 11222333000181 are two different rows. Add a unique index
on the normalized column.
Testing with WebMock
# spec/clients/fatura_pdf_client_spec.rb
RSpec.describe FaturaPdfClient do
let(:url) { "https://#{described_class::HOST}/invoice" }
it "returns binary pdf bytes" do
stub_request(:post, url).to_return(
status: 200,
body: File.binread(Rails.root.join("spec/fixtures/sample.pdf")),
headers: { "Content-Type" => "application/pdf" }
)
pdf = described_class.new(api_key: "test").generate({})
expect(pdf[0, 5]).to eq("%PDF-") # the only assertion that matters
expect(pdf.encoding).to eq(Encoding::BINARY)
end
it "raises with every validation problem" do
stub_request(:post, url).to_return(
status: 400,
body: { error: "invalid_params",
message: "emitente.documento invalido; itens[0].valor_unitario e obrigatorio" }.to_json,
headers: { "Content-Type" => "application/json" }
)
expect { described_class.new(api_key: "test").generate({}) }
.to raise_error(described_class::Error) { |e| expect(e.problems.size).to eq(2) }
end
endRuby-specific notes
Encoding, once and properly
Ruby Strings carry an encoding tag. If a PDF ends up tagged UTF-8 and something calls
encode or scrub on it, bytes get replaced and the file breaks. Tag it
BINARY (alias ASCII-8BIT) at the boundary, as the client does, and it will survive
the trip through ActiveStorage or send_data.
Do not puts the PDF
Obvious, but a real incident: a Rails.logger.debug(response.body) left in place writes
megabytes of binary into the log and, on some log shippers, kills the process. Log
response.body.bytesize instead.
Timeouts are not set by default
Net::HTTP defaults to 60 s read timeout, which is far too long here — median render is
milliseconds. A 15 s read timeout with a 5 s open timeout fails fast and lets your retry logic do
its job.
Frequently asked questions
Do I need a gem like HTTParty or Faraday?
No. Net::HTTP from the standard library is shown above and handles keep-alive, TLS and timeouts. Use Faraday if your app already standardizes on it for middleware and instrumentation — the request shape is identical.
How do I attach the PDF to an ActionMailer email?
attachments['fatura.pdf'] = { mime_type: 'application/pdf', content: pdf }. Pass the binary string directly; Rails base64-encodes it for the MIME part.
Where should the generated PDF live?
ActiveStorage on S3 or equivalent, attached to the order, once the document is final. Regenerating on every request costs quota and — more importantly — risks producing a different document later if the underlying data changed.
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