Dokumentacja Image API
Wysyłasz JSON, dostajesz adres gotowego PNG. Bez edytora, bez przeglądarki po Twojej stronie.
Kontrakt w formie maszynowej: /openapi.json (OpenAPI 3.1 — import do Postmana, generowanie klientów).
Pierwszy render
Potrzebujesz klucza: załóż konto (bez karty, 100 obrazków miesięcznie) — klucz pokaże się od razu po rejestracji. Trzy szablony startowe dostajesz razem z kontem, więc poniższe żądanie zadziała bez żadnej konfiguracji.
?wait=true mówi, że chcesz poczekać na obrazek zamiast odpytywać o wynik.
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": "Dziesięć rzeczy, które psują sen",
"author": "Anna Kowalska",
"site": "twojblog.pl",
"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' => 'Dziesięć rzeczy, które psują sen',
'author' => 'Anna Kowalska',
'site' => 'twojblog.pl',
],
]);
$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: 'Dziesięć rzeczy, które psują sen',
author: 'Anna Kowalska',
site: 'twojblog.pl',
},
}),
});
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": "Dziesięć rzeczy, które psują sen",
"author": "Anna Kowalska",
"site": "twojblog.pl",
},
},
)
url = res.json()["url"]Uwierzytelnianie
Klucz w nagłówku Authorization: Bearer sk_live_…. Klucze wydajesz i odwołujesz przez /v1/keys albo w panelu.
Pełny klucz pokazujemy dokładnie raz, przy wydaniu — w bazie trzymamy tylko jego skrót. Zgubionego nie odzyskasz, wydajesz nowy i odwołujesz stary.
Ostatniego aktywnego klucza nie da się odwołać. To celowe: bez działającego klucza nie wydasz kolejnego i zamkniesz sobie konto od środka.
Async i sync
| Tryb | Odpowiedź | Kiedy |
|---|---|---|
POST /v1/images | 202 + id | Domyślnie. Wynik odbierasz przez GET /v1/images/{id} albo webhookiem. |
POST /v1/images?wait=true | 200 + gotowy url | Gdy nie chcesz odpytywać. Render zwykle trwa około sekundy. |
Tryb synchroniczny ma własne zasoby, oddzielone od kolejki asynchronicznej — zalew zadań w tle nie zagłodzi klienta czekającego na połączeniu. Gdy mimo to nie zdążymy w oknie czasowym, dostajesz 503 z kodem sync_overloaded. Zlecenie wtedy nie przepada: w ciele jest id, więc możesz odpytać o wynik jak w trybie asynchronicznym.
Idempotencja
Dołóż nagłówek Idempotency-Key (dowolny UUID). Powtórzone żądanie z tym samym kluczem i tym samym ciałem zwróci tę samą odpowiedź, bez drugiego renderu i bez drugiego odliczenia z limitu.
Ten sam klucz z innym ciałem to 422 — to prawie zawsze błąd po stronie integracji, a nie zamiar.
Niezależnie od tego identyczny render drugi raz i tak nie kosztuje: ten sam szablon z tymi samymi wartościami zwraca istniejący obrazek i nie zużywa limitu.
Błędy i limity
Pole error to stały kod maszynowy — buduj na nim swój switch. Pole message jest dla człowieka i zmienia język zależnie od nagłówka Accept-Language (obsługujemy en, pl, de, es, fr).
| Kod | error | Znaczenie |
|---|---|---|
| 401 | unauthorized | Brak klucza albo klucz nieprawidłowy lub odwołany. |
| 402 | quota_exceeded | Miesięczny limit wyczerpany. |
| 404 | — | Zasób nie istnieje na tym koncie. Cudzych zasobów nie odróżnisz od nieistniejących. |
| 422 | idempotency_key_reuse, last_key | Błąd walidacji albo konflikt. |
| 429 | — | Za dużo żądań na minutę. |
| 503 | sync_overloaded | Tryb sync nie zdążył. Zlecenie żyje, odpytaj o id. |
Jak długo żyją obrazki
Gotowe pliki nie leżą u nas wiecznie i mówimy o tym wprost, bo adres z naszej odpowiedzi trafia zwykle prosto do cudzego og:image.
| Plan | Obrazek dostępny przez |
|---|---|
| Free | 30 dni od wygenerowania |
| Starter, Pro, Scale | 365 dni od wygenerowania |
Po tym czasie znika plik i wpis w historii. Znaczy to również, że ten sam render zamówiony ponownie nie trafi już w cache i policzy się do limitu drugi raz. Jeśli obrazek ma żyć dłużej niż my go trzymamy, pobierz go do siebie od razu po wygenerowaniu — adres jest zwykłym plikiem PNG.
Własne szablony
Wysyłasz swój HTML i CSS. Pola wstawiasz jako {{ nazwa }} i deklarujesz je w fields. Niezadeklarowane pole to błąd przy zapisie, a nie puste miejsce w gotowym obrazku.
{
"name": "Karta wydarzenia",
"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>"
}
Kilka rzeczy warto wiedzieć zawczasu:
- HTML jest czyszczony przy zapisie —
<script>,<iframe>i atrybutyon*znikają. Odpowiedź zawiera wersję po czyszczeniu, więc od razu widzisz, co realnie się wyrenderuje. - CSS traci
@import,@font-faceiurl()spozadata:. Zasoby z sieci i tak nie zostałyby pobrane. - Fonty wybierasz z naszej listy. Wartości pól są escapowane, więc treść użytkownika nigdy nie stanie się znacznikiem.
- Adresy obrazków pobieramy my i wklejamy jako dane — dlatego pole z obrazkiem musi być wymienione w
images.
Podgląd nie zużywa limitu, więc iterowanie nad wyglądem nic nie kosztuje.
Auto-dopasowanie tekstu
Prędzej czy później ktoś wstawi tytuł na 200 znaków w pole zaprojektowane na 60. Dodaj data-autofit elementowi wewnątrz kontenera .fitbox — zmniejszymy czcionkę, aż tekst się zmieści. Dolną granicę ustawiasz przez data-autofit-min.
<div class="fitbox">
<h1 class="fit" data-autofit data-autofit-min="36">{{ title }}</h1>
</div>
Webhooki
Podaj webhook_url przy zlecaniu renderu, a zawiadomimy Cię, gdy się skończy — zamiast odpytywania w pętli.
{
"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"
}
Każde wywołanie ma nagłówki X-Webhook-Timestamp i X-Webhook-Signature. Sekret do weryfikacji znajdziesz w panelu albo pod GET /v1/account; wymienisz go przez POST /v1/account/webhook-secret. Podpis liczymy z obu wartości, żeby podsłuchanego wywołania nie dało się odtworzyć:
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $webhookSecret);
if (! hash_equals($expected, $request->header('X-Webhook-Signature'))) {
abort(400);
}
Ponawiamy pięć razy z narastającym odstępem, więc chwilowa awaria po Twojej stronie nie gubi zdarzenia. Adres w sieci prywatnej odrzucamy od razu, kodem 422 — zamiast przyjąć zlecenie, policzyć je do limitu i po cichu nie zadzwonić.
Podpisane adresy obrazków (bez kodu po stronie serwera)
Adres, który wklejasz wprost w og:image — dla stron statycznych, Webflow, Shopify, motywów Ghosta i narzędzi no-code, gdzie nie da się trzymać klucza API ani wysłać POST-a. Pola szablonu idą w query stringu, a parametr s to podpis zrobiony Twoim sekretem podpisującym. Dostępne w każdym planie, także Free.
<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=…">
Pierwsze żądanie renderuje obrazek (około sekundy), zużywa jeden render z limitu dokładnie tak jak POST /v1/images i odpowiada przekierowaniem 302 na gotowy PNG. Każde kolejne żądanie o ten sam szablon z tymi samymi wartościami to 302 na istniejący plik — bez renderu i bez limitu. Przekierowanie da się cache'ować, więc roboty i CDN nie muszą nawet pytać nas drugi raz.
Jak podpisać
- Ścieżka:
/i/{account_id}/{template}.png—idTwojego konta (acc_…, jawne, to nie jest poświadczenie) i slug szablonu. Bez hosta. - Weź wszystkie parametry query poza
s(pola szablonu i opcjonalnelocale), posortuj je po nazwie (kolejność bajtowa), zakoduj nazwę i wartość wg RFC 3986 — wszystko pozaA–Z a–z 0–9 - _ . ~, więc spacja to%20— i złącz jakonazwa=wartośćznakiem&. - Kanon = ścieżka +
?+ złączone parametry.?zostaje nawet bez parametrów. s= pierwsze 32 znaki hex (małymi literami) zHMAC-SHA256(kanon, signing_secret)(128 bitów — przy naszych limitach żądań zgadywanie nie ma sensu, a adres jest o połowę krótszy).- Dopisz
sdo adresu. Sam adres może mieć parametry w dowolnej kolejności i spację jako+albo%20: dekodujemy go i składamy kanon sami. Wartości podpisujesz dokładnie takie, jakie wysyłasz — niczego nie przycinamy.
Sprawdź swoją implementację tym wektorem testowym, zanim wkleisz cokolwiek w 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));
Narzędzia no-code
Podpisze wszystko, co ma funkcję HMAC-SHA256: krok z kodem JavaScript w Zapierze, Pipedream i podobnych (dostosuj JavaScript z góry), węzeł Crypto w n8n (akcja Hmac, typ SHA256, kodowanie hex — potem pierwsze 32 znaki) albo krok budowania generatora stron statycznych. Kanon składasz dokładnie jak w krokach wyżej. Nigdy nie podpisuj w JavaScripcie przeglądarki: sekret stałby się publiczny.
Warto wiedzieć
- Sekret podpisujący i
idkonta znajdziesz w panelu i podGET /v1/account(signing_secret,id). To osobny sekret niż klucze API i sekret webhooków: jego wyciek nie daje nikomu dostępu do API — ale pozwala renderować na Twój limit. - Wymienisz go przez
POST /v1/account/signing-secret. Każdy adres podpisany starym sekretem przestaje działać natychmiast, także już opublikowane. Przekierowania zapamiętane przez roboty i CDN mogą żyć jeszcze do 7 dni. - Ostrzej niż w API POST: pole, którego szablon nie deklaruje, to
400— literówka nie wypuści po cichu obrazka bez tytułu. Twoje szablony przyjmująfieldsiimages; wbudowane — swoje udokumentowane pola orazaccentitheme. - Błędy to krótki
text/plain, nigdy HTML:400nieznane pole albolocale,402wyczerpany limit,403brak albo zły podpis,404nieznane konto albo szablon (nie do odróżnienia),429limit żądań,503render jeszcze trwa — ponów poRetry-After. - Limity: 120 poprawnie podpisanych żądań na minutę na konto (żądania ze złym podpisem nigdy nie liczą się do Twojego) i 600 żądań na minutę z jednego adresu IP.
Referencja
Poniższa lista powstaje z tej samej specyfikacji, którą serwujemy pod /openapi.json.
Opisy endpointów są po angielsku niezależnie od języka strony: specyfikacja jest kontraktem maszynowym, z którego generuje się klientów, więc ma jedną, niezmienną wersję.
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.
| Parametr | Miejsce | Opis |
|---|---|---|
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. |
Przykładowe żądanie
{
"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
}
}
| Odpowiedź | Opis |
|---|---|
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.
| Parametr | Miejsce | Opis |
|---|---|---|
limit |
query | |
before |
query | Kursor z `next_cursor`. |
| Odpowiedź | Opis |
|---|---|
200 | A page of renders. |
GET /v1/images/{id} Stan renderu
| Parametr | Miejsce | Opis |
|---|---|---|
id |
path |
| Odpowiedź | Opis |
|---|---|
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.
| Parametr | Miejsce | Opis |
|---|---|---|
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. |
| Odpowiedź | Opis |
|---|---|
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
| Odpowiedź | Opis |
|---|---|
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.
Przykładowe żądanie
{
"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>"
}
| Odpowiedź | Opis |
|---|---|
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
| Parametr | Miejsce | Opis |
|---|---|---|
id |
path |
| Odpowiedź | Opis |
|---|---|
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.
| Parametr | Miejsce | Opis |
|---|---|---|
id |
path |
| Odpowiedź | Opis |
|---|---|
200 | Zapisany. |
404 | Nie istnieje albo jest wbudowany. |
DELETE /v1/templates/{id} Delete a template
| Parametr | Miejsce | Opis |
|---|---|---|
id |
path |
| Odpowiedź | Opis |
|---|---|
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.
Przykładowe żądanie
{
"fields": [
"title"
],
"html": "<h1>{{ title }}</h1>",
"css": "body{background:#0f172a;color:#fff;font-family:Roboto}",
"modifications": {
"title": "Preview"
}
}
| Odpowiedź | Opis |
|---|---|
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.
| Odpowiedź | Opis |
|---|---|
200 | Available font families. |
Account
GET /v1/account/usage Quota usage
| Odpowiedź | Opis |
|---|---|
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.
| Odpowiedź | Opis |
|---|---|
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.
| Odpowiedź | Opis |
|---|---|
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.
| Odpowiedź | Opis |
|---|---|
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.
| Odpowiedź | Opis |
|---|---|
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.
| Odpowiedź | Opis |
|---|---|
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.
| Parametr | Miejsce | Opis |
|---|---|---|
id |
path |
| Odpowiedź | Opis |
|---|---|
204 | Revoked. |
422 | To ostatni aktywny klucz. |