HTML/CSS to Image Alternative: Parameter Map and Migration

Moving off HTML/CSS to Image (hcti.io)? Map every create-image parameter to RenderScreenshot, keep your templates, and see where HCTI is still the better fit.

By Simon Chiu · Updated

Disclosure: RenderScreenshot is our product. HTML/CSS to Image (HCTI, hcti.io) is a direct competitor. Every HCTI fact on this page comes from its public docs and pricing page, checked on 2026-10-15, and links to them.

HCTI and RenderScreenshot both take HTML or a URL and return an image from headless Chrome. The differences are in the details you meet during a migration: how CSS and fonts are attached, how you tell the renderer that the page is ready, and what the response gives you back. HCTI returns a hosted image URL that stays live while your account is active. RenderScreenshot returns the image bytes by default, plus a CDN URL that expires with the cache.

This page maps one API to the other and gives you an adapter that accepts your existing HCTI parameters. For the wider market, see the best HTML to image APIs.

TL;DR

Stay with HCTI if Choose RenderScreenshot if
You need image URLs that never expire and you do not want to run storage You want the bytes in your own pipeline, or signed URLs you generate without an API call
You build templates in a visual editor with non-developers Your templates live in your code and your repo
You need sub-1 device scales (HCTI accepts device_scale from 0.1) You need wait conditions such as networkidle or "wait for this selector"
You use HCTI's batch-templated endpoint heavily You also capture live pages with ad, tracker and chat-widget blocking, Markdown input or PDF output

How the request changes

HCTI uses HTTP Basic auth (API ID as user, API key as password) and POST https://hcti.io/v1/image. The body takes html plus a separate css string, or a url. The response is JSON with a url and an id, as shown in HCTI's API docs.

RenderScreenshot uses Authorization: Bearer rs_live_... and POST https://api.renderscreenshot.com/v1/screenshot with nested JSON. By default the response body is the image itself. Send Accept: application/json to get a JSON body with image.url and cache.expires_at instead, which is the closest match to HCTI's response. Both shapes are in the POST endpoint reference.

# HCTI
curl -X POST https://hcti.io/v1/image \
  -u "$HCTI_API_ID:$HCTI_API_KEY" \
  -d html='<div class="card">Release 2.4</div>' \
  -d css='.card { font: 700 64px sans-serif; padding: 80px }'

# RenderScreenshot
curl -X POST https://api.renderscreenshot.com/v1/screenshot \
  -H "Authorization: Bearer $RS_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{
    "html": "<div class=\"card\">Release 2.4</div>",
    "page": { "styles": [".card { font: 700 64px sans-serif; padding: 80px }"] },
    "viewport": { "width": 1200, "height": 630 }
  }'

Parameter mapping

HCTI names come from its parameters reference. RenderScreenshot names come from our content input and parameter docs.

HCTI parameter RenderScreenshot Notes
html html Same.
url url Same.
css page.styles: ["..."] Works for both html and url input. See page modifications.
`google_fonts: "Inter\ Roboto"` A <link> to fonts.googleapis.com in your HTML
viewport_width, viewport_height viewport.width, viewport.height Default 1280 x 800. See viewport.
device_scale (0.1 to 3) viewport.scale (1 to 3) For a smaller image, render at 1 and resize with output.resize.
selector capture.mode: "element", capture.selector Crops to one element. See capture.
full_screen capture.mode: "full_page" Full scroll height.
ms_delay wait.delay Milliseconds after the wait condition.
render_when_ready + ScreenshotReady() wait.for_selector Set a marker element or attribute when ready. See wait.
max_wait_ms wait.timeout HCTI uses milliseconds; we use seconds (max 60).
transparent_background output.transparent PNG or WebP.
format (png, jpg, webp, pdf) output.format (png, jpeg, webp, avif, pdf) Note jpg becomes jpeg.
color_scheme: "dark" browser.dark_mode: true See browser.
media_type browser.media screen or print.
timezone browser.timezone IANA name.
headers network.headers See network.
block_consent_banners block.cookie_banners We also have block.ads, block.trackers, block.chat_widgets. See blocking.
request_overrides (block patterns) block.requests, block.resources Glob patterns and resource types.
storage_destination_id storage.enabled, storage.path Bucket credentials live in the dashboard. See storage.
dedupe_duration_s Built-in cache (cache.ttl) Identical requests can be served from cache. See caching.
URL query ?width= / ?height= on the image output.resize Resize at render time.
Signed image URLs / templated OG URLs Signed URLs (key_id, expires, signature) See signed URLs.

