API
Everything the studio does, it does through these routes. There is no second, private API.
Last updated 2026-08-19
Keys are not open yet. An API key needs a Creator or Studio plan, and card payment is not live on this deployment. Create an account to be ready, or write to support@pixelforge.app for early access.
Authentication
Create a key on the account page. A key starts with pf_ and is shown once. Send it
on every request except GET /fleet, GET /limits and GET /skills.
Authorization: Bearer pf_xxxxxxxxxxxxxxxxxxxx A key inherits its plan's rate limit: 120 requests a minute on Creator, 600 on Studio. Over the
limit the API answers 429. Every response body is JSON, and every refusal is {"error": "..."}, so one parse handles them all.
Submit a run
POST /jobs takes the prompt and the count and answers 202 with a job id.
Credits are reserved for the whole run at submit; images that never render are refunded when the
run finishes.
POST /jobs
{
"prompt": "a busy harbour at first light",
"n": 4,
"width": 512,
"height": 512,
"steps": 4,
"seed_policy": { "type": "random" }
}
202
{ "job_id": "c47e2316-...", "n": 4, "status": "queued", "inert_knobs": [] } | Field | Default | Notes |
|---|---|---|
prompt | required | up to max_prompt_chars |
n | 1 | 1 to max_variations. Each image gets its own seed. |
width, height | 512 | multiples of 64, product under max_pixel_area |
steps | 8 | 1 to max_steps. Fewer is faster and rougher. |
skills | ["base-xl"] | one or more ids from GET /skills |
mode | "generate" | "edit" needs at least one reference |
seed_policy | {"type":"random"} | random, sequential or fixed; the last two take base_seed |
ref_upload_ids | none | up to 4 ids from POST /uploads |
reference_mode | "reference" | "init" treats the reference as a starting latent |
prompt_variants | none | one shot description per image, rendered in FRONT of the prompt |
tile_grid | none | 2 builds a print-size file from four tiles |
loras | none | [{"id": "...", "multiplier": 1.0}] from GET /loras |
advanced | none | renderer parameters. An unknown key is refused by name with 422. |
Unknown top-level fields are refused by name. The settings live at the top level, not inside a params object.
Varying the shot
The seed does not move the composition on this model. Measured on "a brass diving helmet on a
workbench": six seeds at 4 steps, six at 8 steps, and distilled_guidance at 1.0, 2.0
and 3.5 all returned the same helmet at the same angle with the background rearranged. One fixed
seed with six different shot descriptions returned six different pictures.
{
"prompt": "a brass diving helmet on a workbench",
"n": 4,
"prompt_variants": [
"",
"Wide shot, the whole scene visible.",
"Extreme close-up, shallow depth of field.",
"Aerial view, seen from high above."
]
} Up to 24 entries, each under 200 characters. Each entry goes in FRONT of the prompt, not after
it: appended to a strongly described scene it was ignored. An empty entry renders the prompt as
typed. GET /jobs/{id} returns the entry each image used as prompt_variant.
Keeping something out
negative_prompt is accepted, and on this model it does close to nothing at the
guidance that keeps colour intact. Measured seed-matched over three seeds at txt_cfg 1.0, 1.4, 2.0 and 2.5: the named object survived at 1.0 and 1.4, went at
2.0 with the colour over-saturated, and the picture was unusable at 2.5. Raising the guidance
also runs a second pass per step, which costs about 1.8x the render time.
Name what you want in its place instead. Three shapes of the rule, each measured at a fixed seed and each at the ordinary one-pass cost:
- An object. ", burning logs glowing in the open firebox below" replaced a blue gas flame on all three of seeds 1033906312, 621955972 and 1300003147.
- A colour. "tools with blue-taped handles hanging on a pegboard" replaced the red handles on all four seeds of one run. The strongly saturated red area fell from a mean 2.04% of the frame to 1.12%.
- An absence. Name what fills the space: "an empty stool, a bare wall", not "no person".
The studio sends no negative prompt for this reason.
The model does not render words
Letterforms come back malformed. Measured on "a red telephone box on a village street at dusk"
over four seeds: every sign read TELEP\WONE or TELEPIONE. Naming the
wanted thing does not help here, because the failure is in the shapes of the letters, not in the
subject.
Do not ask for text on a sign, a label or a packet. Add the words yourself, in a layout tool, over the picture the service returns.
Poll a run
GET /jobs/{job_id}. Results arrive as they finish, so take the url of each variation that reaches completed rather than waiting for the
whole run. Poll about once every two seconds.
{
"job_id": "c47e2316-...",
"status": "running",
"n": 4, "completed": 2, "failed": 0,
"params": { "width": 512, "height": 512, "steps": 4 },
"variations": [
{ "idx": 0, "status": "completed", "seed": 1604101814, "kept": false,
"url": "/images/c47e2316-.../0.png", "elapsed_s": 51.1 },
{ "idx": 1, "status": "running", "seed": 1604101815, "kept": false }
]
} A run's status is queued, running, completed, partial, failed or cancelled. A
run of n images takes roughly ceil(n / ready) waves; read ready from GET /fleet.
Take the pictures
GET /images/{job_id}/{idx}.png full size
GET /images/{job_id}/{idx}.thumb.webp thumbnail
GET /images/{job_id}/{idx}.png?download=true with a Content-Disposition
GET /jobs/{job_id}/archive every finished image, one zip
GET /jobs/{job_id}/archive?kept=true only the ones marked kept A job id that is not yours answers 404, the same as one that does not exist. A
delivered PNG carries no parameters chunk, so the prompt does not travel with the
file.
Print size
One render cannot pass about a megapixel on this hardware. Measured on a lane, past this API's
own cap: 1024x1024 completes in about 51 s at 4 steps, while 1536x1536 and 2048x2048 both fail
with generate_image returned no results. A larger file is assembled from tiles
instead.
{
"prompt": "a brass sextant on a chart table",
"n": 4, "width": 1024, "height": 1024, "steps": 8,
"ref_upload_ids": ["<the picture>"],
"reference_mode": "init",
"tile_grid": 2,
"advanced": { "strength": 0.12 }
} tile_grid must be 2, n must be 4, and the run needs exactly one
reference in reference_mode: init. While the tiles render, GET /jobs/{id} reports four variations. Once the join is written it
reports one, at /images/{id}/full.png, with params.width and params.height set to the real size of that file.
The joined size is not a round doubling, because the tiles overlap by 64 source pixels, and
it follows the source's shape.
| Source | Joined | At 300 dpi |
|---|---|---|
1024x1024 | 1928x1928 | 163 x 163 mm |
1152x896 | 2183x1697 | 185 x 144 mm |
896x1152 | 1673x2151 | 142 x 182 mm |
1280x768 | 2438x1464 | 206 x 124 mm |
Send width and height equal to the source's own size. The tile boxes
are cut from the full width AND height: they used to be cut from one size, so a
1152x896 landscape source was cut inside its own 896x896 left square and 22.2% of the width
never reached the print file.
Other routes
| Route | What it does |
|---|---|
GET /limits | the caps this deployment enforces; read them rather than hardcoding them |
GET /skills | the models available; is_edit says whether
one needs a reference |
GET /fleet | lane counts; ready is the number free right
now |
POST /uploads | multipart upload; returns an id for ref_upload_ids |
POST /jobs/{id}/cancel | stops a run; unrendered credits are refunded |
DELETE /jobs/{id} | stops the run, then removes it and its files |
PUT /jobs/{id}/variations/{idx}/keep | marks a
result kept, body {"kept": true} |
GET /jobs?limit=50&offset=0 | run history, newest first, limit up to 100 |
GET /loras | adapters available to this account |
POST /api-keys/create | also /api-keys/list and /api-keys/revoke |
A complete example
KEY=pf_xxxxxxxxxxxxxxxxxxxx
HOST=https://<your-host>
JOB=$(curl -s -X POST "$HOST/jobs" \
-H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"prompt":"a busy harbour","n":4,"width":512,"height":512,"steps":4}' \
| python3 -c 'import sys,json; print(json.load(sys.stdin)["job_id"])')
while :; do
BODY=$(curl -s -H "Authorization: Bearer $KEY" "$HOST/jobs/$JOB")
echo "$BODY" | python3 -c 'import sys,json; d=json.load(sys.stdin); print(d["status"], d["completed"], "of", d["n"])'
echo "$BODY" | grep -q '"status": *"\(completed\|partial\|failed\|cancelled\)"' && break
sleep 2
done
curl -s -H "Authorization: Bearer $KEY" -o 0.png "$HOST/images/$JOB/0.png" Errors
| Status | When |
|---|---|
| 401 | no key, or a revoked key |
| 402 | not enough credits; the message names the balance and the request |
| 403 | the account's plan does not include this feature |
| 404 | not yours, or does not exist |
| 422 | the request is wrong; the message names the field and what is allowed |
| 429 | over the plan's rate limit, or over a per-account cap |
| 501 | the route needs a paid plan that is not yet open |