Official SDK

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

RequirementVersion
Python3.9 or newer
Runtime dependenciesNone; the client uses the standard library
API keyFree, 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
Keep the key on the server

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)
Every URL must be publicly reachable

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",
)
Free-tier renders expire

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
)
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

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,
    )
ExceptionRaised on
AuthenticationError401, missing or invalid API key
InsufficientCreditsError402, no credits remaining; exposes credits_remaining
NotSubscribedError403, no active subscription
NotFoundError404, for example an unknown template slug
ValidationError400 or 422, with details per field
RateLimitError429, rate or quota exceeded; exposes retry_after
TimeoutError408 or 504, or the local timeout elapsed
ServerError5xx, an unexpected renderer error
ConnectionErrorThe request never reached a response
Html2imgErrorBase 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.

MethodSignature
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:

MemberTypeMeaning
successboolWhether the API reported success
idstr | NoneThe render id
urlstr | NoneCDN URL, None while an async job is pending
expires_atstr | NoneISO 8601 expiry on the free tier, None on paid plans
credits_remainingint | NoneCredits left after this call
statusstr | None"processing" for accepted async jobs
messagestr | NoneHuman-readable message, when provided
templatestr | NoneTemplate slug, on template renders
is_processingboolWhether the job is still rendering
is_pdfboolWhether the render came back as a PDF
rawdictThe 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.

Start rendering from Python

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