Generate PDFs in Ruby on Rails with Api2Pdf: ERB Views, Active Job and Active Storage

Summary. Rails apps have generated PDFs for years with Prawn (draw every line by hand), Wicked PDF and PDFKit (both wrap wkhtmltopdf, archived since 2023) or Grover (Puppeteer, which means Node and Chromium on every web and worker dyno). This guide takes a different route: keep writing ERB, render it to an HTML string with render_to_string, and let the Api2Pdf Headless Chrome endpoint turn it into a PDF. You will build a small dependency-free client on Net::HTTP with timeouts, retries and a useful error class, serve invoices from a format.pdf controller action, generate and attach PDFs to Active Storage from an Active Job, assemble a password-protected statement packet with the merge and password endpoints, and test it all with WebMock.

Using another stack? There are companion posts for Laravel, FastAPI and Next.js App Router.

The architecture in one paragraph

The API key lives in Rails credentials, never in the browser. A controller (or a job) renders an ERB template with a print layout to an HTML string and calls POST https://v2.api2pdf.com/chrome/pdf/html with the raw API key in the Authorization header (no Bearer prefix). With ?outputBinary=true the response body is the PDF itself, which the controller hands to send_data or a job attaches to a record. Without it you get JSON with a FileUrl that Api2Pdf hosts for 24 hours, plus a ResponseId; that form is what you chain into other endpoints such as merge and password protection. Chrome never runs inside Puma, Sidekiq or Solid Queue, so your containers stay small and your memory graphs stay flat.

Step 1: store the API key

Create an account and API key at portal.api2pdf.com. Api2Pdf publishes official client libraries for Python, Node, .NET, PHP and Java on GitHub, but there is no official Ruby gem, and you do not need one: the API is plain JSON over HTTPS and Net::HTTP from the standard library covers it. The code below targets Rails 7.1 and 8 on Ruby 3.2+. Add the key to your encrypted credentials:

bin/rails credentials:edit
# config/credentials.yml.enc (decrypted view)
api2pdf:
  api_key: your-api-key

If you deploy with environment variables instead (Heroku, Render, Kamal secrets), the client below falls back to ENV["API2PDF_API_KEY"]. Two optional variables control the endpoint and timeout:

API2PDF_BASE_URL=https://v2.api2pdf.com   # or https://v2-xl.api2pdf.com for very large documents
API2PDF_TIMEOUT=100                        # seconds; standard requests are capped at 90 s server-side

Step 2: a small Api2Pdf client on Net::HTTP

The request body for /chrome/pdf/html takes html plus the optional fileName, inline and options (the Headless Chrome options: margins, paper size, header and footer templates, delay and so on). outputBinary is a query string parameter. The JSON response carries Success, Error, FileUrl, MbOut, Seconds, Cost and ResponseId. The client separates transient failures (timeouts, connection resets, 429 and 5xx responses), which are worth retrying, from permanent ones such as a bad API key or invalid input, which are not:

# app/services/api2pdf/client.rb
require "net/http"
require "json"

