RubyTutorial
HTML to PDF in Ruby on Rails
Generate PDFs in Rails with the standard `Net::HTTP` library.
01. Install
# Net::HTTP is in the Ruby stdlib — no gem required.
# Optional: bundle add httparty (cleaner DSL)
# Set in config/credentials.yml.enc or .env:
# PDFMYHTML_API_KEY=pmh_live_xxxxxxxxxxxxxxxxxxxx02. Basic call
# app/controllers/invoices_controller.rb
require 'net/http'
require 'json'
class InvoicesController < ApplicationController
def show_pdf
invoice = Invoice.find(params[:id])
html = render_to_string(template: 'invoices/template', locals: { invoice: invoice })
uri = URI('https://api.pdfmyhtml.com/v1/html-to-pdf')
req = Net::HTTP::Post.new(uri, 'X-API-Key' => ENV['PDFMYHTML_API_KEY'], 'Content-Type' => 'application/json')
req.body = { html: html, wait: true }.to_json
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
data = JSON.parse(res.body)
pdf_bytes = Net::HTTP.get(URI(data['download_url']))
send_data pdf_bytes, type: 'application/pdf', disposition: 'inline', filename: "invoice-#{invoice.id}.pdf"
end
end03. Error handling
# Robust version: timeout, status check, error logging
require 'net/http'
require 'json'
class InvoicesController < ApplicationController
def show_pdf
invoice = Invoice.find(params[:id])
pdf_bytes = generate_pdf(render_to_string(template: 'invoices/template', locals: { invoice: invoice }))
send_data pdf_bytes, type: 'application/pdf'
rescue Timeout::Error, Net::ReadTimeout
Rails.logger.error('pdfmyhtml timeout')
render plain: 'PDF service unavailable', status: :service_unavailable
rescue => e
Rails.logger.error("pdfmyhtml error: #{e.message}")
render plain: 'Could not generate PDF', status: :internal_server_error
end
private
def generate_pdf(html)
uri = URI('https://api.pdfmyhtml.com/v1/html-to-pdf')
req = Net::HTTP::Post.new(uri, 'X-API-Key' => ENV['PDFMYHTML_API_KEY'], 'Content-Type' => 'application/json')
req.body = { html: html, wait: true }.to_json
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, read_timeout: 30) do |http|
res = http.request(req)
raise "pdfmyhtml HTTP #{res.code}: #{res.body}" if res.code.to_i >= 400
data = JSON.parse(res.body)
raise 'PDF still processing' unless data['status'] == 'COMPLETED'
Net::HTTP.get(URI(data['download_url']))
end
end
end04. Async pattern (high volume)
# Async pattern with Sidekiq: enqueue, worker polls, attach to ActiveStorage.
# Avoids holding the Rails request open for 25s.
# app/jobs/generate_invoice_pdf_job.rb
class GenerateInvoicePdfJob < ApplicationJob
queue_as :default
def perform(invoice_id)
invoice = Invoice.find(invoice_id)
html = ApplicationController.render(template: 'invoices/template', locals: { invoice: invoice })
uri = URI('https://api.pdfmyhtml.com/v1/html-to-pdf')
req = Net::HTTP::Post.new(uri, 'X-API-Key' => ENV['PDFMYHTML_API_KEY'], 'Content-Type' => 'application/json')
req.body = { html: html, wait: false }.to_json
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
job_id = JSON.parse(res.body)['job_id']
60.times do
sleep 2
status_res = Net::HTTP.start('api.pdfmyhtml.com', 443, use_ssl: true) do |http|
http.get("/v1/jobs/#{job_id}", 'X-API-Key' => ENV['PDFMYHTML_API_KEY'])
end
status = JSON.parse(status_res.body)
next unless status['status'] == 'COMPLETED'
pdf = Net::HTTP.get(URI(status['download_url']))
invoice.pdf.attach(io: StringIO.new(pdf), filename: "invoice-#{invoice.id}.pdf", content_type: 'application/pdf')
return
end
raise 'PDF generation timed out'
end
end05. Real-world example
# Real-world: Stripe webhook → invoice PDF → ActionMailer
# config/routes.rb adds: post '/webhooks/stripe' => 'webhooks#stripe'
class WebhooksController < ApplicationController
skip_before_action :verify_authenticity_token, only: [:stripe]
def stripe
payload = request.body.read
event = Stripe::Webhook.construct_event(payload, request.env['HTTP_STRIPE_SIGNATURE'], ENV['STRIPE_WEBHOOK_SECRET'])
if event.type == 'invoice.paid'
invoice_obj = event.data.object
invoice = Invoice.find_or_create_by(stripe_id: invoice_obj.id)
html = render_to_string(template: 'invoices/template', locals: { invoice: invoice })
uri = URI('https://api.pdfmyhtml.com/v1/html-to-pdf')
req = Net::HTTP::Post.new(uri, 'X-API-Key' => ENV['PDFMYHTML_API_KEY'], 'Content-Type' => 'application/json')
req.body = { html: html, wait: true }.to_json
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
pdf = Net::HTTP.get(URI(JSON.parse(res.body)['download_url']))
InvoiceMailer.invoice_email(invoice, pdf).deliver_later
end
render json: { received: true }
end
endCommon gotchas
- 01.Rails' default `render_to_string` without a layout will skip your `<head>` — so external stylesheets won't load. Use `layout: 'pdf'` and create a minimal `pdf.html.erb` layout that includes only the styles you need.
- 02.Active Job + Sidekiq is required for the async pattern at any production scale — don't hold a Puma worker for 25s of sync wait time on user-facing requests.
- 03.Net::HTTP's default `read_timeout` is 60s. Set it to 30s explicitly for sync `wait: true` so you fail fast and fall back to async.
- 04.When deploying to Heroku, ensure your dyno can make outbound HTTPS to `api.pdfmyhtml.com:443` — typically allowed by default but worth verifying.
Going deeper? Read the full guide on cost trade-offs vs self-hosted Headless Chrome.