Everything the studio can forge - sprites, edits, sheets, animations, tilesets - is available over plain HTTPS with an API key. API calls are tied to your SpriteForge account: they spend the same credit balance and run under the same rate limits as generations you start in the studio.
Base URL: https://www.spriteforge.tech - all endpoints below are relative to it. Requests and responses are JSON.
Sign in to the studio, click your account name in the header, and choose Generate API key. The key (sf_…) is shown once - only a hash is stored server-side, so copy it immediately. From the same panel you can deactivate/activate the key (temporarily disable it without losing it), regenerate it (replaces the old key immediately), or delete it. One key exists per account.
Send the key as a bearer token on every request:
Authorization: Bearer sf_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
/api/generate, /api/jobs and /api/balance. They cannot manage your account, start checkouts, or read your asset library; those require a signed-in studio session.The key is a plain bearer token - use it from any device, server, CI job or script, several at once if you like. It isn't tied to a browser, session or IP. Jobs you submit with the key are ignored by the studio's auto-import, so an open studio tab won't race your script for the result (see claiming); everything else - credits, limits, the gallery - is shared account-wide.
API generations spend the same prepaid credits as the studio (top up from the studio header). Every generation has a fixed price determined server-side by the tier and sprite size - the client never sets a price. The flow is reserve-and-settle:
/api/jobs poll and refunded.Prices come from GET /api/config. tiers[label].avg is not the price - it is the per-call cost basis (USD, indexed against tiers[label].sizes): the measured provider cost, floored so that each tier sits at least 15% above the tier below it at the same size. The charge is that basis times the service multiplier, rounded up to the cent:
cents_per_call = ceil(tiers[label].avg[i] * marginMult * 100) // marginMult is in /api/config
# Low @ 16 px: avg 0.0372 USD * 4 -> 15 cents
The size that prices a call is sqrt(px.w * px.h) (so a 24×32 sprite prices like a ~27 px one), interpolated between the quoted buckets. On top of the per-call price:
set per subject, animation per frame, terrain ×3, platformer ×2, multiterrain per terrain, phased per pass (a detailTier combo prices pass 0 at the main tier and the rest at the detail tier). wang is a single call regardless of its 16 output tiles.references add tiers[label].refCents per reference. They ride every drawing call that gets them, and are billed that way: once for single, edit, wang (one fill call behind its 16 tiles) and phased (they attach to the silhouette pass only), and per call for set (per subject) and multiterrain (per terrain). So a 4-subject set with one reference at Low@16 is 4 × (15 + 1) = 64 cents.animation adds refCents × animContextRefs per frame - the frames carry the base pose plus the preceding frames as image context, and a one-off director pass plans the motion, none of which the flat per-call price was measured with. So a 4-frame Low@16 loop is 4 × (15 + 2) = 68 cents.Free first generations. An account that has never topped up gets two generations free - one type: "single" sprite and one type: "wang" autotile set - and the API honours them exactly like the studio: tierLabel: "Low", px at most 16×16, and no references. Each is claimed only when your balance genuinely can't cover the call, so a funded account always pays instead. GET /api/balance reports which of the two is still available. Anti-farming caps ride along - a few free generations per source IP per day, and one per email address ever - and both answer 402 when hit.
| Limit | Value | On breach |
|---|---|---|
| POST /api/generate | 20 requests / minute / account | 429 |
| /api/jobs (GET + POST combined) | 180 requests / minute / account | 429 |
| Concurrent generations | 3 per account | job is queued, not rejected |
| Platform-wide concurrency | shared global cap | job is queued, not rejected |
| Per-IP (pre-auth) | 60/min on generate & account, 600/min on jobs, 240/min on balance | 429 |
When capacity is full your job is accepted with status: "queued" (credits already reserved) and starts automatically when a slot frees. A job left queued for 24 h is failed and refunded. Web and API usage share the per-account limits - they are per account, not per key. Every 429 carries a Retry-After header (seconds); honour it in your retry loop.
Generation is asynchronous: you submit an intent, the server runs the LLM artist to completion (typically 20–120 s for a single sprite depending on tier/size; multi-call types scale with their call count - /api/config durations carries the measured averages per tier/type/size), and you poll for the parsed result. Prompts, model IDs and raw model output never cross the wire.
# 1. submit - you invent the jobId (6-40 chars, [A-Za-z0-9_-])
curl -X POST https://www.spriteforge.tech/api/generate \
-H "Authorization: Bearer $SF_KEY" -H "Content-Type: application/json" \
-d '{
"jobId": "job_a1b2c3d4",
"type": "single",
"tierLabel": "Basic",
"subject": "a tiny copper golem with glowing blue eyes",
"px": { "w": 32, "h": 32 }
}'
# the POST runs the generation to completion, so it answers when the art is ready:
# -> { "ok": true, "jobId": "job_a1b2c3d4", "status": "done" } (or "queued" / "batched")
# 2. poll until your job's status is "done" or "failed" (1-2s interval is plenty)
curl -H "Authorization: Bearer $SF_KEY" https://www.spriteforge.tech/api/jobs
# -> { "ok": true, "jobs": [ { "id": "job_a1b2c3d4", "status": "done",
# "result": { "size": 32, "palette": ["#17161d", …], "grid": [[…]] }, … } ] }
# 3. (optional) claim it so other pollers won't re-import it
curl -X POST https://www.spriteforge.tech/api/jobs \
-H "Authorization: Bearer $SF_KEY" -H "Content-Type: application/json" \
-d '{ "id": "job_a1b2c3d4", "action": "claim" }'
Submitting the same jobId twice is safe - the duplicate is ignored (status: "duplicate") and not double-charged. The POST keeps the connection open until the generation finishes; you may also fire-and-forget it and rely purely on polling, since the server runs the job to completion either way. In the rare case that a job is timed out server-side while its result was still arriving, the POST responds status: "superseded" - the job shows as failed (already refunded) and the result is discarded; treat it like a failure and resubmit.
Long multi-call jobs (big sets and animations at large sizes) run in batches: the POST may respond status: "batched" and the job briefly shows as queued between batches before continuing automatically. Nothing extra to do - keep polling /api/jobs until done/failed as usual; already-drawn frames stay visible in partial while running.
The POST's status is a receipt for the submission, never something to branch on - /api/jobs is the source of truth. The full set: done (ran to completion in this request), queued (no capacity, starts by itself), batched (parked mid-way, resumes by itself), duplicate (this jobId already exists - nothing charged), superseded (the job was already failed and refunded server-side; resubmit under a fresh jobId), plus still-queued and already-claimed, which only appear on the server's internal queue replays.
A live console for this API - works on any device, phones included. Your key goes straight from this page to the API (same origin, kept in memory only, never stored or sent anywhere else). Don't paste your key into third-party API testers; many proxy your requests through their servers.
One endpoint, nine generation types, selected by type. These fields are common to every request:
| Field | Type | Notes |
|---|---|---|
| jobId | string, required | Client-generated ID, 6–40 chars of [A-Za-z0-9_-]. Doubles as the idempotency key. |
| type | string, required | single · edit · set · animation · terrain · phased · wang · platformer · multiterrain |
| tierLabel | string, required | Quality/price tier: Low, Basic, Pro or Ultra (authoritative list: /api/config). Low and Basic run OpenAI GPT-5.5 at low and medium reasoning effort; Pro and Ultra run Anthropic Claude Fable 5 at low and medium effort - the tier keys are the contract and stay the same if the underlying models change. |
| px | object, required* | { "w": int, "h": int }, each 1–64. Sets sprite size and the price. (*not used by edit, which sizes from baseArt.) |
| meta | object, optional | Your own blob (≤4000 chars serialized, else 400) echoed back by /api/jobs - use it to route results in your own pipeline. Three keys are reserved: palette, lockPalette and symmetry in meta override the same options fields on the single path (the studio uses them that way), and the server adds __origin: "api" to every key-authenticated job - that flag is what keeps the studio's auto-import off your results. |
| options | object, optional | Style controls, below. |
| references | array, optional | Reference sprites in the result format, each with an optional role: "style" (copy the look) or "subject" (copy the subject, not the look). Max count via /api/config maxRefs; small per-reference surcharge (tiers[label].refCents). Honoured by single, edit, phased, set, wang and multiterrain. Ignored silently (never an error, never surcharged) by terrain and platformer, which chain their own images between passes, and by animation, whose frames are anchored by seedArt and already carry the base pose plus the preceding frames as context. |
All free-text options are capped at 500 characters. All are optional. Not every type consumes every option: edit reads only projectStyle, projectDesc, palette and lockPalette (the rest are accepted and ignored - the edit prompt is built from baseArt and prompt), and the tile/level types (wang, terrain, platformer, multiterrain) draw seamless fills, so symmetry and lockPalette have no effect there.
| Option | Type | Notes |
|---|---|---|
| perspective | string | e.g. "side view", "3/4 top-down" |
| style | string | art-style line, e.g. "chunky 16-bit fantasy, warm palette" |
| light | string | light direction, e.g. "Top-left" |
| outline | string | e.g. "Dark-tinted", "Black", "None" |
| projectStyle / projectDesc | string | project-wide style/description context |
| symmetry | string | "Off" or a horizontal/vertical symmetry note - mirrored server-side after parsing |
| seedNote | string | extra free-text appended to the artist prompt |
| palette | string[] | up to 64 hex colours to steer toward (type: "edit" caps it at 52) |
| lockPalette | boolean | snap the output strictly onto options.palette |
One sprite. Add subject (string, required, ≤8000 chars).
{ "jobId": "…", "type": "single", "tierLabel": "Pro",
"subject": "a rusty mining pickaxe, worn wooden handle",
"px": { "w": 24, "h": 24 },
"options": { "style": "warm 16-bit fantasy", "outline": "Dark-tinted" } }
Modify an existing sprite. Instead of px, send the sprite itself:
| Field | Type | Notes |
|---|---|---|
| prompt | string, required | the change to make, ≤2000 chars |
| baseArt | object, required | the sprite being edited, in the result format (size 1–64, palette ≤52 hex, grid size×size of palette indexes / -1) |
| maskCells | [x,y][], optional | restrict the edit to these cells (0-based, within the grid). Untouched cells are preserved. |
Per-tier size limits apply: a full-sprite edit is limited to editLimits[tier].maxFullSize per side, a masked edit to editLimits[tier].maxCells selected cells (both in /api/config).
A themed sprite set with one unified palette. Replace subject with subjects (1–8 strings, ≤300 chars each); optional options.setDesc describes the set's shared theme. Result: frames = one frame per subject, frameMode: "set". Priced per subject; if a subject fails, unrun subjects are refunded. Any references are attached to every subject call - the usual way to hold a whole set to one look - and surcharged per call to match.
{ "jobId": "…", "type": "set", "tierLabel": "Basic",
"subjects": ["iron sword", "battle axe", "wooden shield", "short bow"],
"px": { "w": 24, "h": 24 },
"options": { "setDesc": "medieval armory icons on transparent background" } }
A looping animation of one subject. Every requested frame is freshly drawn, in order, each one seeing the base pose and the frames before it as image context so the motion keeps its direction and the character doesn't drift. For frameCount ≥ 2 a cheap text-only director pass runs first and plans a beat per frame (anticipation, action, follow-through, settle); a frame you direct yourself through motion always overrides its planned beat. The director and the frame context are what the per-frame animContextRefs surcharge pays for; a failed director pass just leaves the frames unplanned, never fails the job.
| Field | Type | Notes |
|---|---|---|
| subject | string, required | ≤8000 chars |
| frameCount | int, required | 1–8 motion frames - the drawn frames of the loop. A seeded animation's base sprite is the loop's rest pose and is not counted here (so 1 frame is a valid base ⇄ motion bounce). |
| motion | string, optional | ≤1000 chars. Lines starting with a motion-frame number direct that frame only ("3: raise the sword"); other lines describe the loop. |
| aggression | string, optional | subtle · normal (default) · strong - how hard poses are pushed |
| seedArt | object, optional | an existing sprite (result format) to animate. The frames stay on-model to it and it becomes the loop's rest pose (frame 0); the frameCount fresh motion frames advance out of it and lead back into it. Its size must equal max(px.w, px.h). It is not itself counted or charged - you pay for the frameCount motion frames. Omit it to draw a standalone animation from scratch (frame 1 is then a clean base pose); the studio always seeds, so this is an API-only path. |
| openEnded | boolean, optional | default false - the frames close a loop, so the last one is steered back into the rest pose. Set true for a one-shot progression that should not return to the start: the last frame keeps advancing the motion, which is what you want when extending a work-in-progress animation frame by frame (the Edit studio's flow) rather than forging a finished loop. |
Result: frames in loop order - a seeded animation leads with the base rest pose, then the motion frames - frameMode: "anim". If a later frame fails after earlier ones landed, the shorter loop is delivered and the undrawn frames are refunded (progress says how many arrived).
A wall/floor/transition tile trio for top-down maps. subject = the wall material (required); subjectB = the floor material (optional, defaults to subject). Result: 3 frames - wall, wall -> floor transition, floor - frameMode: "terrain".
Premium staged pipeline (silhouette -> colour -> detail) with a render check between passes - the strongest choice for humanoids and hard anatomy. Add subject (required) and optionally passes (2 or 3, default 3) and detailTier (a second tier label used for the passes after the silhouette - e.g. main tier "Ultra", detailTier: "Basic"). references ride on the silhouette pass only.
Result: one sprite, same single-frame shape as single - the passes are internal, not frames. If a later pass fails, the last good pass is delivered and the unrun passes are refunded, with a note in progress.
A 16-tile Wang autotile terrain set derived deterministically from one seamless fill. subject = the terrain material (required). px must be square. Result: 16 frames in Wang mask order, frameMode: "wang". Any references attach to that one fill call and are surcharged once; make them role: "style" - a seamless fill has no subject or pose for a subject reference to transfer.
options.perspective - free text such as "top-down (bird's-eye)" or "isometric".Side-view platformer ground tiles: an earth fill plus a top cap, expanded into directional edge and slope tiles. subject = the earth material (required); subjectB = the surface cap (optional, e.g. "lush grass"). px must be square.
Result: 49 frames, frameMode: "platformer" - the 47 distinct blob-autotile neighbour masks in mask order, then an up-slope and a down-slope tile as the last two. Index a painted map into the first 47 by its neighbour mask exactly as you would any blob tileset; the slopes are placed by hand.
Several terrains that blend into each other on a paintable dual-grid map.
| Field | Type | Notes |
|---|---|---|
| terrains | array, required | 2–6 of { id (≤64 chars), name (≤200), color? [r,g,b], role? (e.g. "river") } |
| cols / rows | int, required | map dimensions, each 4–32 |
| terrainMap | int[][], optional | rows×cols of terrain indexes - the pre-painted map |
| terrainRules | object, optional | edge-blend rules (≤4000 chars serialized) |
px must be square (the per-terrain tile size). Omit terrainMap and the server paints a plausible layout for you. Any references are attached to every per-terrain fill call (and surcharged per call), so one style reference keeps every terrain in the same palette and shading.
Result: one composed map, not a tileset - frameMode: "multiterrain", with grid (and the single entry in frames) holding the whole rendered map at rows·px.h by cols·px.w pixels, blended edges already resolved. The pieces to rebuild or re-paint it yourself ride in terrainMeta:
"terrainMeta": {
"terrains": [ …your terrains, echoed… ],
"fills": [ { "grid": [["A","B",…],…], "palette": { "A": [23,22,29], … } }, … ],
// the drawn seamless fill per terrain, in terrains order - note this is the
// internal letter-grid form (letter per cell, "." = transparent, letter -> RGB),
// NOT the hex/index result format the rest of this page uses
"mapGrid": [[ 0, 1, … ]], // rows×cols terrain indexes actually used
"rulesDict": { …your terrainRules, echoed… },
"tile": 16 // px per tile
}
Your jobs from the last 48 hours (newest first, max 30). Polling this endpoint also self-heals: stale jobs are failed and refunded, and queued jobs are nudged toward free capacity.
{ "ok": true, "jobs": [ {
"id": "job_a1b2c3d4",
"status": "queued" | "running" | "done" | "failed" | "claimed",
"meta": { …your meta blob… },
"progress": "2/5" | "queued - starts automatically" | null,
"partial": { "index": 1, "total": 5, "current": {…}, "done": [{…}] }, // running (and queued between batches): live-paint state
"result": { …result format… }, // done only
"error": "…", // failed only
"created_at": "2026-07-17T12:00:00.000Z"
} ] }
partial streams the in-progress grid while the artist draws (throttled to ~0.5 s), so you can render live progress exactly like the studio does. It is also kept on a job parked as queued between batches, so already-drawn frames stay visible; a freshly submitted queued job has partial: null.
Body { "id": "<jobId>", "action": "claim" } - marks a done job as consumed and drops its stored result, so other tabs/devices/pollers of the same account won't import it twice. Optional for single-consumer API pipelines, but recommended once you've stored the result. Response: { "ok": true, "claimed": true|false }.
Jobs submitted with an API key are tagged server-side and skipped by the studio's background auto-import - your open studio tabs will never claim them out from under your script, and the result stays available to you until you claim it (results are also dropped when the 48-hour job window expires). Jobs started in the studio behave as before: any of your studio tabs may import and claim them, so treat those results as studio-owned.
Your remaining credits and free-generation state. Top-ups are done in the studio.
{
"balance_cents": 1234, // USD cents; null only if the database is unavailable
"freeSpriteAvailable": false, // the free Low ≤16×16 "single" is still unclaimed
"freeTileAvailable": true, // the free Low ≤16×16 "wang" set is still unclaimed
"freeGenAvailable": true, // either of the two (back-compat)
"everToppedUp": true // the account has made at least one purchase
}
Public discovery endpoint (no auth). Read tier labels and prices from here rather than hardcoding them.
| Field | Meaning |
|---|---|
| tiers | { label: { sizes: [8,16,24,32,48,64], avg: [USD…], refCents } } - avg is the per-call cost basis at each size (measured provider cost, ladder-floored so tiers price in order), not the price (see pricing); refCents is the per-reference surcharge in cents. |
| marginMult | the service multiplier applied to avg to get the charge (ceil(avg × marginMult × 100) cents). |
| maxRefs | cap on references per request. |
| animContextRefs | reference-equivalents billed per animation frame for the frame context + director pass (see animation). |
| editLimits | { label: { maxFullSize, maxCells } } - the full-sprite side limit and masked-cell limit per tier for type: "edit". |
| maxSpriteSize | largest px.w/px.h (and baseArt.size/seedArt.size) the API accepts. |
| durations | { tier: { type: { sizeBucket: avgSeconds } } } - measured average runtimes, for your own ETAs. A tier×type×size with too few samples is absent rather than zero; treat missing as unknown. |
| providers | which upstream providers are configured (a liveness signal; no keys or model IDs). |
palettePriceCents and clerkPublishableKey are also returned - both serve the studio's own UI (the palette-refine tool and browser sign-in) and are not part of the generation API.
Every finished job's result (and every sprite you send - baseArt, seedArt, references) uses the same shape:
{
"size": 32, // longest side of the art (see the caveat below)
"palette": ["#17161d", "#dcb684", …], // ≤64 hex colours
"grid": [[ -1, 0, 1, … ]], // rows of palette indexes, -1 = transparent
"name": "cute_slime", // optional: 2-4 lowercase words the artist called its own subject
"frames": [ { "grid": [[…]] }, … ], // multi-frame types: one grid per frame, shared palette
"frameMode": "set" | "anim" | "terrain" | "wang" | "platformer" | "multiterrain" // multi-frame types only
}
name is the artist’s own short name for what it drew, present on single-sprite types (single, phased) whenever the model emits it. The studio uses it to name the gallery asset, so a prompt like “make me a cute slime” saves as cute_slime rather than the first three words of the prompt. Treat it as advisory: it can be absent, and it is never a unique identifier.
Rendering to PNG is a straight pixel loop: cell value -1 -> transparent, otherwise palette[value]. grid is always the first frame's grid on multi-frame types, so a single-frame renderer still shows something sensible.
size. size is the longest side, and it only equals both dimensions on square output. A non-square single/edit/phased sprite is padded into a square size×size grid with the art in the top-left and the rest transparent; non-square multi-frame results keep their true px.h×px.w grids; and a multiterrain map is rows·px.h×cols·px.w. grid.length (rows) and grid[0].length (columns) are correct in every case.Errors are JSON: { "error": { "message": "…" } }.
| Status | Meaning |
|---|---|
| 400 | Invalid intent - the message names the offending field and its constraint. Also returned when meta serializes past 4000 chars. |
| 401 | Missing/invalid/deactivated API key, or the key was used on an endpoint keys can't access. |
| 402 | Insufficient credits for the reservation - top up in the studio. Also covers the free-generation caps below. |
| 405 | Wrong HTTP method for the endpoint (e.g. GET /api/generate). |
| 429 | Rate limit exceeded - back off and retry after a minute. |
| 503 | Temporarily unavailable, nothing charged. Either the platform's daily generation capacity is reached (paid and free generations alike are paused until UTC midnight - "We've hit today's generation capacity"), or the platform is at capacity and a free-generation request couldn't be queued ("The studio is busy right now") - retry later. |
| 5xx / 502 / 504 | Upstream/model failure - the reservation is refunded automatically; retry with a fresh jobId. |