module Api2pdf
  class Error < StandardError
    attr_reader :status, :response_id

    def initialize(message, status: nil, response_id: nil)
      super(message)
      @status = status
      @response_id = response_id
    end
  end

  # Timeouts, connection errors, 429 and 5xx: safe to retry later.
  class TransientError < Error; end

  class Client
    NETWORK_ERRORS = [
      Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNRESET,
      Errno::ECONNREFUSED, EOFError, OpenSSL::SSL::SSLError
    ].freeze

    def initialize(api_key: nil, base_url: nil, read_timeout: nil, retries: 2, base_delay: 0.5)
      @api_key = api_key || Rails.application.credentials.dig(:api2pdf, :api_key) || ENV["API2PDF_API_KEY"]
      raise ArgumentError, "Api2Pdf API key is missing" if @api_key.blank?

      @base_url = base_url || ENV.fetch("API2PDF_BASE_URL", "https://v2.api2pdf.com")
      @read_timeout = read_timeout || Integer(ENV.fetch("API2PDF_TIMEOUT", "100"))
      @retries = retries
      @base_delay = base_delay
    end

    # HTML -> PDF bytes (binary String). Use for downloads and attachments.
    def html_to_pdf(html, file_name: "document.pdf", options: {})
      post("/chrome/pdf/html", { html: html, fileName: file_name, inline: false, options: options }, binary: true)
    end

    # HTML -> hosted PDF. Returns the JSON hash ("FileUrl", "ResponseId", ...).
    def html_to_pdf_url(html, file_name: "document.pdf", options: {})
      post("/chrome/pdf/html", { html: html, fileName: file_name, inline: false, options: options })
    end

    # Merge PDFs (public or pre-signed URLs) in the given order.
    def merge(urls, file_name: "merged.pdf")
      post("/pdfsharp/merge", { urls: urls, fileName: file_name, inline: false })
    end

    # Add a user (open) password and optionally an owner (permissions) password.
    def add_password(url, user_password:, owner_password: nil, file_name: "protected.pdf")
      body = { url: url, userpassword: user_password, fileName: file_name, inline: false }
      body[:ownerpassword] = owner_password if owner_password
      post("/pdfsharp/password", body)
    end

    # Delete a hosted file before its 24 hour expiry.
    def delete_file(response_id)
      uri = endpoint("/file/#{response_id}")
      request = Net::HTTP::Delete.new(uri)
      request["Authorization"] = @api_key
      response = perform(uri, request)
      return true if response.is_a?(Net::HTTPSuccess)

      raise Error.new("Api2Pdf delete failed: HTTP #{response.code}", status: response.code.to_i, response_id: response_id)
    end

    private

    def post(path, body, binary: false)
      uri = endpoint(path)
      uri.query = "outputBinary=true" if binary

      request = Net::HTTP::Post.new(uri)
      request["Authorization"] = @api_key
      request["Content-Type"] = "application/json"
      request.body = JSON.generate(body)

      response = perform(uri, request)
      payload = response.body.to_s

      # With outputBinary=true a successful response is the raw PDF.
      return payload.b if binary && response.is_a?(Net::HTTPSuccess) && payload.start_with?("%PDF")

      data = parse_json(payload)
      if response.is_a?(Net::HTTPSuccess) && data["Success"] != false
        return data
      end

      message = data["Error"].presence || "Api2Pdf HTTP #{response.code}"
      error_class = transient_status?(response) ? TransientError : Error
      raise error_class.new(message, status: response.code.to_i, response_id: data["ResponseId"])
    end

    def perform(uri, request)
      attempt = 0
      loop do
        attempt += 1
        begin
          response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
                                     open_timeout: 10, read_timeout: @read_timeout) do |http|
            http.request(request)
          end
          return response unless transient_status?(response) && attempt <= @retries
        rescue *NETWORK_ERRORS => e
          raise TransientError.new("Api2Pdf request failed: #{e.class}: #{e.message}") if attempt > @retries
        end
        sleep(@base_delay * (2**(attempt - 1)) + rand * @base_delay)
      end
    end

    def transient_status?(response)
      code = response.code.to_i
      code == 429 || code >= 500
    end

    def endpoint(path)
      URI.join(@base_url, path)
    end

    def parse_json(text)
      JSON.parse(text)
    rescue JSON::ParserError
      {}
    end
  end
end

A few details worth calling out:

  • No Bearer prefix. The Authorization header is the key itself. A Bearer prefix is the most common cause of a 401.
  • Retrying a POST is safe here. PDF generation has no side effects on your account beyond the usage of the attempt, so a retry after a timeout cannot create a duplicate invoice. Net::HTTP never retries a POST on its own, which is why the client does it explicitly, with exponential backoff and jitter.
  • Binary responses. The body is returned with .b (ASCII-8BIT), so nothing downstream tries to treat PDF bytes as UTF-8. Failures still come back as JSON, so the client checks for the %PDF signature before trusting the body.
  • Timeouts. Standard requests are limited to 90 seconds of processing, so a 100 second read timeout leaves room for the network. On V2 XL (5 minute limit) raise it to around 310.

Step 3: an ERB invoice with a print layout

