API Reference
Generate an image
Draws one or more frames from a prompt, working from reference images when you pass them.
POST/api/v1/images
A JSON POST with one header for the key: Authorization: Bearer sk-sl-…. The route serves image models only — a text model named here is refused rather than answered. How keys are sent
This is the route for working from a frame you already have: the images to start from go in input_references, and the reply carries raw base64 in data[].b64_json. The same models answer on chat completions, where the prompt comes from messages and the frame comes back as a data URL.
Request body
Two fields are required; everything else has a default. What each field accepts, and where it stops, is in values and limits below.
| Field | Type | Description |
|---|---|---|
| model | stringrequired | Catalogue identifier of an image model. |
| prompt | stringrequired | What to draw. Checked before anything is charged. |
| input_references | array | Frames to start from, as URLs or content parts. |
| n | integer | How many frames to return. Default 1. |
| size | string | Exact pixels or a tier. Pixels outrank the rest. |
| resolution, image_size | string | The tier on its own; both names are one field. |
| aspect_ratio, aspect | string | Shape of the frame, written W:H. |
| quality | string | Rendering quality. Default high. |
| output_format | string | Encoding of the returned bytes. PNG by default. |
| output_compression | integer | Encoding quality for jpeg and webp. Ignored for png. |
| background | string | Transparent, opaque, or left to the model. |
| stream | boolean | Reply as server-sent events, not one JSON body. |
| seed | integer | Accepted, no effect: results are not repeatable. |
| user | string | Accepted, no effect. Not forwarded anywhere. |
Scroll the table sideways for the description.
The model is named by its catalogue identifier and answers as itself: there are no aliases, and nothing is put in its place. prompt has to be a non-empty string — a missing or blank one is refused before the request can cost anything.
A reference is either a base64 data URL, an http(s) URL we fetch for you, or the content-part object {"type":"image_url","image_url":{"url":"…"}}. References are input: each one is counted as about 2,900 tokens in usage.prompt_tokens and billed at the model's input rate, so six of them cost noticeably more than a bare prompt.
Both fields are part of the request shape, so sending them is not an error and never will be. Neither changes the result: identical requests with the same seed still return different frames. If your code relies on reproducibility, this is the place to find that out — not after an hour of comparing two frames and blaming the prompt.
Values and limits
These fields are enumerated. A value we do not recognise comes back as a 400 naming the field, never as a frame drawn on a guess. The same fields are also read from an image_config object — the form the chat route uses; every value you send is checked wherever it sits, and if a field is set in both places the top-level one is used.
- size
WxHin pixels, a tier, orauto. Pixels are honoured exactly and are capped at 3840 on a side and 8.29 megapixels (8,294,400 px) in total; a side under 16 px is refused. Both ceilings are checked before anything is reserved. Pixels that contradict a tier or an aspect ratio in the same body are a400— choosing one of the two silently would mean ignoring the other without telling you.- resolution, image_size
512,1K,2K,4Korauto. A tier is a normalised level rather than an exact frame: it sets the short edge of what is delivered, and the aspect ratio — from your request, or from the frame itself when you did not ask for one — sets the other side.- aspect_ratio, aspect
- Any ratio written
W:H, fractional sides included (9:19.5), orauto. Zero on either side is refused. Alongside exact pixels it has to agree with them. - quality
auto,low,medium,high; defaulthigh. Not every size has a quality knob to turn, so this is a request rather than a guarantee.- output_format
png,jpeg,webp. Encoding happens on our side once the frame is drawn, and an unknown value is refused up front rather than after you have paid for it. A frame already in the requested format is returned byte for byte instead of being squeezed a second time.- output_compression
- An integer from
0to100, applied to jpeg and webp. Ignored for png. - background
auto,opaque,transparent. Transparency needs a format that carries alpha, so it cannot be combined withjpeg, and only some models support it — for a few, the requested frame matters too. Where it is not supported the request is refused with a400before anything is billed, and the refusal names the model rather than the frame size — better than an opaque frame you discover by opening the file.- n
1to10, default1. Larger numbers are clamped to 10; a value that is not a number is a400. Fewer frames can come back than you asked for, and you are billed for the ones that arrive.- input_references
- At most 16 entries. A URL is fetched with a 10-second timeout and a 20 MB ceiling, has to answer with an image content type, and redirects are not followed. A reference that cannot be fetched is a
400before the reserve — never a frame quietly drawn without it.
Response
200, application/json, one object for the whole request:
{
"created": 1748372400,
"data": [
{
"b64_json": "<base64-encoded-image>",
"media_type": "image/png"
}
],
"usage": {
"prompt_tokens": 2909,
"completion_tokens": 5250,
"total_tokens": 8159,
"cost": 0.189811
}
}| Field | Type | Description |
|---|---|---|
| created | integerrequired | Unix time in seconds when the reply was built. |
| data | arrayrequired | One entry per frame returned. |
| data[]. | stringrequired | Image bytes in base64, with no data: prefix. |
| data[]. | string | MIME type, read from the returned bytes. |
| usage. | integer | Input counted: the prompt and every reference. |
| usage. | integer | Output tokens of the frames returned — the frames themselves. |
| usage. | integer | The two above, added. |
| usage. | number | USD charged for the whole request, all frames included. |
Scroll the table sideways for the description.
Two habits worth forming. Take the file extension from media_type rather than from what you asked for: it is read from the bytes that actually came back, so it stays right even when re-encoding did not happen. And read usage.cost as the amount charged for this request — the figures above are an example, not a rate. An image model is metered in tokens: the prompt and the references are input at the model's pricing.prompt, and the frame is output at its pricing.image_output, both from the catalogue. How many output tokens a frame takes is set by its shape rather than its area, so usage.completion_tokens is the number to watch; the guide lists measured counts for common frames. What a frame costs Read the catalogue from the API
Streaming
With stream: true the same 200 arrives as text/event-stream: one image_generation.completed event per frame, then the [DONE] sentinel.
data: {"type":"image_generation.completed","b64_json":"<base64-encoded-image>","media_type":"image/png","created":1748372400,"usage":{"prompt_tokens":2909,"completion_tokens":5250,"total_tokens":8159,"cost":0.189811}}
data: [DONE]Two properties to build on. A frame is drawn whole, so its event appears only when it is finished — there is no partial image to paint as it fills in. And usage rides on the last event only, because the charge is one per request: with n above 1 the earlier events carry none, and adding up what you see would count the same money twice.
Example
One frame, edited from a local file and written back to disk:
curl https://api.gtokens.io/api/v1/images \
-H "Authorization: Bearer sk-sl-your-key" \
-H "Content-Type: application/json" \
-d '{
"model": "openai/gpt-5.4-image-2",
"prompt": "Replace the sky with a clear night sky",
"size": "1080x1920",
"input_references": ["data:image/png;base64,iVBORw0KG..."]
}'import base64
import requests
reference = base64.b64encode(open("frame.png", "rb").read()).decode()
r = requests.post(
"https://api.gtokens.io/api/v1/images",
headers={"Authorization": "Bearer sk-sl-your-key"},
json={
"model": "openai/gpt-5.4-image-2",
"prompt": "Replace the sky with a clear night sky",
"size": "1080x1920", # exact pixels, honoured as asked
"input_references": ["data:image/png;base64," + reference],
},
timeout=300,
)
if r.status_code != 200:
raise SystemExit(r.json()["error"]["message"])
frame = r.json()["data"][0]
open("out.png", "wb").write(base64.b64decode(frame["b64_json"]))import { readFile, writeFile } from 'node:fs/promises'
const reference = (await readFile('frame.png')).toString('base64')
const r = await fetch('https://api.gtokens.io/api/v1/images', {
method: 'POST',
headers: {
Authorization: 'Bearer sk-sl-your-key',
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'openai/gpt-5.4-image-2',
prompt: 'Replace the sky with a clear night sky',
size: '1080x1920', // exact pixels, honoured as asked
input_references: ['data:image/png;base64,' + reference],
}),
})
if (!r.ok) throw new Error((await r.json()).error.message)
const { data } = await r.json()
await writeFile('out.png', Buffer.from(data[0].b64_json, 'base64'))The reply is a single object, and data[0].b64_json is the new frame — exactly 1080x1920, because the size was named in pixels. Drop input_references and the same call generates from the prompt alone.
Errors
Every refusal arrives in one shape, and the field to branch on is metadata.error_type rather than the status alone — one status covers several cases.
{
"error": {
"code": 400,
"message": "prompt must not be empty",
"metadata": { "error_type": "invalid_prompt" }
}
}| Status | error_type | Meaning |
|---|---|---|
| 400 | invalid_request | Malformed body, unknown value, clashing geometry. |
| 400 | invalid_prompt | prompt missing or empty, or an unreadable part. |
| 400 | invalid_image | A data: reference that is not a decodable image. |
| 400 | image_download_failed | A reference URL could not be fetched. |
| 401 | authentication | No bearer token, or an unknown or revoked key. |
| 402 | payment_required | Balance cannot cover it, or a ceiling was reached. |
| 403 | permission_denied | The account is suspended; the key is fine. |
| 404 | not_found | No model with that identifier. |
| 429 | rate_limit_exceeded | Too many requests upstream. Carries Retry-After. |
| 500 | server | Our fault. Repeating the request is reasonable. |
| 502 | provider_unavailable | A reply arrived but could not be used. |
| 503 | provider_overloaded | Nothing is free right now. Carries Retry-After. |
| 504 | timeout | No reply in the time allowed. |
Scroll the table horizontally for the last column.
When there is something more specific to say, it arrives next to the typed code in metadata.reason: a 400 whose reason is model_not_available means the model is real and listed, but not served through this route yet — retrying will not change that, and no other model is substituted for it. A 503 means the same thing about this moment rather than about the model: wait out Retry-After and send the same request again. It is never quietly moved to a different source.
Everything that can be checked — the prompt, the field values, the size ceilings, the references — is checked before any money is held, and a 402 costs nothing at all. If a request fails after the hold is taken, the hold is released; when it succeeds, only the frames that actually came back are billed, even if you asked for more.
