The usual Rails PDF gems put a browser engine on your server: wicked_pdf drives wkhtmltopdf, Grover drives Puppeteer and Chrome. This guide keeps your ERB views and render_to_string, and sends the HTML to an API that prints it with Chrome. Every snippet below was run on Rails 7.2.3 and Ruby 3.3.8 (Ubuntu 26.04) against the live API, and we checked the PDFs it returned.
| Option | What it needs on your server |
|---|---|
| wicked_pdf wkhtmltopdf (archived in 2023, 2012-era WebKit) | The wkhtmltopdf binary. Ubuntu 26.04 has no wkhtmltopdf package (apt-cache policy: no candidate), and the wkhtmltopdf-binary gem 0.12.6.10 is a 451.8 MB download (RubyGems, 2026-10-22). |
| Grover Puppeteer from Ruby | Node, the puppeteer npm package and a Chrome it downloads, on every web or job server. |
| Net::HTTP + API Chrome on someone else's server | Nothing: Net::HTTP and JSON ship with Ruby. Modern CSS (flexbox, grid, web fonts) works because it's current Chrome. |
No gems. Set SHOTMINT_KEY (free key) and run it:
require "net/http"
require "json"
uri = URI("https://shotmint.creasework.com/v1/pdf")
req = Net::HTTP::Post.new(uri, "X-API-Key" => ENV.fetch("SHOTMINT_KEY"), "Content-Type" => "application/json")
req.body = {
html: "<h1 style='font-family:sans-serif'>Hello from Ruby</h1>",
format: "A4",
margin: "15mm"
}.to_json
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 60) { |h| h.request(req) }
raise "Render failed (#{res.code}): #{res.body}" unless res.is_a?(Net::HTTPSuccess)
File.binwrite("hello.pdf", res.body)
puts "Saved hello.pdf (#{res.body.bytesize} bytes)"
A bad or missing key raises with the API's message, e.g. Render failed (401): {"error":"Missing or invalid API key. ...","get_key":...}, so you never write an error body into a .pdf file.
app/services/shotmint_pdf.rb. Extra options (landscape, margin, page_numbers, header_template ...) pass straight through:
require "net/http"
require "json"
class ShotmintPdf
ENDPOINT = URI("https://shotmint.creasework.com/v1/pdf")
class Error < StandardError; end
def self.call(html, **options)
req = Net::HTTP::Post.new(ENDPOINT, "X-API-Key" => ENV.fetch("SHOTMINT_KEY"), "Content-Type" => "application/json")
req.body = { html: html, format: "A4", print_background: true, **options }.to_json
res = Net::HTTP.start(ENDPOINT.host, ENDPOINT.port, use_ssl: true, open_timeout: 5, read_timeout: 60) { |h| h.request(req) }
raise Error, "Shotmint #{res.code}: #{res.body}" unless res.is_a?(Net::HTTPSuccess)
res.body
end
end
/invoices/1042 shows the HTML page and /invoices/1042.pdf the PDF. Rails already knows the pdf MIME type, so respond_to needs no setup. formats: [:html] makes render_to_string pick show.html.erb while the request format is pdf:
class InvoicesController < ApplicationController
def show
@invoice = Invoice.find(params[:id])
respond_to do |format|
format.html
format.pdf do
html = render_to_string(:show, layout: "pdf", formats: [:html])
send_data ShotmintPdf.call(html, page_numbers: true),
filename: "invoice-#{@invoice.number}.pdf",
type: "application/pdf", disposition: "inline"
end
end
end
end
In our test this returned 200, application/pdf, inline; filename="invoice-INV-1042.pdf": a one-page A4 PDF with the flexbox header, the table and "1 / 1" at the bottom, in 0.14 s for the whole request. ERB escaping still applies, so a customer named Ünïcode & Co <script> printed as text, not markup.
Your normal layout links CSS with stylesheet_link_tag, which writes a relative path like /assets/application-3f2a.css. When you send HTML (not a URL), the renderer has no host to resolve that path against, so the stylesheet is skipped without an error. We measured it: the same HTML with a relative <link rel="stylesheet"> came back in Liberation Serif, the browser default (pdffonts); with the CSS reachable it came back in the stylesheet's sans-serif font.
The fix is a small PDF layout that inlines the CSS. app/views/layouts/pdf.html.erb:
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<%# The renderer can't resolve /assets/... paths, so inline the CSS %>
<style><%= Rails.root.join("app/assets/stylesheets/pdf.css").read.html_safe %></style>
</head>
<body><%= yield %></body>
</html>
with app/assets/stylesheets/pdf.css:
body { font-family: "DejaVu Sans", sans-serif; color: #222; }
.head { display: flex; justify-content: space-between; border-bottom: 3px solid #4f46e5; }
table { width: 100%; border-collapse: collapse; }
td, th { padding: 6px; border-bottom: 1px solid #ddd; text-align: left; }
.total { background: #eef2ff; font-weight: bold; }
and the view app/views/invoices/show.html.erb:
<div class="head"><h1>Invoice <%= @invoice.number %></h1><p><%= @invoice.customer %></p></div>
<table>
<tr><th>Item</th><th>Amount</th></tr>
<% @invoice.lines.each do |name, cents| %>
<tr><td><%= name %></td><td><%= number_to_currency(cents / 100.0, unit: "€") %></td></tr>
<% end %>
<tr class="total"><td>Total</td><td><%= number_to_currency(@invoice.lines.sum { _2 } / 100.0, unit: "€") %></td></tr>
</table>
If your stylesheets and images are on a public host, there's a second fix: add <base href="https://your-app.example/"> in the PDF layout's head (or send "base_url": "https://your-app.example/" alongside html and the API adds it for you) and the relative paths resolve (tested: the font switched to the stylesheet's). Pages on localhost or behind a login can't be fetched by the API, so inline CSS is the safer default. For images, use absolute public URLs or data: URIs.
ShotmintPdf.call returns the PDF bytes, so in a job you can attach them instead of sending them: invoice.pdf.attach(io: StringIO.new(bytes), filename: "invoice.pdf", content_type: "application/pdf"). Render the HTML with ApplicationController.render(template: "invoices/show", layout: "pdf", assigns: { invoice: invoice }), since jobs have no controller.
| wicked_pdf | Here |
|---|---|
render pdf: "invoice" | send_data ShotmintPdf.call(render_to_string(...)) |
wicked_pdf_stylesheet_link_tag | Inline <style> in the PDF layout (section 4) |
footer: { right: "[page] of [topage]" } | page_numbers: true, or footer_template with <span class="pageNumber"> |
orientation: "Landscape", margin: { top: 10 } | landscape: true, margin: "10mm" (always give a unit) |
CSS that wkhtmltopdf ignored (flexbox gaps, grid, break-inside) now works, so check old workarounds like float layouts. More in the wkhtmltopdf alternative guide.
One PDF = one credit. €5 buys 2,000 renders, credits never expire and there's no subscription. Try your HTML in the free HTML to PDF tool first, then see the API docs for every option.
No signup, no card. Get a key in one click, then buy credits only if you need more (from €5 for 2,000, never expire). From a script, an AI agent, Make or Zapier, POST /v1/keys/free (no body) returns a key as JSON (openapi.json, no-code recipes).
All guides: guides · integrations · comparisons