HTML to Image API for Python
The official Python client is built on the standard library, so it adds nothing to your dependency tree. It ships a synchronous client, an async client, inline type hints, and an html2img command you can run straight from the terminal.
pip install html2img-client Requires: Python 3.9 or newer. Every account starts with 50 free credits, no card needed.
See also Django JavaScript Ruby
The client is built on the standard library, so installing it adds one package
and nothing else to your dependency tree. It ships a synchronous client, an async
client with the same method names, inline type hints with a py.typed marker, and
an html2img command you can run straight from a terminal.
Building a Django site? The Django integration sits on top of this client and adds a model mixin, template tags, an admin panel and a management command for Open Graph images.
What you can build
- Open Graph and social images rendered from a Jinja or Django template and cached against the record.
- Reports, invoices and receipts, as PNGs on screen or as vector PDFs for email and archives.
- Charts as images. Render Chart.js, ECharts or Vega in the page and capture the result, so an email client that will not run JavaScript still shows the chart.
- Website screenshots for link previews, monitoring or a visual record, through the Screenshot API.
- Batch renders in a data pipeline, concurrently with
asyncio.gather.
Requirements
| Requirement | Version |
|---|---|
| Python | 3.9 or newer |
| Runtime dependencies | None; the client uses the standard library |
| API key | Free, from your dashboard |
The distribution is named html2img-client; the import name is html2img.
Installation
pip install html2img-client
Set the key in the environment. The client reads it automatically, so nothing has to be passed at every call site:
HTML2IMG_API_KEY=your-api-key
This is a server-side client: web applications, background workers, serverless functions and scripts. A key shipped to a browser or a mobile app spends your credits for whoever finds it.
Quick start
from html2img import Html2img
client = Html2img() # reads HTML2IMG_API_KEY from the environment
response = client.html(
"<h1 style='font: 700 64px system-ui'>Hello from Python</h1>",
width=1200,
height=630,
dpi=2,
)
print(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. A client is cheap to construct and safe to share between threads, so building one at import time and reusing it is fine.
HTML to image
POST /api/html takes a complete HTML document. Inline your CSS in a style
block, or pull in remote stylesheets and web fonts with link tags in the head.
from html2img import Html2img
client = Html2img()
response = client.html(
document,
css="body { background: #0f172a; color: #fff; }", # injected after load
width=1200,
height=630,
dpi=2, # retina: the file comes back 2400x1260
)
response.url
str(response) is the URL, so a response drops straight into an f-string or a
template context without reaching for .url.
Rendering a Jinja template
from jinja2 import Environment, FileSystemLoader
from html2img import Html2img
env = Environment(loader=FileSystemLoader("templates"), autoescape=True)
client = Html2img()
def og_image(post) -> str:
html = env.get_template("og/post.html").render(post=post)
return client.html(html, width=1200, height=630, dpi=2).url
autoescape=True matters: a post title containing an angle bracket would
otherwise break the document.
Building the request separately
When the option list gets long, or when several renders share a base configuration, build the request object instead:
from html2img import Html2img, HtmlRequest
request = HtmlRequest(
html=document,
width=1200,
height=630,
dpi=2,
wait_for_selector="#chart-ready",
)
response = Html2img().html(request)
The renderer fetches your fonts, images and stylesheets from its own servers, so
http://localhost:8000/logo.png resolves to nothing and comes out blank. Use
absolute public URLs, inline small assets as data URIs, or expose your development
server with a tunnel while you iterate. Google Fonts always work, because they are
already public.
Website screenshots
POST /api/screenshot captures a live, publicly reachable URL in real Chrome.
# 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.
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.
from html2img import Format, Html2img
client = Html2img()
response = client.html(invoice_html, format=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, f"invoices/{invoice.number}.pdf")
Format is a string enum, so format="pdf" and format=Format.PDF are
interchangeable. width, height, dpi, fullpage and selector are ignored in
PDF mode, since the page size is fixed. response.is_pdf tells you what came
back. See the format and
scale_to_fit docs and the
HTML to PDF API overview.
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",
},
)
# Keyword arguments work too, and merge over the mapping
response = client.template("invoice-image", invoice_number="INV-2026-0042", total="£750.00")
Python projects most often reach for the code screenshot, certificate, receipt, invoice and business card templates. Inputs for each are in the template reference.
Saving and downloading
download() gives you the bytes, save() writes them to a path and creates the
parent directories for you:
response = client.html(document, width=1200, height=630)
data = client.download(response) # bytes
path = client.save(response, "og/post-42.png") # pathlib.Path
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")
For anything other than the local filesystem, hand the bytes to whatever storage library you already use:
import boto3
boto3.client("s3").put_object(
Bucket="my-bucket",
Key=f"og/{post.id}.png",
Body=client.download(response),
ContentType="image/png",
)
response.expires_at is an ISO 8601 timestamp on the free tier and None on paid
plans, where renders are hosted permanently. Download a copy if a free-tier image
needs to outlive that window; upgrading also makes earlier renders permanent.
Async
AsyncHtml2img mirrors the synchronous client method for method:
import asyncio
from html2img import AsyncHtml2img
async def main():
async with AsyncHtml2img() as client:
response = await client.html(document, width=1200, height=630)
print(response.url)
asyncio.run(main())
Rendering a batch concurrently is then asyncio.gather:
async with AsyncHtml2img() as client:
responses = await asyncio.gather(
*(client.html(render_card(post), width=1200, height=630) for post in posts)
)
Requests run on the default thread pool executor, which keeps the package
dependency-free while leaving the event loop free during a render. A render is a
single request, so the pool is rarely the bottleneck; to share your application’s
own connection pool, pass an httpx- or aiohttp-backed
custom transport. client.sync returns an equivalent
synchronous client if you need one inside an async codebase.
Flask
import os
from flask import Flask, jsonify, render_template
from html2img import Html2img, Html2imgError
app = Flask(__name__)
client = Html2img() # constructed once, reused across requests
@app.get("/posts/<int:post_id>/og-image")
def og_image(post_id: int):
html = render_template("og.html", post=get_post(post_id))
try:
response = client.html(html, width=1200, height=630, dpi=2)
except Html2imgError as error:
app.logger.error("html2img failed: %s (%s)", error, error.error_code)
return jsonify(error="Could not generate the image"), 502
return jsonify(url=response.url)
Rendering on every request spends a credit on every request. In practice you want this behind a cache: render on publish, store the URL on the record, and serve the stored value.
FastAPI
The async client fits FastAPI directly:
from fastapi import FastAPI, HTTPException
from html2img import AsyncHtml2img, Html2imgError
app = FastAPI()
client = AsyncHtml2img()
@app.get("/og-image")
async def og_image(title: str):
try:
response = await client.html(card(title), width=1200, height=630, dpi=2)
except Html2imgError as error:
# Forward the real upstream status rather than a blanket 500
raise HTTPException(status_code=error.status_code or 502, detail=str(error))
return {"url": response.url}
There is no connection pool to tear down, so the client needs no shutdown hook: it holds configuration and a transport, nothing else.
Celery and background work
A render is a natural background task, especially a full-page capture:
from celery import shared_task
from html2img import Html2img, ServerError, ValidationError
client = Html2img()
@shared_task(bind=True, max_retries=3, autoretry_for=(ServerError,), retry_backoff=True)
def generate_og_image(self, post_id: int) -> str:
post = Post.objects.get(pk=post_id)
try:
response = client.html(render_card(post), width=1200, height=630, dpi=2)
except ValidationError:
# Permanent: the same request will fail the same way. Do not retry.
raise
Post.objects.filter(pk=post_id).update(og_image_url=response.url)
return response.url
Retry a ServerError or a ConnectionError; do not retry a ValidationError or
an InsufficientCreditsError. For captures that will not finish inside the 30
second synchronous budget, prefer
asynchronous delivery over a long-running task.
Asynchronous delivery
Pass a webhook_url and the API responds immediately with status: "processing"
and no URL, then POSTs the finished URL to your endpoint once rendering completes:
response = client.screenshot(
"https://example.com/very-long-report",
fullpage=True,
webhook_url="https://your-app.example.com/hooks/html2img",
)
if response.is_processing:
# The final URL arrives at your webhook, not on this response.
Render.objects.create(render_id=response.id, status="pending")
The payload shape is documented in the
webhook_url reference.
Configuration
from html2img import Html2img
client = Html2img(
api_key="your-api-key", # default: $HTML2IMG_API_KEY
base_url="https://app.html2img.com", # default: $HTML2IMG_BASE_URI, then this
timeout=35.0, # seconds
)
| 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 |
Constructing a client with no key and none in the environment raises ValueError
immediately, rather than failing on the first render. The 35 second default
timeout sits just above the API’s 30 second synchronous budget; for longer
captures pass a webhook_url rather than raising it.
Custom transports
All HTTP goes through a single callable, which is the seam for retry middleware,
proxies, connection pooling and tests. The default is UrllibTransport. To use
requests instead:
import requests
from html2img import Html2img
session = requests.Session()
def requests_transport(*, method, url, headers, body, timeout):
response = session.request(method, url, headers=headers, data=body, timeout=timeout)
return response.status_code, response.content
client = Html2img(transport=requests_transport)
The client still sends the X-API-Key, Accept and Content-Type headers, and
still maps every status onto the same typed exceptions.
Error handling
Every request-time failure raises an Html2imgError or a subclass. Catch that one
type to handle anything. No raw urllib error escapes the package, and invalid
arguments are reported before a request is sent, as a plain ValueError or
TypeError.
from html2img import (
Html2img,
Html2imgError,
InsufficientCreditsError,
RateLimitError,
TimeoutError,
ValidationError,
)
try:
response = Html2img().html(document, width=1200, height=630)
except ValidationError as error:
# 400 or 422: the request was malformed, retrying will not help
for field, messages in error.details.items():
logger.warning("html2img rejected %s: %s", field, ", ".join(messages))
except InsufficientCreditsError as error:
logger.error("html2img out of credits: %s left", error.credits_remaining)
except RateLimitError as error:
logger.warning("html2img rate limited, retry after %s", error.retry_after)
except TimeoutError:
# Re-send with a webhook_url rather than retrying synchronously
...
except Html2imgError as error:
logger.error(
"html2img failed: status=%s code=%s body=%s",
error.status_code,
error.error_code,
error.payload,
)
| Exception | Raised on |
|---|---|
AuthenticationError | 401, missing or invalid API key |
InsufficientCreditsError | 402, no credits remaining; exposes credits_remaining |
NotSubscribedError | 403, no active subscription |
NotFoundError | 404, for example an unknown template slug |
ValidationError | 400 or 422, with details per field |
RateLimitError | 429, rate or quota exceeded; exposes retry_after |
TimeoutError | 408 or 504, or the local timeout elapsed |
ServerError | 5xx, an unexpected renderer error |
ConnectionError | The request never reached a response |
Html2imgError | Base type for all of the above |
TimeoutError and ConnectionError share a name with the built-ins and are
strict subclasses of them, so an existing except TimeoutError: keeps working
either way. Retries are left to you, deliberately: a 5xx or a ConnectionError is
worth retrying, a 4xx is not, and only you know what a duplicate render costs. The
full status-code reference is in the
getting started guide.
Values are range-checked locally before a request is sent, so an out-of-range
width raises ValueError immediately rather than spending a credit on a render
the API would reject.
Command line
Installing the package also installs an html2img command:
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. The render options mirror the client: --width, --height, --dpi,
--fullpage, --css, --format, --ms-delay and --wait-for-selector. 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.
Testing
A transport is the simplest way to keep a test suite off the network and off your credit balance:
import json
from html2img import Html2img
def fake_transport(*, method, url, headers, body, timeout):
return 200, json.dumps({
"success": True,
"id": "test-render",
"url": "https://i.html2img.com/test.png",
"credits_remaining": 49,
}).encode()
def test_renders_a_card():
client = Html2img("test-key", transport=fake_transport)
assert client.html("<h1>Hi</h1>").url == "https://i.html2img.com/test.png"
Return a 402 status the same way to exercise the out-of-credits path, and capture
the body argument in the stub to assert on what was actually sent.
Type checking
The package ships inline type hints and a py.typed marker, so mypy and Pyright
check your calls with no stubs to install:
from typing import Optional
from html2img import Html2img, RenderResponse
def og_image_url(document: str) -> Optional[str]:
response: RenderResponse = Html2img().html(document, width=1200, height=630)
return response.url
Without the SDK
The API is one POST with a header, so requests or httpx will do:
import os
import requests
response = requests.post(
"https://app.html2img.com/api/html",
headers={"X-API-Key": os.environ["HTML2IMG_API_KEY"]},
json={"html": document, "width": 1200, "height": 630, "dpi": 2},
timeout=35,
)
if response.status_code != 200:
payload = response.json()
raise RuntimeError(f"html2img returned {response.status_code}: {payload.get('message')}")
url = response.json()["url"]
Async, with httpx:
import os
import httpx
async def render_html(html: str, **options) -> str:
async with httpx.AsyncClient(timeout=35.0) as client:
response = await client.post(
"https://app.html2img.com/api/html",
headers={"X-API-Key": os.environ["HTML2IMG_API_KEY"]},
json={"html": html, **options},
)
response.raise_for_status()
return response.json()["url"]
Screenshots, PDFs and templates are the same call with a different path or body:
requests.post(".../api/screenshot", json={"url": url, "fullpage": True}, headers=headers)
requests.post(".../api/html", json={"html": html, "format": "pdf"}, headers=headers)
requests.post(".../api/v1/templates/invoice-image", json=payload, headers=headers)
raise_for_status() collapses every failure into one HTTPError. If you want to
respond differently per case, branch on response.status_code: 400 and 422 for
validation (with a details object naming the fields), 401 for a bad key, 402 for
credits, 403 for no plan, 429 for rate limits, 504 for a render that exceeded the
budget. That mapping is what the SDK’s exception hierarchy replaces.
Package reference
Html2img(api_key=None, *, base_url=None, timeout=None, transport=None) and
AsyncHtml2img(...), which takes the same arguments.
| Method | Signature |
|---|---|
html() | html(html: str | HtmlRequest, **options) -> RenderResponse |
screenshot() | screenshot(url: str | ScreenshotRequest, **options) -> RenderResponse |
template() | template(slug: str, data: Mapping | None = None, **fields) -> RenderResponse |
download() | download(image: RenderResponse | str) -> bytes |
save() | save(image: RenderResponse | str, path: str | PathLike) -> Path |
AsyncHtml2img exposes the same five as coroutines, plus sync (an equivalent
synchronous client) and async context-manager support.
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 left as None is omitted
from the request, so the server applies its own default. Ranges and behaviour are
in the parameter reference.
RenderResponse is a frozen dataclass:
| Member | Type | Meaning |
|---|---|---|
success | bool | Whether the API reported success |
id | str | None | The render id |
url | str | None | CDN URL, None while an async job is pending |
expires_at | str | None | ISO 8601 expiry on the free tier, None on paid plans |
credits_remaining | int | None | Credits left after this call |
status | str | None | "processing" for accepted async jobs |
message | str | None | Human-readable message, when provided |
template | str | None | Template slug, on template renders |
is_processing | bool | Whether the job is still rendering |
is_pdf | bool | Whether the render came back as a PDF |
raw | dict | The full decoded JSON payload |
Also exported: HtmlRequest, ScreenshotRequest, Format, Transport,
UrllibTransport and the ten exception classes above.
Troubleshooting
ValueError: No html2img API key. The environment variable did not reach the
process. A .env file is not read automatically: load it with python-dotenv
before constructing the client, or export the variable. In Docker, remember that
ENV in the image and the runtime environment are different things.
Images and fonts are missing from the render. Chrome fetches them over the
public internet, so localhost:8000, *.test and private network addresses are
invisible. Use absolute public URLs, inline small assets as data URIs, or tunnel
your dev server while iterating.
The image is blank or half-drawn. The capture happened before the content did.
Add wait_for_selector pointing at something the finished page contains, or
ms_delay as a fallback for iframe content, which selectors cannot see.
except TimeoutError catches more than I expected. The package’s
TimeoutError subclasses the built-in, deliberately, so existing handlers keep
working. Catch html2img.TimeoutError explicitly if you need to distinguish it.
ValidationError from template(). Each template validates its own payload.
error.details names the field and the reason; check the template’s inputs in the
template reference.
Async renders are not running concurrently. AsyncHtml2img dispatches to the
default thread pool executor. If you are rendering hundreds at once, raise the
executor’s max_workers or pass an httpx-backed transport that shares one
connection pool.
The CLI is not on PATH after pip install. The console script lands in the
same bin directory as the interpreter that installed it. Inside a virtualenv,
activate it first; with pipx, pipx install html2img-client puts it on PATH for
you.
FAQ
Why is the package called html2img-client but imported as html2img?
The distribution name is html2img-client on PyPI; the module it installs is
html2img. pip install html2img-client, then from html2img import Html2img.
Do I need requests?
No. The client uses urllib from the standard library. If your application
already has requests or httpx, you can route the client through it with a
custom transport so everything shares one connection pool.
Should I use this or the Django package on a Django site?
Both, usually. The Django package handles Open Graph
images end to end (mixin, template tags, admin, management command) and installs
this client as a dependency, so anything else you want to render is one
Html2img() away.
Is the client thread-safe? Yes. It holds configuration and a transport, no per-request state, so one client shared across threads or Gunicorn workers is fine.
Does it work on AWS Lambda? Yes, and with no dependencies there is nothing to vendor into the deployment package. Construct the client at module scope so warm invocations reuse it, and mind the platform timeout against the API’s 30 second synchronous budget.
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.
Related integrations
The same API and the same key across every stack. These are the neighbours of the Python integration.
Start rendering from Python
50 free credits, no card required. One credit renders one image or one PDF.