Pictika API

Design a template once, then generate images from it with a single HTTP request. Every successful response refers to a PNG image: by default you get JSON describing it, and Accept: image/png gives you the bytes directly. No browser, no headless Chrome, no queue.

Authentication

Create a key on the API keys page. Send it as a bearer token. Keys look like pictika_sk_live_….

Authorization: Bearer pictika_sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

The full key is shown once, when you create it. We store only a hash, so we cannot recover it for you. Lost it? Revoke and create a new one.

When authentication fails

Every authentication failure is 401 unauthorized. There is no 403 — we never confirm that a key exists but lacks permission, because that turns the endpoint into a tool for probing valid keys. The reason field tells you which case you hit:

reasonMeaning
missing_headerNo Authorization header
malformed_headerNot Bearer pictika_sk_live_…
unknown_keyNo such key
revoked_keyKey was revoked
{ "error": { "code": "unauthorized", "reason": "revoked_key" } }

Keys do not expire. A key belongs to exactly one organization and can only reach that organization's templates — a valid key used against someone else's template id gets 404, not 403, for the same reason.

POST /v1/images

Renders the template and stores the result. Use this when you want a stable URL to keep — a generated social card, a certificate, a receipt image.

curl https://pictika.com/v1/images \
  -H "Authorization: Bearer $PICTIKA_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "template": "tpl_abc123",
    "modifications": { "title": "Hello world", "author": "Ada" }
  }'

Response

{
  "url": "https://pictika.com/i/img_9f3c1e7b21.png",
  "width": 1200,
  "height": 630,
  "content_type": "image/png"
}

The image is stored and the URL is created on every successful call — including when you ask for the bytes directly. You never have to choose between the two.

When text does not fit

Text longer than its layer box is clipped — the call still returns 200, because clipping is often intentional. What we never do is clip silently. If any text layer was clipped, the response carries a truncated field naming the exact keys, and every response also carries the x-pictika-warnings header:

{
  "url": "https://pictika.com/i/img_9f3c1e7b21.png",
  "width": 1200,
  "height": 630,
  "content_type": "image/png",
  "truncated": ["title"]
}

x-pictika-warnings: truncated:title

The field is absent when nothing was clipped. The header matters most when you request the bytes directly with Accept: image/png — there is no JSON body to read in that case, so the header is the only place the warning can live. The same header is sent by GET /v1/img, including on cache hits.

Detection is deliberately cautious: it may warn about text that in fact fits by a pixel or two, but it will not miss text that was actually cut. We would rather be noisy than hand you a broken image quietly.

Getting the bytes directly

Send Accept: image/png and the response body is the PNG itself. The URL is still created, and returned in the X-Pictika-Url header — so you can render immediately and keep the link for later.

modifications

An object mapping each template layer key to its new value. Migrating from Bannerbear? The array shape works too, so existing code runs unchanged:

{
  "template": "tpl_abc123",
  "modifications": [
    { "name": "title",  "text": "Hello world" },
    { "name": "avatar", "image_url": "https://example.com/ada.png" }
  ]
}

Both shapes are normalised to the same values, so they produce the same image, the same charge and the same cache entry. Pick one and stay with it.

GET /v1/img/{template}

Renders on demand, with parameters in the query string. Use this where you cannot set a header: <img src>, an Open Graph tag, an email, a Notion embed, a spreadsheet cell.

<meta property="og:image"
  content="https://pictika.com/v1/img/tpl_abc123?title=Hello%20world&author=Ada" />

This endpoint is public — it takes no API key. That is deliberate: a browser fetching <img src> cannot send an Authorization header. Anyone who knows this URL can request this image, and any render it triggers is billed to the template's owner — the request may be served from cache, in which case nothing is charged. Treat it like a signed URL, not like a public identifier — share it the way you would share a private link.

Caching

Identical parameters return the cached image. Cached responses are free — you are billed per distinct image, not per request. Check the X-Pictika-Cache header:

Cache identity is based only on what actually changes the image. Parameters we do not recognise — utm_source and friends — are ignored, so adding tracking to a link does not double your bill. Editing the template creates a new template version and invalidates renders cached from older versions.

ValueMeaningBilled
hitserved from cacheno
missrendered for you1 credit
coalescedanother identical request was already rendering; you got its resultno

That last row matters when a link goes viral: hundreds of crawlers hitting the same URL at once produce one render and one credit.

URLs are limited to 4096 characters.

Idempotency

Send an Idempotency-Key header on POST /v1/images and a retry returns the original response instead of rendering — and charging — twice. Keys are remembered for 24 hours, scoped to your organization.

Idempotency-Key: 0a7f1c9e-4b2d-4c1a-9f77-2b8e5d1a6c30

Quota and rate limits

One credit is one rendered image. Cache hits, coalesced requests, replayed idempotent requests and failed renders cost nothing.

Your quota resets on a monthly cycle anchored to the day you signed up. Running out returns 402 quota_exhausted with quota and used.

Rate limit: 60 renders per 10 seconds, per API key for POST /v1/images and per template for GET /v1/img. Exceeding it returns 429 with a Retry-After header and a retry_after field. Both are in seconds, never milliseconds.

429 means slow down. 402 means out of credits. They are never interchangeable.

Errors

Every error is JSON with an error.code. Some codes add fields you can act on, so read code first and treat everything else as extra:

Branch on error.code only. Fields other than code are specific to each error. Ignore fields you do not recognise — new ones may appear without changing what any existing code means.

{ "error": { "code": "template_not_found" } }

{ "error": { "code": "quota_exhausted", "quota": 1000, "used": 1000 } }

{ "error": { "code": "rate_limited", "retry_after": 3 } }

{ "error": {
    "code": "missing_glyph",
    "details": { "font": "Roboto, Noto Sans JP, …", "characters": ["م", "ر"] }
} }
StatusCodeWhat happened
400invalid_jsonBody is not valid JSON
400invalid_bodyBody is not a JSON object
400template_requiredtemplate is missing or empty
401unauthorizedMissing, malformed, revoked or unknown API key
402quota_exhaustedOut of credits for this cycle
404template_not_foundNo such template in your organization
409request_in_progressSame idempotency key is still running
414url_too_longURL exceeds 4096 characters
422idempotency_key_reusedSame key, different body
422missing_glyphA character has no glyph in the available fonts
422image_url_blockedImage URL rejected (private address or unsupported scheme)
422image_fetch_failedRemote image could not be fetched
422invalid_template_schemaTemplate document is not valid
429rate_limitedToo many renders; see retry_after
500internal_errorOur fault. Retry is safe with an idempotency key
500storage_failedImage rendered but could not be stored. Not charged. Retry is safe with the same idempotency key
504render_timeoutRender exceeded 10 seconds. Not charged

About missing_glyph

We refuse to render text we cannot draw, instead of emitting boxes — a silent success that ships a broken image is worse than an error. The response lists exactly which characters failed, in details.characters.

Currently supported scripts:

Fonts are managed by Pictika and are the same for every template — you cannot upload your own.