Give PDFs their own layout so they do not inherit your navigation, Turbo and JavaScript. Two rules make the output match your browser: inline the CSS, and make every asset reachable from the internet. Api2Pdf renders your HTML on its own servers, so a stylesheet_link_tag that points at localhost:3000 or a private asset host simply never loads.

<%# app/views/layouts/pdf.html.erb %>
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title><%= yield :title %></title>
  <style>
    @page { size: Letter; }
    * { box-sizing: border-box; }
    body { font: 12px/1.5 -apple-system, "Segoe UI", Helvetica, Arial, sans-serif; color: #111827; margin: 0; }
    h1 { font-size: 26px; margin: 0 0 4px; }
    .muted { color: #6b7280; }
    .head { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 32px; }
    table { width: 100%; border-collapse: collapse; }
    thead { display: table-header-group; } /* repeat the header row on every page */
    th { text-align: left; font-size: 11px; text-transform: uppercase; color: #6b7280; border-bottom: 2px solid #111827; padding: 8px 6px; }
    td { padding: 8px 6px; border-bottom: 1px solid #e5e7eb; }
    tr { break-inside: avoid; }
    .num { text-align: right; font-variant-numeric: tabular-nums; }
    .total td { font-weight: 700; font-size: 14px; border-bottom: none; }
  </style>
</head>
<body>
  <%= yield %>
</body>
</html>
<%# app/views/invoices/pdf.html.erb %>
<% content_for :title, "Invoice #{@invoice.number}" %>
<div class="head">
  <div>
    <%= inline_image "logo.png", width: 140 %>
    <p class="muted">Acme Inc.<br>100 Congress Ave, Austin, TX</p>
  </div>
  <div style="text-align: right">
    <h1>Invoice <%= @invoice.number %></h1>
    <div class="muted">Issued <%= l @invoice.issued_on, format: :long %></div>
    <div class="muted">Due <%= l @invoice.due_on, format: :long %></div>
  </div>
</div>

<p><strong>Bill to</strong><br><%= @invoice.customer_name %><br><%= simple_format @invoice.billing_address, {}, wrapper_tag: "span" %></p>

<table>
  <thead>
    <tr><th>Description</th><th class="num">Qty</th><th class="num">Unit price</th><th class="num">Amount</th></tr>
  </thead>
  <tbody>
    <% @invoice.line_items.each do |item| %>
      <tr>
        <td><%= item.description %></td>
        <td class="num"><%= item.quantity %></td>
        <td class="num"><%= number_to_currency item.unit_price %></td>
        <td class="num"><%= number_to_currency item.quantity * item.unit_price %></td>
      </tr>
    <% end %>
    <tr class="total">
      <td colspan="3" class="num">Total</td>
      <td class="num"><%= number_to_currency @invoice.total %></td>
    </tr>
  </tbody>
</table>

ERB escapes everything by default, which matters here: customer-supplied text ends up inside a document rendered by a real browser, so never pass it through raw or html_safe. For the logo, a tiny helper embeds the file as a data URI so there is no network fetch at all:

# app/helpers/pdf_helper.rb
module PdfHelper
  MIME = { ".png" => "image/png", ".jpg" => "image/jpeg", ".jpeg" => "image/jpeg", ".svg" => "image/svg+xml" }.freeze

  def inline_image(name, **attrs)
    path = Rails.root.join("app/assets/images", name)
    data = [File.binread(path)].pack("m0") # strict Base64, no newlines
    tag.img(src: "data:#{MIME.fetch(path.extname.downcase)};base64,#{data}", alt: attrs.delete(:alt) || "", **attrs)
  end
end

For larger images, product photos or web fonts, use absolute URLs on a public host instead (for example by setting config.asset_host to your CDN in production) so the HTML stays small.

Step 4: a format.pdf controller action

Rails already registers the application/pdf MIME type as :pdf, so /invoices/42.pdf routes to the same show action as the HTML page. Render the PDF template to a string with formats: [:html] (otherwise Rails looks for a .pdf.erb file), send it to Api2Pdf and stream the bytes back with send_data:

# app/controllers/invoices_controller.rb
class InvoicesController < ApplicationController
  PDF_OPTIONS = {
    width: "8.5in", height: "11in",            # US Letter; the default is A4
    marginTop: "0.6in", marginBottom: "0.8in",
    marginLeft: "0.5in", marginRight: "0.5in",
    printBackground: true,
    displayHeaderFooter: true,
    headerTemplate: "<span></span>",
    footerTemplate: <<~HTML
      <div style="font-size:9px;width:100%;padding:0 0.5in;color:#6b7280;display:flex;justify-content:space-between">
        <span class="title"></span>
        <span>Page <span class="pageNumber"></span> of <span class="totalPages"></span></span>
      </div>
    HTML
  }.freeze

  def show
    @invoice = Current.user.invoices.find(params[:id]) # scope to the signed-in user

    respond_to do |format|
      format.html
      format.pdf do
        html = render_to_string(template: "invoices/pdf", layout: "pdf", formats: [:html])
        filename = "invoice-#{@invoice.number}.pdf"
        pdf = Api2pdf::Client.new.html_to_pdf(html, file_name: filename, options: PDF_OPTIONS)
        send_data pdf, filename: filename, type: "application/pdf", disposition: "attachment"
      rescue Api2pdf::Error => e
        Rails.logger.error("api2pdf_failed invoice=#{@invoice.id} status=#{e.status} response_id=#{e.response_id} #{e.message}")
        redirect_to invoice_path(@invoice), alert: "We could not generate the PDF. Please try again."
      end
    end
  end
end

The download link is a normal link. Turbo Drive does not intercept URLs that end in .pdf, so no data-turbo attribute is needed. Use disposition: "inline" instead if you want the PDF to open in the browser's viewer.

<%= link_to "Download PDF", invoice_path(@invoice, format: :pdf) %>

The footer uses Chrome's special classes: pageNumber, totalPages, title (the document's <title>), date and url. Header and footer templates are rendered in isolation, so they need inline styles and an explicit font-size; without one, the text renders at a tiny default size. The bottom margin has to be tall enough to hold the footer, which is why it is larger than the others.

Step 5: generate and attach PDFs from Active Job

Rendering takes a few seconds, and Puma threads are a scarce resource. For anything that does not need to be streamed immediately, such as sending an invoice when it is issued, generate the PDF in a job and attach it with Active Storage. ApplicationController.render renders templates outside a request; give it a host so URL helpers produce real links instead of example.org:

# app/models/invoice.rb
class Invoice < ApplicationRecord
  belongs_to :user
  has_many :line_items, dependent: :destroy
  has_one_attached :pdf

  after_create_commit -> { InvoicePdfJob.perform_later(self) }
end
# app/jobs/invoice_pdf_job.rb
class InvoicePdfJob < ApplicationJob
  queue_as :default

  # Retry outages and timeouts with backoff; a 4xx (bad key, bad input) will not fix itself.
  retry_on Api2pdf::TransientError, wait: :polynomially_longer, attempts: 6
  discard_on ActiveJob::DeserializationError

  def perform(invoice)
    renderer = ApplicationController.renderer.new(http_host: "app.example.com", https: true)
    html = renderer.render(template: "invoices/pdf", layout: "pdf", assigns: { invoice: invoice })

    filename = "invoice-#{invoice.number}.pdf"
    pdf = Api2pdf::Client.new.html_to_pdf(html, file_name: filename, options: InvoicesController::PDF_OPTIONS)

    invoice.pdf.attach(io: StringIO.new(pdf), filename: filename, content_type: "application/pdf")
    InvoiceMailer.with(invoice: invoice).issued.deliver_later
  end
end
# app/mailers/invoice_mailer.rb
class InvoiceMailer < ApplicationMailer
  def issued
    @invoice = params[:invoice]
    attachments[@invoice.pdf.filename.to_s] = @invoice.pdf.download
    mail to: @invoice.user.email, subject: "Invoice #{@invoice.number}"
  end
end

This works the same with Solid Queue, Sidekiq or GoodJob. Templates rendered from a job have no session, so anything they need (the invoice, the current account, the locale) must be passed in through assigns or locals; Current.user will be nil. Once the file is in Active Storage it lives in your bucket under your retention rules, and the Api2Pdf copy is never created at all, because outputBinary=true returns the bytes directly.

Step 6: a password-protected statement packet

Some documents are made of several parts: a cover letter, twelve monthly statements and a terms appendix, delivered as one file that needs a password to open. Chain three endpoints: render each part to a hosted PDF (JSON mode, so you get a FileUrl), merge the URLs in order with /pdfsharp/merge, then protect the result with /pdfsharp/password. Download the final file into Active Storage and delete every intermediate copy:

# app/services/statement_packet.rb
require "net/http"

class StatementPacket
  def initialize(account, year:, client: Api2pdf::Client.new)
    @account = account
    @year = year
    @client = client
    @response_ids = []
  end

  def call(user_password:)
    renderer = ApplicationController.renderer.new(http_host: "app.example.com", https: true)

    parts = [render_part(renderer, "statements/cover", "cover.pdf")]
    @account.statements.where(year: @year).order(:month).each do |statement|
      parts << render_part(renderer, "statements/month", "statement-#{statement.month}.pdf", statement: statement)
    end

    merged = track(@client.merge(parts, file_name: "packet.pdf"))
    locked = track(@client.add_password(merged["FileUrl"], user_password: user_password,
                                        file_name: "statements-#{@year}.pdf"))

    @account.statement_packets.create!(year: @year).file.attach(
      io: StringIO.new(download(locked["FileUrl"])),
      filename: "statements-#{@year}.pdf",
      content_type: "application/pdf"
    )
  ensure
    @response_ids.each do |id|
      @client.delete_file(id)
    rescue Api2pdf::Error => e
      Rails.logger.warn("api2pdf_delete_failed response_id=#{id} #{e.message}")
    end
  end

  private

  def render_part(renderer, template, file_name, **assigns)
    html = renderer.render(template: template, layout: "pdf", assigns: assigns.merge(account: @account, year: @year))
    track(@client.html_to_pdf_url(html, file_name: file_name, options: InvoicesController::PDF_OPTIONS))["FileUrl"]
  end

  def track(result)
    @response_ids << result["ResponseId"]
    result
  end

  def download(url)
    response = Net::HTTP.get_response(URI(url))
    raise Api2pdf::TransientError, "Download failed: HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)

    response.body.b
  end
end

Run it from a job (StatementPacket.new(account, year: 2026).call(user_password: account.statement_pin)) so the dozen or so requests never hold up a web request; independent parts could also be rendered in parallel threads if the packet is large. The user password is what the reader types to open the file. Pass owner_password: as well if you want a separate password that controls permissions. Merge accepts any URLs Api2Pdf can download, so you can mix generated parts with PDFs already in your bucket by passing Active Storage URLs that are publicly reachable for a few minutes (for example blob.url(expires_in: 10.minutes) on an S3 or GCS service). For other page operations see extracting page ranges and the watermark API.

Step 7: test it with WebMock

Your test suite should never call the real API. Add webmock to the :test group of your Gemfile and stub the HTTP layer. Because the client takes api_key: and base_delay: arguments, tests do not need credentials and retries do not slow them down:

# test/services/api2pdf/client_test.rb
require "test_helper"
require "webmock/minitest"

class Api2pdf::ClientTest < ActiveSupport::TestCase
  setup do
    @client = Api2pdf::Client.new(api_key: "test-key", retries: 1, base_delay: 0)
  end

  test "returns the PDF bytes and sends the raw key" do
    stub = stub_request(:post, "https://v2.api2pdf.com/chrome/pdf/html?outputBinary=true")
      .with(headers: { "Authorization" => "test-key" }, body: hash_including("html" => "<h1>Hi</h1>"))
      .to_return(status: 200, body: "%PDF-1.7 fake", headers: { "Content-Type" => "application/pdf" })

    assert_equal "%PDF-1.7 fake", @client.html_to_pdf("<h1>Hi</h1>")
    assert_requested stub
  end

  test "raises a permanent error with the Api2Pdf message" do
    stub_request(:post, %r{/chrome/pdf/html}).to_return(
      status: 400,
      body: { Success: false, Error: "Html is required", ResponseId: "abc-123" }.to_json,
      headers: { "Content-Type" => "application/json" }
    )

    error = assert_raises(Api2pdf::Error) { @client.html_to_pdf("") }
    assert_not_kind_of Api2pdf::TransientError, error
    assert_equal "Html is required", error.message
    assert_equal "abc-123", error.response_id
  end

  test "retries a 503 and then succeeds" do
    stub_request(:post, %r{/chrome/pdf/html})
      .to_return({ status: 503, body: "" }, { status: 200, body: "%PDF-1.7" })

    assert_equal "%PDF-1.7", @client.html_to_pdf("<p>x</p>")
  end

  test "gives up on repeated timeouts with a transient error" do
    stub_request(:post, %r{/chrome/pdf/html}).to_timeout

    assert_raises(Api2pdf::TransientError) { @client.html_to_pdf("<p>x</p>") }
  end
end

For controller and system tests, stub at a higher level instead: give Api2pdf::Client a fake in test_helper.rb that returns a fixed "%PDF-1.7" string, then assert on response.media_type and the Content-Disposition header.

Production notes

  • Keep the key server-side. Never call Api2Pdf from Stimulus or any browser code; the key in the Authorization header would be visible to every visitor. Always go through a controller that authorizes the user first.
  • Pick the right path for each document. Stream small documents (one to a few pages) straight from the controller; move anything big, bulk or chained to Active Job so Puma threads are free for page views.
  • Use V2 XL for large documents. Standard requests get 2 GB of memory and 90 seconds. Long reports with hundreds of pages or heavy charts can point the client at https://v2-xl.api2pdf.com (4 CPU, 8 GB, 5 minute limit, priced at 10x the standard rate) with a longer read_timeout. Use a second client instance just for those jobs rather than sending everything there.
  • Mind the 24 hour FileUrl. Hosted files are deleted after 24 hours. Copy anything you need to keep into Active Storage, delete sensitive files early with DELETE /file/{responseId}, or have Api2Pdf write straight into your bucket with useCustomStorage (see batch PDF generation: zip PDFs and upload to S3).
  • Log the ResponseId. It appears in the request logs in the portal, and support can trace a specific request with it.
  • Cost is per use. Billing is based on compute seconds and bandwidth (each JSON response reports Seconds, MbOut and Cost), so a one-page invoice usually costs a fraction of a cent. Instrument the client with ActiveSupport::Notifications if you want per-feature cost dashboards.

Troubleshooting

  • 401 Unauthorized. The header must be the key on its own. Remove any Bearer prefix, check for trailing whitespace or newlines from credentials, and make sure production has the key (bin/rails runner 'p Rails.application.credentials.dig(:api2pdf, :api_key).present?').
  • Styles missing in the PDF. The CSS was linked from a host Api2Pdf cannot reach. Inline it in the PDF layout, or use absolute public URLs via config.asset_host.
  • Charts or JavaScript-rendered content is blank. The PDF was printed before the scripts finished. Add delay (milliseconds) to the options, or wait for a specific condition with puppeteerWaitForMethod and puppeteerWaitForValue, as described in the Headless Chrome options. Better still, render charts as SVG on the server.
  • Net::ReadTimeout. The document took longer than your read_timeout. Check for slow external images or fonts first, then raise the timeout or move the job to V2 XL.
  • Tiny header or footer text, or none at all. Set displayHeaderFooter: true, give the template an explicit font-size, and leave enough top or bottom margin for it to fit.
  • Page breaks in the wrong place. Use break-inside: avoid on rows and cards, break-before: page for new sections, and display: table-header-group to repeat table headers. If anything looks different from your browser, test the HTML locally first with Chrome's print preview.

Wrap-up

You now have a Rails PDF pipeline with no native dependencies: ERB templates you already know how to write, a 130-line Net::HTTP client with timeouts and retries, a format.pdf action for instant downloads, an Active Job that attaches PDFs to Active Storage, a merged and password-protected statement packet, and tests that never touch the network. The same client covers the rest of the API: Markdown to PDF, screenshots, Office conversion through LibreOffice and file to Markdown extraction are all one more method each. Create a free account to get an API key, or browse the full request reference in the interactive API docs.