PDFPipe

Guide

Generate a PDF from HTML in Ruby on Rails

Most Rails apps need a PDF eventually: an invoice, a receipt, a packing slip, a report. The classic answer is wicked_pdf plus a bundled wkhtmltopdf binary. This guide shows a lighter approach: POST your HTML to the PDFPipe API from a controller action and stream the bytes back with send_data. No system binary, no asset pipeline tricks.

The idea

Render an ERB template to an HTML string as you normally would, send that string to POST /v1/pdf, and pass the returned application/pdf bytes straight to the browser. The API does the heavy rendering, so your dynos stay small and stateless.

Render the HTML

Use render_to_string so you can reuse an existing view and layout. This keeps your invoice markup in one place instead of inlining HTML in Ruby.

ruby
# app/controllers/invoices_controller.rb
class InvoicesController < ApplicationController
  def show
    @invoice = Invoice.find(params[:id])

    html = render_to_string(
      template: "invoices/show",
      layout: "pdf",
      formats: [:html]
    )

    pdf = PdfPipe.render(html, format: "A4")

    send_data pdf,
      filename: "invoice-#{@invoice.number}.pdf",
      type: "application/pdf",
      disposition: "inline"
  end
end

The API call with Net::HTTP

No gems required. Wrap the call in a small service object (a plain Ruby class under app/services) so controllers stay thin. Store the key in Rails.application.credentials or an environment variable, never in source.

ruby
# app/services/pdf_pipe.rb
require "net/http"
require "json"
require "uri"

module PdfPipe
  ENDPOINT = URI("https://api.pdfpipe.xyz/v1/pdf")

  def self.render(html, format: "A4")
    request = Net::HTTP::Post.new(ENDPOINT)
    request["Authorization"] = "Bearer #{ENV.fetch('PDFPIPE_API_KEY')}"
    request["Content-Type"]  = "application/json"
    request.body = JSON.generate(
      html: html,
      options: { format: format }
    )

    response = Net::HTTP.start(
      ENDPOINT.hostname, ENDPOINT.port, use_ssl: true
    ) { |http| http.request(request) }

    unless response.is_a?(Net::HTTPSuccess)
      raise "PDFPipe error #{response.code}: #{response.body}"
    end

    response.body # raw application/pdf bytes
  end
end

Or with Faraday

If your app already uses Faraday, the same call is a few lines. Faraday gives you connection reuse, retries, and timeouts for free.

ruby
# app/services/pdf_pipe.rb
require "faraday"
require "json"

module PdfPipe
  def self.render(html, format: "A4")
    conn = Faraday.new(url: "https://api.pdfpipe.xyz") do |f|
      f.options.timeout = 30
    end

    response = conn.post("/v1/pdf") do |req|
      req.headers["Authorization"] = "Bearer #{ENV.fetch('PDFPIPE_API_KEY')}"
      req.headers["Content-Type"]  = "application/json"
      req.body = JSON.generate(
        html: html,
        options: { format: format }
      )
    end

    raise "PDFPipe error #{response.status}" unless response.success?

    response.body
  end
end

How this compares to wicked_pdf

wicked_pdf is a solid, well loved gem. It wraps wkhtmltopdf, which renders locally with no network call. That is a real advantage if you cannot make outbound requests. The tradeoffs show up in deployment and in CSS fidelity: wkhtmltopdf is built on an old WebKit fork, so modern flexbox, grid, and many web fonts render unpredictably, and the project is no longer actively maintained.

Aspectwicked_pdf + wkhtmltopdfPDFPipe API
InstallSystem binary per platform, buildpack or Docker layerNone, just an HTTP call
Rendering engineOld WebKit forkCurrent browser engine
Modern CSS (grid, flexbox)Partial, often buggyFull support
Web fontsFlakyStandard
Network dependencyNone (local)Outbound HTTPS
Maintenancewkhtmltopdf archivedManaged service

Generating in a background job

For large reports or batches, move the call out of the request cycle. Render in an Active Job, attach the result with Active Storage, and email or notify the user when it is ready. The service object above works unchanged inside the job.

ruby
# app/jobs/invoice_pdf_job.rb
class InvoicePdfJob < ApplicationJob
  queue_as :default

  def perform(invoice)
    html = ApplicationController.render(
      template: "invoices/show",
      layout: "pdf",
      assigns: { invoice: invoice }
    )

    pdf = PdfPipe.render(html, format: "A4")

    invoice.document.attach(
      io: StringIO.new(pdf),
      filename: "invoice-#{invoice.number}.pdf",
      content_type: "application/pdf"
    )
  end
end

Notes on reliability

Set a sensible timeout (PDF rendering for a complex page can take a few seconds), handle non 2xx responses by raising so the job retries, and make asset URLs absolute in your pdf layout so images and stylesheets resolve. Because the call is stateless, you can scale rendering simply by scaling requests.

Paste your invoice HTML into the live playground on the home page to preview the output, then read the full request and option reference in the docs.

See pricing →