π₯ The bakery is open: HTTP endpoints for AI-powered sprite generation, packing, and asset management. Aimed at indie game devs (Godot / Unity / custom engines) who want AI in their pipeline without a vendored editor.
If this is your first time, read the Getting started guide first β that walks you from sign-up to your first export. This document is the canonical reference for every endpoint the server exposes, what it accepts, what it returns, and how it fails.
Document version: 0.2.0 β Wave 3 Ciclo 7 closed (2026-05-09).
Server version: see GET /api/sprite-lab/status for the live build hash.
Production endpoints require a Spriteoven user (Bearer JWT) and consume credits when they trigger an AI provider call. BYOK was retired 2026-05-19 β provider API keys (OpenAI, xAI, Gemini) live in server environment variables only.
Endpoints marked Auth: Bearer JWT require a Supabase-issued JSON Web Token. Pass it on every request:
Authorization: Bearer <SUPABASE_JWT>
You obtain a JWT via the Spriteoven login flow (email + password, magic link, or Google OAuth β see Getting started). JWTs are scoped to the user; row-level security (RLS) on the database enforces ownership for every read/write.
Failures:
unauthorized missing_token β header absent.unauthorized invalid_token β signature/expiry/format invalid.unauthorized verify_failed β Supabase verification errored.PAYMENT_REQUIRED β caller has 0 credits AND 0 wow remaining.
Body returns checkout_endpoints with Founder / Starter / Topup paths.missing_api_key β server env var for the upstream provider
(OPENAI_API_KEY, XAI_API_KEY, GEMINI_API_KEY) is not configured.π Never logged. Provider keys never enter server logs, error bodies, telemetry, or asset metadata. If you find one leaking, file an incident β that is a security bug.
All Spriteoven business endpoints live under /api/sprite-lab/* (and a
small set under /api/* for cross-cutting concerns: auth, credits,
providers). On the hosted instance the base is
https://spriteoven.com. On a local dev server it's
http://localhost:3000 by default.
All JSON endpoints take Content-Type: application/json and return
application/json; charset=utf-8.
The two exceptions:
/api/sprite-lab/tilemap/export returns either application/xml (TMX)
or text/plain (TSCN) depending on the format field./api/sprite-lab/batch/:batch_id/stream returns
text/event-stream (Server-Sent Events).Every image in/out is base64-encoded PNG (without a data: prefix β
the server tolerates the prefix and strips it, but the canonical form is
raw base64). Field names ending in Base64 or _b64 follow this
convention. Sheet outputs are returned in the response body, not as file
attachments β call sites decode and write to disk as needed.
Most JSON endpoints return errors using one of two shapes:
{ "ok": false, "error": "ERROR_CODE", "message": "human-readable detail" }
{ "error": "error_code", "message": "human-readable detail" }
The ok: false shape is the dominant one (Sprite Lab endpoints + auth
guards); the bare error shape is used by a smaller set of legacy /
cross-cutting endpoints. Both forms always carry an error string code
and a message. Some errors include extra fields (e.g. errors[] for
multi-field validation, valid for enum hints, retry_after for rate
limits) β those are documented per endpoint.
Status codes follow standard semantics: 400 invalid input, 401
unauthenticated, 403 not your resource, 404 not found, 410 deleted,
429 rate-limited, 500 server bug, 503 upstream/config not ready.
Runs the canonical post-processing pipeline on a generated PNG: chroma key (lime green β transparent), bbox crop / autopad, optional palette quantization, and slice into N frames according to a strategy (auto, forced grid, or hint-driven).
Auth: none.
Request body:
{
"imageBase64": "<base64 PNG>",
"targetGrid": 64,
"keyColor": "auto",
"sliceHint": "auto",
"expectedRows": null,
"expectedCols": null,
"hueTolerance": 22,
"satTolerance": 0.4,
"valTolerance": 0.4,
"islandRemovalMinArea": 16,
"cleanAlphaRGB": true,
"targetPalette": {
"algorithm": "floyd-steinberg",
"colors": ["#000000", "#ffffff", "#ff0080", "#00ff80"],
"preserveAlpha": true
}
}
| Field | Type | Notes |
|---|---|---|
imageBase64 |
string | Required. Base64 PNG. |
targetGrid |
number | "auto" |
Frame side length in pixels. Default 64. |
keyColor |
"auto" | {h,s,v} |
Chroma key color. auto β default lime green. |
sliceHint |
string | "auto", "square", "horizontal", "vertical". |
expectedRows / expectedCols |
int | null | Force grid dims. |
hueTolerance / satTolerance / valTolerance |
number | Chroma tolerance overrides. |
islandRemovalMinArea |
int | Min connected-component area to keep. 0 disables. |
cleanAlphaRGB |
bool | Zero RGB on alpha=0 pixels (PNG-size win). |
targetPalette |
object | null | C7-01 palette quantizer. algorithm β {nearest, floyd-steinberg, atkinson, bayer4x4}. colors[] β₯ 4 hex strings. |
Response (200):
{
"ok": true,
"transparentPngBase64": "<base64 PNG>",
"boundingBox": { "x": 12, "y": 8, "width": 240, "height": 248 },
"strategy": {
"type": "uniform-grid",
"rows": 4, "cols": 4,
"cellW": 64, "cellH": 64,
"frameCount": 16,
"forced": false,
"forcedTo": null,
"overrideFromCaller": false
},
"frames": [
{
"frameIndex": 0,
"row": 0, "col": 0,
"pngBase64": "<base64 PNG>",
"paddedSize": [64, 64],
"sourceRegion": {"x": 0, "y": 0, "width": 64, "height": 64},
"placement": {"row": 0, "col": 0}
}
],
"metadata": { "...": "pipeline metadata, see implementation" },
"palette": {
"applied": true,
"algorithm": "floyd-steinberg",
"palette_size": 4,
"preserveAlpha": true
}
}
Errors:
| Status | Code | When |
|---|---|---|
| 400 | EMPTY_IMAGE |
imageBase64 missing. |
| 400 | BAD_BASE64 |
imageBase64 cannot be decoded. |
| 400 | PALETTE_BAD_SHAPE / PALETTE_BAD_ALGORITHM / PALETTE_TOO_SMALL / PALETTE_BAD_COLOR |
Palette validation failure. Body includes valid_algorithms. |
| 500 | POSTPROCESS_FAILED |
Pipeline raised. Body includes stack. |
Example (curl):
curl -X POST https://spriteoven.com/api/sprite-lab/postprocess \
-H 'Content-Type: application/json' \
-d "$(jq -n --arg img "$(base64 -w0 sheet.png)" \
'{imageBase64:$img, targetGrid:64, sliceHint:"auto"}')"
Example (JS fetch):
const png = await fs.promises.readFile("sheet.png");
const r = await fetch("/api/sprite-lab/postprocess", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
imageBase64: png.toString("base64"),
targetGrid: 64,
sliceHint: "auto",
}),
});
const { transparentPngBase64, frames, strategy } = await r.json();
Single-engine sprite-sheet packer. Takes N base64 PNG frames, packs them
with maxrects-packer (custom engine), and returns one of two shapes
depending on whether outputs[] was supplied (DEC-SF-022 multi-output
path, DEC-SF-018 single-engine).
Auth: none.
Request body:
{
"engine": "auto",
"frames": ["<base64 PNG>", "<base64 PNG>"],
"packOptions": {
"maxWidth": 2048,
"maxHeight": 2048,
"padding": 1,
"trim": false,
"allowRotation": false,
"extrude": 0
},
"tresOptions": {
"resourcePath": "res://sprites/",
"pngFilename": "knight.png",
"resourceName": "knight",
"animations": [
{ "name": "idle", "frames": [0, 1, 2, 3], "loop": true, "speed": 8 }
]
},
"outputs": ["png-sliced", "tres", "godot-bundle"],
"targetPalette": null,
"assetMetadata": {
"type": "character",
"asset_id": "knight-001",
"provider": "nb2"
}
}
| Field | Type | Notes |
|---|---|---|
engine |
string | "auto" or "custom". "aseprite" returns 410 Gone (DEC-SF-018). |
frames |
string[] | Required. Base64 PNGs. Non-empty. |
packOptions.extrude |
int 0..8 | Border-replication padding. |
outputs[] |
string[] | Optional multi-output. Values: "png-sliced", "tres", "aseprite-bundle", "godot-bundle". |
targetPalette |
object | null | Same shape as /postprocess. |
assetMetadata |
object | Optional ride-along context for asset-metadata.json in bundles. |
Response β legacy shape (no outputs[]):
{
"ok": true,
"engine": "custom",
"sheetPngBase64": "<base64 PNG>",
"dimensions": { "width": 256, "height": 256 },
"tresContent": "[gd_resource type=\"SpriteFrames\" ...]",
"animationsCount": 4,
"layout": [{ "frame": 0, "x": 0, "y": 0, "w": 64, "h": 64, "rot": false }],
"extrude": 0,
"elapsedMs": 142,
"packMs": 12,
"palette": { "applied": false }
}
Response β multi-output shape (outputs[] supplied):
{
"ok": true,
"engine": "custom",
"packMs": 14,
"sheet": { "width": 256, "height": 256 },
"outputs": {
"png-sliced": { "mime": "application/zip", "filename": "frames.zip", "data": "<base64>" },
"tres": { "mime": "text/plain", "filename": "knight.tres", "data": "..." },
"godot-bundle": { "mime": "multi", "sheet_data": "<base64>", "tres_data": "..." }
}
}
Errors:
| Status | Code | When |
|---|---|---|
| 400 | INVALID_ENGINE |
engine β auto/custom/aseprite. |
| 400 | EMPTY_OUTPUTS_ARRAY / INVALID_OUTPUT |
Bad outputs[]. |
| 400 | EMPTY_FRAMES |
frames missing/empty. |
| 400 | BAD_FRAME_BASE64 |
Frame not decodable. |
| 400 | INVALID_EXTRUDE |
packOptions.extrude β [0,8]. |
| 400 | PALETTE_* |
Palette validation. Includes valid_algorithms. |
| 410 | deprecated |
engine=aseprite (since Wave 5 / DEC-SF-018). |
| 500 | MULTI_OUTPUT_FAILED |
Multi-output bundle assembly failed. Body includes failed_outputs[]. |
| 500 | PACK_FAILED |
Generic pack error. Body includes stack. |
Composes N single-direction PNG strips (each 1Γcols) into a uniform
rowsΓcols sheet. Sits PRE-pipeline; the stitched buffer feeds /pack
unchanged. API-only at the moment (DEC-SF-021
formalization pending).
Auth: none.
Request body:
{
"inputs": [
{ "name": "down", "imageBase64": "<base64 PNG>" },
{ "name": "left", "imageBase64": "<base64 PNG>" },
{ "name": "right", "imageBase64": "<base64 PNG>" },
{ "name": "up", "imageBase64": "<base64 PNG>" }
],
"stitchOptions": {
"rows": 4,
"cols": 4,
"rowOrder": ["down", "left", "right", "up"]
}
}
Response (200):
{
"ok": true,
"stitched": {
"filename": "stitched-4x4.png",
"data": "<base64 PNG>",
"width": 256,
"height": 256,
"rows": 4,
"cols": 4,
"frame_size": [64, 64],
"cellWidthExact": true,
"rowOrder": ["down", "left", "right", "up"]
}
}
Errors (all 400 unless noted):
STITCH_EMPTY_INPUTS, STITCH_BAD_INPUT, STITCH_ROWS_MISMATCH,
STITCH_ROW_ORDER_LENGTH, STITCH_ROW_ORDER_AMBIGUOUS,
STITCH_ROW_ORDER_NAME_UNKNOWN, STITCH_WIDTH_MISMATCH,
STITCH_HEIGHT_MISMATCH, STITCH_WIDTH_NOT_DIVISIBLE. 500
STITCH_FAILED for unexpected.
CRUD over the projects table. All five routes require Bearer JWT,
and RLS enforces ownership: cross-user reads return 404 (not 403, to
avoid leaking existence). Only DELETE cascades to assets /
asset_versions / jobs.
Auth: Bearer JWT.
Request: { "name": "My game", "config_json": { "...": "..." } }
Response (200): { "ok": true, "project": { "id":"...", "user_id":"...", "name":"...", "config_json":{...}, "created_at":"...", "updated_at":"..." } }
Errors: 400 PROJECT_NAME_REQUIRED / PROJECT_NAME_TOO_LONG (>200) /
PROJECT_INSERT_FAILED. 503 SUPABASE_NOT_CONFIGURED.
Auth: Bearer JWT. Response: { "ok": true, "projects": [...] }.
Ordered by updated_at desc. 500 PROJECT_LIST_FAILED.
Auth: Bearer JWT. Response: { "ok": true, "project": {...} }.
404 PROJECT_NOT_FOUND (also for cross-user: RLS hides existence).
Auth: Bearer JWT. Request: { "name": "...", "config_json": {...} }
(both optional, at least one required).
Errors: 400 PROJECT_PATCH_EMPTY / PROJECT_NAME_INVALID /
PROJECT_CONFIG_INVALID / PROJECT_NAME_TOO_LONG. 404 PROJECT_NOT_FOUND.
Auth: Bearer JWT. Response: { "ok": true, "deleted": {...} }.
Cascades through FK ON DELETE CASCADE. 404 PROJECT_NOT_FOUND.
In-memory batch executor for long-running pipelines. Each job uses the
same code path as the corresponding individual endpoint, so validation
rules carry over verbatim. result_summary is intentionally compact β
for full base64 outputs, call the individual endpoint.
Auth: Bearer JWT.
Request body:
{
"concurrency": 3,
"jobs": [
{ "client_job_id": "myref-001", "type": "stitch", "params": { "...": "..." } },
{ "client_job_id": "myref-002", "type": "postprocess", "params": { "...": "..." } },
{ "client_job_id": "myref-003", "type": "pack", "params": { "...": "..." } }
]
}
| Field | Type | Notes |
|---|---|---|
concurrency |
int 1..5 | Default 3, max 5. |
jobs |
object[] | Max 100 entries. |
jobs[].type |
string | One of "stitch", "postprocess", "pack", "iconpack" (NYI), "tileset" (NYI). |
jobs[].client_job_id |
string | Optional caller-side correlation id. |
jobs[].params |
object | Same body the individual endpoint accepts. |
Response (200):
{
"ok": true,
"batch_id": "01H...XYZ",
"jobs_count": 3,
"stream_url": "/api/sprite-lab/batch/01H...XYZ/stream"
}
Errors: 400 BATCH_BAD_REQUEST / BATCH_EMPTY_JOBS /
BATCH_TOO_MANY_JOBS (>100) / BATCH_BAD_JOB / BATCH_BAD_JOB_TYPE /
BATCH_BAD_CLIENT_JOB_ID / BATCH_BAD_CONCURRENCY. Body includes
valid_job_types.
Auth: Bearer JWT. Returns: text/event-stream. The stream is
ownership-gated: only the user who created the batch can subscribe.
SSE events:
event: job_started
data: {"job_id":"...","client_job_id":"myref-001","type":"stitch","started_at":"2026-..."}
event: job_progress
data: {"job_id":"...","percent":42,"stage":"chroma"}
event: job_completed
data: {"job_id":"...","result":{...},"elapsedMs":143}
event: job_failed
data: {"job_id":"...","error":"...","code":"..."}
event: batch_completed
data: {"batch_id":"...","stats":{...}}
Errors: 404 BATCH_NOT_FOUND. 403 BATCH_FORBIDDEN (caller is not
the batch owner).
Generates a tileset (NxN grid of tiles) with chroma key + autotile rules
tileset-bundle ZIP.Auth: Bearer JWT. If project_id is supplied, ownership is also
asserted (cross-user β 403 / 404 depending on RLS path).
Request body:
{
"tile_size": 32,
"tile_count": 16,
"biome": "forest moss with stone path",
"style_preset": "pixel-art",
"seamless_edges": true,
"auto_tile_rules": "blob",
"model": "nb2",
"target_palette": null,
"project_id": "uuid-or-null"
}
| Field | Allowed values |
|---|---|
tile_size |
16, 24, 32, 48, 64 |
tile_count |
4, 8, 16, 24, 32 |
biome |
free-text, β€ 200 chars |
style_preset |
"pixel-art", "stylized-vector", "outlined-flat" |
auto_tile_rules |
"blob", "wang", "none" |
model |
"gpt-image-2", "nb2" |
Response (200):
{
"ok": true,
"bundle": { "format": "tileset-bundle", "zipBase64": "<base64 ZIP>" },
"sheet": { "base64": "<base64 PNG>", "width": 128, "height": 128 },
"grid": { "rows": 4, "cols": 4, "tile_size": 32, "tile_count": 16 },
"auto_tile": { "rule": "blob", "expected_tiles": 47, "warnings": [] },
"seamless": { "ok": true, "avg_mismatch_pct": 1.2, "warning": false, "warnings": [] },
"provider": { "model": "models/imagen-3.0-fast-generate-001", "elapsedMs": 4280, "estimatedCostUsd": 0.039 },
"project_id": "uuid-or-null"
}
Errors: 400 INVALID_TILESET_PARAMS (body includes errors[] and
valid enum hints); 400 NO_API_KEY; 503 GPT_IMAGE_2_API_NOT_GA; 429
RATE_LIMITED; 500 AI_PROVIDER_ERROR / TILESET_GENERATE_FAILED.
Generates an iconpack (N icons of a category in coherent style). Same
shape as /tileset/generate minus seamless edges; adds bbox-consistency
check.
Auth: Bearer JWT (+ optional project_id ownership).
Request body:
{
"category": "weapons",
"count": 16,
"icon_size": 32,
"grid_layout": "auto",
"theme": "fantasy weapons, ornate",
"style_preset": "pixel-art",
"model": "nb2",
"target_palette": null,
"seed_examples": [],
"project_id": "uuid-or-null"
}
| Field | Allowed values |
|---|---|
category |
weapons, shields, consumables, ui, armor, accessories, tools, magical |
count |
16, 24, 32, 48, 64 |
icon_size |
16, 24, 32, 48, 64 |
grid_layout |
"auto" or "RxC" string |
style_preset |
"pixel-art", "stylized-vector", "outlined-flat" |
theme |
free-text, β€ 200 chars |
seed_examples[] |
up to 16 base64 PNG references |
model |
"gpt-image-2", "nb2" |
Response (200): mirrors /tileset/generate with consistency instead
of seamless, and category / theme echoed at root.
Errors: 400 INVALID_ICONPACK_PARAMS (with errors[] + valid); rest
identical to tileset.
Auth: Bearer JWT + project ownership (cross-user β 403
TILEMAP_FORBIDDEN). Persists a tilemap as a row in assets
(type=tilemap).
Request body:
{
"project_id": "uuid",
"name": "level-01",
"tileset_id": "uuid-or-null",
"grid_size": [32, 32],
"layers": [{ "name":"ground","z":0 }],
"cells": [{ "layer":0,"x":0,"y":0,"tile":3 }]
}
Response: { "ok": true, "asset": { "...row...": "..." } }.
Errors: 400 PROJECT_ID_REQUIRED / NAME_REQUIRED; 403
TILEMAP_FORBIDDEN; 500 TILEMAP_SAVE_FAILED; 503
SUPABASE_NOT_CONFIGURED.
Auth: none. Download-only serializer. Body:
{ "tilemap_data": {...}, "format": "tmx" | "tscn" }. Returns
application/xml (TMX) or text/plain (TSCN). Errors: 400
TILEMAP_INVALID / TILEMAP_FORMAT_INVALID; 500 TILEMAP_EXPORT_FAILED.
These are thin pass-throughs to upstream image providers. They exist so
clients can experiment with providers directly without going through the
canonicalize / pack pipeline. Production callers generally use
/postprocess + /pack after a single provider call.
Google Gemini Nano Banana 2 (image generation).
Auth: none, but requires a Gemini key (X-Gemini-Key header or
GEMINI_API_KEY env).
Request: { "prompt": "...", "referenceImageBase64": "...", "label": "...", "aspectRatio": "1:1" }
Response (200): { "ok": true, "imageBase64": "...", "mime": "image/png", "dimensions": {"width":1024,"height":1024}, "elapsedMs": 4200, "estimatedCostUsd": 0.039, "model": "...", "savedTo": "..." }
Errors: 400 EMPTY_PROMPT / NO_API_KEY; 429 RATE_LIMITED (body
includes suggestedBackoffMs); 500 NB2_API_ERROR / INTERNAL.
OpenAI GPT Image 2, Sprite Lab variant. Now async: the POST enqueues a
background job and returns 202 Accepted in <100ms. The OpenAI call (90-180s
for quality:"high" + reference image) runs in the worker, not under the HTTP
request. The client polls GET /api/sprite-lab/jobs/:id for the result.
Auth: Bearer JWT required (the job row is owned by req.user.id for
RLS). Server env OPENAI_API_KEY is the only key path β BYOK retired
2026-05-19. Credit gate peeks balance at submit (no DB write) and
the worker decrements at status="done".
Request: { "prompt": "...", "referenceImageBase64": "...", "label": "...", "aspectRatio": "1:1" }
Response (202 Accepted):
{
"ok": true,
"job_id": "0d4a8e2e-9f53-4d3c-8f1c-7b2a0c9d3e10",
"status": "pending",
"created_at": "2026-05-17T15:42:01.123Z",
"poll_url": "/api/sprite-lab/jobs/0d4a8e2e-9f53-4d3c-8f1c-7b2a0c9d3e10",
"poll_interval_ms": 3000
}
Errors: 400 EMPTY_PROMPT / INVALID_JOB_ID; 401 AUTH_REQUIRED; 402
PAYMENT_REQUIRED (quota peek failed β no balance AND no WOW remaining,
no DB write); 503 GPT_IMAGE_2_API_NOT_GA (body includes fallbackPath and
detectionReason); 500 ENQUEUE_FAILED.
Credit semantics: the gate peeks (not consumes) at submit. Credits decrement only on
status="done". A failed job costs nothing. Seeserver/lib/gen-jobs-worker.jsfor the worker contract; migration005_wave5_gen_jobs_async.sqlfor the schema (REUSE ofpublic.jobswith two new columnsprocessor_picked_at+error_json); migration006_wave5_drop_byok_key.sqlfor the BYOK retirement cleanup.
Poll a generation job for its status. RLS-scoped via the caller's JWT: a
foreign user's job returns 404 JOB_NOT_FOUND to avoid existence leak.
Auth: Bearer JWT required.
Response (200) while pending/running:
{
"ok": true,
"job_id": "0d4a8e2e-...",
"type": "sprite_generation_gpt_image_2",
"status": "pending",
"params": { "prompt": "...", "ref_images_count": 1, "model": "gpt-image-2" },
"result_json": null,
"error_json": null,
"progress_pct": 0,
"processor_picked_at": null,
"started_at": null,
"completed_at": null,
"created_at": "2026-05-17T15:42:01.123Z",
"duration_ms": null
}
Response (200) at status="done":
{
"ok": true,
"job_id": "0d4a8e2e-...",
"status": "done",
"result_json": {
"saved_to": "sessions/<user>/run-xyz/output.png",
"image_base64": null,
"mime": "image/png",
"dimensions": { "width": 1536, "height": 512 },
"elapsed_ms": 142_300,
"estimated_cost_usd": 0.04,
"model": "gpt-image-2",
"credit_source": "credits",
"wow_remaining": 0
},
"duration_ms": 142_300
}
Response (200) at status="failed":
{
"ok": true,
"job_id": "0d4a8e2e-...",
"status": "failed",
"error_json": {
"code": "GPT_IMAGE_2_TIMEOUT",
"message": "GPT_IMAGE_2_TIMEOUT",
"retryable": true,
"status": 0,
"elapsed_ms": 240_000
}
}
Error codes:
GPT_IMAGE_2_TIMEOUT (retryable) β OpenAI exceeded GPT_IMAGE_2_TIMEOUT_MSGPT_IMAGE_2_API_ERROR (retryable only for 429/503)MISSING_API_KEY (non-retryable)WORKER_STUCK (retryable) β job sat in running state >10min and the
in-process stuck detector flipped it failed (worker crashed mid-call)Errors: 400 INVALID_JOB_ID; 401 AUTH_REQUIRED; 404 JOB_NOT_FOUND
(includes the RLS isolation case); 503 supabase_not_configured; 500
JOB_LOOKUP_FAILED.
Sensitive-field stripping: the view strips
payload_json.referenceImageBase64(heavy) before responding.params.reference_image_presentsurfaces the presence without the value. Legacypayload_json.byok_key(pre-2026-05-19 rows the migration missed) is also stripped defensively.
Production GPT Image 2 endpoint. Synchronous (not jobs-async like
/api/sprite-lab/gpt-image-2); intended for programmatic / MCP callers.
Auth: Bearer JWT + credit gate (requireGenCredits). API key from
server env OPENAI_API_KEY.
Request: { "prompt": "...", "refImages": [...], "size": "1024x1024", "quality": "high", "model": "gpt-image-2", "endpoint": "..." }
Response (200): { "sheet": "<base64 PNG>", "meta": { ... }, "credit_source": "credits", "wow_remaining": 0 }
Errors: 402 PAYMENT_REQUIRED; 503 missing_api_key;
provider-specific GptImage2Error.code (e.g. quota_exceeded,
model_not_available) surfaced with the upstream status; 500 internal.
xAI Grok Image (Aurora MoE), pinned to grok-imagine-image-quality.
Auth: Bearer JWT + credit gate (requireGenCredits). API key from
server env XAI_API_KEY / GROK_API_KEY.
Request: { "prompt": "...", "refImages": [...], "size": "1024x1024", "quality": "high" }
Response (200): { "ok": true, "sheet": "<base64 PNG>", "meta": {...} }
Errors: 400 EMPTY_PROMPT; 402 PAYMENT_REQUIRED; 503
missing_api_key; 429 with retry_after; 500 INTERNAL /
provider-specific (GrokImageError.code).
Compares two PNG assets and returns a similarity score. Used to verify identity preservation across providers, style consistency in iconpacks, and dedup in libraries.
Auth: Bearer JWT.
Request body:
{
"asset_a": "<base64 PNG or {imageBase64: ...}>",
"asset_b": "<base64 PNG or {imageBase64: ...}>",
"method": "phash"
}
method β "phash" (default), "ssim", "perceptual".
Response (200): depends on method; always includes a score field
in [0,1] and method-specific diagnostics.
Errors: 400 bad_request (asset_a_and_asset_b_required,
unknown_method:<m>, asset_decode_failed).
Wave 4 update (2026-05-09) β Wave 3 closed and Wave 4 opened only 1 calendar day apart, so the scan is short. No catalog changes: NB2 + gpt-image-2 + Grok remain Tier 0/1, Imagen 4 family stays out of the dropdown pending DEC-SF-030 formal sign-off, DALL-E 2/3 sunset is T-3 days (2026-05-12). New radar entry: Qwen-Image-2 (Alibaba, Apache 2, April 2026) opens as a Tier 2 cold candidate β open-weights so it does not fit the BYOK model directly; possible path is via a SaaS aggregator (Replicate / Fal) in Ciclo 8+. PixelLab "Vibe Coding" MCP traction stays tibia (no hockey-stick after a month) so the Spriteoven-own MCP server item drops to Ciclo 10+ in the backlog. Full report:
EVIDENCE/cycle7/ai-models-scan/wave-4.md.
A short echo of the Wave 3 model-landscape scan (full report in
EVIDENCE/cycle7/ai-models-scan/wave-3.md, lookback ~30 days).
/api/sprite-lab/providers/imagen4/generate (credit-gated, Tier 1 paid). Wave 3
smoke real surfaced two distinct outcomes: Imagen 4 Fast ignores
sprite-sheet prompt structure (returns 1-frame painterly outputs +
wrong background) β DROPPED Ciclo 7. Imagen 4 Standard PASSES quality
but $0.04/img is 2Γ Grok's $0.02/img with no diferencial β reclassified
as Tier 2 candidate Ciclo 8+ (not Tier 1). Imagen 4 Ultra DROPPED
for fixed (premium pricing without test plan that justifies it). See
DEC-SF-030 (formalized at Wave 3 closure).requireGenCredits + server env XAI_API_KEY
only (BYOK retired 2026-05-19). DEC-SF-029 retirada.gpt-image-2-2026-04-21 so no migration needed (DEC-SF-012
vindicated).This section consolidates the 15 endpoints that landed across Tracks G
(Library), H (Providers extended), and I (Versions) during Wave 3
Ciclo 7. They are GA. Auth is Bearer JWT on every endpoint, with
RLS enforcing transitive ownership through assets β projects β user_id (cross-user reads return 404 ASSET_NOT_FOUND to avoid
existence leak; cross-user mutations return 403 ASSET_FORBIDDEN /
BULK_FORBIDDEN once an attempt is made).
List the caller's assets. RLS-scoped; soft-deleted rows excluded.
Auth: Bearer JWT.
Query parameters:
project_id (optional) β filter to one project.tags (optional) β comma-separated; OR-overlap filter (returns rows
whose tags intersect the supplied set).q (optional) β free-text search; tokenized on whitespace and
joined with | against the search_vector tsvector column
(config simple).limit (default 50, max 200), offset (default 0) β pagination.Response (200):
{
"ok": true,
"assets": [{ "id": "<uuid>", "project_id": "<uuid>", "asset_id": "...",
"name": "...", "tags": ["walk","hero"], "metadata_json": {...},
"created_at": "...", "deleted_at": null }],
"limit": 50, "offset": 0
}
Errors: 401 unauthorized; 500 ASSET_LIST_FAILED.
curl -H "Authorization: Bearer $JWT" \
"$BASE/api/sprite-lab/assets?project_id=$PID&tags=walk,hero&limit=20"
Same as ?q=... on the list endpoint, but stricter: requires q,
caps limit at 100, default 30.
Errors: 400 QUERY_REQUIRED (no q); 500 ASSET_SEARCH_FAILED.
Single-asset read. Soft-deleted rows return 404.
Response (200): { "ok": true, "asset": {...} }.
Errors: 400 ASSET_ID_REQUIRED; 404 ASSET_NOT_FOUND (missing /
RLS-hidden / soft-deleted); 500 ASSET_GET_FAILED.
Update name and/or tags. Bytes / metadata_json are not editable
through this endpoint (use a new version via /restore for bytes).
Request body: any subset of { "name": "...", "tags": ["..."] }.
Response (200): { "ok": true, "asset": {...} }.
Errors: 400 ASSET_NAME_INVALID / ASSET_NAME_TOO_LONG /
ASSET_TAGS_INVALID / ASSET_PATCH_EMPTY; 404 ASSET_NOT_FOUND;
500 ASSET_UPDATE_FAILED.
Soft delete (sets deleted_at = now()). Versions are NOT cascade-
deleted at this layer β they become inaccessible until you restore
the asset (deleted_at=NULL) via a Wave 4 endpoint.
Response (200): { "ok": true, "deleted": { "id": "...", "deleted_at": "..." } }.
Errors: 404 ASSET_NOT_FOUND; 500 ASSET_DELETE_FAILED.
Batch soft-delete. All-or-nothing ownership: any cross-user / missing
id aborts the entire batch with 403 BULK_FORBIDDEN and a missing_count
hint β no partial deletes.
Request body: { "asset_ids": ["uuid1", "uuid2", ...] } (max 200).
Response (200):
{ "ok": true, "deleted_count": 5, "deleted_ids": ["..."] }.
Errors: 400 BULK_EMPTY / BULK_TOO_MANY (>200) / BULK_BAD_ID;
403 BULK_FORBIDDEN (with missing_count); 500 BULK_LOOKUP_FAILED /
BULK_DELETE_FAILED.
Batch add/remove tags. Read-modify-write per row (tag set diff).
Request body:
{
"asset_ids": ["..."],
"tags_add": ["new-tag"],
"tags_remove": ["old-tag"]
}
At least one of tags_add / tags_remove must be non-empty.
Response (200): { "ok": true, "updated_count": 12 }.
Errors: 400 BULK_TAG_NOOP (both empty); same all-or-nothing
ownership errors as bulk-delete; 500 BULK_TAG_LOOKUP_FAILED /
BULK_TAG_UPDATE_FAILED (carries partial_updated count if mid-loop).
Spawns a single-job batch (subscribable via the existing
/api/sprite-lab/batch/:batch_id/stream SSE) that builds a metadata-only
ZIP (one JSON entry per asset + manifest.json). Storage-blob
dereferencing lands when Track I storage pipeline closes (Wave 4+).
Request body: { "asset_ids": ["..."] } (max 200).
Response (200):
{
"ok": true,
"job_id": "<batch_id>",
"batch_id": "<batch_id>",
"asset_count": 17,
"stream_url": "/api/sprite-lab/batch/<batch_id>/stream"
}
Subscribe to the SSE stream for live progress + final result.
Errors: same as bulk-delete for ownership / validation;
500 BULK_EXPORT_LOOKUP_FAILED / BULK_EXPORT_FAILED.
Versions are immutable rows on asset_versions (FK to assets.id,
unique on (asset_id, version_number)). A Postgres trigger
(enforce_version_retention) keeps max 10 unpinned versions per
asset; pinned versions are exempt. All mutations route through
RLS-scoped clients.
βΉοΈ Cross-user access to any version endpoint converts the 404 ownership signal into 403
ASSET_FORBIDDEN(matching thetilemap/saveprecedent). Within an owned asset, a missing / wrong-version-id returns 404VERSION_NOT_FOUND.
List versions ordered by created_at DESC.
Response (200):
{ "ok": true, "versions": [{ "id": "...", "version_number": 7, "pinned": false, "metadata_json": {...}, "bytes": 12345, "created_at": "..." }, ...] }.
Read a single version row.
Errors: 400 PARAMS_REQUIRED; 404 VERSION_NOT_FOUND;
500 VERSION_GET_FAILED.
Clone bytes from a source version into a new version (no
overwrite). New version_number = max(existing) + 1. Tag set to
"restored from v<N>"; metadata_json.restored_from_version_id +
restored_from_version_number recorded.
Response (200): { "ok": true, "version": { ..., "version_number": 8, "metadata_json": { "tag": "restored from v3", ... }, ... } }.
The retention trigger may evict the oldest unpinned version on insert when the count would exceed 10.
Errors: 404 VERSION_NOT_FOUND (source);
500 VERSION_NUM_FAILED / VERSION_RESTORE_FAILED.
Toggle the pinned flag. Pinned versions are exempt from retention
eviction; you can always recover them by unpinning + restoring.
Response (200): { "ok": true, "version": { ..., "pinned": true } }.
Delete a version. Refuses to delete pinned rows: returns 403
VERSION_PINNED with message: "unpin before deleting". Unpin first
(POST /pin toggles), then DELETE.
Response (200): { "ok": true, "deleted": { "id": "...", "version_number": 5 } }.
Errors: 403 VERSION_PINNED; 404 VERSION_NOT_FOUND;
500 VERSION_DELETE_FAILED.
Calls Google's Imagen 4 family via the Gemini API. Three quality tiers map to distinct upstream models and prices.
Auth: Bearer JWT + credit gate (requireGenCredits). API key from
server env GEMINI_API_KEY only β BYOK retired 2026-05-19.
Request body:
{
"prompt": "pixel art knight, walk strip 4 frames, magenta bg",
"quality": "fast" | "standard" | "ultra", // default "fast"
"size": "1024x1024" | null, // optional, hint only
"style": "16-bit JRPG" // optional positive fragment
}
Quality β model snapshot:
| Quality | Model | Price/image |
|---|---|---|
fast |
imagen-4.0-fast-generate-001 |
$0.02 |
standard |
imagen-4.0-generate-001 |
$0.04 |
ultra |
imagen-4.0-ultra-generate-preview-06-06 |
$0.08 |
β οΈ Status DEC-SF-030: Wave 3 smoke flagged
fastas not production-ready for Spriteoven sprite-sheet prompts (ignores chroma + frame-strip structure).standardPASSES quality but exceeds the cost threshold for Tier 1; reclassified as Tier 2 candidate Ciclo 8+.ultranot enabled in production. Keep this endpoint for opt-in experiments only β credit-gated like every paid surface.
Response (200):
{
"ok": true,
"image": "data:image/png;base64,...",
"meta": {
"provider": "imagen4",
"model_snapshot": "imagen-4.0-fast-generate-001",
"quality": "fast",
"aspect_ratio": "1:1",
"cost_usd": 0.02,
"elapsedMs": 1234
}
}
Errors: 400 IMAGEN4_QUALITY_INVALID / IMAGEN4_PROMPT_REQUIRED;
402 PAYMENT_REQUIRED; 503 missing_api_key; 429 RATE_LIMITED; 500
IMAGEN4_GENERATE_FAILED.
Auth: Bearer JWT. Returns
{ "id": "...", "email": "...", "created_at": "..." }.
Admin-only Resend smoke endpoint. Sends a transactional template render via Resend.
Auth: Bearer JWT + req.user.email === ADMIN_EMAIL. The
deployment uses the operator's email; configure via the ADMIN_EMAIL
env var.
Request: { "template": "welcome" | "magic-link" | "batch-completed", "to_override": "..." }.
Response: { "ok": true, "template": "...", "to": "...", "email_id": "...", "response": {...} }.
Errors: 400 missing_template / unknown_template; 403
admin_only; 500 resend_failed.
Auth: none. Returns build hash + provider availability flags. Use this for health checks and "which model can I use right now" decisions.
Custom Spriteoven error codes (across all endpoints). Standard HTTP-status-only errors (e.g. plain 404 for unknown routes) are not listed.
| Code | Status | Endpoint(s) | Meaning |
|---|---|---|---|
EMPTY_PROMPT |
400 | nb2, gpt-image-2, grok | prompt missing/blank. |
NO_API_KEY |
400 | nb2, tileset, iconpack | Provider key absent. |
EMPTY_IMAGE |
400 | postprocess | imageBase64 missing. |
BAD_BASE64 |
400 | postprocess | Cannot decode base64. |
EMPTY_FRAMES |
400 | pack | frames[] empty. |
BAD_FRAME_BASE64 |
400 | pack | Frame not decodable. |
INVALID_ENGINE |
400 | pack | engine not auto/custom/aseprite. |
INVALID_EXTRUDE |
400 | pack | packOptions.extrude β [0,8]. |
INVALID_OUTPUT / EMPTY_OUTPUTS_ARRAY |
400 | pack | Bad outputs[]. |
MULTI_OUTPUT_FAILED |
500 | pack | Bundle assembly failure. |
PACK_FAILED |
500 | pack | Generic pack error. |
PALETTE_* |
400 | postprocess, pack | Palette validation failure. |
STITCH_* |
400 | stitch | Stitch validation failure. |
STITCH_FAILED |
500 | stitch | Stitch internal error. |
INVALID_TILESET_PARAMS |
400 | tileset/generate | Validation. |
INVALID_ICONPACK_PARAMS |
400 | iconpack/generate | Validation. |
GPT_IMAGE_2_API_NOT_GA |
503 | gpt-image-2, tileset (model=gpt-image-2) | Upstream API not GA. |
RATE_LIMITED |
429 | nb2, gpt-image-2, tileset, iconpack | Provider rate-limit. |
AI_PROVIDER_ERROR |
500 | tileset, iconpack | Generic upstream error. |
TILESET_GENERATE_FAILED |
500 | tileset/generate | Internal. |
ICONPACK_GENERATE_FAILED |
500 | iconpack/generate | Internal. |
TILEMAP_INVALID / TILEMAP_FORMAT_INVALID |
400 | tilemap/export | Validation. |
TILEMAP_FORBIDDEN |
403 | tilemap/save | Cross-user project. |
TILEMAP_SAVE_FAILED / TILEMAP_EXPORT_FAILED |
500 | tilemap/* | Internal. |
BATCH_BAD_REQUEST / BATCH_EMPTY_JOBS / BATCH_TOO_MANY_JOBS / BATCH_BAD_JOB / BATCH_BAD_JOB_TYPE / BATCH_BAD_CONCURRENCY |
400 | batch | Validation. |
BATCH_NOT_FOUND |
404 | batch/stream | Unknown id. |
BATCH_FORBIDDEN |
403 | batch/stream | Caller is not owner. |
PROJECT_NAME_REQUIRED / PROJECT_NAME_TOO_LONG / PROJECT_NAME_INVALID / PROJECT_CONFIG_INVALID / PROJECT_PATCH_EMPTY / PROJECT_ID_REQUIRED |
400 | projects/* | Validation. |
PROJECT_NOT_FOUND |
404 | projects/:id | Not found OR cross-user (RLS hides existence). |
PROJECT_INSERT_FAILED / PROJECT_LIST_FAILED / PROJECT_GET_FAILED / PROJECT_UPDATE_FAILED / PROJECT_DELETE_FAILED / PROJECT_LOOKUP_FAILED |
500 | projects/* | DB error. |
SUPABASE_NOT_CONFIGURED |
503 | projects, tilemap | Server env missing Supabase creds. |
unauthorized |
401 | every Bearer JWT route | Missing / invalid / unverifiable token. |
admin_only |
403 | email/test | Caller is not the configured admin. |
missing_api_key |
503 | providers/gpt-image-2, grok/generate, imagen4/generate | Provider env key absent on server. |
PAYMENT_REQUIRED |
402 | every paid endpoint | 0 balance AND 0 wow remaining. Body returns checkout_endpoints. |
bad_request |
400 | validate-similarity | Body validation failure. reason field carries detail. |
deprecated |
410 | pack (engine=aseprite) | Engine removed in Wave 5 / DEC-SF-018. |
INTERNAL |
500 | nb2, gpt-image-2, grok | Generic catch-all. |
ASSET_NOT_FOUND |
404 | assets/* (single, list filters) | Missing OR RLS-hidden OR soft-deleted. |
ASSET_FORBIDDEN |
403 | assets/:id/versions/* | Cross-user access to versions endpoints (RLS-404 escalated). |
ASSET_ID_REQUIRED / ASSET_NAME_INVALID / ASSET_NAME_TOO_LONG / ASSET_TAGS_INVALID / ASSET_PATCH_EMPTY |
400 | assets PATCH | Validation. |
ASSET_LIST_FAILED / ASSET_SEARCH_FAILED / ASSET_GET_FAILED / ASSET_UPDATE_FAILED / ASSET_DELETE_FAILED / ASSET_LOOKUP_FAILED / ASSET_OP_FAILED |
500 | assets/* | DB error. |
QUERY_REQUIRED |
400 | assets/search | ?q= missing. |
BULK_EMPTY / BULK_TOO_MANY / BULK_BAD_ID |
400 | assets/bulk-* | Validation (asset_ids shape; max 200). |
BULK_TAG_NOOP |
400 | assets/bulk-tag | Both tags_add and tags_remove empty. |
BULK_FORBIDDEN |
403 | assets/bulk-* | All-or-nothing: any cross-user/missing id aborts (missing_count carried). |
BULK_LOOKUP_FAILED / BULK_DELETE_FAILED / BULK_TAG_LOOKUP_FAILED / BULK_TAG_UPDATE_FAILED / BULK_TAG_FAILED / BULK_EXPORT_LOOKUP_FAILED / BULK_EXPORT_FAILED |
500 | assets/bulk-* | Internal. |
PARAMS_REQUIRED |
400 | versions/* | :id or :version_id empty. |
VERSION_NOT_FOUND |
404 | versions/* | Within owned asset, version row missing. |
VERSION_PINNED |
403 | versions DELETE | Refuses to delete pinned rows; unpin first. |
VERSION_GET_FAILED / VERSION_NUM_FAILED / VERSION_RESTORE_FAILED / VERSION_PIN_FAILED / VERSION_DELETE_FAILED / VERSIONS_LIST_FAILED |
500 | versions/* | Internal. |
IMAGEN4_QUALITY_INVALID / IMAGEN4_PROMPT_REQUIRED |
400 | providers/imagen4/generate | Validation. |
IMAGEN4_GENERATE_FAILED |
500 | providers/imagen4/generate | Upstream / internal. |
grok_test_failed |
4xx | providers/grok/test-key | Mirrors Grok upstream status. |
Spriteoven does not yet apply server-side rate limits β those land with C7-NEW-05 in Wave 5. In the meantime, the upstream provider limits are the binding ones:
gpt-image-2.
Burst usage above this returns 429 RATE_LIMITED with
suggestedBackoffMs: 30000. Tier upgrades remove the cap; see
docs/PROVIDERS.md.retry_after when
exceeded.suggestedBackoffMs: 30000.When you hit a 429, the response body always includes a backoff hint
(suggestedBackoffMs or retry_after). Honor it: retrying immediately
will cascade further rate-limits.
| Doc version | Date | Notes |
|---|---|---|
0.1.0 |
2026-05-08 | Initial publication. Wave 3 endpoints documented as TODO placeholders pending Tracks G/H/I closure. Covers Wave 1 + Wave 2 + Wave 3 housekeeping endpoints in main. |
0.2.0 |
2026-05-09 | Wave 3 NEW: Library + Versions + Providers extended + Imagen 4 (Fast DROPPED Ciclo 7 per DEC-SF-030, Standard reclassified Tier 2 Ciclo 8+). 15 new endpoints documented in Β§10 with full schemas + curl examples. AI Models Roadmap echo (Β§9). New error codes: ASSET_*, BULK_*, VERSION_*, IMAGEN4_*. /docs/api-reference middleware order verified empirically (route registered pre-catch-all line ~152, GET returns Content-Type text/markdown; reported bug not reproducible in HEAD ea3a9a7). |