---
name: thumb-im-og-images
description: Create and manage a website's OpenGraph / social card images through the thumb.im HTTP API, with no headless browser to install. Use when the task is to generate an og:image or twitter:image for a blog post, docs page, release, or landing page; to turn HTML into an image; to screenshot a URL; or to claim and verify a domain with thumb.im.
---

# thumb.im: OG images through an HTTP API

thumb.im turns HTML into an image, and URLs into screenshots, and hands back a
permanent URL that goes straight into a `<meta>` tag. You don't need Playwright,
a build step, or a committed binary — one HTTP call is enough.

Base URL: `https://thumb.im`. This file lives at `https://thumb.im/skill.md`.

## Credentials

1. Create an API key at **https://thumb.im/api-keys/** (Dashboard → API Keys).
   If you can't sign in, ask the user to create one.
2. Send it in the `X-API-Key` header.
3. Keep it in an environment variable (the examples use `THUMB_IM_API_KEY`).
   **Never** write the key into committed code, config, or the HTML you send for
   rendering — rendered markup is stored and the image URL is public.

```bash
export THUMB_IM_API_KEY=ti_...
```

## Decide first: designed card or screenshot?

| You want | Use | Needs a domain? |
|---|---|---|
| A **designed card**: title/description on a background, brand colors | `POST /apis/render/` | **No.** Works on the very first call |
| A **screenshot of a real, live page** | the path form `https://thumb.im/<options>/https://…` or `/shot/` | **Yes** — the domain must be claimed (and verified, if the server requires it) first |

Default to the HTML render for blog posts, docs, and releases. You already have
the title, description, and brand context; a screenshot only makes sense when
the page itself is the picture, and it can't exist before the page is deployed.

## Render HTML to an image

```bash
curl -sS -X POST https://thumb.im/apis/render/ \
  -H "X-API-Key: $THUMB_IM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"html": "<html>…</html>", "width": 1200, "height": 630}'
```

`width` and `height` are optional and default to 1200×630, the OpenGraph size.
Response (`201` for a new render, `200` for markup seen before):

```json
{
  "id": 42,
  "content_hash": "…",
  "image_url": "https://thumb.im/rendered/<hash>.png",
  "width": 1200,
  "height": 630,
  "created": "2025-01-01T00:00:00Z"
}
```

`image_url` is permanent and hotlinkable. Use it as-is; don't download and
re-host it.

Constraints that will otherwise cost you a round trip:

- **Identical markup is deduplicated.** The same HTML at the same size returns
  the same URL immediately, with no re-render. Change the HTML and you get a
  new URL. This makes it safe to call from a build on every run.
- **256 KB** maximum markup size.
- **16–3000 px** for each of width and height.
- **Absolute URLs or `data:` URIs only** for images, fonts, and stylesheets.
  There is no page origin, so relative paths (`/logo.png`) don't resolve.
- **Subresources must be publicly reachable.** Requests to localhost and private
  addresses are blocked. Inline small logos as `data:` URIs.
- Write the card's size into the page: set `body { margin:0; width:1200px;
  height:630px }` so the layout fills the image exactly.
- Fonts: system fonts always work. A web font must be loaded from a public
  absolute URL (e.g. a Google Fonts `<link>`) or embedded as a `data:` URI.

### Template: title and description card

Fill in `{{title}}`, `{{description}}`, `{{site}}`, and the colors. Escape the
text for HTML first (`&`, `<`, `>`, `"`).

```html
<html>
<body style="margin:0;width:1200px;height:630px;box-sizing:border-box;padding:80px;
  display:flex;flex-direction:column;justify-content:space-between;
  background:#0f172a;color:#fff;font-family:system-ui,sans-serif">
  <div style="font-size:32px;color:#93c5fd">{{site}}</div>
  <div>
    <div style="font-size:72px;font-weight:700;line-height:1.1">{{title}}</div>
    <div style="margin-top:28px;font-size:36px;color:#cbd5e1;line-height:1.3">{{description}}</div>
  </div>
</body>
</html>
```

### Template: release card

