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
Bearerprefix. TheAuthorizationheader is the key itself. ABearerprefix 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::HTTPnever 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%PDFsignature 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
Authorizationheader 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 longerread_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 withDELETE /file/{responseId}, or have Api2Pdf write straight into your bucket withuseCustomStorage(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,MbOutandCost), so a one-page invoice usually costs a fraction of a cent. Instrument the client withActiveSupport::Notificationsif you want per-feature cost dashboards.
Troubleshooting
- 401 Unauthorized. The header must be the key on its own. Remove any
Bearerprefix, 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 withpuppeteerWaitForMethodandpuppeteerWaitForValue, as described in the Headless Chrome options. Better still, render charts as SVG on the server. Net::ReadTimeout. The document took longer than yourread_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 explicitfont-size, and leave enough top or bottom margin for it to fit. - Page breaks in the wrong place. Use
break-inside: avoidon rows and cards,break-before: pagefor new sections, anddisplay: table-header-groupto 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.



