Integration guide · Ruby on Rails

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.

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

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

rubyapp/clients/fatura_pdf_client.rb
# 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
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

Building the payload from a model

rubyapp/services/invoice_payload.rb
# 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

rubyapp/controllers/invoices_controller.rb
# 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
end
send_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

rubyapp/jobs/generate_invoice_pdf_job.rb
# 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)
end

ActiveStorage 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

rubytax_id.rb
# 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
end
Store digits, display masked

Persisting "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

rubyspec/clients/fatura_pdf_client_spec.rb
# 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
end

Ruby-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

Related guides