---
title: "HTML to Image API for Python | Official Python SDK"
description: "Render HTML to PNG, screenshot URLs and export PDFs from Python. Official zero-dependency SDK with sync and async clients, a CLI, and Flask and Celery recipes."
url: "https://html2img.com/integrations/python/"
---

# 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.

- **Install:** `pip install html2img-client`
- **Registry:** [PyPI](https://pypi.org/project/html2img-client/)
- **Source:** https://github.com/html2img/html2img-python
- **Licence:** MIT
- **Requires:** Python 3.9 or newer

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](https://html2img.com/integrations/django/) 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 through the [HTML to PDF API](https://html2img.com/html-to-pdf/).
- **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](https://html2img.com/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](https://app.html2img.com/register) |

The distribution is named `html2img-client`; the import name is `html2img`.

## Installation

```bash
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:

```dotenv
HTML2IMG_API_KEY=your-api-key
```

> **Warning: 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

```python
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.

```python
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

```python
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:

```python
from html2img import Html2img, HtmlRequest

request = HtmlRequest(
    html=document,
    width=1200,
    height=630,
    dpi=2,
    wait_for_selector="#chart-ready",
)

response = Html2img().html(request)
```

> **Warning: 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.

```python
# 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`](https://html2img.com/docs/parameters/fullpage/),
[`selector`](https://html2img.com/docs/parameters/selector/),
[`dimensions`](https://html2img.com/docs/parameters/dimensions/),
[`dpi`](https://html2img.com/docs/parameters/dpi/),
[`wait_for_selector`](https://html2img.com/docs/parameters/wait_for_selector/),
[`ms_delay`](https://html2img.com/docs/parameters/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.

```python
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`](https://html2img.com/docs/parameters/format/) and
[`scale_to_fit`](https://html2img.com/docs/parameters/scale-to-fit/) docs and the
[HTML to PDF API](https://html2img.com/html-to-pdf/) overview.

## Named templates

`POST /api/v1/templates/{slug}` renders a [named template](https://html2img.com/templates/) from a
data payload, with no markup of your own. Templates output PNG only.

```python
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](https://html2img.com/templates/code-screenshot/),
[certificate](https://html2img.com/templates/certificate-of-completion/),
[receipt](https://html2img.com/templates/receipt-image/),
[invoice](https://html2img.com/templates/invoice-image/) and
[business card](https://html2img.com/templates/business-card/) templates. Inputs for each are in the
[template reference](https://html2img.com/docs/templates/).

## Saving and downloading

`download()` gives you the bytes, `save()` writes them to a path and creates the
parent directories for you:

```python
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:

```python
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:

```python
import boto3

boto3.client("s3").put_object(
    Bucket="my-bucket",
    Key=f"og/{post.id}.png",
    Body=client.download(response),
    ContentType="image/png",
)
```

> **Tip: 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:

```python
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`:

```python
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](#custom-transports). `client.sync` returns an equivalent
synchronous client if you need one inside an async codebase.

## Flask

```python
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:

```python
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:

```python
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](#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:

```python
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](https://html2img.com/docs/parameters/webhook-url/).

## Configuration

```python
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:

```python
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`.

```python
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](https://html2img.com/docs/getting-started/).

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:

```bash
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`](https://html2img.com/docs/account/).

## Testing

A transport is the simplest way to keep a test suite off the network and off your
credit balance:

```python
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:

```python
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:

```python
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`:

```python
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:

```python
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](https://html2img.com/docs/parameters/).

**`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](https://html2img.com/docs/templates/).

**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](#custom-transports) so everything shares one connection pool.

**Should I use this or the Django package on a Django site?**
Both, usually. The [Django package](https://html2img.com/integrations/django/) 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`](https://html2img.com/docs/account/). `html2img test` performs a real render and
does cost one.
