Generate PNG and WebP images from HTML in Python
You have HTML and CSS that looks right in a browser, and you want it back as a picture that you can post, email, or put on a page. This guide uses Python and the requests library to send that HTML to thirds.ai and save the result as a PNG, a WebP, and a JPEG. The same code works for a social card, a product image, or a chart preview.
What you need
- Python 3.9 or newer and
pip install requests. - A thirds.ai API key. The free plan gives you 25 credits a month, and one finished image costs one credit in every format.
- Some HTML. The example below uses a small card, but a full page with web fonts and grid layout works the same way, because a real Chromium browser lays it out.
Keep the key in an environment variable such as THIRDS_API_KEY, and never paste it into a script that you commit.
Write the HTML
Give the body the size of the image you want and set its margin to zero. thirds.ai captures exactly the rectangle you ask for at device scale 1, and it does not scroll, so anything outside the rectangle is not in the file. Add <meta charset="utf-8"> so symbols and accented names render.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<style>
body {
margin: 0;
width: 1200px;
height: 630px;
background: #0a46ff;
color: white;
font: 32px/1.4 sans-serif;
}
main {
padding: 64px;
}
h1 {
font-size: 64px;
margin: 0 0 24px;
}
</style>
</head>
<body>
<main>
<h1>September report</h1>
<p>24 orders completed</p>
</main>
</body>
</html>
Send the request
POST /v1/image takes the HTML as one string in the html field and the output settings in the image object. Set format to png, webp, or jpeg, and set width and height in pixels. This is the request body for the PNG.
{
"html": "<!doctype html><html lang='en'><head><meta charset='utf-8'><style>body{margin:0;width:1200px;height:630px;background:#0a46ff;color:white;font:32px/1.4 sans-serif}main{padding:64px}h1{font-size:64px;margin:0 0 24px}</style></head><body><main><h1>September report</h1><p>24 orders completed</p></main></body></html>",
"image": {
"format": "png",
"width": 1200,
"height": 630
},
"wait": false
}
In Python, one function sends the request, one waits for the job, and one downloads the file. The Idempotency-Key header makes a retry safe: the same key with the same body returns the same job for 24 hours, so a dropped connection never costs a second credit.
import hashlib
import os
import time
import requests
API = "https://thirds.ai"
HEADERS = {"Authorization": f"Bearer {os.environ['THIRDS_API_KEY']}"}
def create_image(html: str, fmt: str, width: int, height: int, key: str) -> dict:
response = requests.post(
f"{API}/v1/image",
headers={**HEADERS, "Idempotency-Key": key},
json={
"html": html,
"image": {"format": fmt, "width": width, "height": height},
"wait": False,
},
timeout=30,
)
response.raise_for_status()
return response.json()
def wait_for_job(job: dict, deadline_seconds: int = 120) -> dict:
stop = time.monotonic() + deadline_seconds
while job["status"] in ("queued", "running"):
if time.monotonic() > stop:
raise TimeoutError(f"job {job['id']} is still {job['status']}")
time.sleep(1.5)
response = requests.get(f"{API}/v1/image/{job['id']}", headers=HEADERS, timeout=30)
response.raise_for_status()
job = response.json()
if job["status"] != "succeeded":
raise RuntimeError(f"{job['status']}: {job['error']['code']}")
return job
def download(job: dict, path: str) -> None:
response = requests.get(f"{API}{job['download']['url']}", timeout=60)
response.raise_for_status()
data = response.content
assert len(data) == job["artifact"]["byte_size"]
assert hashlib.sha256(data).hexdigest() == job["artifact"]["sha256"]
with open(path, "wb") as file:
file.write(data)
download.url is a relative path such as /v1/downloads/..., so the code adds https://thirds.ai in front of it. The signed link lasts 15 minutes and needs no API key. The file itself stays for 30 days. The two assert lines compare the bytes with the job manifest, so a cut-off transfer never reaches your storage.
The same HTML in three formats
Run the three formats in one loop. Each finished image costs one credit, so the loop spends three credits. Give each format its own key, because a different body with the same key returns 409 idempotency_conflict.
html = open("card.html", encoding="utf-8").read()
for fmt, extension in (("png", "png"), ("webp", "webp"), ("jpeg", "jpg")):
job = create_image(html, fmt, 1200, 630, key=f"september-report-{fmt}")
job = wait_for_job(job)
download(job, f"september-report.{extension}")
print(fmt, job["artifact"]["byte_size"], "bytes")
The default size is 1280 by 720. Width can be 320 to 7680 pixels and height can be 200 to 4320 pixels. For a transparent PNG or WebP, set "transparent": true in the image object and leave the HTML background transparent too. For JPEG and WebP, add "quality" from 1 to 100; the default is 80. Set PDF and image options lists every field.
Which format to pick
| Pick | When |
|---|---|
png | Sharp text, flat colours, logos, and anything that needs a transparent background. Largest file. |
webp | Almost anything on the web. Small files with text or photos, and transparency works too. |
jpeg | Photos, email clients, and old tools that do not read WebP. No transparency. |
For a link preview card, PNG is the safe choice, because every social site reads it. For an image on your own site, WebP is usually a third the size of the PNG with the same look. For an email, JPEG or PNG is safer than WebP, because some email clients still do not show WebP.
Use a saved template instead of raw HTML
When the design stays the same and only the words change, save it once as a template with {{ }} fields, then send template_id and a data object in place of html. The request shape is the same, and the image options stay in the image object. This square product card is one gallery template that works this way.
You can also send html with {{ }} fields and a data object in the same request, and thirds.ai fills the fields before it renders without saving anything. Use templates with your data explains both paths.
Fix the common failures
A failed render costs nothing. Read error.code on a request error, or error.category and error.code on a failed job.
422 invalid_requestwith a field detail: theimageobject has a size outside the limits, ortransparentis true for a JPEG.unsafe_assetorinvalid_inputon the job: an image or font address points at a private host, or the HTML failed to load. Fix the address and send a new request.429 rate_limited: wait forRetry-Afterand retry with the same key.402 insufficient_credits: add a pack or wait for the next monthly grant.
Fonts must come from a public HTTPS address through @font-face, or from a generic family such as sans-serif. A font on your laptop is not available to the renderer. Retry requests and download files covers every code.
Read next
- The HTML to image page lists the formats, sizes, and limits.
- Make Open Graph images from HTML makes a link preview card with
curl. - Receive results with webhooks replaces polling when you render many images.
Sign in to get 25 free credits a month and render your first image.

