Building a PDF feature?
Save this to your Work Desktop.

All Tutorials
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_xxxxxxxxxxxxxxxxxxxx

02. 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 end

03. 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 end

04. 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 end

05. 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 end

Common 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.