{
    "openapi": "3.1.0",
    "info": {
        "title": "Image API",
        "version": "1.0.0",
        "description": "Open Graph images from a single HTTP request.\n\n**How long images live.** Free plan: 30 days from generation. Paid plans: 365 days. After that both the file and the history entry are gone, and the same render ordered again no longer hits the cache and counts against your quota."
    },
    "servers": [
        {
            "url": "https://imageapi.benchkithq.com/v1"
        }
    ],
    "security": [
        {
            "bearerAuth": []
        }
    ],
    "components": {
        "securitySchemes": {
            "bearerAuth": {
                "type": "http",
                "scheme": "bearer",
                "description": "API key in the `Authorization: Bearer sk_live_...` header."
            }
        },
        "schemas": {
            "Render": {
                "type": "object",
                "properties": {
                    "id": {
                        "type": "string",
                        "example": "img_01m08s3ez1zcv28h335g9923bw"
                    },
                    "status": {
                        "type": "string",
                        "enum": [
                            "queued",
                            "processing",
                            "completed",
                            "failed"
                        ]
                    },
                    "url": {
                        "type": [
                            "string",
                            "null"
                        ],
                        "description": "URL of the finished PNG. Present once the status is completed."
                    },
                    "error": {
                        "type": [
                            "string",
                            "null"
                        ],
                        "description": "Always English — this is a database row, not an HTTP response."
                    },
                    "duration_ms": {
                        "type": [
                            "integer",
                            "null"
                        ]
                    },
                    "created_at": {
                        "type": "string"
                    },
                    "completed_at": {
                        "type": [
                            "string",
                            "null"
                        ]
                    }
                }
            },
            "Template": {
                "type": "object",
                "properties": {
                    "id": {
                        "type": "string",
                        "example": "tpl_01m08r57bvcppea9w35qrrctcv"
                    },
                    "slug": {
                        "type": "string",
                        "example": "event-card"
                    },
                    "name": {
                        "type": "string"
                    },
                    "width": {
                        "type": "integer"
                    },
                    "height": {
                        "type": "integer"
                    },
                    "engine": {
                        "type": "string",
                        "enum": [
                            "blade",
                            "html"
                        ],
                        "description": "blade = built-in template, html = yours."
                    },
                    "editable": {
                        "type": "boolean"
                    },
                    "fields": {
                        "type": "array",
                        "items": {
                            "type": "string"
                        }
                    },
                    "images": {
                        "type": "array",
                        "items": {
                            "type": "string"
                        }
                    },
                    "html": {
                        "type": [
                            "string",
                            "null"
                        ],
                        "description": "The sanitised version — the one that actually renders."
                    },
                    "css": {
                        "type": [
                            "string",
                            "null"
                        ]
                    }
                }
            },
            "Error": {
                "type": "object",
                "properties": {
                    "error": {
                        "type": "string",
                        "description": "A stable machine-readable code. Never translated, never changed."
                    },
                    "message": {
                        "type": "string",
                        "description": "Human-readable text. Its language follows the Accept-Language header."
                    }
                }
            }
        }
    },
    "tags": [
        {
            "name": "Images",
            "description": "Requesting renders and collecting results."
        },
        {
            "name": "Templates",
            "description": "Your own HTML templates with `{{ }}` fields."
        },
        {
            "name": "Account",
            "description": "Quota and API keys."
        }
    ],
    "paths": {
        "/images": {
            "post": {
                "tags": [
                    "Images"
                ],
                "summary": "Request a render",
                "description": "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.",
                "parameters": [
                    {
                        "name": "wait",
                        "in": "query",
                        "schema": {
                            "type": "boolean"
                        },
                        "description": "Wait for the result instead of polling."
                    },
                    {
                        "name": "Idempotency-Key",
                        "in": "header",
                        "schema": {
                            "type": "string"
                        },
                        "description": "The same `Idempotency-Key` with the same body returns the same response, with no second render."
                    }
                ],
                "requestBody": {
                    "required": true,
                    "content": {
                        "application/json": {
                            "schema": {
                                "type": "object",
                                "required": [
                                    "template",
                                    "modifications"
                                ],
                                "properties": {
                                    "template": {
                                        "type": "string",
                                        "description": "Template slug.",
                                        "example": "blog-og"
                                    },
                                    "modifications": {
                                        "type": "object",
                                        "description": "Values for the template fields."
                                    },
                                    "locale": {
                                        "type": "string",
                                        "enum": [
                                            "en",
                                            "pl",
                                            "de",
                                            "es",
                                            "fr"
                                        ],
                                        "description": "Language for text the template adds itself (date, reading time)."
                                    },
                                    "webhook_url": {
                                        "type": "string",
                                        "description": "We call this URL when the render finishes. Private-network addresses are rejected with a 422 at request time, so a webhook that could never fire never costs you a render."
                                    }
                                }
                            },
                            "example": {
                                "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
                                }
                            }
                        }
                    }
                },
                "responses": {
                    "200": {
                        "description": "Finished (only with `?wait=true`).",
                        "content": {
                            "application/json": {
                                "schema": {
                                    "$ref": "#/components/schemas/Render"
                                }
                            }
                        }
                    },
                    "202": {
                        "description": "Accepted into the queue.",
                        "content": {
                            "application/json": {
                                "example": {
                                    "id": "img_01m08…",
                                    "status": "queued",
                                    "url": "…/v1/images/img_01m08…"
                                }
                            }
                        }
                    },
                    "402": {
                        "description": "Monthly quota exhausted (`quota_exceeded`).",
                        "content": {
                            "application/json": {
                                "schema": {
                                    "$ref": "#/components/schemas/Error"
                                }
                            }
                        }
                    },
                    "422": {
                        "description": "Validation error, or the same `Idempotency-Key` with a different body."
                    },
                    "503": {
                        "description": "Sync mode ran out of time (`sync_overloaded`). The body carries `id` — nothing is lost."
                    }
                }
            },
            "get": {
                "tags": [
                    "Images"
                ],
                "summary": "Render history",
                "description": "Cursor pagination: pass `before` from the `next_cursor` field. Renders arriving while you page through will not duplicate results.",
                "parameters": [
                    {
                        "name": "limit",
                        "in": "query",
                        "schema": {
                            "type": "integer",
                            "default": 25,
                            "maximum": 100
                        }
                    },
                    {
                        "name": "before",
                        "in": "query",
                        "schema": {
                            "type": "string"
                        },
                        "description": "Kursor z `next_cursor`."
                    }
                ],
                "responses": {
                    "200": {
                        "description": "A page of renders."
                    }
                }
            }
        },
        "/images/{id}": {
            "get": {
                "tags": [
                    "Images"
                ],
                "summary": "Stan renderu",
                "parameters": [
                    {
                        "name": "id",
                        "in": "path",
                        "required": true,
                        "schema": {
                            "type": "string"
                        }
                    }
                ],
                "responses": {
                    "200": {
                        "description": "Stan renderu.",
                        "content": {
                            "application/json": {
                                "schema": {
                                    "$ref": "#/components/schemas/Render"
                                }
                            }
                        }
                    },
                    "404": {
                        "description": "Nie ma takiego renderu na tym koncie."
                    }
                }
            }
        },
        "/templates": {
            "get": {
                "tags": [
                    "Templates"
                ],
                "summary": "List templates",
                "responses": {
                    "200": {
                        "description": "Templates on this account, both built-in and your own."
                    }
                }
            },
            "post": {
                "tags": [
                    "Templates"
                ],
                "summary": "Create a template",
                "description": "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.\n\nThe 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.\n\nLimits: HTML and CSS up to 64 KB each, dimensions from 200 to 4000 px.",
                "requestBody": {
                    "required": true,
                    "content": {
                        "application/json": {
                            "example": {
                                "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>"
                            }
                        }
                    }
                },
                "responses": {
                    "201": {
                        "description": "Utworzony.",
                        "content": {
                            "application/json": {
                                "schema": {
                                    "$ref": "#/components/schemas/Template"
                                }
                            }
                        }
                    },
                    "422": {
                        "description": "A field used in the HTML is not declared, `images` goes beyond `fields`, or a size limit was exceeded."
                    }
                }
            }
        },
        "/templates/{id}": {
            "get": {
                "tags": [
                    "Templates"
                ],
                "summary": "Template details",
                "parameters": [
                    {
                        "name": "id",
                        "in": "path",
                        "required": true,
                        "schema": {
                            "type": "string"
                        }
                    }
                ],
                "responses": {
                    "200": {
                        "description": "Szablon."
                    },
                    "404": {
                        "description": "Nie istnieje na tym koncie."
                    }
                }
            },
            "patch": {
                "tags": [
                    "Templates"
                ],
                "summary": "Replace a template",
                "description": "Replaces the whole definition. Built-in templates (`engine: blade`) cannot be edited — you get a 404.",
                "parameters": [
                    {
                        "name": "id",
                        "in": "path",
                        "required": true,
                        "schema": {
                            "type": "string"
                        }
                    }
                ],
                "responses": {
                    "200": {
                        "description": "Zapisany."
                    },
                    "404": {
                        "description": "Nie istnieje albo jest wbudowany."
                    }
                }
            },
            "delete": {
                "tags": [
                    "Templates"
                ],
                "summary": "Delete a template",
                "parameters": [
                    {
                        "name": "id",
                        "in": "path",
                        "required": true,
                        "schema": {
                            "type": "string"
                        }
                    }
                ],
                "responses": {
                    "204": {
                        "description": "Deleted."
                    }
                }
            }
        },
        "/templates/preview": {
            "post": {
                "tags": [
                    "Templates"
                ],
                "summary": "Preview without using quota",
                "description": "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.",
                "requestBody": {
                    "content": {
                        "application/json": {
                            "example": {
                                "fields": [
                                    "title"
                                ],
                                "html": "<h1>{{ title }}</h1>",
                                "css": "body{background:#0f172a;color:#fff;font-family:Roboto}",
                                "modifications": {
                                    "title": "Preview"
                                }
                            }
                        }
                    }
                },
                "responses": {
                    "200": {
                        "description": "Preview URL.",
                        "content": {
                            "application/json": {
                                "example": {
                                    "url": "…/storage/previews/…png"
                                }
                            }
                        }
                    }
                }
            }
        },
        "/fonts": {
            "get": {
                "tags": [
                    "Templates"
                ],
                "summary": "Available fonts",
                "description": "Families installed in our renderer. You cannot upload your own — the render page content security policy blocks external addresses.",
                "responses": {
                    "200": {
                        "description": "Available font families.",
                        "content": {
                            "application/json": {
                                "example": {
                                    "data": [
                                        "Roboto",
                                        "Open Sans",
                                        "Carlito",
                                        "DejaVu Sans",
                                        "DejaVu Serif",
                                        "DejaVu Sans Mono",
                                        "Ubuntu",
                                        "Noto Sans CJK JP",
                                        "Noto Sans CJK SC",
                                        "Noto Color Emoji"
                                    ]
                                }
                            }
                        }
                    }
                }
            }
        },
        "/account/usage": {
            "get": {
                "tags": [
                    "Account"
                ],
                "summary": "Quota usage",
                "responses": {
                    "200": {
                        "description": "The current period.",
                        "content": {
                            "application/json": {
                                "example": {
                                    "plan": "free",
                                    "period": {
                                        "start": "2026-08-01 00:00:00",
                                        "end": "2026-08-31 23:59:59"
                                    },
                                    "quota": 100,
                                    "used": 80,
                                    "remaining": 20
                                }
                            }
                        }
                    }
                }
            }
        },
        "/account": {
            "get": {
                "tags": [
                    "Account"
                ],
                "summary": "Account details",
                "description": "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.",
                "responses": {
                    "200": {
                        "description": "The account.",
                        "content": {
                            "application/json": {
                                "example": {
                                    "id": "acc_01m08s3ez1zcv28h335g9923bw",
                                    "email": "ty@twojadomena.pl",
                                    "name": null,
                                    "plan": "free",
                                    "webhook_secret": "whsec_...",
                                    "signing_secret": "sign_...",
                                    "created_at": "2026-08-01 12:00:00"
                                }
                            }
                        }
                    }
                }
            }
        },
        "/account/webhook-secret": {
            "post": {
                "tags": [
                    "Account"
                ],
                "summary": "Rotate the webhook secret",
                "description": "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.",
                "responses": {
                    "200": {
                        "description": "The new secret.",
                        "content": {
                            "application/json": {
                                "example": {
                                    "webhook_secret": "whsec_..."
                                }
                            }
                        }
                    }
                }
            }
        },
        "/account/signing-secret": {
            "post": {
                "tags": [
                    "Account"
                ],
                "summary": "Rotate the image URL signing secret",
                "description": "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.",
                "responses": {
                    "200": {
                        "description": "The new secret.",
                        "content": {
                            "application/json": {
                                "example": {
                                    "signing_secret": "sign_..."
                                }
                            }
                        }
                    }
                }
            }
        },
        "/i/{account}/{template}.png": {
            "get": {
                "tags": [
                    "Images"
                ],
                "summary": "Signed image URL (for og:image)",
                "servers": [
                    {
                        "url": "https://imageapi.benchkithq.com"
                    }
                ],
                "security": [],
                "description": "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`.\n\nSignature: 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.\n\nAn 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.",
                "parameters": [
                    {
                        "name": "account",
                        "in": "path",
                        "required": true,
                        "schema": {
                            "type": "string"
                        },
                        "description": "Your account `id` from `GET /account` (`acc_…`). Public, not a credential."
                    },
                    {
                        "name": "template",
                        "in": "path",
                        "required": true,
                        "schema": {
                            "type": "string"
                        },
                        "description": "Template slug, e.g. `blog-og`."
                    },
                    {
                        "name": "s",
                        "in": "query",
                        "required": true,
                        "schema": {
                            "type": "string",
                            "pattern": "^[0-9a-f]{32}$"
                        },
                        "description": "Signature — see above."
                    },
                    {
                        "name": "locale",
                        "in": "query",
                        "schema": {
                            "type": "string",
                            "enum": [
                                "en",
                                "pl",
                                "de",
                                "es",
                                "fr"
                            ]
                        },
                        "description": "Language for text the template adds itself. Signed like any other parameter."
                    },
                    {
                        "name": "{field}",
                        "in": "query",
                        "schema": {
                            "type": "string"
                        },
                        "description": "Any field the template declares (`fields`, `images`, `defaults`; built-in templates: their documented fields plus `accent` and `theme`). An undeclared field is a 400."
                    }
                ],
                "responses": {
                    "302": {
                        "description": "Redirect to the finished PNG."
                    },
                    "400": {
                        "description": "A field the template does not declare, an unsupported `locale`, or a non-string parameter."
                    },
                    "402": {
                        "description": "Monthly quota exhausted (only when a new render was needed)."
                    },
                    "403": {
                        "description": "Missing, malformed or invalid signature."
                    },
                    "404": {
                        "description": "No such account or template. The two are indistinguishable."
                    },
                    "429": {
                        "description": "Rate limit. See `Retry-After`."
                    },
                    "500": {
                        "description": "The render failed. No quota is used."
                    },
                    "503": {
                        "description": "The render did not finish in time. It keeps going — retry after `Retry-After`."
                    }
                }
            }
        },
        "/keys": {
            "get": {
                "tags": [
                    "Account"
                ],
                "summary": "List API keys",
                "description": "We return the prefix only — the full key cannot be recovered, the database holds just its sha256.",
                "responses": {
                    "200": {
                        "description": "Keys on this account."
                    }
                }
            },
            "post": {
                "tags": [
                    "Account"
                ],
                "summary": "Issue an API key",
                "description": "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.",
                "responses": {
                    "201": {
                        "description": "Nowy klucz.",
                        "content": {
                            "application/json": {
                                "example": {
                                    "id": "key_01m08…",
                                    "secret": "sk_live_…"
                                }
                            }
                        }
                    }
                }
            }
        },
        "/keys/{id}": {
            "delete": {
                "tags": [
                    "Account"
                ],
                "summary": "Revoke a key",
                "description": "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.",
                "parameters": [
                    {
                        "name": "id",
                        "in": "path",
                        "required": true,
                        "schema": {
                            "type": "string"
                        }
                    }
                ],
                "responses": {
                    "204": {
                        "description": "Revoked."
                    },
                    "422": {
                        "description": "To ostatni aktywny klucz."
                    }
                }
            }
        }
    }
}