May 21, 2026

PDF API in Ruby: Rails, Sidekiq & Production Patterns

RubyRailsPDF APISidekiqproduction

Ruby's ecosystem — Faraday for HTTP, Sidekiq for async jobs, Rails for the web layer — maps cleanly onto PDF API integration. This guide focuses on production-grade Ruby patterns: a reusable service object, retry middleware, async PDF generation via Sidekiq, and webhook-based delivery.

For a quick comparison of Ruby and Go with basic code examples, see the Ruby and Go quickstart guide. For Node.js, Python, and PHP, see the language quickstart guide. If you prefer Go's goroutine-based approach, see the Go PDF API Guide.

Prerequisites

# Set your API key
export FUNBREW_PDF_API_KEY="sk-your-api-key"
# Gemfile
gem 'faraday'
gem 'faraday-retry'
gem 'sidekiq'       # For async generation

Sign up for free to get your API key. See the API documentation for the full option reference.

Service Object Pattern

Encapsulate all PDF API calls in a single service class. This makes it easy to swap implementations, add logging, and test in isolation.

# app/services/pdf_generator.rb
require 'faraday'
require 'faraday/retry'
require 'json'

class PdfGenerator
  BASE_URL = 'https://api.pdf.funbrew.cloud'.freeze

  # Retry on rate limits and transient server errors.
  RETRY_OPTIONS = {
    max: 3,
    interval: 1.0,
    backoff_factor: 2,
    retry_statuses: [429, 500, 502, 503],
    retry_if: ->(_env, _err) { true }
  }.freeze

  def initialize(api_key: ENV.fetch('FUNBREW_PDF_API_KEY'))
    @conn = Faraday.new(url: BASE_URL) do |f|
      f.request :retry, RETRY_OPTIONS
      f.options.timeout      = 60
      f.options.open_timeout = 10
      f.headers['Authorization'] = "Bearer #{api_key}"
      f.headers['Content-Type']  = 'application/json'
    end
  end

  # Generate from raw HTML. Returns PDF binary on success, raises on error.
  def from_html(html, format: 'A4', engine: 'quality', **opts)
    payload = { html: html, engine: engine, format: format }.merge(opts)
    response = @conn.post('/v1/pdf/from-html', payload.to_json)
    raise PdfGenerationError.new(response.status, response.body) unless response.success?
    response.body
  end

  # Generate from a registered template.
  def from_template(template_id, data, format: 'A4')
    payload = { template_id: template_id, data: data, format: format }
    response = @conn.post('/v1/pdf/from-template', payload.to_json)
    raise PdfGenerationError.new(response.status, response.body) unless response.success?
    response.body
  end

  # Convert a URL to PDF.
  def from_url(url, format: 'A4', wait_for: 'networkidle')
    payload = { url: url, format: format, wait_for: wait_for }
    response = @conn.post('/v1/pdf/from-url', payload.to_json)
    raise PdfGenerationError.new(response.status, response.body) unless response.success?
    response.body
  end

  class PdfGenerationError < StandardError
    attr_reader :status, :body

    def initialize(status, body)
      @status = status
      @body   = body
      super("PDF API error #{status}: #{body}")
    end

    def retryable?
      [429, 500, 502, 503].include?(@status)
    end
  end
end

Usage anywhere in your Rails app:

generator = PdfGenerator.new

# On-demand HTML generation
pdf = generator.from_html('<h1>Invoice</h1><p>Total: $1,000</p>')
File.binwrite('invoice.pdf', pdf)

# Template-based generation
pdf = generator.from_template('invoice-v1', {
  company_name:   'Acme Corp',
  invoice_number: 'INV-2026-0042',
  items: [
    { name: 'Web Consulting', quantity: 1, price: 1500 },
    { name: 'Design Work',    quantity: 3, price:  500 },
  ],
  total: 3000
})

Rails Controller Integration

Serve PDFs as inline previews or forced downloads from a Rails controller.

# app/controllers/invoices_controller.rb
class InvoicesController < ApplicationController
  before_action :set_invoice, only: [:pdf]

  # GET /invoices/:id.pdf
  def pdf
    generator = PdfGenerator.new

    pdf_bytes = generator.from_template('invoice-v1', invoice_payload)

    respond_to do |format|
      format.pdf do
        send_data pdf_bytes,
          filename:    "invoice-#{@invoice.number}.pdf",
          type:        'application/pdf',
          disposition: params[:download] ? 'attachment' : 'inline'
      end
    end
  rescue PdfGenerator::PdfGenerationError => e
    Rails.logger.error("PDF generation failed: #{e.message}")
    render json: { error: 'PDF generation failed' }, status: :service_unavailable
  end

  private

  def set_invoice
    @invoice = Invoice.find(params[:id])
    authorize @invoice  # Pundit / CanCanCan authorization
  end

  def invoice_payload
    {
      company_name:   @invoice.company_name,
      invoice_number: @invoice.number,
      issued_at:      @invoice.issued_at.strftime('%Y-%m-%d'),
      items: @invoice.line_items.map do |item|
        { name: item.name, quantity: item.quantity, price: item.unit_price_cents / 100.0 }
      end,
      total: @invoice.total_cents / 100.0
    }
  end
