Skip to content
CastPDF

Generate PDFs in Ruby with nothing but the standard library

To generate a PDF in Ruby, build a hash with your HTML (or a saved template ID) and the data, send it to CastPDF with Net::HTTP, and write the body with File.binwrite. No gem, no wkhtmltopdf, no Node or Chromium on the box. It is the same PDF generation API as every other language and runs on Ruby 3.

  • Updated October 2026
  • 100 free PDFs a month

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.

PDF options for Ruby
OptionHow it worksWorth knowing
PrawnA pure Ruby library: you place text, images and shapes with a Ruby DSLFast, no external binaries, precise control; layouts live in code, so a designer cannot hand you HTML and CSS
Wicked PDFRenders a Rails view to HTML, then calls the wkhtmltopdf binaryFamiliar ERB views; wkhtmltopdf's repository was archived in January 2023 and its WebKit lacks modern CSS such as grid
GroverSends your HTML to Puppeteer, so a real headless Chrome prints itModern CSS and JavaScript; every server needs Node, the Puppeteer package and a Chromium build
CastPDFOne HTTPS request; Chromium plus print features runs on our sideA 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.

certificate.rb (Ruby 3, standard library only)
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.

cohort_certificates.rb
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.

cast_pdf.rb (retrying client)
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
end

Call 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_timeout above the default. Net::HTTP waits 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 second open_timeout is 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::HTTP connection 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.fetch so a missing variable fails loudly at boot instead of sending Bearer with nothing after it. In Rails, encrypted credentials work just as well. Switch cpdf_test_ for a cpdf_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. Plain Time#to_s output is not ISO 8601, so format_date may reject it.
  • Stay under 50 pages and 40 MB per PDF; compress signatures and logos before embedding them.

Common errors from Ruby code

Errors Ruby developers see first, and what fixes them
Symptom or codeLikely causeFix
KeyError: key not found: "CASTPDF_API_KEY"The variable is missing in the process that runs the job, often a worker started without your .envSet it for the worker service too, or load it with your credentials setup
Net::ReadTimeoutThe client stopped waiting before the render was doneRaise 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 hashCompare 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 templateRead 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 boxLet 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.

FAQ

Common questions

Can CastPDF replace Wicked PDF in a Ruby app?

Yes, and the views can stay largely as they are. Render the ERB view to an HTML string as you do today, then send that string to the API instead of handing it to wkhtmltopdf. You gain modern CSS and drop a binary from your images.

Is there a Ruby gem for CastPDF?

There is no gem, and you do not need one. One POST request with a JSON body is the whole integration, so Net::HTTP from the standard library is enough. Faraday or HTTParty work just as well if your app already uses them.

Can I keep using ERB for my PDF layouts?

Yes. Render the ERB to finished HTML in Ruby and send it as html without a data field, so it is used exactly as sent. Use Liquid tags only for documents whose design you move into a saved CastPDF template.

How do I create certificates for hundreds of learners from Ruby?

Send one request per learner, ideally from one background job per certificate so failures retry on their own. Give each request an idempotency key based on the learner and course. The rate limit headers tell you how many requests you have left this minute.

When is Prawn still the better choice?

When the layout is fixed, simple and owned by developers, Prawn is fast and needs no network at all. Labels, simple receipts and documents drawn from coordinates are good examples. Choose HTML rendering when designers or long tables are involved.

Do test renders from Ruby count towards my plan?

No. Documents made with a test key are free, unlimited and watermarked. Live documents count once each regardless of page count, and the Free plan includes 100 of them every month.

Make your first PDF in 5 minutes

Pick a template, add your details and download your PDF. You get 100 free PDFs every month, and you don’t need a card.