Image API

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

ModeResponseWhen
POST /v1/images202 + idThe default. Collect the result from GET /v1/images/{id} or by webhook.
POST /v1/images?wait=true200 + gotowy urlWhen 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).

StatuserrorMeaning
401unauthorizedMissing key, or a key that is invalid or revoked.
402quota_exceededMonthly quota exhausted.
404—The resource does not exist on this account. Somebody else's resources are indistinguishable from nonexistent ones.
422idempotency_key_reuse, last_keyValidation error or a conflict.
429—Too many requests per minute.
503sync_overloadedSync 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.

PlanImage available for
Free30 days from generation
Starter, Pro, Scale365 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:

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

  1. Path: /i/{account_id}/{template}.png — your account id (acc_…, public, not a credential) and the template slug. No host.
  2. Take every query parameter except s (template fields and the optional locale), sort them by name (byte order), percent-encode the name and the value per RFC 3986 — everything except A–Z a–z 0–9 - _ . ~, so a space is %20 — and join them as name=value with &.
  3. Canonical string = path + ? + the joined parameters. The ? stays even when there are no parameters.
  4. s = the first 32 lowercase hex characters of HMAC-SHA256(canonical, signing_secret) (128 bits — plenty against guessing behind our rate limits, and half the URL length).
  5. Append s to 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

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.

ParameterInDescription
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
    }
}
ResponseDescription
200Finished (only with `?wait=true`).
202Accepted into the queue.
402Monthly quota exhausted (`quota_exceeded`).
422Validation error, or the same `Idempotency-Key` with a different body.
503Sync 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.

ParameterInDescription
limit query
before query Kursor z `next_cursor`.
ResponseDescription
200A page of renders.
GET /v1/images/{id} Stan renderu
ParameterInDescription
id path
ResponseDescription
200Stan renderu.
404Nie 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.

ParameterInDescription
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.
ResponseDescription
302Redirect to the finished PNG.
400A field the template does not declare, an unsupported `locale`, or a non-string parameter.
402Monthly quota exhausted (only when a new render was needed).
403Missing, malformed or invalid signature.
404No such account or template. The two are indistinguishable.
429Rate limit. See `Retry-After`.
500The render failed. No quota is used.
503The render did not finish in time. It keeps going — retry after `Retry-After`.

Templates

GET /v1/templates List templates
ResponseDescription
200Templates 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>"
}
ResponseDescription
201Utworzony.
422A field used in the HTML is not declared, `images` goes beyond `fields`, or a size limit was exceeded.
GET /v1/templates/{id} Template details
ParameterInDescription
id path
ResponseDescription
200Szablon.
404Nie 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.

ParameterInDescription
id path
ResponseDescription
200Zapisany.
404Nie istnieje albo jest wbudowany.
DELETE /v1/templates/{id} Delete a template
ParameterInDescription
id path
ResponseDescription
204Deleted.
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"
    }
}
ResponseDescription
200Preview 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.

ResponseDescription
200Available font families.

Account

GET /v1/account/usage Quota usage
ResponseDescription
200The 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.

ResponseDescription
200The 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.

ResponseDescription
200The 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.

ResponseDescription
200The 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.

ResponseDescription
200Keys 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.

ResponseDescription
201Nowy 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.

ParameterInDescription
id path
ResponseDescription
204Revoked.
422To ostatni aktywny klucz.