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
| Requirement | Version |
|---|---|
| Ruby | 3.1 or newer (tested on 3.1, 3.2, 3.3, 3.4 and 4.0) |
| Rails | Any version with a Railtie; Rails support is automatic when Rails is present |
| Runtime dependencies | None; the gem uses Net::HTTP from the standard library |
| API key | Free, 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
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")
| Variable | Default | Purpose |
|---|---|---|
HTML2IMG_API_KEY | none | Your key, sent as the X-API-Key header |
HTML2IMG_BASE_URI | https://app.html2img.com | API 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 %> · <%= 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 %>
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.
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"
)
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
| Exception | Raised on |
|---|---|
Html2img::AuthenticationError | 401, missing or invalid API key |
Html2img::InsufficientCreditsError | 402, no credits remaining; exposes credits_remaining |
Html2img::NotSubscribedError | 403, no active subscription |
Html2img::NotFoundError | 404, for example an unknown template slug |
Html2img::ValidationError | 400 or 422, with details per field |
Html2img::RateLimitError | 429, rate or quota exceeded; exposes retry_after |
Html2img::TimeoutError | 408 or 504, or the local timeout elapsed |
Html2img::ServerError | 5xx, an unexpected renderer error |
Html2img::ConnectionError | The request never reached a response |
Html2img::Error | Base 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)
| Method | Signature |
|---|---|
html | html(html, **options) -> RenderResponse |
screenshot | screenshot(url, **options) -> RenderResponse |
template | template(slug, data = {}, **fields) -> RenderResponse |
download | download(image) -> String |
save | save(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:
| Member | Returns | Meaning |
|---|---|---|
success? | Boolean | Whether the API reported success |
id | String | The render id |
url | String | CDN URL, nil while an async job is pending |
expires_at | String | ISO 8601 expiry on the free tier, nil on paid plans |
credits_remaining | Integer | Credits left after this call |
status | String | "processing" for accepted async jobs |
message | String | Human-readable message, when provided |
template | String | Template slug, on template renders |
processing? | Boolean | Whether the job is still rendering |
pdf? | Boolean | Whether the render came back as a PDF |
raw | Hash | The 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.
Related integrations
The same API and the same key across every stack. These are the neighbours of the Ruby integration.
Start rendering from Ruby
50 free credits, no card required. One credit renders one image or one PDF.