Official SDKRails support

HTML to Image API for Ruby and Ruby on Rails

The official Ruby gem is built on Net::HTTP from the standard library, so it adds no runtime dependencies. In a Rails application a Railtie wires it up for you, and an install generator writes the initializer.

bundle add html2img-client

Requires: Ruby 3.1 or newer. Every account starts with 50 free credits, no card needed.

See also Python PHP JavaScript

One gem covers both plain Ruby and Rails. It is built on Net::HTTP from the standard library, so it adds no runtime dependencies to your Gemfile.lock. In a Rails application a Railtie wires it up before your initializers run, an install generator writes the config, and the response drops straight into an Active Storage attachment or an ERB view.

What you can build

  • Open Graph images per record, rendered from an Action View template on publish and stored on the model.
  • Invoices and receipts as PNGs on screen and as vector PDFs attached to an Action Mailer message.
  • Certificates, tickets and passes generated when an order completes.
  • Website screenshots for link previews, listing thumbnails or a visual record, through the Screenshot API.
  • Digest images for email, where a chart drawn in HTML will not render but a PNG of it will.

Requirements

RequirementVersion
Ruby3.1 or newer (tested on 3.1, 3.2, 3.3, 3.4 and 4.0)
RailsAny version with a Railtie; Rails support is automatic when Rails is present
Runtime dependenciesNone; the gem uses Net::HTTP from the standard library
API keyFree, from your dashboard

The gem is named html2img-client; the namespace is Html2img.

Installation

bundle add html2img-client

Or in your Gemfile:

gem "html2img-client"

Bundler requires the gem for you in a Rails application, so the explicit require is only needed in plain scripts:

require "html2img/client"

Set the key in the environment. The client reads it automatically:

HTML2IMG_API_KEY=your-api-key
Keep the key on the server

This is a server-side client. A key shipped to a browser, a mobile app or a public repository spends your credits for whoever finds it. Issuing and rotating keys is covered in the authentication docs.

Quick start

require "html2img/client"

client = Html2img::Client.new # reads HTML2IMG_API_KEY from the environment

response = client.html(
  "<h1 style='font: 700 64px system-ui'>Hello from Ruby</h1>",
  width: 1200,
  height: 630,
  dpi: 2
)

puts response.url # => "https://i.html2img.com/abc123def456.png"

The API returns a JSON envelope containing the CDN URL of the render rather than the bytes, so you can store the URL and re-serve it. to_s on a response is the URL, so it drops straight into string interpolation or a view.

Configuration

Build a client with explicit configuration:

client = Html2img::Client.new(
  api_key: "your-api-key",              # default: ENV["HTML2IMG_API_KEY"]
  base_url: "https://app.html2img.com", # default: ENV["HTML2IMG_BASE_URI"], then this
  timeout: 35                           # seconds
)

Or configure the process once and use the module-level shortcuts, which is usually what an application wants:

# config/initializers/html2img.rb
Html2img.configure do |config|
  config.api_key = ENV.fetch("HTML2IMG_API_KEY")
  config.timeout = 45
end

Html2img.html(document, width: 1200, height: 630)
Html2img.screenshot("https://example.com")
Html2img.template("invoice-image", invoice_number: "INV-1042")
VariableDefaultPurpose
HTML2IMG_API_KEYnoneYour key, sent as the X-API-Key header
HTML2IMG_BASE_URIhttps://app.html2img.comAPI base URL; you rarely need to change this

Html2img.client is a memoised client built from that configuration; Html2img.reset! forgets both, which is mostly useful in tests. A client is cheap to build and safe to share between threads, so a memoised one is fine under Puma.

The 35 second default timeout sits just above the API’s 30 second synchronous render budget. For captures likely to exceed it, pass a webhook_url rather than raising the timeout.

Rails setup

The gem detects Rails and loads a Railtie, so there is nothing to require. Generate an initializer:

bin/rails generate html2img:install

That writes a commented config/initializers/html2img.rb reading your key from the environment. Or configure it from any environment file instead, which is handy for per-environment settings:

# config/environments/production.rb
config.html2img.api_key = Rails.application.credentials.html2img_api_key
config.html2img.timeout = 45

Both routes end at the same place. The Railtie runs before config/initializers, so an explicit Html2img.configure block wins if you use both.

HTML to image

POST /api/html takes a complete HTML document. Inline your CSS in a style block, or reference remote stylesheets and web fonts with link tags in the head.

response = client.html(
  document,                                          # a complete HTML document
  css: "body { background: #0f172a; color: #fff; }", # injected after load
  width: 1200,
  height: 630,
  dpi: 2                                             # retina: the file is 2400x1260
)