Three behaviors need attention in a migration:

  1. The ready signal. HCTI waits for a global ScreenshotReady() call. We wait for a selector. Change one line in your template: replace ScreenshotReady() with document.body.setAttribute('data-ready', '').
  2. Fonts. HCTI loads google_fonts for you. We load whatever your HTML links. The default wait.until is load, which normally covers web fonts. Use networkidle if a font loads late.
  3. URL lifetime. HCTI's image URL is permanent while your account is active (pricing FAQ lists unlimited image retention on paid plans). Our CDN URL lives for the cache TTL: 24 hours by default and 30 days at most. If you store image URLs in a database today, copy the bytes into your own bucket during migration.

Migration adapter

The adapter accepts the parameter object you already send to HCTI and returns { url, expiresAt }. You can swap it in behind a feature flag and compare outputs before you cut over.

Node.js (Node 22+, no dependencies)

// Drop-in replacement for an HCTI "create image" call.
// Takes the same parameter names you send to hcti.io today and returns { url, expiresAt }.
const API = 'https://api.renderscreenshot.com/v1/screenshot';

export function toRenderScreenshot(p) {
  const fonts = (p.google_fonts ?? '').split('|').filter(Boolean)
    .map((f) => `family=${encodeURIComponent(f).replace(/%20/g, '+')}`).join('&');
  const fontLink = fonts ? `<link rel="stylesheet" href="https://fonts.googleapis.com/css2?${fonts}&display=swap">` : '';

  const body = p.url ? { url: p.url } : { html: fontLink + (p.html ?? '') };
  body.viewport = {
    width: p.viewport_width ?? 1200,
    height: p.viewport_height ?? 630,
    scale: Math.min(Math.max(p.device_scale ?? 1, 1), 3), // HCTI allows 0.1-3; we allow 1-3
  };
  body.output = { format: p.format === 'jpg' ? 'jpeg' : (p.format ?? 'png') };
  if (p.transparent_background) body.output.transparent = true;
  if (p.css) body.page = { styles: [p.css] };
  if (p.selector) body.capture = { mode: 'element', selector: p.selector };
  if (p.full_screen) body.capture = { mode: 'full_page' };
  body.wait = { until: 'networkidle', timeout: 30 };
  if (p.ms_delay) body.wait.delay = Math.min(p.ms_delay, 10_000);
  // render_when_ready: replace `ScreenshotReady()` in your template with
  // `document.body.setAttribute('data-ready', '')`, then wait for that selector.
  if (p.render_when_ready) body.wait.for_selector = 'body[data-ready]';
  if (p.color_scheme) body.browser = { dark_mode: p.color_scheme === 'dark' };
  if (p.timezone) body.browser = { ...body.browser, timezone: p.timezone };
  if (p.media_type) body.browser = { ...body.browser, media: p.media_type };
  if (p.block_consent_banners) body.block = { cookie_banners: true };
  if (p.headers) body.network = { headers: p.headers };
  return body;
}