end

Register the MIME type in config/initializers/mime_types.rb:

Mime::Type.register 'application/pdf', :pdf

Sidekiq Async PDF Generation

For large or low-priority PDFs, move generation off the request thread. Sidekiq workers are the standard pattern in Rails apps.

# app/jobs/generate_invoice_pdf_job.rb
class GenerateInvoicePdfJob
  include Sidekiq::Job

  sidekiq_options queue: 'pdf', retry: 3

  def perform(invoice_id, notify_user: true)
    invoice   = Invoice.find(invoice_id)
    generator = PdfGenerator.new

    pdf_bytes = generator.from_template('invoice-v1', invoice_payload(invoice))

    # Store in Active Storage (or S3 / GCS directly)
    invoice.pdf_attachment.attach(
      io:       StringIO.new(pdf_bytes),
      filename: "invoice-#{invoice.number}.pdf",
      content_type: 'application/pdf'
    )

    invoice.update!(pdf_generated_at: Time.current)

    # Email the PDF to the customer
    InvoiceMailer.pdf_ready(invoice).deliver_later if notify_user
  rescue PdfGenerator::PdfGenerationError => e
    Rails.logger.error("[GenerateInvoicePdfJob] Invoice #{invoice_id} failed: #{e.message}")
    raise  # Let Sidekiq's retry mechanism handle it
  end

  private

  def invoice_payload(invoice)
    {
      company_name:   invoice.company_name,
      invoice_number: invoice.number,
      issued_at:      invoice.issued_at.strftime('%Y-%m-%d'),
      items: invoice.line_items.map { |i|
        { name: i.name, quantity: i.quantity, price: i.unit_price_cents / 100.0 }
      },
      total: invoice.total_cents / 100.0
    }
  end
end

Enqueue from a controller or model callback:

# Enqueue after invoice creation
class Invoice < ApplicationRecord
  after_commit :enqueue_pdf_generation, on: :create

  private

  def enqueue_pdf_generation
    GenerateInvoicePdfJob.perform_async(id)
  end
end

Sidekiq Configuration

# config/sidekiq.yml
:queues:
  - [critical, 3]
  - [default, 2]
  - [pdf, 1]        # PDF generation — lower priority than critical paths

:concurrency: 10

Set a longer timeout for PDF jobs since generation can take several seconds:

# config/initializers/sidekiq.rb
Sidekiq.configure_server do |config|
  config.server_middleware do |chain|
    # Optional: add instrumentation middleware here
  end
end

Batch PDF Generation

For month-end invoice runs or bulk certificate issuance, generate PDFs in parallel using Ruby threads.

# lib/pdf_batch_processor.rb
require 'parallel'

class PdfBatchProcessor
  DEFAULT_CONCURRENCY = 5

  def initialize(concurrency: DEFAULT_CONCURRENCY)
    @concurrency = concurrency
    # Each thread needs its own Faraday connection — PdfGenerator is not thread-safe
    # because Faraday connections are not designed to be shared across threads.
    @thread_local_generator = Hash.new { |h, k| h[k] = PdfGenerator.new }
  end

  # Generates PDFs for all items in parallel.
  # Returns array of { id:, status: :ok/:error, path:, error: } hashes.
  def run(items, &html_builder)
    Parallel.map(items, in_threads: @concurrency) do |item|
      generator = @thread_local_generator[Thread.current.object_id]

      begin
        html      = html_builder.call(item)
        pdf_bytes = generator.from_html(html, engine: 'fast')
        path      = write_pdf(item[:id], pdf_bytes)
        { id: item[:id], status: :ok, path: path, bytes: pdf_bytes.bytesize }
      rescue PdfGenerator::PdfGenerationError => e
        { id: item[:id], status: :error, error: e.message }
      end
    end
  end

  private

  def write_pdf(id, bytes)
    path = Rails.root.join('tmp', 'pdfs', "#{id}.pdf")
    FileUtils.mkdir_p(path.dirname)
    File.binwrite(path, bytes)
    path.to_s
  end
end

Usage for a month-end invoice run:

invoices = Invoice.where(period: Date.today.beginning_of_month..)

processor = PdfBatchProcessor.new(concurrency: 5)
results   = processor.run(invoices.map { |i| { id: i.id, invoice: i } }) do |item|
  ApplicationController.render(
    template: 'invoices/pdf',
    assigns:  { invoice: item[:invoice] },
    layout:   'pdf'
  )
end

