How Ruby apps usually make PDFs
The Ruby world has settled on a handful of tools, and they differ mainly in where the layout comes from. Prawn asks you to describe the page in Ruby. The others take HTML and hand it to a rendering engine, either an old WebKit binary or a headless Chrome driven through Node. Each has happy users; the question is how much of the engine you want to run yourself.
| Option | How it works | Worth knowing |
|---|---|---|
| Prawn | A pure Ruby library: you place text, images and shapes with a Ruby DSL | Fast, no external binaries, precise control; layouts live in code, so a designer cannot hand you HTML and CSS |
| Wicked PDF | Renders a Rails view to HTML, then calls the wkhtmltopdf binary | Familiar ERB views; wkhtmltopdf's repository was archived in January 2023 and its WebKit lacks modern CSS such as grid |
| Grover | Sends your HTML to Puppeteer, so a real headless Chrome prints it | Modern CSS and JavaScript; every server needs Node, the Puppeteer package and a Chromium build |
| CastPDF | One HTTPS request; Chromium plus print features runs on our side | A network call per document and a monthly plan above the free tier |
Prawn remains a strong pick for fixed documents that never change shape, such as a shipping label. When the document is designed in HTML, though, you either accept an outdated engine or you maintain Node and Chromium next to Ruby in every image. The example on this page, a course completion certificate, shows the third way: keep writing HTML and CSS, and let the rendering happen somewhere else.
Your first certificate with Net::HTTP
Grab a test key from the dashboard (its prefix is cpdf_test_) and export it as CASTPDF_API_KEY. Test documents are free, do not count towards any allowance and show a watermark. The script needs only net/http and json, both part of Ruby itself, so there is no Gemfile to touch. Save it as certificate.rb and run ruby certificate.rb.
require "json"
require "net/http"
html = <<~'HTML'
<section class="cert">
<p class="kicker">Certificate of completion</p>
<h1>{{ learner.name }}</h1>
<p>has completed <strong>{{ course.title }}</strong> ({{ course.hours }} hours)</p>
<p>Awarded {{ course.completed | format_date: "long" }}, certificate {{ certificate_no }}</p>
</section>
HTML
css = <<~'CSS'
.cert { text-align: center; padding-top: 35mm; font-family: "Noto Serif", serif; }
.cert h1 { font-size: 34pt; margin: 8mm 0; }
.kicker { letter-spacing: 0.2em; text-transform: uppercase; }
CSS
payload = {
html: html,
css: css,
data: {
learner: { name: "Tomás Ortega" },
course: { title: "Food Safety Level 2", hours: 6, completed: "2026-10-02" },
certificate_no: "FS2-0918"
},
format: "A4",
orientation: "landscape",
filename: "certificate-FS2-0918"
}
uri = URI("https://api.castpdf.com/v1/pdf")
request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch("CASTPDF_API_KEY")}"
request["Content-Type"] = "application/json"
request.body = JSON.generate(payload)
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 10, read_timeout: 75) do |http|
http.request(request)
end
unless response.is_a?(Net::HTTPSuccess)
error = JSON.parse(response.body).fetch("error")
abort "#{error["code"]}: #{error["message"]}"
end
File.binwrite("certificate.pdf", response.body)
puts "Saved certificate.pdf (#{response["x-pages"]} page)"The single quotes in <<~'HTML' switch off Ruby interpolation, so nothing in the markup is evaluated by Ruby. The {{ }} tags are Liquid, filled in by CastPDF because the request contains data; format_date: "long" turns the ISO date into "October 2, 2026". JSON.generate turns symbol keys into strings for you. The page is A4 in landscape, and the serif face is Noto Serif, one of the fonts already installed on the renderer, so it looks identical on every machine.
Use File.binwrite, not File.write. On Windows, text mode rewrites line endings and quietly corrupts the PDF bytes, and binwrite is correct on every platform.
Certificates for a whole cohort from a saved template
Once the certificate design is settled, move it into a saved template. You keep editing HTML and CSS in the dashboard with a live preview, and course staff can correct sample values in Simple mode without opening a code editor. Your Ruby code then sends only the template ID and each learner's details. The script below reuses one HTTPS connection for the whole cohort, makes one request per learner, and collects signed download links instead of files.
require "json"
require "net/http"
course = { code: "FS2", title: "Food Safety Level 2", hours: 6, completed: "2026-10-02" }
learners = [
{ id: 4102, name: "Amara Nwosu", score: 94 },
{ id: 4103, name: "Jonas Berg", score: 88 }
]
uri = URI("https://api.castpdf.com/v1/pdf")
links = {}
Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 10, read_timeout: 75) do |http|
learners.each do |learner|
number = "#{course[:code]}-#{learner[:id]}"
request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch("CASTPDF_API_KEY")}"
request["Content-Type"] = "application/json"
request["Idempotency-Key"] = "certificate-#{number}"
request.body = JSON.generate({
template_id: ENV.fetch("CERTIFICATE_TEMPLATE_ID"),
data: { learner: learner, course: course, certificate_no: number },
filename: "certificate-#{number}",
response: "url"
})
response = http.request(request)
body = JSON.parse(response.body)
raise "#{body["error"]["code"]}: #{body["error"]["message"]}" unless response.is_a?(Net::HTTPSuccess)
links[learner[:id]] = { url: body["url"], expires_at: body["expires_at"] }
end
end
links.each { |id, link| puts "#{id}: #{link[:url]} (valid until #{link[:expires_at]})" }Every request carries an Idempotency-Key built from the course code and the learner ID. Run the script twice by accident and the second pass returns the certificates that already exist, without new charges. Because data replaces the template's sample data entirely, each request includes the full course and learner hashes, even fields that never change.
The JSON answer contains id, pages, bytes, url and expires_at. The link opens without an API key until it expires, which makes it suitable for a "Download your certificate" button. Save the id against the enrolment record: GET /v1/pdf/:id returns a new link whenever a learner comes back later. The API reference lists every field of both endpoints.
Errors and retries in Ruby
Failures arrive as JSON: an error hash with a stable code, a readable message and a docs_url. Retry only what can succeed on a second try. That means 429 rate_limited after the delay in Retry-After, 503 service_busy with growing pauses, 409 idempotency_conflict (the first request with this key is still running) and network exceptions. Ruby raises several different classes for network trouble, so the module groups them in one constant and uses rescue inside the block.
require "json"
require "net/http"
module CastPdf
ENDPOINT = URI("https://api.castpdf.com/v1/pdf")
RETRYABLE_STATUS = [409, 429, 503].freeze
NETWORK_ERRORS = [Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNRESET, Errno::ECONNREFUSED, SocketError].freeze
class Error < StandardError
attr_reader :status, :code
def initialize(status, code, message)
super("#{status} #{code}: #{message}")
@status = status
@code = code
end
end
def self.render(payload, idempotency_key:, attempts: 5)
1.upto(attempts) do |attempt|
response = post(payload, idempotency_key)
return response.body if response.is_a?(Net::HTTPSuccess)
status = response.code.to_i
if RETRYABLE_STATUS.include?(status) && attempt < attempts
retry_after = response["Retry-After"].to_s
sleep(retry_after.match?(/\A\d+\z/) ? retry_after.to_i : 2**attempt)
next
end
error = JSON.parse(response.body).fetch("error", {})
raise Error.new(status, error["code"], error["message"])
rescue *NETWORK_ERRORS
raise if attempt == attempts
sleep(2**attempt)
end
end
def self.post(payload, idempotency_key)
request = Net::HTTP::Post.new(ENDPOINT)
request["Authorization"] = "Bearer #{ENV.fetch("CASTPDF_API_KEY")}"
request["Content-Type"] = "application/json"
request["Idempotency-Key"] = idempotency_key
request.body = JSON.generate(payload)
Net::HTTP.start(ENDPOINT.host, ENDPOINT.port, use_ssl: true, open_timeout: 10, read_timeout: 75) do |http|
http.request(request)
end
end
private_class_method :post
endCall it as CastPdf.render(payload, idempotency_key: "certificate-FS2-4102") and rescue CastPdf::Error where you can react, for example by flagging the enrolment when the code is template_render_error. A Net::ReadTimeout is exactly the case the idempotency key exists for: you cannot tell whether the first attempt finished, and sending the same key again makes that uncertainty harmless.
Production checklist for Ruby
- Raise
read_timeoutabove the default.Net::HTTPwaits 60 seconds by default, while a long document may take up to 30 seconds to render plus queue time. Something like 75 seconds with a 10 secondopen_timeoutis a sensible start. - Generate in a background job. Sidekiq, GoodJob or any Active Job backend keeps a Puma thread from waiting on a render, and keeps
Rack::Timeout, which is often set far below 60 seconds, from killing the request halfway. - Do not share one
Net::HTTPconnection across threads; it is not thread safe. Open a connection per job, or keep one per thread when a job sends many requests in a row. - Read the key with
ENV.fetchso a missing variable fails loudly at boot instead of sendingBearerwith nothing after it. In Rails, encrypted credentials work just as well. Switchcpdf_test_for acpdf_live_key at launch. - Build idempotency keys from database IDs, such as
certificate-FS2-4102, so a retried job can never issue a second certificate. - Send dates and times as ISO 8601 strings with
iso8601. PlainTime#to_soutput is not ISO 8601, soformat_datemay reject it. - Stay under 50 pages and 40 MB per PDF; compress signatures and logos before embedding them.
Common errors from Ruby code
| Symptom or code | Likely cause | Fix |
|---|---|---|
KeyError: key not found: "CASTPDF_API_KEY" | The variable is missing in the process that runs the job, often a worker started without your .env | Set it for the worker service too, or load it with your credentials setup |
Net::ReadTimeout | The client stopped waiting before the render was done | Raise read_timeout above 60 seconds and retry with the same idempotency key |
invalid_request (400) | A misspelt field such as orientation: :landscap, or both html and template_id in one hash | Compare your keys with the API reference; unknown fields are always rejected |
template_render_error (422) | A Liquid problem, often ERB like <%= learner.name %> pasted into the template | Read details.line and details.column, then use Liquid tags |
content_lost (422) | Print mode found content that would be clipped, such as a very long name in a fixed-height box | Let the box grow, shrink the font for long names, or allow wrapping |
rate_limited (429) | More requests per minute than allowed (test keys: 20) | Sleep for the Retry-After seconds, then send again |
The quickstart walks through keys, starter templates and the dashboard editor. If your Ruby lives in a Rails app, the Rails page shows a controller action and an Active Job that use the same calls. Writing scripts in Python as well? The Python page covers the same flow with requests.