export async function createImage(p) {
  const res = await fetch(API, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.RS_API_KEY}`,
      'Content-Type': 'application/json',
      Accept: 'application/json', // JSON with a CDN URL instead of image bytes
    },
    body: JSON.stringify(toRenderScreenshot(p)),
    signal: AbortSignal.timeout(45_000),
  });
  const data = await res.json();
  if (!res.ok) throw new Error(`${data.error?.code ?? res.status}: ${data.error?.message ?? 'render failed'}`);
  return { url: data.image.url, expiresAt: data.cache?.expires_at };
}

We cap wait.delay at 10,000 ms in the adapter. Long fixed delays are the slowest way to wait. A ready marker plus wait.for_selector returns as soon as the template is done.

Python

import os
from urllib.parse import quote_plus

import requests

API = "https://api.renderscreenshot.com/v1/screenshot"


def to_renderscreenshot(p: dict) -> dict:
    fonts = "&".join(f"family={quote_plus(f)}" for f in p.get("google_fonts", "").split("|") if f)
    font_link = (f'<link rel="stylesheet" href="https://fonts.googleapis.com/css2?{fonts}&display=swap">'
                 if fonts else "")
    body = {"url": p["url"]} if p.get("url") else {"html": font_link + p.get("html", "")}
    body["viewport"] = {
        "width": p.get("viewport_width", 1200),
        "height": p.get("viewport_height", 630),
        "scale": min(max(p.get("device_scale", 1), 1), 3),
    }
    fmt = p.get("format", "png")
    body["output"] = {"format": "jpeg" if fmt == "jpg" else fmt}
    if p.get("transparent_background"):
        body["output"]["transparent"] = True
    if p.get("css"):
        body["page"] = {"styles": [p["css"]]}
    if p.get("selector"):
        body["capture"] = {"mode": "element", "selector": p["selector"]}
    if p.get("full_screen"):
        body["capture"] = {"mode": "full_page"}
    body["wait"] = {"until": "networkidle", "timeout": 30}
    if p.get("ms_delay"):
        body["wait"]["delay"] = min(p["ms_delay"], 10_000)
    if p.get("render_when_ready"):
        body["wait"]["for_selector"] = "body[data-ready]"
    browser = {}
    if p.get("color_scheme"):
        browser["dark_mode"] = p["color_scheme"] == "dark"
    if p.get("timezone"):
        browser["timezone"] = p["timezone"]
    if p.get("media_type"):
        browser["media"] = p["media_type"]
    if browser:
        body["browser"] = browser
    if p.get("block_consent_banners"):
        body["block"] = {"cookie_banners": True}
    if p.get("headers"):
        body["network"] = {"headers": p["headers"]}
    return body


def create_image(p: dict) -> dict:
    resp = requests.post(
        API,
        headers={"Authorization": f"Bearer {os.environ['RS_API_KEY']}", "Accept": "application/json"},
        json=to_renderscreenshot(p),
        timeout=45,
    )
    data = resp.json()
    if not resp.ok:
        raise RuntimeError(f"{data.get('error', {}).get('code', resp.status_code)}: render failed")
    return {"url": data["image"]["url"], "expires_at": data.get("cache", {}).get("expires_at")}

Ruby

require 'net/http'
require 'json'
require 'erb'

API = URI('https://api.renderscreenshot.com/v1/screenshot')

def to_renderscreenshot(p)
  fonts = p.fetch(:google_fonts, '').split('|').reject(&:empty?)
           .map { |f| "family=#{ERB::Util.url_encode(f).gsub('%20', '+')}" }.join('&')
  font_link = fonts.empty? ? '' : %(<link rel="stylesheet" href="https://fonts.googleapis.com/css2?#{fonts}&display=swap">)

  body = p[:url] ? { url: p[:url] } : { html: font_link + p.fetch(:html, '') }
  body[:viewport] = { width: p.fetch(:viewport_width, 1200), height: p.fetch(:viewport_height, 630),
                      scale: p.fetch(:device_scale, 1).clamp(1, 3) }
  body[:output] = { format: p[:format] == 'jpg' ? 'jpeg' : p.fetch(:format, 'png') }
  body[:output][:transparent] = true if p[:transparent_background]
  body[:page] = { styles: [p[:css]] } if p[:css]
  body[:capture] = { mode: 'element', selector: p[:selector] } if p[:selector]
  body[:capture] = { mode: 'full_page' } if p[:full_screen]
  body[:wait] = { until: 'networkidle', timeout: 30 }
  body[:wait][:delay] = [p[:ms_delay], 10_000].min if p[:ms_delay]
  body[:wait][:for_selector] = 'body[data-ready]' if p[:render_when_ready]
  browser = {}
  browser[:dark_mode] = p[:color_scheme] == 'dark' if p[:color_scheme]
  browser[:timezone] = p[:timezone] if p[:timezone]
  browser[:media] = p[:media_type] if p[:media_type]
  body[:browser] = browser unless browser.empty?
  body[:block] = { cookie_banners: true } if p[:block_consent_banners]
  body[:network] = { headers: p[:headers] } if p[:headers]
  body
end

def create_image(p)
  res = Net::HTTP.start(API.host, API.port, use_ssl: true, read_timeout: 45) do |http|
    http.post(API.path, to_renderscreenshot(p).to_json,
              'Authorization' => "Bearer #{ENV.fetch('RS_API_KEY')}",
              'Content-Type' => 'application/json', 'Accept' => 'application/json')
  end
  data = JSON.parse(res.body)
  raise "#{data.dig('error', 'code') || res.code}: render failed" unless res.is_a?(Net::HTTPSuccess)

  { url: data.dig('image', 'url'), expires_at: data.dig('cache', 'expires_at') }
end

Tested on 2026-10-15 with Node 22.18.0, Python 3.14 (requests 2.34.2) and Ruby 4.0.7 against a local mock of our API. We checked the logged request bodies against our docs. We did not call HCTI or the live API in this test.

Comparing output on your own templates

We have not published a side-by-side render of the two services yet, because a fair test needs both services' current engines on the same day. Run it yourself in four steps:

  1. Pick 5 templates: one with Google Fonts, one with emoji, one with a selector crop, one with transparent_background, one that calls ScreenshotReady().
  2. Send each template to HCTI with your current parameters, and to the adapter above with the same object.
  3. Download both images and compare them with an image diff tool (pixelmatch, odiff or ImageMagick compare). Look at font weight, emoji style and edge antialiasing.
  4. Record latency for 10 calls each, cold (new template) and warm (same template again).

Emoji are the most likely difference. HCTI injects Twemoji into HTML renders by default (its disable_twemoji parameter turns this off). We do not inject Twemoji. Emoji use whatever emoji font the renderer has, so load an emoji web font in your HTML if you need a specific style.

Price at three volumes

Prices as listed on HCTI's pricing page and our pricing page, checked on 2026-10-15, monthly billing in USD.

Images per month HCTI RenderScreenshot
1,000 Basic, $14/month (1,000 images) Starter, $10/month (2,500 credits)
10,000 Basic at the 10,000 tier (price shown on their volume slider) Growth, $40/month (12,000 credits)
100,000 Pro at the 100,000 tier (Pro starts at $149/month for 30,000) More than Scale's 60,000 credits ($100/month); contact us

HCTI's free plan lists 50 images per month. Ours includes 150 credits every month, with no credit card. Count what each plan includes, not only the price: HCTI's paid plans include permanent hosting of every image, and that has real value if you would otherwise run storage.

When HCTI is the better choice

  • You want a visual template editor so non-developers can change designs.
  • You store image URLs in a CMS and never want to think about expiry or buckets.
  • You need device scales below 1 for tiny thumbnails straight from the renderer.
  • Your workflow is built on its batch-templated endpoint and template versions.

If none of these apply, and you want the render in your own pipeline with wait conditions and blocking options, create a free key and point the adapter at three of your templates. The HTML to image API page shows the rest of the HTML workflow.

Frequently asked questions

Do I have to rewrite my HCTI templates?
No. Send the same HTML. Put your css string into page.styles, and turn the google_fonts value into a Google Fonts link tag at the top of the HTML. The adapter on this page does both.
What replaces ScreenshotReady() and render_when_ready?
Make your template set a marker when it is ready, for example document.body.setAttribute('data-ready', ''), and send wait.for_selector set to body[data-ready]. The render waits until the selector appears or the timeout ends.
Are RenderScreenshot image URLs permanent like hcti.io/v1/image URLs?
No. The CDN URL in X-Cache-URL or image.url expires with the cache TTL, which is 30 days at most. For permanent URLs, upload the bytes to your own bucket or use the storage option.
Can RenderScreenshot capture live URLs as well as HTML?
Yes. Send url instead of html. HCTI supports url too, so this is not a reason to switch on its own.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.