SpriteForge
Open the studio
API REFERENCE · v1 · 2026-09

The SpriteForge API

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.

Authentication & API keys

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 keys are deliberately scoped to generation endpoints only - /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.

Credits & pricing

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:

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:

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.

Rate limits & concurrency

LimitValueOn breach
POST /api/generate20 requests / minute / account429
/api/jobs (GET + POST combined)180 requests / minute / account429
Concurrent generations3 per accountjob is queued, not rejected
Platform-wide concurrencyshared global capjob is queued, not rejected
Per-IP (pre-auth)60/min on generate & account, 600/min on jobs, 240/min on balance429

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.

How a generation works

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.

Try it in your browser

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.

Runs a real 16×16 Low-tier generation charged from your credits.

POST/api/generate

One endpoint, nine generation types, selected by type. These fields are common to every request:

FieldTypeNotes
jobIdstring, requiredClient-generated ID, 6–40 chars of [A-Za-z0-9_-]. Doubles as the idempotency key.
typestring, requiredsingle · edit · set · animation · terrain · phased · wang · platformer · multiterrain
tierLabelstring, requiredQuality/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.
pxobject, required*{ "w": int, "h": int }, each 1–64. Sets sprite size and the price. (*not used by edit, which sizes from baseArt.)
metaobject, optionalYour 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.
optionsobject, optionalStyle controls, below.
referencesarray, optionalReference 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.

options - shared style controls

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.

OptionTypeNotes
perspectivestringe.g. "side view", "3/4 top-down"
stylestringart-style line, e.g. "chunky 16-bit fantasy, warm palette"
lightstringlight direction, e.g. "Top-left"
outlinestringe.g. "Dark-tinted", "Black", "None"
projectStyle / projectDescstringproject-wide style/description context
symmetrystring"Off" or a horizontal/vertical symmetry note - mirrored server-side after parsing
seedNotestringextra free-text appended to the artist prompt
palettestring[]up to 64 hex colours to steer toward (type: "edit" caps it at 52)
lockPalettebooleansnap the output strictly onto options.palette

type: "single" 1 call

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" } }

type: "edit" 1 call

Modify an existing sprite. Instead of px, send the sprite itself:

FieldTypeNotes
promptstring, requiredthe change to make, ≤2000 chars
baseArtobject, requiredthe sprite being edited, in the result format (size 1–64, palette ≤52 hex, grid size×size of palette indexes / -1)
maskCells[x,y][], optionalrestrict 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).

type: "set" 1 call per subject

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" } }

type: "animation" 1 call per frame + a director pass

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.

FieldTypeNotes
subjectstring, required≤8000 chars
frameCountint, required1–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).
motionstring, optional≤1000 chars. Lines starting with a motion-frame number direct that frame only ("3: raise the sword"); other lines describe the loop.
aggressionstring, optionalsubtle · normal (default) · strong - how hard poses are pushed
seedArtobject, optionalan 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.
openEndedboolean, optionaldefault 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).

type: "terrain" 3 calls

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".

The studio no longer surfaces this type directly - it now models raised walls as a thin render layer over a Wang set - but it remains fully supported over the API for the classic 3-tile wall/floor/transition output.

type: "phased" 2–3 calls

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.

type: "wang" 1 call

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.

The studio's tile menu maps onto the API like this: a top-down or isometric Wang tile - and a Wang tile in Wall mode - all generate this same 16-tile set (the wall overlay and isometric diamond are presentation the studio applies on the client; the API output is always the flat 16 tiles, which you render, reproject or overlay however you like). The studio's Platformer perspective instead routes to the separate platformer type. Perspective and other look hints ride in options.perspective - free text such as "top-down (bird's-eye)" or "isometric".

type: "platformer" 2 calls

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.

type: "multiterrain" 1 call per terrain

Several terrains that blend into each other on a paintable dual-grid map.

FieldTypeNotes
terrainsarray, required2–6 of { id (≤64 chars), name (≤200), color? [r,g,b], role? (e.g. "river") }
cols / rowsint, requiredmap dimensions, each 4–32
terrainMapint[][], optionalrows×cols of terrain indexes - the pre-painted map
terrainRulesobject, optionaledge-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
}

GET/api/jobs

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.

POST/api/jobs

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.

GET/api/balance

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
}

GET/api/config

Public discovery endpoint (no auth). Read tier labels and prices from here rather than hardcoding them.

FieldMeaning
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.
marginMultthe service multiplier applied to avg to get the charge (ceil(avg × marginMult × 100) cents).
maxRefscap on references per request.
animContextRefsreference-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".
maxSpriteSizelargest 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.
providerswhich 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.

The result format

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.

Read the dimensions off the grid, not off 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

Errors are JSON: { "error": { "message": "…" } }.

StatusMeaning
400Invalid intent - the message names the offending field and its constraint. Also returned when meta serializes past 4000 chars.
401Missing/invalid/deactivated API key, or the key was used on an endpoint keys can't access.
402Insufficient credits for the reservation - top up in the studio. Also covers the free-generation caps below.
405Wrong HTTP method for the endpoint (e.g. GET /api/generate).
429Rate limit exceeded - back off and retry after a minute.
503Temporarily 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 / 504Upstream/model failure - the reservation is refunded automatically; retry with a fresh jobId.
Treat your API key like a password: it can spend your credits. If it leaks, Regenerate (or Deactivate) it immediately from the account panel in the studio.