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:
reason | Meaning |
|---|---|
missing_header | No Authorization header |
malformed_header | Not Bearer pictika_sk_live_… |
unknown_key | No such key |
revoked_key | Key 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:titleThe 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.
- Keys you omit keep the value saved in the template.
- Keys we do not recognise are ignored, and reported back in the
X-Pictika-Warningsheader so a typo never fails silently. Unknown keys never affect rendering or billing. - Layer keys uniquely identify the editable layers of a template. You choose them in the editor, and you use the same keys here.
- Text is compared after Unicode NFC normalisation, so
étyped two different ways is one image, not two. - Leading and trailing spaces are preserved — they change the rendered image.
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.
| Value | Meaning | Billed |
|---|---|---|
hit | served from cache | no |
miss | rendered for you | 1 credit |
coalesced | another identical request was already rendering; you got its result | no |
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
- Replayed responses carry
Idempotent-Replay: true. - Same key, different body →
422 idempotency_key_reused. We refuse rather than hand you the wrong image. - Same key while the first call is still running →
409 request_in_progress. - If the request failed, the key is released — a transient error is never frozen for 24 hours.
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": ["م", "ر"] }
} }| Status | Code | What happened |
|---|---|---|
| 400 | invalid_json | Body is not valid JSON |
| 400 | invalid_body | Body is not a JSON object |
| 400 | template_required | template is missing or empty |
| 401 | unauthorized | Missing, malformed, revoked or unknown API key |
| 402 | quota_exhausted | Out of credits for this cycle |
| 404 | template_not_found | No such template in your organization |
| 409 | request_in_progress | Same idempotency key is still running |
| 414 | url_too_long | URL exceeds 4096 characters |
| 422 | idempotency_key_reused | Same key, different body |
| 422 | missing_glyph | A character has no glyph in the available fonts |
| 422 | image_url_blocked | Image URL rejected (private address or unsupported scheme) |
| 422 | image_fetch_failed | Remote image could not be fetched |
| 422 | invalid_template_schema | Template document is not valid |
| 429 | rate_limited | Too many renders; see retry_after |
| 500 | internal_error | Our fault. Retry is safe with an idempotency key |
| 500 | storage_failed | Image rendered but could not be stored. Not charged. Retry is safe with the same idempotency key |
| 504 | render_timeout | Render 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:
- Latin, Greek, Cyrillic
- Japanese, Korean, Simplified Chinese
- Not supported: right-to-left scripts such as Arabic, Hebrew and Persian. Sending them returns
422, never a broken image. - Emoji are supported, including country flags. They are drawn from a fixed image set, so skin-tone and other variant sequences fall back to the base emoji or are rejected — the same limitation every template API in this space has.
Fonts are managed by Pictika and are the same for every template — you cannot upload your own.