Image API documentation
Send JSON, get back a finished PNG URL. No editor, no browser on your side.
The machine-readable contract: /openapi.json (OpenAPI 3.1 — import into Postman, generate clients).
Your first render
You need a key: create an account (no card, 100 images a month) — the key appears right after signup. Three starter templates come with the account, so the request below works with no configuration at all.
?wait=true says you want to wait for the image rather than poll for the result.
curl -X POST "https://imageapi.benchkithq.com/v1/images?wait=true" \
-H "Authorization: Bearer sk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"template": "blog-og",
"locale": "pl",
"modifications": {
"title": "Ten things that ruin your sleep",
"author": "Anna Kowalska",
"site": "yourblog.com",
"accent": "#f97316"
}
}'$response = Http::withToken('sk_live_TWOJ_KLUCZ')
->post('https://imageapi.benchkithq.com/v1/images?wait=true', [
'template' => 'blog-og',
'locale' => 'pl',
'modifications' => [
'title' => 'Ten things that ruin your sleep',
'author' => 'Anna Kowalska',
'site' => 'yourblog.com',
],
]);
$url = $response->json('url');const res = await fetch('https://imageapi.benchkithq.com/v1/images?wait=true', {
method: 'POST',
headers: {
Authorization: 'Bearer sk_live_TWOJ_KLUCZ',
'Content-Type': 'application/json',
},
body: JSON.stringify({
template: 'blog-og',
locale: 'pl',
modifications: {
title: 'Ten things that ruin your sleep',
author: 'Anna Kowalska',
site: 'yourblog.com',
},
}),
});
const { url } = await res.json();import requests
res = requests.post(
"https://imageapi.benchkithq.com/v1/images?wait=true",
headers={"Authorization": "Bearer sk_live_TWOJ_KLUCZ"},
json={
"template": "blog-og",
"locale": "pl",
"modifications": {
"title": "Ten things that ruin your sleep",
"author": "Anna Kowalska",
"site": "yourblog.com",
},
},
)
url = res.json()["url"]Authentication
The key goes in the Authorization: Bearer sk_live_… header. You issue and revoke keys through /v1/keys or in the dashboard.
We show the full key exactly once, when it is issued — the database holds only its digest. A lost key cannot be recovered: issue a new one and revoke the old.
The last active key cannot be revoked. That is deliberate: without a working key you could not issue another one, and you would lock yourself out.
Async and sync
| Mode | Response | When |
|---|---|---|
POST /v1/images | 202 + id | The default. Collect the result from GET /v1/images/{id} or by webhook. |
POST /v1/images?wait=true | 200 + gotowy url | When you would rather not poll. A render usually takes about a second. |
Synchronous mode has its own resources, separate from the async queue — a flood of background jobs cannot starve a client waiting on a connection. If we still run out of time, you get a 503 with the code sync_overloaded. The job is not lost: the body carries an id, so you can poll for the result exactly as in async mode.
Idempotency
Add an Idempotency-Key header (any UUID). A repeated request with the same key and the same body returns the same response, with no second render and no second deduction from your quota.
The same key with a different body is a 422 — almost always a bug in the integration rather than an intention.
Separately from that, an identical render never costs twice anyway: the same template with the same values returns the existing image and uses no quota.
Errors and limits
The error field is a stable machine-readable code — build your switch on it. The message field is for humans and follows the Accept-Language header (we support en, pl, de, es, fr).
| Status | error | Meaning |
|---|---|---|
| 401 | unauthorized | Missing key, or a key that is invalid or revoked. |
| 402 | quota_exceeded | Monthly quota exhausted. |
| 404 | — | The resource does not exist on this account. Somebody else's resources are indistinguishable from nonexistent ones. |
| 422 | idempotency_key_reuse, last_key | Validation error or a conflict. |
| 429 | — | Too many requests per minute. |
| 503 | sync_overloaded | Sync mode ran out of time. The job is alive — poll it by id. |
How long images live
Finished files do not sit here forever, and we say so plainly, because the URL from our response usually goes straight into somebody's og:image.
| Plan | Image available for |
|---|---|
| Free | 30 days from generation |
| Starter, Pro, Scale | 365 days from generation |
After that both the file and the history entry are gone. It also means the same render ordered again no longer hits the cache and counts against your quota a second time. If an image must outlive our retention, download it right after generating — the URL is a plain PNG file.
Your own templates
You send your own HTML and CSS. Fields go in as {{ nazwa }} and are declared in fields. An undeclared field is an error on save, not a blank spot in the finished image.
{
"name": "Event card",
"fields": [
"headline",
"place",
"photo"
],
"images": [
"photo"
],
"css": "body{background:#111827;color:#f9fafb;padding:72px;font-family:Roboto}",
"html": "<div class=\"fitbox\"><h1 class=\"fit\" data-autofit>{{ headline }}</h1></div><span>{{ place }}</span>"
}
A few things worth knowing up front:
- The HTML is sanitised on save —
<script>,<iframe>andon*attributes are dropped. The response contains the sanitised version, so you see immediately what will actually render. - The CSS loses
@import,@font-faceand anyurl()outsidedata:. Network resources would not be fetched anyway. - Fonts come from our list. Field values are escaped, so user content can never become markup.
- We fetch image URLs ourselves and inline them as data — which is why an image field must be listed in
images.
Preview uses no quota, so iterating on the design costs nothing.
Text auto-fitting
Sooner or later somebody puts a 200-character title into a field designed for 60. Add data-autofit to an element inside a .fitbox container and we shrink the font until the text fits. Set the floor with data-autofit-min.
<div class="fitbox">
<h1 class="fit" data-autofit data-autofit-min="36">{{ title }}</h1>
</div>
Webhooks
Pass webhook_url when requesting a render and we will call you when it finishes — instead of you polling in a loop.
{
"event": "image.completed",
"id": "img_01m08s3ez1zcv28h335g9923bw",
"status": "completed",
"url": "https://cdn.yourdomain.com/renders/img_01m08s3ez1zcv28h335g9923bw.png",
"error": null,
"created_at": "2026-08-17T21:13:54+00:00"
}
Every call carries X-Webhook-Timestamp and X-Webhook-Signature headers. The secret to verify them is in the dashboard or at GET /v1/account; rotate it through POST /v1/account/webhook-secret. We sign both values so an intercepted call cannot be replayed:
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $webhookSecret);
if (! hash_equals($expected, $request->header('X-Webhook-Signature'))) {
abort(400);
}
We retry five times with growing backoff, so a brief outage on your side does not lose the event. A private-network address is rejected immediately with a 422 — rather than accepted, charged against your quota, and then quietly never called.
Signed image URLs (no server code)
A URL you paste straight into og:image — for static sites, Webflow, Shopify, Ghost themes and no-code tools, where you cannot keep an API key or send a POST. Template fields go in the query string, and the s parameter is a signature made with your signing secret. Available on every plan, Free included.
<meta property="og:image" content="https://imageapi.benchkithq.com/i/acc_YOUR_ACCOUNT_ID/blog-og.png?title=Ten%20things%20that%20ruin%20your%20sleep&s=…">
The first request renders the image (about a second), uses one render of your quota exactly like POST /v1/images, and answers with a 302 to the finished PNG. Every later request for the same template and the same values is a 302 to the existing file — no rendering, no quota. The redirect is cacheable, so crawlers and CDNs do not even have to ask us again.
How to sign
- Path:
/i/{account_id}/{template}.png— your accountid(acc_…, public, not a credential) and the template slug. No host. - Take every query parameter except
s(template fields and the optionallocale), sort them by name (byte order), percent-encode the name and the value per RFC 3986 — everything exceptA–Z a–z 0–9 - _ . ~, so a space is%20— and join them asname=valuewith&. - Canonical string = path +
?+ the joined parameters. The?stays even when there are no parameters. s= the first 32 lowercase hex characters ofHMAC-SHA256(canonical, signing_secret)(128 bits — plenty against guessing behind our rate limits, and half the URL length).- Append
sto the URL. The URL itself may list parameters in any order and encode spaces as+or%20: we decode it and rebuild the canonical string ourselves. Values are signed exactly as sent — we do not trim them.
Check your implementation against this test vector before you paste anything into og:image:
secret sign_example
path /i/acc_01k8example0000000000000000/blog-og.png
params title=Hello, world! author=Ann & Bob locale=en
canonical /i/acc_01k8example0000000000000000/blog-og.png?author=Ann%20%26%20Bob&locale=en&title=Hello%2C%20world%21
s cdc6c6ad85a071f083b5f1374b0a6d98
PHP
function signedImageUrl(string $accountId, string $template, array $params, string $secret): string
{
$path = "/i/{$accountId}/{$template}.png";
ksort($params, SORT_STRING);
$query = implode('&', array_map(
fn ($name, $value) => rawurlencode((string) $name).'='.rawurlencode((string) $value),
array_keys($params),
$params,
));
$s = substr(hash_hmac('sha256', $path.'?'.$query, $secret), 0, 32);
return 'https://imageapi.benchkithq.com'.$path.'?'.($query === '' ? '' : $query.'&').'s='.$s;
}
echo signedImageUrl('acc_YOUR_ACCOUNT_ID', 'blog-og', [
'title' => 'Ten things that ruin your sleep',
'author' => 'Anna Kowalska',
], getenv('IMAGE_API_SIGNING_SECRET'));
JavaScript (Node)
import { createHmac } from 'node:crypto';
// encodeURIComponent leaves !'()* alone; RFC 3986 (and PHP's rawurlencode) does not.
const enc = (v) => encodeURIComponent(String(v))
.replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase());
export function signedImageUrl(accountId, template, params, secret) {
const path = `/i/${accountId}/${template}.png`;
const query = Object.keys(params).sort()
.map((name) => `${enc(name)}=${enc(params[name])}`)
.join('&');
const s = createHmac('sha256', secret).update(`${path}?${query}`).digest('hex').slice(0, 32);
return `https://imageapi.benchkithq.com${path}?${query ? query + '&' : ''}s=${s}`;
}
console.log(signedImageUrl('acc_YOUR_ACCOUNT_ID', 'blog-og', {
title: 'Ten things that ruin your sleep',
author: 'Anna Kowalska',
}, process.env.IMAGE_API_SIGNING_SECRET));
No-code tools
Anything with an HMAC-SHA256 function can sign: a JavaScript code step in Zapier, Pipedream and similar tools (adapt the JavaScript above), n8n's Crypto node (action Hmac, type SHA256, encoding hex — then keep the first 32 characters), or the build step of a static site generator. Build the canonical string exactly as in the steps above. Never sign in browser JavaScript: the secret would be public.
Good to know
- The signing secret and your account
idare in the dashboard and atGET /v1/account(signing_secret,id). It is a separate secret from your API keys and the webhook secret: if it leaks, nobody gets API access — but they can render on your quota. - Rotate it through
POST /v1/account/signing-secret. Every URL signed with the old secret stops working immediately, including ones already published. Redirects already cached by crawlers and CDNs may live up to 7 days. - Stricter than the POST API: a field the template does not declare is a
400, so a typo cannot quietly ship an image with no title. Your own templates acceptfieldsandimages; built-in ones accept their documented fields plusaccentandtheme. - Errors are short
text/plainbodies, never HTML:400unknown field orlocale,402quota exhausted,403missing or invalid signature,404unknown account or template (indistinguishable),429rate limit,503render still running — retry afterRetry-After. - Limits: 120 correctly signed requests per minute per account (requests with a bad signature never count against yours) and 600 requests per minute per IP address.
Reference
The list below is generated from the same spec we serve at /openapi.json.
Endpoint descriptions are in English regardless of the page language: the spec is a machine contract that clients are generated from, so it has one fixed version.
Images
POST /v1/images Request a render
Asynchronous by default: you get a 202 and an `id`, then collect the result from `GET /images/{id}`. With `?wait=true` we hold the connection until the image is ready and return 200 — or 503 `sync_overloaded` if we run out of time.
| Parameter | In | Description |
|---|---|---|
wait |
query | Wait for the result instead of polling. |
Idempotency-Key |
header | The same `Idempotency-Key` with the same body returns the same response, with no second render. |
Example request
{
"template": "blog-og",
"locale": "en",
"modifications": {
"eyebrow": "Health",
"title": "Ten things that ruin your sleep",
"author": "Anna Kowalska",
"site": "yourblog.com",
"accent": "#f97316",
"date": "2026-08-17",
"reading_time": 7
}
}
| Response | Description |
|---|---|
200 | Finished (only with `?wait=true`). |
202 | Accepted into the queue. |
402 | Monthly quota exhausted (`quota_exceeded`). |
422 | Validation error, or the same `Idempotency-Key` with a different body. |
503 | Sync mode ran out of time (`sync_overloaded`). The body carries `id` — nothing is lost. |
GET /v1/images Render history
Cursor pagination: pass `before` from the `next_cursor` field. Renders arriving while you page through will not duplicate results.
| Parameter | In | Description |
|---|---|---|
limit |
query | |
before |
query | Kursor z `next_cursor`. |
| Response | Description |
|---|---|
200 | A page of renders. |
GET /v1/images/{id} Stan renderu
| Parameter | In | Description |
|---|---|---|
id |
path |
| Response | Description |
|---|---|
200 | Stan renderu. |
404 | Nie ma takiego renderu na tym koncie. |
GET /i/{account}/{template}.png Signed image URL (for og:image)
A URL you paste straight into `<meta property="og:image">` — no server code, no API key. Template fields go in the query string, `s` is an HMAC signature made with your `signing_secret`.
Signature: build the canonical string `/i/{account}/{template}.png` + `?` + every query parameter except `s`, sorted by name (byte order), each name and value percent-encoded per RFC 3986 (space = `%20`), joined as `name=value` with `&` (the `?` stays even with no parameters). `s` = the first 32 lowercase hex characters of HMAC-SHA256(canonical, signing_secret). The URL itself may use any order and encoding (`+` or `%20`) — we decode it and rebuild the canonical string.
An identical render that already exists is a 302 with no quota used and no rendering. Otherwise we render synchronously, use one render of quota exactly like `POST /images`, and redirect. Redirects carry `Cache-Control: public` for up to 7 days (never past the file's retention). Errors are short `text/plain` bodies, never HTML. Limits: 120 correctly signed requests per minute per account, 600 requests per minute per IP.
| Parameter | In | Description |
|---|---|---|
account |
path | Your account `id` from `GET /account` (`acc_…`). Public, not a credential. |
template |
path | Template slug, e.g. `blog-og`. |
s |
query | Signature — see above. |
locale |
query | Language for text the template adds itself. Signed like any other parameter. |
{field} |
query | Any field the template declares (`fields`, `images`, `defaults`; built-in templates: their documented fields plus `accent` and `theme`). An undeclared field is a 400. |
| Response | Description |
|---|---|
302 | Redirect to the finished PNG. |
400 | A field the template does not declare, an unsupported `locale`, or a non-string parameter. |
402 | Monthly quota exhausted (only when a new render was needed). |
403 | Missing, malformed or invalid signature. |
404 | No such account or template. The two are indistinguishable. |
429 | Rate limit. See `Retry-After`. |
500 | The render failed. No quota is used. |
503 | The render did not finish in time. It keeps going — retry after `Retry-After`. |
Templates
GET /v1/templates List templates
| Response | Description |
|---|---|
200 | Templates on this account, both built-in and your own. |
POST /v1/templates Create a template
You send your own HTML and CSS. Fields go in as `{{ name }}` and **must be declared** in `fields` — a typo fails with a 422 on save rather than leaving a blank spot in the image.
The HTML is sanitised on save (`<script>`, `<iframe>` and `on*` attributes are dropped), and the CSS loses `@import`, `@font-face` and any `url()` outside `data:`. We store the sanitised version — the `html` field in the response returns it.
Limits: HTML and CSS up to 64 KB each, dimensions from 200 to 4000 px.
Example request
{
"name": "Event card",
"width": 1200,
"height": 630,
"fields": [
"headline",
"place",
"photo"
],
"images": [
"photo"
],
"css": "body{background:#111827;color:#f9fafb;padding:72px;font-family:Roboto} h1{font-size:88px;font-weight:900}",
"html": "<div class=\"fitbox\"><h1 class=\"fit\" data-autofit>{{ headline }}</h1></div><img src=\"{{ photo }}\"><span>{{ place }}</span>"
}
| Response | Description |
|---|---|
201 | Utworzony. |
422 | A field used in the HTML is not declared, `images` goes beyond `fields`, or a size limit was exceeded. |
GET /v1/templates/{id} Template details
| Parameter | In | Description |
|---|---|---|
id |
path |
| Response | Description |
|---|---|
200 | Szablon. |
404 | Nie istnieje na tym koncie. |
PATCH /v1/templates/{id} Replace a template
Replaces the whole definition. Built-in templates (`engine: blade`) cannot be edited — you get a 404.
| Parameter | In | Description |
|---|---|---|
id |
path |
| Response | Description |
|---|---|
200 | Zapisany. |
404 | Nie istnieje albo jest wbudowany. |
DELETE /v1/templates/{id} Delete a template
| Parameter | In | Description |
|---|---|---|
id |
path |
| Response | Description |
|---|---|
204 | Deleted. |
POST /v1/templates/preview Preview without using quota
Renders a saved template (`template`) **or a draft** you have not saved yet (`html`, `css`, `fields`). Creates no history entry and **uses no quota** — so refining a template costs nothing. Its own rate limit: 30 calls per minute.
Example request
{
"fields": [
"title"
],
"html": "<h1>{{ title }}</h1>",
"css": "body{background:#0f172a;color:#fff;font-family:Roboto}",
"modifications": {
"title": "Preview"
}
}
| Response | Description |
|---|---|
200 | Preview URL. |
GET /v1/fonts Available fonts
Families installed in our renderer. You cannot upload your own — the render page content security policy blocks external addresses.
| Response | Description |
|---|---|
200 | Available font families. |
Account
GET /v1/account/usage Quota usage
| Response | Description |
|---|---|
200 | The current period. |
GET /v1/account Account details
Includes `webhook_secret` — the same one we sign the `X-Webhook-Signature` header with — and `signing_secret`, which you sign image URLs (`GET /i/{account}/{template}.png`) with. Unlike an API key both are symmetric: both sides need the same value, so they can be read back at any time.
| Response | Description |
|---|---|
200 | The account. |
POST /v1/account/webhook-secret Rotate the webhook secret
Rotation takes effect **immediately, with no grace period**: the old secret stops being valid at once, so until you paste the new one on your side you will reject our calls. We retry five times with growing backoff, so the event is not lost. This route deliberately ignores `Idempotency-Key` — the response contains a plaintext secret.
| Response | Description |
|---|---|
200 | The new secret. |
POST /v1/account/signing-secret Rotate the image URL signing secret
Every signed image URL issued so far stops working **immediately** — including the ones already published in `og:image` tags. That is the point: a leaked secret lets anyone render on your quota. Redirects already cached by crawlers and CDNs may live up to 7 days. This route deliberately ignores `Idempotency-Key` — the response contains a plaintext secret.
| Response | Description |
|---|---|
200 | The new secret. |
GET /v1/keys List API keys
We return the prefix only — the full key cannot be recovered, the database holds just its sha256.
| Response | Description |
|---|---|
200 | Keys on this account. |
POST /v1/keys Issue an API key
The response carries `secret`, and this is the **only** time you will see it. This route deliberately ignores `Idempotency-Key`: storing the response would mean keeping a plaintext credential in the database.
| Response | Description |
|---|---|
201 | Nowy klucz. |
DELETE /v1/keys/{id} Revoke a key
The last active key cannot be revoked (422 `last_key`) — without a working key you could not issue a new one and would lock yourself out.
| Parameter | In | Description |
|---|---|---|
id |
path |
| Response | Description |
|---|---|
204 | Revoked. |
422 | To ostatni aktywny klucz. |