DEV Community

Custodia-Admin
Custodia-Admin

Posted on • Originally published at pagebolt.dev

Screenshot API for Ruby on Rails: Screenshots and PDFs Without wkhtmltopdf

Screenshot API for Ruby on Rails: Screenshots and PDFs Without wkhtmltopdf

Generating PDFs and screenshots in Rails is one of those problems that looks simple until you actually try to deploy it. wkhtmltopdf requires a system binary. WickedPDF wraps wkhtmltopdf and adds a Rails DSL on top, but you're still shipping a binary in Docker and fighting with font paths in production. Prawn gives you programmatic PDFs, but it's a layout engine — you write Ruby to position every line of text, which means your HTML invoice template and your PDF invoice are two separate things to maintain.

The REST API approach sidesteps all of this. You send a URL or an HTML string, get back a PDF or PNG. No binary dependencies. No DSL. No Dockerfile changes.

Why Not wkhtmltopdf or WickedPDF?

  • System dependency — wkhtmltopdf must be installed on every machine (dev, CI, production). Docker images grow by 100–200 MB. ARM vs x86 issues are common.
  • Maintenance — wkhtmltopdf is no longer actively maintained. Its Chromium rendering engine is old and renders modern CSS inconsistently.
  • WickedPDF adds a DSL — you configure headers, footers, margins, and page breaks through Ruby options instead of CSS. CSS @media print rules still exist but feel bolted-on.
  • Prawn is a layout engine, not a renderer — it doesn't understand HTML. You can't take an existing ERB invoice template and hand it to Prawn.

A screenshot API uses a real, maintained headless Chromium instance. Your ERB template renders the same in the browser and in the PDF.

Setup

Add Faraday to your Gemfile if you don't already have it:

# Gemfile
gem 'faraday'
Enter fullscreen mode Exit fullscreen mode

Store your API key in credentials:

rails credentials:edit
Enter fullscreen mode Exit fullscreen mode
pagebolt:
  api_key: your_api_key_here
Enter fullscreen mode Exit fullscreen mode

A PageBolt Service Object

Create app/services/pagebolt_client.rb:

# app/services/pagebolt_client.rb
class PageboltClient
  BASE_URL = "https://pagebolt.dev/api/v1".freeze

  def initialize
    @api_key = Rails.application.credentials.dig(:pagebolt, :api_key)
    @conn = Faraday.new(url: BASE_URL) do |f|
      f.request :json
      f.response :raise_error
      f.adapter Faraday.default_adapter
    end
  end

  def screenshot(url:, full_page: false, **opts)
    post("/screenshot", { url: url, fullPage: full_page }.merge(opts))
  end

  def pdf(url: nil, html: nil, **opts)
    raise ArgumentError, "Provide url or html" if url.nil? && html.nil?
    payload = html ? { html: html } : { url: url }
    post("/pdf", payload.merge(opts))
  end

  private

  def post(path, payload)
    response = @conn.post(path, payload) do |req|
      req.headers["x-api-key"] = @api_key
    end
    response.body
  end
end
Enter fullscreen mode Exit fullscreen mode

This gives you a thin, reusable wrapper. Call it from any service, job, or controller.

Use Case 1: Invoice PDF Generation

You have an ERB template at app/views/invoices/show.html.erb. You want a pixel-perfect PDF that looks exactly like the browser version.

# app/services/invoice_pdf_service.rb
class InvoicePdfService
  def initialize(invoice)
    @invoice = invoice
  end

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

    PageboltClient.new.pdf(html: html, pdfOptions: { format: "A4" })
  end
end
Enter fullscreen mode Exit fullscreen mode

Use a dedicated layouts/pdf.html.erb layout that inlines CSS (external stylesheets aren't loaded when you pass raw HTML). Then call it from a controller action:

# app/controllers/invoices_controller.rb
def download
  invoice = Invoice.find(params[:id])
  pdf_bytes = InvoicePdfService.new(invoice).generate

  send_data pdf_bytes,
    filename: "invoice-#{invoice.number}.pdf",
    type: "application/pdf",
    disposition: "attachment"
end
Enter fullscreen mode Exit fullscreen mode

No wkhtmltopdf installed. No Prawn layout code. Your ERB template is the single source of truth.

Use Case 2: ActionMailer Attachment

Send the PDF as an email attachment when an invoice is finalized:

# app/mailers/invoice_mailer.rb
class InvoiceMailer < ApplicationMailer
  def finalized(invoice)
    @invoice = invoice
    pdf_bytes = InvoicePdfService.new(invoice).generate

    attachments["invoice-#{invoice.number}.pdf"] = {
      mime_type: "application/pdf",
      content: pdf_bytes
    }

    mail(
      to: invoice.client_email,
      subject: "Invoice ##{invoice.number} from #{invoice.account_name}"
    )
  end
end
Enter fullscreen mode Exit fullscreen mode

Call it from your job:

InvoiceMailer.finalized(invoice).deliver_later
Enter fullscreen mode Exit fullscreen mode

Use Case 3: Website Monitoring with Active Job

Capture a daily screenshot of your app to detect visual regressions — broken layouts, 500 pages, missing assets:

# app/jobs/visual_monitor_job.rb
class VisualMonitorJob < ApplicationJob
  queue_as :default

  def perform(url)
    client = PageboltClient.new
    png_bytes = client.screenshot(url: url, full_page: true, blockBanners: true)

    date_stamp = Date.today.iso8601
    path = Rails.root.join("tmp", "snapshots", "#{date_stamp}.png")
    FileUtils.mkdir_p(path.dirname)
    File.binwrite(path, png_bytes)

    Rails.logger.info "Snapshot saved: #{path}"
  end
end
Enter fullscreen mode Exit fullscreen mode

Schedule it with Sidekiq Cron or Whenever:

# config/schedule.rb (whenever)
every 1.day, at: "9:00 am" do
  runner "VisualMonitorJob.perform_later('https://yourapp.com')"
end
Enter fullscreen mode Exit fullscreen mode

Using Net::HTTP Instead of Faraday

If you'd rather avoid the Faraday dependency, the standard library works fine:

require "net/http"
require "uri"
require "json"

def fetch_pdf(html_string)
  uri = URI("https://pagebolt.dev/api/v1/pdf")
  http = Net::HTTP.new(uri.host, uri.port)
  http.use_ssl = true

  request = Net::HTTP::Post.new(uri.path)
  request["x-api-key"] = Rails.application.credentials.dig(:pagebolt, :api_key)
  request["Content-Type"] = "application/json"
  request.body = JSON.generate({ html: html_string, pdfOptions: { format: "A4" } })

  response = http.request(request)
  raise "PageBolt error: #{response.code}" unless response.is_a?(Net::HTTPSuccess)
  response.body
end
Enter fullscreen mode Exit fullscreen mode

Both approaches return the raw binary. Pass it to send_data or write it to disk.

A Concern for DRY Integration

If you need PageBolt calls across multiple models or controllers, wrap it in a concern:

# app/controllers/concerns/pdf_exportable.rb
module PdfExportable
  extend ActiveSupport::Concern

  included do
    def export_pdf(html:, filename:)
      pdf_bytes = PageboltClient.new.pdf(html: html)
      send_data pdf_bytes,
        filename: filename,
        type: "application/pdf",
        disposition: "attachment"
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Free Tier

PageBolt includes 100 requests per month on the free tier — no credit card required. That's enough to build and test invoice generation, monitoring, and email attachments end to end before you commit to a paid plan.

Get started: pagebolt.dev

Top comments (0)