```html
<html>
<body style="margin:0;width:1200px;height:630px;display:flex;flex-direction:column;
  align-items:center;justify-content:center;gap:24px;text-align:center;
  background:linear-gradient(135deg,#1e3a8a,#7c3aed);color:#fff;font-family:system-ui,sans-serif">
  <div style="font-size:40px;opacity:.8">{{project}}</div>
  <div style="font-size:140px;font-weight:800">{{version}}</div>
  <div style="font-size:36px;opacity:.9">{{tagline}}</div>
</body>
</html>
```

### Template: card with a logo

Inline the logo so nothing depends on a relative path:

```html
<html>
<body style="margin:0;width:1200px;height:630px;box-sizing:border-box;padding:80px;
  background:#fff;color:#111827;font-family:system-ui,sans-serif">
  <img src="data:image/svg+xml;base64,{{logo_base64}}" style="height:72px" />
  <div style="margin-top:80px;font-size:76px;font-weight:700;line-height:1.1">{{title}}</div>
</body>
</html>
```

## Worked example: a card for a blog post

1. Take the post's title, description, and site name.
2. Build the HTML from a template above, with the text HTML-escaped.
3. Call `POST /apis/render/` and read `image_url`.
4. Put the URL in the page's `<head>`.

```python
import html, json, os, urllib.request

def render_card(title: str, description: str, site: str) -> str:
    markup = f"""<html><body style="margin:0;width:1200px;height:630px;box-sizing:border-box;
      padding:80px;display:flex;flex-direction:column;justify-content:space-between;
      background:#0f172a;color:#fff;font-family:system-ui,sans-serif">
      <div style="font-size:32px;color:#93c5fd">{html.escape(site)}</div>
      <div><div style="font-size:72px;font-weight:700;line-height:1.1">{html.escape(title)}</div>
      <div style="margin-top:28px;font-size:36px;color:#cbd5e1">{html.escape(description)}</div></div>
    </body></html>"""
    request = urllib.request.Request(
        "https://thumb.im/apis/render/",
        data=json.dumps({"html": markup, "width": 1200, "height": 630}).encode(),
        headers={"X-API-Key": os.environ["THUMB_IM_API_KEY"], "Content-Type": "application/json"},
    )
    with urllib.request.urlopen(request) as response:
        return json.load(response)["image_url"]
```

Then emit the tags, with the same URL in both `og:image` and `twitter:image`:

```html
<meta property="og:image" content="https://thumb.im/rendered/<hash>.png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content="https://thumb.im/rendered/<hash>.png" />
```

Store or template the returned URL; because the URL is a hash of the markup,
re-running the same call later returns it unchanged.

## Screenshot a real page

Put your page URL after ours. No API call and no key are needed to *fetch* the
image, so it works directly in a meta tag:

```html
<meta property="og:image" content="https://thumb.im/opengraph/https://example.com/pricing" />
<meta name="twitter:image" content="https://thumb.im/opengraph/https://example.com/pricing" />
```

Options are path segments between `thumb.im/` and the target URL, in any order:

| Segment | Meaning |
|---|---|
| `small` · `medium` · `large` · `opengraph` | 375 px, 650 px, 1024 px wide, or 1200×630 |
| `1200x630` | Exact width×height, up to 5000 px |
| `dark` · `light` | Force a color scheme |
| `transparent` | Transparent background |
| `fullpage` | Whole page instead of the viewport |
| `zoom:2` | Pixel density, up to 3 |
| `delay:500` | Wait N ms after load, up to 10000 |
| `wait:networkidle` | `load` (default), `domcontentloaded`, or `networkidle` |
| `ratio:16x9` | Derive height from width |

Example: `https://thumb.im/1200x630/dark/zoom:2/https://example.com`.
Unknown segments are ignored, so a typo won't break the image.

The same options are available as query parameters on
`https://thumb.im/shot/?url=https://example.com&size=opengraph` (`url`, `size`,
`width`, `height`, `aspect_ratio`, `zoom`, `delay`, `wait_until`, `full_page`,
`color_scheme`, `transparent`).

For the JSON form, `POST /apis/screenshots/` with `{"url": "…", "size":
"opengraph"}` returns `200` if the image already exists, `202` while it's being
captured (poll `GET /apis/screenshots/{id}/status/`), and `403` if the domain
isn't allowed.

