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
The prefix is case-sensitive: exactly Bearer, one space, then the key. bearer sf_… is a 401.
/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. CORS allows only https://www.spriteforge.tech, so a browser page on another site can't call the API: call it from a server or script. 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:
single, edit and wang (one call): refunded in full.set and animation: a call that fails after earlier ones finished is retried (up to two retries per job). If it still fails, you get the finished sprites or frames and pay only for those calls; the rest is refunded. If the first call fails, the whole job is refunded.terrain, platformer and multiterrain: a part-built tileset or map is unusable, so the whole job is refunded and nothing is delivered.phased: if the silhouette pass fails, refunded in full. If a later pass fails, you get the last good pass and the failed and unrun passes are refunded./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 within each model every effort level sits at least 15% above the one 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
# Sonnet @ 16 px: avg 0.0294 USD * 4 -> 12 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 Sonnet@16 is 4 × (12 + 1) = 52 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 Sonnet@16 loop is 4 × (12 + 2) = 56 cents.Free credit on sign-up. A new account gets a one-time $1.00 credit (signupCreditCents in /api/config), added to its balance on the first GET /api/balance or POST /api/generate. It is ordinary balance - any type, tier or size it covers. Anti-farming caps ride along: one credit per email address ever, and a few new-account credits per source IP per day (an account over that cap gets its credit on a later call).
| 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, 120/min on config | 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 s to a few minutes for a single sprite depending on tier, size and how complex the subject is - the model thinks before it paints; 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": "Sonnet",
"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, which can take about 13 minutes (one slow call, or the first batch of a big job). Give your HTTP client a timeout longer than that, or fire the POST without waiting and rely on polling - the server runs the job to completion either way. Refusals (400, 402, 429, 503) answer within seconds and create no job, so wait for those before you switch to polling. 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.
Job IDs. A jobId is unique per account, and finished jobs are kept for 30 days, so an id stays taken at least that long: reusing one returns status: "duplicate" and runs nothing. Generate a fresh id for every job. /api/jobs lists only your newest 30 jobs from the last 48 hours and has no lookup by id. Jobs from the studio and from the Claude connector share that list (connector jobs carry meta.__client: "claude"), so a script should pick out and claim only the ids it created - a prefix of your own makes that easy.
Every multi-call type (set, animation, terrain, platformer, multiterrain, phased) runs in batches of calls, each sized to fit one server run. The batch size depends on the tier and the sprite size - Opus High at 64 px runs one call per batch - so batching is normal at 32 px and up. The POST then responds status: "batched" and the job shows as queued between batches before continuing by itself. 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 - a model plus a reasoning effort (authoritative list: /api/config). Haiku, Sonnet and Opus run Anthropic Claude Haiku, Sonnet and Opus 5.5 at medium effort (the recommended settings); append Low or High for the other efforts, e.g. Sonnet Low, Opus High. Higher effort thinks longer: slower and pricier, more careful. The retired labels still work as aliases (Low→Haiku, Basic→Sonnet, Pro→Opus, Ultra→Opus High; see tierAliases) and are billed at the tier they map to. |
| 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. Claude-connector jobs get the same flag plus __client: "claude". |
| options | object, optional | Style controls, below. |
| references | array, optional | Reference sprites in the result format, each square (a size×size grid) and 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, lockPalette and seedNote have no effect there.
| Option | Type | Notes |
|---|---|---|
| perspective | string | e.g. "side view" (the default when omitted), "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 | a mirror applied server-side after parsing. Omitted, "Off", "none" or "" (any case) means no mirror. A value containing "vertical" copies the left half onto the right; any other value copies the top half onto the bottom (the studio sends "Vertical (left / right)" and "Horizontal (top / bottom)"). |
| seedNote | string | extra free-text appended to the artist prompt (single, set, animation, phased; ignored by wang, terrain, platformer and multiterrain) |
| 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": "Opus",
"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; a failed subject is retried, and if it still fails you get the finished sprites and pay only for those (see refunds). 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": "Sonnet",
"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, it is retried (up to two retries per job); if it still fails, 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 -> detail and colour -> touch-up) 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; 2 skips the touch-up) and detailTier (a second tier label used for the passes after the silhouette - e.g. main tier "Opus High", detailTier: "Sonnet"). 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 failed and unrun passes are refunded, with a note in progress.
A 16-tile Wang autotile terrain set, plus 4 variants of its solid tile, derived deterministically from one seamless fill. subject = the terrain material (required). px must be square. Result: 20 frames, frameMode: "wang". Frames 0-15 are in Wang mask order: frame i is the tile for corner mask i, where NW=1, NE=2, SW=4, SE=8 and a bit is set when the map cell at that corner is painted: frame 15 is the solid fill, frame 0 is empty. Draw one tile at every cell corner of your map (offset half a tile), picked by the four cells around that corner. Frames 16-19 are variants of frame 15 with the same border pixels, so they join everything frame 15 joins: where a corner resolves to mask 15, pick frame 15 or one of them at random (seeded by the corner's position) and a large floor stops repeating every tile. Sets generated before 2026-10-24 have the 16 frames only. 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 ground-surface cap, expanded into directional edge and slope tiles. subject = the ground surface, drawn as the top cap (required, e.g. "lush grass"); subjectB = the earth fill underneath (optional, e.g. "dark packed earth"; defaults to subject). px must be square.
Result: 49 frames, frameMode: "platformer" - the 47 distinct blob-autotile tiles in ascending key order, then an up-slope and a down-slope tile as the last two. A tile's key adds N=128, E=64, S=32, W=16, NE=8, SE=4, SW=2, NW=1 for each painted neighbour, and a corner counts only when both edges beside it are painted (NE needs N and E). So frame 0 is key 0 (an isolated tile) and frame 46 is key 255 (fully surrounded). Index a painted map by each cell's key, at its position in that ascending list; 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 per terrain pair (≤4000 chars serialized), below |
Order is priority. terrains[0] is drawn on top and the last terrain is the background: where two terrains meet, the edge of the one earlier in the list is drawn over the other's fill. terrainRules sets how each pair's edge looks, keyed by the two ids joined with |, lower id first in string order:
"terrainRules": { "grass|water": { "style": "bank", "edge": "extend" },
"grass|sand": { "style": "dither", "edge": "cutoff" } }
// style: "hard" | "outline" | "feather" | "dither" | "bank" | "fringe"
// edge: "extend" - the upper terrain reaches 1.5 px further; "cutoff" - 1.5 px shorter;
// anything else - centred
// a pair you leave out: bank + extend if either terrain is watery (role "river", or a
// name like water, lake, lava or swamp), else feather + cutoff
px must be square (the per-terrain tile size). Omit terrainMap and the server paints a plausible layout for you: the last terrain as the background, a watery one as a winding band, the others as blobs. 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), including studio and Claude-connector jobs. There is no lookup by id: find yours in the list by id (see job IDs). 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. Top-ups are done in the studio. The first call on a new account also adds its one-time sign-up credit.
{
"balance_cents": 1234, // USD cents; null only if the database is unavailable
"everToppedUp": true, // the account has made at least one purchase
"signup_credit_granted_cents": 0, // cents of sign-up credit THIS call added (100 once, else 0)
"signup_credit_cents": 100, // the sign-up credit amount
"freeSpriteAvailable": false, // retired free generations - always false (back-compat)
"freeTileAvailable": false,
"freeGenAvailable": false
}
Public discovery endpoint (no auth, 120 requests/min per IP). Read tier labels and prices from here rather than hardcoding them. It also returns ok: true and backend: "spriteforge", so it doubles as a liveness check.
| Field | Meaning |
|---|---|
| tiers | { label: { sizes: [8,16,24,32,48,64], avg: [USD…], refCents, family, effort, recommended, seconds: [s…] } } - avg is the per-call cost basis at each size (measured provider cost, ladder-floored so each model's efforts price in order), not the price (see pricing); refCents is the per-reference surcharge in cents; family/effort name the model and reasoning effort; recommended marks the three medium tiers; seconds is the benchmarked single-sprite runtime at each size (a fallback until durations has live samples). |
| tierAliases | { retiredLabel: label } - retired tier labels the API still accepts, and the tier each one now runs (and bills) as. |
| signupCreditCents | the one-time free credit a new account receives. |
| 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, keyed by the nine current tier labels only. 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.
An edit session parks a sprite on the server under an unguessable link, so it can be opened in SpriteForge's pixel editor without an account. No API key, no credits. The Claude plugins use it to hand art to the editor, and your own scripts can too. Call it from a server or script (curl is fine): a create or save sent by a browser from another site (its Origin isn't spriteforge.tech) gets 403.
# create from a PNG file
curl -s --data-binary @hero.png -H "Content-Type: image/png" \
"https://www.spriteforge.tech/api/edit-session?name=hero"
# -> 201 { "ok": true, "id": "<22-char id>", "name": "hero",
# "editUrl": "https://www.spriteforge.tech/edit?s=<id>", "pngUrl": "…", "artUrl": "…",
# "width": 32, "height": 32, "colors": 12, "frames": 1, "expiresAt": "…" }
# after editing: download the exact pixels
curl -s -o hero.png "https://www.spriteforge.tech/api/edit-session?id=<id>&format=png"
| Request | Does |
|---|---|
| POST /api/edit-session | Create a session. Body: a raw PNG (Content-Type: image/png, name via ?name=), or JSON { "png": "<base64>" } or { "art": … } in the result format, with an optional "name". A finished /api/jobs result works as-is: { "art": result }. Returns 201 with the links. |
| GET ?id=<id> | The session as JSON: the same links plus art, revision and updatedAt. |
| GET ?id=<id>&format=png | One frame as a PNG with real transparency. &frame=N picks the frame (default: the first, or a Wang set's solid tile 15); &scale=N enlarges it, up to 1024 px a side. |
| GET ?id=<id>&format=sheet | Every frame in one PNG. &cols=N sets the columns (default: an animation in one row, a set or tileset about square); &scale=N as above. |
| PUT ?id=<id> | Save new art, with the same body as create. Returns { "ok": true, "id": "…", "revision": 2, "expiresAt": "…" }. The last save wins. |
editUrl opens the Edit studio at /edit?s=<id> for a sprite, and the tileset window at /project?s=<id> for a wang, terrain or platformer set (studioUrl always opens the Edit studio). Anyone holding the link can view and edit the sprite; the editor saves to the session as they work, and you read the result back with the GET.
Limits: 128×128 px per frame, 256 colours, 64 frames, a 2 MB request body (413 over that) and 1 MB of stored art. PNG pixels under 50% opacity become transparent. A session expires 24 hours after its last save, and never more than 7 days after it was created; after that the id answers 404. Per IP: 30 new sessions an hour, 600 reads and 240 saves a minute (429 with Retry-After). If the service's edit-session storage is full, a create, or a save that makes the sprite bigger, gets 503; try again later.
SpriteForge also works inside Claude (claude.ai, the Claude apps and Claude Code) through two MCP connectors. Add one by URL in Claude's connector settings, or install its plugin.
| Connector | What it does |
|---|---|
| SpriteForge Editor /mcp/editor | Free, no account. Opens pixel art in SpriteForge's editors through edit sessions and reads it back: open_editor (the compact editor), open_full_editor (layers, selection, shapes, frames), open_tileset_editor (Wang and platformer sets) and get_sprite (the current pixels and a PNG link). Plugin: OndrejKnizat/spriteforge-editor. |
| SpriteForge /mcp | Your account, signed in with OAuth (Clerk). Projects and their style settings; your sprite library (browse, download links, upload_sprite); the editors on library sprites, kept with save_to_library; and generation. quote_generation prices a job; generate needs max_cents and refuses a job that costs more, and an optional request_id makes retries safe (the same id never starts or charges a second job); get_job waits for the result and saves it into your library. It spends the same credits, through the same pipeline, as this API. Plugin: OndrejKnizat/spriteforge-claude-plugin. |
Connector URLs: https://www.spriteforge.tech/mcp/editor and https://www.spriteforge.tech/mcp. The plugin READMEs list every tool. Connector jobs show up in your /api/jobs list with meta.__client: "claude"; don't claim them from a script - the connector saves them into your library itself, and a claimed job can't be saved.
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", …], // hex colours (counts below)
"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.
Colours. set, animation, terrain, wang and platformer merge every frame onto one shared palette of at most 16 colours, so a seeded animation's base frame can come back slightly recoloured. single, phased and edit sprites, and a multiterrain map, use up to 62.
size. size is the longest side, and it only equals both dimensions on square output. Every grid has its true shape: px.h rows of px.w cells (the tile types are square; a seeded animation's first frame keeps seedArt's square shape), baseArt.size square for edit, and rows·px.h×cols·px.w for a multiterrain map. grid.length (rows) and grid[0].length (columns) are correct in every case. Sprites you send - baseArt, seedArt and references - must be square, size×size, so pad a non-square result before sending it back: a size×size grid with the art in the top-left and -1 everywhere else.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, and as Bad JSON body when the body isn't valid JSON. Keep request bodies under 200 KB, the server's read limit - a larger body can be refused the same way. |
| 401 | Missing/invalid/deactivated API key, a malformed Authorization header (the Bearer prefix is case-sensitive), or the key was used on an endpoint keys can't access. |
| 402 | Insufficient credits for the reservation - top up in the studio. Only ever returned before a job starts. |
| 405 | Wrong HTTP method for the endpoint (e.g. GET /api/generate). |
| 429 | Rate limit exceeded - wait the number of seconds in the Retry-After header, then retry. |
| 503 | Temporarily unavailable, nothing charged: the platform's daily generation capacity is reached and generations are paused until UTC midnight ("We've hit today's generation capacity") - retry later. |
| 502 / 504 | The generation failed after it was accepted - 504 when the model call timed out, 502 for anything else - and the job was refunded as described under credits. The job's error in /api/jobs says what went wrong; retry with a fresh jobId. |
| 500 | An unexpected server error. A job it hit is failed and refunded in full. |