response.url

Rendering a Rails view

ApplicationController.render gives you the string, so the card lives with the rest of your views:

# app/models/og_image.rb
class OgImage
  SIZE = { width: 1200, height: 630, dpi: 2 }.freeze

  def self.html_for(post)
    ApplicationController.render(
      template: "og_images/post",
      layout: false,
      assigns: { post: post }
    )
  end

  def self.for(post)
    Html2img.html(html_for(post), **SIZE).url
  end
end
<%# app/views/og_images/post.html.erb %>
<!doctype html>
<html>
  <head>
    <meta charset="utf-8">
    <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;800&display=swap">
    <style>
      * { box-sizing: border-box }
      body {
        margin: 0; width: 1200px; height: 630px; padding: 80px;
        display: flex; flex-direction: column; justify-content: space-between;
        font-family: Inter, system-ui, sans-serif;
        background: linear-gradient(160deg, #0e1521, #16233a); color: #fff;
      }
      h1 { font-size: 64px; line-height: 1.1; margin: 0; font-weight: 800; letter-spacing: -0.02em }
      .meta { font-size: 22px; color: #aeb7c6 }
    </style>
  </head>
  <body>
    <h1><%= @post.title %></h1>
    <p class="meta"><%= @post.author.name %> &middot; <%= l(@post.published_at.to_date, format: :long) %></p>
  </body>
</html>

Then output it in your layout:

<% if @post.og_image_url.present? %>
  <meta property="og:image" content="<%= @post.og_image_url %>">
  <meta property="og:image:width" content="1200">
  <meta property="og:image:height" content="630">
  <meta name="twitter:card" content="summary_large_image">
<% end %>
Asset URLs must be publicly reachable

Chrome runs on our servers, so asset_path pointing at localhost:3000 resolves to nothing and renders blank. Use asset_url against a public host in production, and in development either reference publicly hosted assets, inline them as data URIs, or expose the app with a tunnel and set Rails.application.routes.default_url_options[:host] to it while you iterate. Google Fonts always work.

Website screenshots

POST /api/screenshot captures a live, publicly reachable URL.

# Viewport capture
client.screenshot("https://example.com", width: 1200, height: 630)

# The whole scroll length of the page
client.screenshot("https://example.com/pricing", fullpage: true)

# One element, cropped to its own bounding box
client.screenshot("https://example.com/pricing", selector: "#plans", width: 1400)

# Hide a cookie banner and a chat widget before capturing
client.screenshot(
  "https://example.com",
  css: ".cookie-banner, .intercom-launcher { display: none !important; }",
  width: 1440,
  height: 900,
  dpi: 2
)

# Wait for content that arrives after load
client.screenshot(
  "https://your-app.example.com/reports/42",
  wait_for_selector: "#chart-rendered",
  ms_delay: 400,
  width: 1440,
  height: 900
)

Injected rules usually need !important, because the page’s own styles win on specificity. Prefer wait_for_selector over ms_delay wherever you control the markup: it returns as soon as the element exists, where a delay always waits the full duration. Behaviour in full: fullpage, selector, dimensions, dpi, wait_for_selector, ms_delay.

Screenshotting your own app

A capture is an anonymous request from the public internet, so an authenticated route comes back as your sign-in page. Either render the view directly with Html2img.html, which needs no HTTP round trip, or expose a route with a signed token and capture that.

HTML to PDF

Pass format: "pdf" on either render and the result comes back as an A4 portrait vector PDF: selectable text, embedded fonts and automatic pagination, for the same single credit.

response = client.html(invoice_html, format: "pdf")

# Wide content, such as a data table, can be scaled down to the page width
response = client.html(report_html, format: "pdf", scale_to_fit: true)

client.save(response, "invoices/#{invoice.number}.pdf")

width, height, dpi, fullpage and selector are ignored in PDF mode, since the page size is fixed. response.pdf? tells you what came back. See the format and scale_to_fit docs and the HTML to PDF API overview.

Attaching one to an Action Mailer message:

class InvoiceMailer < ApplicationMailer
  def receipt(invoice)
    response = Html2img.html(
      ApplicationController.render(template: "invoices/show", layout: false, assigns: { invoice: invoice }),
      format: "pdf"
    )

    attachments["invoice-#{invoice.number}.pdf"] = Html2img.download(response)

    mail(to: invoice.client.email, subject: "Your invoice")
  end
end

Named templates

POST /api/v1/templates/{slug} renders a named template from a data payload, with no markup of your own. Templates output PNG only.

response = client.template(
  "invoice-image",
  invoice_number: "INV-2026-0042",
  business_name: "Coastline Coffee Co",
  client_name: "Riverside Bakery",
  total: "£750.00"
)

# A hash works too, when your data is already one
response = client.template("invoice-image", invoice.as_json)

Rails applications most often use the invoice, receipt, certificate, event ticket and real estate listing templates. Inputs for each are in the template reference.

Active Job and background rendering

A render belongs in a job, especially a full-page capture. The gem’s error classes map cleanly onto Active Job’s retry declarations:

class GenerateOgImageJob < ApplicationJob
  queue_as :default

  retry_on Html2img::ServerError, Html2img::ConnectionError,
           wait: :polynomially_longer, attempts: 3
  discard_on Html2img::ValidationError

  def perform(post)
    response = Html2img.html(OgImage.html_for(post), width: 1200, height: 630, dpi: 2)

    post.update!(og_image_url: response.url)
  end
end

Retrying a ServerError or a ConnectionError is worthwhile; retrying a ValidationError is not, since the same request will fail the same way.

Enqueue it when the card’s inputs change, not on every save, so an unchanged card never spends a credit:

class Post < ApplicationRecord
  OG_FIELDS = %w[title excerpt author_id published_at].freeze

  after_commit :queue_og_image, on: %i[create update]

  private

  def queue_og_image
    return unless (previous_changes.keys & OG_FIELDS).any?

    GenerateOgImageJob.perform_later(self)
  end
end

after_commit rather than after_save matters: a worker can pick the job up before the transaction lands otherwise.

Asynchronous delivery

For captures that will not finish inside the 30 second synchronous budget, pass a webhook_url. The API responds immediately with status: "processing" and no URL, then POSTs the finished URL to your endpoint:

response = client.screenshot(
  "https://example.com/very-long-report",
  fullpage: true,
  webhook_url: hooks_html2img_url
)

if response.processing?
  Render.create!(render_id: response.id, status: "pending")
end

Handle the callback on a route that skips CSRF verification, since it is a server-to-server POST:

class Hooks::Html2imgController < ActionController::API
  def create
    Render.find_by!(render_id: params[:id]).update!(status: "done", url: params[:url])

    head :ok
  end
end

The payload shape is in the webhook_url reference.

Saving and Active Storage

download gives you the bytes and save writes them to a path, creating parent directories as needed:

response = client.html(document, width: 1200, height: 630)

bytes = client.download(response)               # => String (binary)
path  = client.save(response, "og/post-42.png") # => "og/post-42.png"

Both accept a URL string as well as a response, so an earlier render can be re-downloaded:

client.save("https://i.html2img.com/abc123.png", "thumbnails/abc123.png")

Attaching to Active Storage, which also covers S3 and every other configured service:

class Post < ApplicationRecord
  has_one_attached :og_image
end

response = Html2img.html(OgImage.html_for(post), width: 1200, height: 630, dpi: 2)

post.og_image.attach(
  io: StringIO.new(Html2img.download(response)),
  filename: "og-#{post.id}.png",
  content_type: "image/png"
)
Free-tier renders expire

response.expires_at is an ISO 8601 string on the free tier and nil on paid plans, where renders are hosted permanently. Attach or download promptly if a free-tier image needs to outlive that window; upgrading also makes earlier renders permanent.

Error handling

Every request-time failure raises an Html2img::Error or a subclass. Rescue that one type to handle anything. No raw Net::HTTP exception escapes the gem, and invalid arguments are reported before a request is sent, as a plain ArgumentError.

begin
  response = client.html(document, width: 1200, height: 630)
rescue Html2img::ValidationError => e
  # 400 or 422: the request was malformed, retrying will not help
  e.details.each { |field, messages| logger.warn("#{field}: #{messages.join(', ')}") }
rescue Html2img::InsufficientCreditsError => e
  logger.error("html2img out of credits: #{e.credits_remaining} left")
rescue Html2img::RateLimitError => e
  logger.warn("html2img rate limited, retry after #{e.retry_after}")
rescue Html2img::Error => e
  logger.error("html2img failed: #{e.status_code} #{e.error_code} #{e.payload}")
end
ExceptionRaised on
Html2img::AuthenticationError401, missing or invalid API key
Html2img::InsufficientCreditsError402, no credits remaining; exposes credits_remaining
Html2img::NotSubscribedError403, no active subscription
Html2img::NotFoundError404, for example an unknown template slug
Html2img::ValidationError400 or 422, with details per field
Html2img::RateLimitError429, rate or quota exceeded; exposes retry_after
Html2img::TimeoutError408 or 504, or the local timeout elapsed
Html2img::ServerError5xx, an unexpected renderer error
Html2img::ConnectionErrorThe request never reached a response
Html2img::ErrorBase type for all of the above

Options are checked locally before a request is sent, so a typo or an out-of-range value raises immediately rather than spending a credit on a rejected render:

client.html(document, widht: 1200)
# => ArgumentError: Unknown option(s): widht. Valid options are: css, dpi, format, ...

client.html(document, width: 9000)
# => ArgumentError: The width must be between 1 and 5000, got 9000.

Retries are left to you, deliberately: a 5xx or a ConnectionError is worth retrying, a 4xx is not. In Rails, retry_on and discard_on express exactly that. The full status-code reference is in the getting started guide.

Custom transports

All HTTP goes through a single object responding to #call, which is the seam for retry middleware, proxies, connection pooling and tests. The default is Html2img::Transport, built on Net::HTTP. To use Faraday instead:

class FaradayTransport
  def initialize(connection) = @connection = connection

  def call(method:, url:, headers:, body:, timeout:)
    response = @connection.run_request(method.downcase.to_sym, url, body, headers) do |request|
      request.options.timeout = timeout
    end

    [response.status, response.body.to_s]
  end
end

client = Html2img::Client.new(transport: FaradayTransport.new(Faraday.new))

The client still sends the X-API-Key, Accept and Content-Type headers, and still maps every status onto the same typed errors.

Testing

A transport is the simplest way to keep a suite off the network and off your credit balance, with no HTTP stubbing library:

transport = ->(**) { [200, '{"success": true, "url": "https://i.html2img.com/test.png"}'] }
client = Html2img::Client.new(api_key: "test", transport: transport)

expect(client.html("<h1>Hi</h1>").url).to eq("https://i.html2img.com/test.png")

In a Rails suite, set it once and reset afterwards:

# spec/support/html2img.rb
RSpec.configure do |config|
  config.before do
    Html2img.configure do |c|
      c.api_key = "test"
      c.transport = ->(**) { [200, '{"success": true, "url": "https://i.html2img.com/test.png"}'] }
    end
  end

  config.after { Html2img.reset! }
end

Return a 402 body the same way to exercise the out-of-credits path. To test the job rather than the render, have_enqueued_job(GenerateOgImageJob) is usually enough.

Command line

Installing the gem also installs an html2img executable:

html2img test                                              # verify your setup
html2img html card.html --width 1200 --height 630 -o card.png
html2img html - --format pdf -o report.pdf < report.html   # read stdin
html2img screenshot https://example.com --fullpage -o shot.png
html2img screenshot https://example.com --selector "#hero" -o hero.png
html2img template invoice-image --data '{"invoice_number": "INV-1042"}'

Every command prints the resulting URL, and --out/-o also saves the render locally. Run html2img --help for the full list. html2img test performs a real render and costs one credit; to check a key and balance without spending one, call GET /api/me.

Without the gem

The API is one POST with a header, so Net::HTTP alone will do:

require "net/http"
require "json"

def html2img(path, payload)
  uri = URI("https://app.html2img.com/api/#{path}")

  request = Net::HTTP::Post.new(uri)
  request["Content-Type"] = "application/json"
  request["X-API-Key"] = ENV.fetch("HTML2IMG_API_KEY")
  request.body = payload.to_json

  response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, read_timeout: 35) do |http|
    http.request(request)
  end

  body = JSON.parse(response.body)

  raise "html2img returned #{response.code}: #{body['message']}" unless response.is_a?(Net::HTTPSuccess)

  body
end

html2img("html", { html: document, width: 1200, height: 630, dpi: 2 })
html2img("screenshot", { url: "https://example.com", fullpage: true })
html2img("html", { html: document, format: "pdf", scale_to_fit: true })
html2img("v1/templates/invoice-image", { invoice_number: "INV-1042", total: "£750.00" })

With Faraday, and retries on the failures worth retrying:

require "faraday"
require "faraday/retry"

class Html2ImgClient
  def initialize(api_key)
    @conn = Faraday.new(url: "https://app.html2img.com/api/") do |f|
      f.request :retry, max: 3, interval: 0.5, backoff_factor: 2,
                exceptions: [Faraday::ConnectionFailed, Faraday::TimeoutError]
      f.request :json
      f.response :json
      f.headers["X-API-Key"] = api_key
      f.adapter Faraday.default_adapter
    end
  end

  def render_html(html, options = {})
    post("html", { html: html }.merge(options))
  end

  def screenshot(url, options = {})
    post("screenshot", { url: url }.merge(options))
  end

  def render_template(slug, payload)
    post("v1/templates/#{slug}", payload)
  end

  private

  def post(path, payload)
    response = @conn.post(path, payload)
    # body["url"] is nil on error responses, so check the status first
    raise "html2img returned #{response.status}" unless response.success?

    response.body["url"]
  end
end

What you give up: local option checks, one error hierarchy instead of raw Faraday::Error, the download and save helpers, the Railtie and the CLI.

Package reference

Html2img::Client.new(api_key: nil, base_url: nil, timeout: nil, transport: nil)

MethodSignature
htmlhtml(html, **options) -> RenderResponse
screenshotscreenshot(url, **options) -> RenderResponse
templatetemplate(slug, data = {}, **fields) -> RenderResponse
downloaddownload(image) -> String
savesave(image, path) -> String

Module-level shortcuts. Html2img.html, Html2img.screenshot, Html2img.template, Html2img.download and Html2img.save all delegate to Html2img.client, a memoised client built from Html2img.configure. Html2img.reset! clears both.

Render options. Shared by both renders: css, width, height, fullpage, dpi, webhook_url, ms_delay, wait_for_selector, format, scale_to_fit. screenshot additionally accepts selector. Anything you leave out is omitted from the request, so the server applies its own default. Ranges and behaviour are in the parameter reference.

Html2img::RenderResponse is frozen:

MemberReturnsMeaning
success?BooleanWhether the API reported success
idStringThe render id
urlStringCDN URL, nil while an async job is pending
expires_atStringISO 8601 expiry on the free tier, nil on paid plans
credits_remainingIntegerCredits left after this call
statusString"processing" for accepted async jobs
messageStringHuman-readable message, when provided
templateStringTemplate slug, on template renders
processing?BooleanWhether the job is still rendering
pdf?BooleanWhether the render came back as a PDF
rawHashThe full decoded JSON payload

to_s is the URL.

Rails: bin/rails generate html2img:install writes the initializer; config.html2img.api_key, .base_url, .timeout and .transport are available in any environment file.

Troubleshooting

The key is not picked up in production. ENV.fetch("HTML2IMG_API_KEY") reads the process environment, which under systemd or a container is not your shell’s. In Rails, Rails.application.credentials in an environment file is often the tidier route.

Images and fonts are missing from the render. Chrome fetches them over the public internet, so localhost:3000, *.test and private addresses are invisible. Use asset_url with a public host, inline small assets as data URIs, or tunnel your dev server.

A screenshot of my own app returns the sign-in page. Captures are anonymous. Render the view directly with Html2img.html, or expose a signed route for the capture.

ArgumentError: Unknown option(s). The gem checks option names locally, which catches widht and full_page before they cost a credit. The message lists the valid names.

The job never runs. Check that a worker is running for the queue, and that config.active_job.queue_adapter is not still :async or :inline in the environment you are testing.

Every save spends a credit. Guard the enqueue on previous_changes, as in the Active Job section above, so only a meaningful edit triggers a render.

Html2img::TimeoutError on full-page captures. The render exceeded the 30 second synchronous budget. Send the same request with a webhook_url rather than raising the client timeout.

Html2img::ConnectionError in a container. Outbound HTTPS to app.html2img.com needs to be allowed and the image needs CA certificates; a slim base image with an empty certificate store is a common cause.

FAQ

Is there a separate Rails gem? No, and there does not need to be. html2img-client loads a Railtie when Rails is present, ships an install generator, and its error classes are designed to be used with retry_on and discard_on. Everything on this page applies to both plain Ruby and Rails.

Which Ruby versions are supported? Ruby 3.1 or newer. The gem is tested on 3.1, 3.2, 3.3, 3.4 and 4.0.

Does it work with Sidekiq, GoodJob or Solid Queue? Yes. There is nothing adapter-specific: the job calls the client and writes the URL back. The Active Job example above works on any backend.

Can I render an ERB partial rather than a whole template? Yes. ApplicationController.render accepts partial: as well as template:, but the API needs a complete HTML document, so wrap the partial in one before sending it.

Is a PDF more expensive than an image? No. One render is one credit, whether it comes back as a PNG or as a multi-page PDF.

How do I check my balance without spending a credit? Call GET /api/me. html2img test performs a real render and does cost one.

Can I use Faraday instead of Net::HTTP? Yes, through a custom transport, which keeps the typed errors and local option checks while sharing your application’s connection pool.

Start rendering from Ruby

50 free credits, no card required. One credit renders one image or one PDF.