### Read the response, don't assume

A hotlink URL always answers with an image, never an error page, so you must
inspect the response to know what you got:

- **`200` with `image/png`** — the capture is ready. This is the real image.
- **A redirect (`302`)** — *not* a failure and not the image. It points at a
  fallback (the page's own og:image or a branded placeholder). Read the
  **`X-Screenshot-Status`** header to learn why:

| `X-Screenshot-Status` | Meaning | What to do |
|---|---|---|
| `pending` / `processing` | Still generating | Wait a few seconds (`Retry-After: 5`) and fetch again |
| `failed` | The capture failed; it retries on its own periodically | Check the target page loads publicly; use `POST /apis/screenshots/{id}/regenerate/` once it does |
| `domain-not-verified` | This domain isn't allowed for your organization | Claim and verify the domain (below). Retrying won't help |

```bash
curl -sS -o /dev/null -D - https://thumb.im/opengraph/https://example.com | grep -i -E "^(HTTP|location|x-screenshot-status)"
```

The first request for a new screenshot waits a few seconds for the capture, so
it often returns `200` straight away. Don't treat any redirect as final: fetch
again until you get `200`, or until the status is `failed` or
`domain-not-verified`. Don't poll in a tight loop.

## Domains and verification

Only needed for screenshots; **not** for `POST /apis/render/`.

1. **Claim** the domain:
   ```bash
   curl -sS -X POST https://thumb.im/apis/domains/ \
     -H "X-API-Key: $THUMB_IM_API_KEY" -H "Content-Type: application/json" \
     -d '{"domain": "example.com"}'
   ```
   The response includes `id`, `verification_token`, and **`captures_allowed`**.
   A claim covers subdomains too. `201` is new, `200` means it was already yours.
2. If `captures_allowed` is `true`, you're done. If it's `false`, the domain has
   to be verified.
3. **Publish the token** (`verification_token`), either:
   - as a file at `https://example.com/.well-known/thumb-im-verification.txt`
     containing only the token, or
   - as `<meta name="thumb-im-verification" content="TOKEN">` on the homepage.
4. **Verify**:
   ```bash
   curl -sS -X POST https://thumb.im/apis/domains/<id>/verify/ -H "X-API-Key: $THUMB_IM_API_KEY"
   ```
   This always returns `200`; read `verified`. `false` just means the token isn't
   visible yet (caches and DNS can take minutes) — publish it, then check again
   later rather than looping.

`GET /apis/domains/` lists claimed domains; trust each one's `captures_allowed`.

Until a domain is allowed, screenshot URLs **do not fail loudly**: they redirect
to a branded "unverified" placeholder with `X-Screenshot-Status:
domain-not-verified`. If a card looks like a placeholder, this is why. If you
can edit the site, publishing the token is something you can do yourself;
otherwise tell the user exactly what to publish and where.

## Errors and what to do

| Status | Meaning | What to do |
|---|---|---|
| **400** | Markup or dimensions rejected: empty `html`, over 256 KB, a side outside 16–3000 px, non-integer sizes, or the HTML couldn't be rendered. The body is `{"error": "…"}` | Read `error`, fix the input, resend. Don't resend unchanged |
| **401** | Missing, invalid, or revoked API key | Check `X-API-Key` and the env var. Ask the user for a new key. Don't retry |
| **403** | Domain not allowed for this organization (screenshots), or an organization you can't act as | Claim and verify the domain. **Don't retry**; it won't change by itself |
| **429** | Rate limited (new renders and new screenshots are budgeted; cached ones are free) | Back off: honor `Retry-After` if present, otherwise wait minutes, not seconds. Don't hammer. Reuse existing URLs rather than re-rendering |

## Practices

- Render once per piece of content and store the URL; identical markup is free.
- Keep the key out of the repo and out of generated HTML.
- Use the same absolute `image_url` for `og:image` and `twitter:image`.
- Prefer `POST /apis/render/` unless the real page's appearance is the point.

Full API reference: https://thumb.im/public/docs/ · Interactive schema:
https://thumb.im/apis/docs