success_count = results.count { |r| r[:status] == :ok }
Rails.logger.info("Batch complete: #{success_count}/#{results.size} invoices generated")

For detailed batch patterns and queue-based processing, see the PDF API Batch Processing Guide.

Error Handling

The service object pattern above raises PdfGenerator::PdfGenerationError on failure. For higher-level retry logic outside of Faraday's built-in middleware:

# lib/pdf_generator_with_circuit_breaker.rb
class PdfGeneratorWithCircuitBreaker
  MAX_FAILURES    = 5
  OPEN_DURATION   = 60  # seconds

  def initialize
    @generator    = PdfGenerator.new
    @failures     = 0
    @circuit_open = false
    @opened_at    = nil
    @mutex        = Mutex.new
  end

  def from_html(html, **opts)
    @mutex.synchronize do
      if @circuit_open
        if Time.current - @opened_at > OPEN_DURATION
          # Half-open: try one request
          @circuit_open = false
          @failures     = 0
        else
          raise PdfGenerator::PdfGenerationError.new(503, 'Circuit breaker open')
        end
      end
    end

    result = @generator.from_html(html, **opts)
    @mutex.synchronize { @failures = 0 }
    result
  rescue PdfGenerator::PdfGenerationError => e
    @mutex.synchronize do
      @failures += 1
      if @failures >= MAX_FAILURES
        @circuit_open = true
        @opened_at    = Time.current
        Rails.logger.warn("PDF circuit breaker opened after #{@failures} failures")
      end
    end
    raise
  end
end

For full error handling patterns including retry strategies, see the PDF API Error Handling Guide.

Secure API Key Management

Never hardcode your API key. In Rails, use credentials or environment variables:

# .env (never commit to Git)
FUNBREW_PDF_API_KEY=sk-live-abc123
# config/credentials.yml.enc (encrypted, safe to commit)
# Access via: Rails.application.credentials.funbrew_pdf_api_key
funbrew_pdf_api_key: sk-live-abc123
# Initializer — choose one source
api_key = ENV['FUNBREW_PDF_API_KEY'] ||
          Rails.application.credentials.funbrew_pdf_api_key
generator = PdfGenerator.new(api_key: api_key)

For production security practices including IP whitelisting and SSRF prevention, see the PDF API Security Guide.

Testing

Use WebMock or VCR to avoid hitting the real API in tests.

# spec/services/pdf_generator_spec.rb
require 'webmock/rspec'

RSpec.describe PdfGenerator do
  let(:generator) { described_class.new(api_key: 'test-key') }
  let(:fake_pdf)  { '%PDF-1.4 fake'.b }

  before do
    stub_request(:post, 'https://api.pdf.funbrew.cloud/v1/pdf/from-html')
      .with(
        headers: { 'Authorization' => 'Bearer test-key' },
        body: hash_including('html' => '<h1>Test</h1>')
      )
      .to_return(status: 200, body: fake_pdf, headers: { 'Content-Type' => 'application/pdf' })
  end

  it 'returns PDF bytes on success' do
    result = generator.from_html('<h1>Test</h1>')
    expect(result).to eq(fake_pdf)
  end

  context 'when the API returns 429' do
    before do
      stub_request(:post, 'https://api.pdf.funbrew.cloud/v1/pdf/from-html')
        .to_return(status: 429, body: '{"error":"rate_limited"}')
    end

    it 'raises PdfGenerationError' do
      expect { generator.from_html('<h1>Test</h1>') }
        .to raise_error(PdfGenerator::PdfGenerationError) do |err|
          expect(err.status).to eq(429)
          expect(err.retryable?).to be true
        end
    end
  end
end

Production Checklist

Before deploying to production, verify:

Item Detail
API key in env vars Never in source code or Rails credentials committed unencrypted
Faraday retry configured retry_statuses: [429, 500, 502, 503] with backoff
Sidekiq queue isolated Separate pdf queue so PDF backlog doesn't block critical jobs
Timeout set At least 60 s for complex HTML; Sidekiq job timeout aligned
Error logging PdfGenerationError captured in Sentry/Bugsnag with invoice ID
Active Storage or S3 Generated PDFs stored externally, not in Rails tmp
IP allowlisting Configure production server IPs in FUNBREW PDF dashboard

For the full production checklist covering monitoring, scaling, and cost management, see the PDF API Production Guide.

Summary

Ruby's strengths — Faraday's middleware chain, Sidekiq's reliable async processing, Rails' conventions — make it straightforward to build a robust PDF generation pipeline:

  • Service object: One class per integration, injectable and testable
  • Faraday retry: Automatic backoff on 429/5xx without manual retry loops
  • Sidekiq worker: Move generation off the request thread with reliable retries
  • Batch with Parallel: Thread-safe concurrent generation for bulk jobs
  • Circuit breaker: Prevent cascade failures during API outages

Related

Powered by FUNBREW PDF