# thumb.im: full documentation > thumb.im turns HTML into images and URLs into screenshots, and returns permanent URLs for `og:image` and `twitter:image` tags. This is the whole documentation in one file, so you don't have to follow links. A shorter overview with links is at https://thumb.im/llms.txt. Base URL: `https://thumb.im`. Everything below is generated from the running app (the API reference, MCP tool list, option lists and domain statuses) or is written for this file; the last section is the agent skill, verbatim. ## What thumb.im does - **Screenshots of URLs.** Give it a page URL and get a PNG back, captured by a real browser and cached. - **Images from HTML.** POST markup and get a PNG of it; ideal for designed OpenGraph cards. - **Permanent URLs.** Both produce a stable URL you paste into `og:image` / `twitter:image` meta tags. Rendered images are addressed by a hash of the markup and never change. ## Two ways in (and a third for assistants) 1. **Hotlink URLs.** `https://thumb.im/https://example.com` or `https://thumb.im/shot/?url=https://example.com`. No key and no API call needed to *fetch* the image, so it works directly inside a `` tag. The target domain must be claimed first. 2. **The JSON API** at `/apis/`, authenticated with an `X-API-Key` header. Use it to render HTML, request screenshots, manage domains and read organizations. 3. **The MCP server** at `https://thumb.im/mcp` (no trailing slash), for AI assistants. It accepts an `X-API-Key` header or OAuth. Decide first: for a **designed card** (title, description, brand colors) use `POST /apis/render/`, which needs no domain. For a **screenshot of a real, live page** use a hotlink URL or `POST /apis/screenshots/`, which needs a claimed domain. ## Accounts, organizations and API keys - Sign up at https://thumb.im/accounts/signup/. Every account gets a personal organization. - The tenant is an **organization**, not a person. Domains, screenshots and API keys belong to an organization, and everyone in it sees the same data. Invite teammates at https://thumb.im/organizations/. Inviting and removing members is dashboard-only; an API key can read who it acts as but never grant access. - Create an API key at https://thumb.im/api-keys/. A key acts as the organization it was created in. If an account belongs to several organizations, name another one with the `X-Organization: ` header (naming one you don't belong to is a 403). - Keys are hashed at rest and revocable. Keep yours in an environment variable and never put it in committed code or in HTML you send for rendering. - Download a ready-made skill (instructions, a single-file Python client and a fresh key) from the API Keys page. Agents that can't unzip a bundle can read the same instructions at https://thumb.im/skill.md. ## Authentication **API key.** Send `X-API-Key: ti_...` on every `/apis/` request (except `/apis/version/`) and on `/mcp`. A missing key is a 401; an invalid or revoked key is a 401 `{"error": "Invalid or revoked API key."}`. Repeated failures lock the calling IP out for a while (10 failures in 5 minutes). **MCP OAuth.** Hosted assistants (Claude.ai, ChatGPT) connect to `https://thumb.im/mcp` and sign the user in through OAuth; no key is pasted. Discovery documents: - `https://thumb.im/.well-known/oauth-protected-resource/mcp` - `https://thumb.im/.well-known/oauth-authorization-server/oauth` Endpoints: `/oauth/authorize`, `/oauth/token`, `/oauth/register` (dynamic client registration) and `/oauth/revoke`. An unauthenticated request to `/mcp` gets a 401 whose `WWW-Authenticate` header points at the protected-resource document. Over OAuth the acting organization is the user's own unless the client sends `X-Organization`. **Hotlink URLs** (`/shot/`, `/https://…`, `/rendered/.png`) need no credentials. ## MCP tools `https://thumb.im/mcp` speaks the Model Context Protocol. Tools for any signed-in account: - `get_current_organization`: Show which organization this API key is acting as. Screenshots and domains returned by the other tools all belong to it. - `list_organizations`: List the organizations this account belongs to. To act as a different one, send its slug in an X-Organization header alongside the API key. - `list_organization_members`: List the people in the organization this key acts as. To invite someone, visit /organizations/ in a browser — invitations can't be sent through the API. - `capture_screenshot`: Capture a screenshot of a URL on a domain this organization has claimed. Returns immediately: status 'completed' means the image_url is ready, 'pending' or 'processing' means poll get_screenshot_status with the returned id. An identical earlier capture is reused rather than redone. - `get_screenshot_status`: Check whether a screenshot has finished. Returns its status and, once status is 'completed', the image_url to fetch. - `list_screenshots`: List this organization's screenshots, newest first. Filter by a substring of the URL, or by status (pending, processing, completed, failed). - `list_allowed_domains`: List the domains this organization has claimed. captures_allowed says whether each can be captured right now — check here first when a capture is refused. - `regenerate_screenshot`: Force a fresh capture of a screenshot whose page has changed, discarding the cached image. Returns immediately with status 'pending' — poll get_screenshot_status for the new image_url. - `render_html`: Render supplied HTML into a PNG and return a permanent, hotlinkable URL — the one to put in an og:image tag. No domain verification applies, because the markup is the caller's own, so this is the way to produce an image for a page whose domain isn't verified. Identical markup returns the existing image. - `claim_domain`: Claim a domain for this organization so it can be captured. Check captures_allowed on the result: if it is false, this server requires verification — publish the returned token, then call verify_domain. Re-claiming a domain you already hold returns the existing claim, so this is safe to retry. A domain another organization has verified can't be claimed. - `verify_domain`: Check whether a claimed domain publishes its verification token, and mark it verified if so. Verifying is optional unless captures_allowed is false: it proves control and makes the claim exclusive. Publish the token first, either as the well-known URL or as a meta tag on the homepage. A 'verified: false' result means the token isn't visible yet — that is an expected answer, not an error. Do not call this in a loop; ask the user to publish the token and try again when they say it is live. Captures are asynchronous: `capture_screenshot` returns an id with status `pending` or `processing`, and `get_screenshot_status` returns the image URL once it is `completed`. ## Hotlink options Put your page URL after ours. Screenshots default to 1200x630 (the OpenGraph size), captured at 2x pixel density, and are cached after the first request. Query strings on your URL are preserved. ``` https://thumb.im/https://example.com https://thumb.im/opengraph/https://example.com/pricing https://thumb.im/small/dark/zoom:2/https://example.com https://thumb.im/shot/?url=https://example.com&size=opengraph&color_scheme=dark ``` Path segments (between `https://thumb.im/` and the target URL, in any order): - `small`: 375 px wide - `medium`: 650 px wide - `large`: 1024 px wide - `opengraph`: 1200x630, the OpenGraph size (the default) - `1200x630` (any `WIDTHxHEIGHT`): exact dimensions, up to 5000 px each - `dark`: force the dark color scheme - `light`: force the light color scheme - `transparent`: transparent background - `fullpage`: capture the whole page, not just the viewport - `zoom:VALUE`: pixel density, up to 3. Example: `zoom:2` - `delay:VALUE`: wait N milliseconds after load before capturing, up to 10000. Example: `delay:500` - `wait:VALUE`: when the page counts as loaded: `domcontentloaded`, `load`, `networkidle` (default `load`). Example: `wait:networkidle` - `ratio:VALUE`: derive the height from the width by aspect ratio. Example: `ratio:16x9` Query parameters on `https://thumb.im/shot/`: - `url`: the page to capture (required) - `size`: a size preset: `small`, `medium`, `large`, `opengraph` - `width`: width in px, up to 5000 - `height`: height in px, up to 5000 - `aspect_ratio`: aspect ratio such as `16:9`; derives the height from the width - `zoom`: pixel density, up to 3 - `delay`: milliseconds to wait after load, up to 10000 - `wait_until`: `domcontentloaded`, `load`, `networkidle` - `full_page`: `true` to capture the whole page - `color_scheme`: `dark`, `light` - `transparent`: `true` for a transparent background Unknown path segments are ignored rather than breaking the image, so a typo in a template won't take down every card on a site. ### What a hotlink answers A hotlink URL always answers with an image, never an error page, so inspect the response: - `200` with `image/png`: the capture is ready, served with long cache headers and `ETag` / `Last-Modified` revalidation. - `302` redirect: *not* the image. It points at a fallback (the page's own og:image or a branded placeholder). Read the `X-Screenshot-Status` response header: `pending` / `processing` (still generating, retry after `Retry-After: 5`), `failed` (the capture failed; it retries on its own), or `domain-not-verified` (the domain isn't claimed, or isn't permitted to capture, for the organization; claim it, and verify if needed). - `429` with `Retry-After`: too many *new* screenshots from one IP. Serving cached screenshots is never rate limited. - Screenshots older than two weeks re-capture in the background while the current image keeps serving. ## Domains and verification Only screenshots need a domain; `POST /apis/render/` does not. 1. **Claim** the domain (dashboard: https://thumb.im/alloweddomains/, or `POST /apis/domains/` with `{"domain": "example.com"}`). A claim covers the domain and all its subdomains. Full URLs are reduced to the hostname. Claiming a domain you already hold returns the existing claim, so it is safe to retry. 2. Read `captures_allowed` on the result. If true, you're done. If false, the server requires verification. 3. **Publish the token** (`verification_token`, shaped like `thumb-im-verification=...`) either: - as a file at `https://example.com/.well-known/thumb-im-verification.txt` containing only the token, or - as a meta tag on the homepage: ``. 4. **Verify** with `POST /apis/domains/{id}/verify/` (or the Verify button on the domain's page). It always returns 200; read `verified`. `false` means the token isn't visible yet (caches can take minutes); publish it, then check again later rather than looping. Whether verification is *required* depends on the deployment; always trust `captures_allowed` on the domain rather than assuming. Verifying is optional where it isn't required, but it settles ownership: only one organization can hold the verified claim on a domain, and once a domain is verified, unverified claims on it stop capturing. Domain statuses, as shown on the domains list, the domain page and the dashboard: - **Verified** (`verified`): The token was found on the site. Captures work, and no other organization can claim this domain or its subdomains. - **Needs verification** (`needs_verification`): Claimed, but captures are refused until the domain is verified. Publish the token and verify. - **Blocked** (`blocked`): Another organization has verified this domain, so this claim can never capture. Verifying is no way out; release the claim. - **Active, unverified** (`active_unverified`): Claimed and not verified, but captures work because the server doesn't require verification. Verifying settles ownership. - **Inactive** (`inactive`): The claim has been switched off, so it permits no captures. Until a domain may capture, its screenshot URLs do not fail loudly: they redirect to a branded placeholder with `X-Screenshot-Status: domain-not-verified`. If a card looks like a placeholder, this is why. ## API reference Base path `/apis/`. All bodies and responses are JSON. Authenticate with `X-API-Key` unless an endpoint says otherwise. Add `X-Organization: ` to act as another organization you belong to. List endpoints take `limit` and `offset` where shown. This reference is generated from the app's OpenAPI schema (the interactive copy at /apis/docs is for signed-in staff only). ### `GET /apis/version/` Version. Auth: none. Responses: - `200`: OK ### `GET /apis/organizations/` List the organizations this account belongs to. Auth: `X-API-Key` header. Responses: - `200`: OK - A JSON array of: - `name` (string, required) - `slug` (string, required) - `is_personal` (boolean, required) - `role` (string | null, optional) ### `GET /apis/organizations/current/` The organization this request is scoped to. Everything else the API returns — screenshots, domains — belongs to this organization, so a client that gets an unexpected empty list should check here first. Auth: `X-API-Key` header. Responses: - `200`: OK - `name` (string, required) - `slug` (string, required) - `is_personal` (boolean, required) - `role` (string | null, optional) ### `GET /apis/organizations/members/` List who is in the acting organization. Usernames and roles only. Email addresses are what an invitation is addressed to, so handing them to every API key would turn a leaked key into a roster of people to target. Auth: `X-API-Key` header. Responses: - `200`: OK - A JSON array of: - `username` (string, required) - `role` (string, required) - `joined` (datetime string, required) ### `GET /apis/screenshots/` List the organization's screenshots, newest first. Filter with q/status/size. Auth: `X-API-Key` header. Parameters: - `q` (query, string | null, optional) - `status` (query, string | null, optional) - `size` (query, string | null, optional) - `limit` (query, integer, optional) - `offset` (query, integer, optional) Responses: - `200`: OK - `items` (array of ScreenshotResponse, required) - `id` (integer, required) - `url` (string, required) - `status` (string, required) - `image_url` (string | null, optional) - `file_size` (integer | null, optional) - `error_message` (string | null, optional) - `created` (datetime string, required) - `completed_at` (datetime string | null, optional) - `count` (integer, required) ### `POST /apis/screenshots/` Request a screenshot. Returns existing if available, or creates new task. Auth: `X-API-Key` header. JSON body: - `url` (string, required) - `size` (string | null, optional) - `width` (integer | null, optional) - `height` (integer | null, optional) - `aspect_ratio` (string | null, optional) - `zoom` (number | null, optional) - `delay` (integer | null, optional) - `wait_until` (string | null, optional) - `full_page` (boolean | null, optional) - `color_scheme` (string | null, optional) - `transparent` (boolean | null, optional) Responses: - `200`: OK - `id` (integer, required) - `url` (string, required) - `status` (string, required) - `image_url` (string | null, optional) - `file_size` (integer | null, optional) - `error_message` (string | null, optional) - `created` (datetime string, required) - `completed_at` (datetime string | null, optional) - `202`: Accepted - `id` (integer, required) - `url` (string, required) - `status` (string, required) - `image_url` (string | null, optional) - `file_size` (integer | null, optional) - `error_message` (string | null, optional) - `created` (datetime string, required) - `completed_at` (datetime string | null, optional) - `400`: Bad Request - `error` (string, required) - `403`: Forbidden - `error` (string, required) ### `GET /apis/screenshots/{screenshot_id}/` Get screenshot details. Auth: `X-API-Key` header. Parameters: - `screenshot_id` (path, integer, required) Responses: - `200`: OK - `id` (integer, required) - `url` (string, required) - `status` (string, required) - `image_url` (string | null, optional) - `file_size` (integer | null, optional) - `error_message` (string | null, optional) - `created` (datetime string, required) - `completed_at` (datetime string | null, optional) ### `GET /apis/screenshots/{screenshot_id}/status/` Check screenshot processing status. Auth: `X-API-Key` header. Parameters: - `screenshot_id` (path, integer, required) Responses: - `200`: OK - `id` (integer, required) - `status` (string, required) - `image_url` (string | null, optional) - `error_message` (string | null, optional) ### `POST /apis/screenshots/{screenshot_id}/regenerate/` Force re-capture of a screenshot. Auth: `X-API-Key` header. Parameters: - `screenshot_id` (path, integer, required) Responses: - `202`: Accepted - `id` (integer, required) - `url` (string, required) - `status` (string, required) - `image_url` (string | null, optional) - `file_size` (integer | null, optional) - `error_message` (string | null, optional) - `created` (datetime string, required) - `completed_at` (datetime string | null, optional) ### `GET /apis/domains/` List the domains this organization has claimed. Check here first when a capture is refused: `captures_allowed` says whether each domain can be captured right now. Auth: `X-API-Key` header. Responses: - `200`: OK - A JSON array of: - `id` (integer, required) - `domain` (string, required) - `is_verified` (boolean, required) - `is_active` (boolean, required) - `verified_at` (datetime string | null, optional) - `verification_token` (string, required) - `covers` (string, required) - `captures_allowed` (boolean, required) ### `POST /apis/domains/` Claim a domain for this organization, which lets it capture the domain. Check `captures_allowed` on the result. If it's false, the server requires verification: publish the returned `verification_token`, then POST to /verify/. Re-claiming a domain you already hold returns the existing claim (200) rather than erroring, so this is safe to retry. A domain another organization has verified can't be claimed (400). Auth: `X-API-Key` header. JSON body: - `domain` (string, required) Responses: - `201`: Created - `id` (integer, required) - `domain` (string, required) - `is_verified` (boolean, required) - `is_active` (boolean, required) - `verified_at` (datetime string | null, optional) - `verification_token` (string, required) - `covers` (string, required) - `captures_allowed` (boolean, required) - `200`: OK - `id` (integer, required) - `domain` (string, required) - `is_verified` (boolean, required) - `is_active` (boolean, required) - `verified_at` (datetime string | null, optional) - `verification_token` (string, required) - `covers` (string, required) - `captures_allowed` (boolean, required) - `400`: Bad Request - `error` (string, required) ### `POST /apis/domains/{domain_id}/verify/` Check whether the domain publishes its verification token. Optional unless the server requires it: verifying proves control and makes the claim exclusive. Publish the token first, either way: - `https:///.well-known/thumb-im-verification.txt` containing the token - or a `` tag on the homepage Always returns 200 with `verified` telling you the outcome — a failed check is an expected answer ("not published yet"), not an error. Auth: `X-API-Key` header. Parameters: - `domain_id` (path, integer, required) Responses: - `200`: OK - `domain` (string, required) - `verified` (boolean, required) - `message` (string, required) - `verification_token` (string, required) - `well_known_url` (string, required) ### `DELETE /apis/domains/{domain_id}/` Release a claim. Screenshots attributed to it are left in place. Auth: `X-API-Key` header. Parameters: - `domain_id` (path, integer, required) Responses: - `204`: No Content ### `POST /apis/render/` Render HTML to an image and return its permanent URL. Identical markup returns the existing image (200) instead of re-rendering; new markup renders and returns 201. Auth: `X-API-Key` header. JSON body: - `html` (string, required) - `width` (integer | null, optional) - `height` (integer | null, optional) Responses: - `200`: OK - `id` (integer, required) - `content_hash` (string, required) - `image_url` (string, required) - `width` (integer, required) - `height` (integer, required) - `created` (datetime string, required) - `201`: Created - `id` (integer, required) - `content_hash` (string, required) - `image_url` (string, required) - `width` (integer, required) - `height` (integer, required) - `created` (datetime string, required) - `400`: Bad Request - `error` (string, required) - `429`: Too Many Requests - `error` (string, required) ## Errors | Status | Meaning | What to do | |---|---|---| | 400 | Input rejected: empty or oversized `html`, a side outside 16-3000 px, a bad option, or a domain that can't be claimed. Body is `{"error": "..."}` | Read `error`, fix the input, resend. Don't resend it unchanged | | 401 | Missing, invalid or revoked API key (or OAuth token) | Check the `X-API-Key` header; ask the user for a new key. Don't retry | | 403 | Domain not allowed for this organization, or an organization you aren't a member of | Claim (and verify) the domain, or fix `X-Organization`. Retrying won't change it | | 404 | No such screenshot or domain in the acting organization | Check the id and the organization | | 429 | Rate limited. Honor `Retry-After` when present | Back off for minutes, not seconds. Reuse existing URLs instead of re-rendering | ## Limits - **HTML renders:** 256 KB of markup at most; width and height 16-3000 px (default 1200x630). Identical markup is deduplicated (same URL, no re-render, not rate limited). New renders are budgeted: 120 per hour per signed-in account (fewer for anonymous playground use). - **New screenshots from hotlinks:** 30 per 60 seconds per IP. Cached screenshots are never limited. - **Screenshot options:** dimensions up to 5000 px, zoom up to 3, delay up to 10000 ms. - **Absolute URLs only** in rendered HTML (or `data:` URIs): there is no page origin, so `/logo.png` doesn't resolve. Subresources must be publicly reachable; requests to localhost and private addresses are blocked. ## Plans Listed at https://thumb.im/pricing/. Cached screenshots don't count against a quota; only new, unique captures do. - **Starter**, $30/year: 5,000 screenshots/month, 5 allowed domains, all screenshot options, email support. - **Pro**, $10/month: 25,000 screenshots/month, 25 allowed domains, all screenshot options, API access, priority support. - **Business**, $49/month: 100,000 screenshots/month, unlimited domains, all screenshot options, full API access, dedicated support, custom integrations. For custom pricing, email hello@thumb.im. ## Worked examples Render an HTML card (returns `201` for new markup, `200` for markup seen before): ```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": "Hello", "width": 1200, "height": 630}' ``` ```json {"id": 42, "content_hash": "...", "image_url": "https://thumb.im/rendered/.png", "width": 1200, "height": 630, "created": "2025-01-01T00:00:00Z"} ``` Claim and verify a domain, then request a screenshot and poll it: ```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"}' curl -sS -X POST https://thumb.im/apis/domains/1/verify/ -H "X-API-Key: $THUMB_IM_API_KEY" curl -sS -X POST https://thumb.im/apis/screenshots/ \ -H "X-API-Key: $THUMB_IM_API_KEY" -H "Content-Type: application/json" \ -d '{"url": "https://example.com/pricing", "size": "opengraph"}' curl -sS -H "X-API-Key: $THUMB_IM_API_KEY" https://thumb.im/apis/screenshots/1/status/ ``` Check what a hotlink answered without downloading the image: ```bash curl -sS -o /dev/null -D - https://thumb.im/opengraph/https://example.com | grep -i -E "^(HTTP|location|x-screenshot-status)" ``` The HTML tags to paste, with the same URL in both `og:image` and `twitter:image`: ```html ``` For a rendered card, use the `image_url` the API returned instead of the hotlink URL. ## Pages a person uses - Dashboard: https://thumb.im/dashboard/ (domains, screenshots and keys at a glance) - Generator: https://thumb.im/generate/ (build a screenshot URL or try an HTML render, no code) - Allowed domains: https://thumb.im/alloweddomains/ (claim, verify, see each status) - API keys: https://thumb.im/api-keys/ (create and revoke keys, download the skill bundle) - Organizations: https://thumb.im/organizations/ (switch, invite, leave) - Pricing: https://thumb.im/pricing/ - Docs: https://thumb.im/docs/ --- # Appendix: the agent skill (https://thumb.im/skill.md), verbatim # 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 `` 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//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": "…", "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/.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 ``) 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
{{site}}
{{title}}
{{description}}
``` ### Template: release card ```html
{{project}}
{{version}}
{{tagline}}
``` ### Template: card with a logo Inline the logo so nothing depends on a relative path: ```html
{{title}}
``` ## 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 ``. ```python import html, json, os, urllib.request def render_card(title: str, description: str, site: str) -> str: markup = f"""
{html.escape(site)}
{html.escape(title)}
{html.escape(description)}
""" 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 ``` 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 ``` 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 `` on the homepage. 4. **Verify**: ```bash curl -sS -X POST https://thumb.im/apis/domains//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