Spriteoven API Reference

πŸ₯ 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.


Table of contents

  1. Authentication
  2. Conventions
  3. Sprite endpoints β€” postprocess, pack, stitch
  4. Project endpoints β€” projects CRUD
  5. Batch endpoints β€” batch + SSE stream
  6. Tilesets, iconpacks, tilemaps
  7. Provider endpoints β€” NB2, GPT Image 2, Grok
  8. Validation β€” similarity
  9. Recent AI Models Roadmap updates
  10. Library + Versions + Providers (Wave 3 GA)
  11. Auth & admin
  12. Error reference
  13. Rate limiting
  14. Changelog

Authentication

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.

Spriteoven user (Bearer JWT)

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:

πŸ”’ 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.


Conventions

Base URL

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.

Content type

All JSON endpoints take Content-Type: application/json and return application/json; charset=utf-8.

The two exceptions:

Image payloads

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.

Error envelope

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.


Sprite endpoints

POST /api/sprite-lab/postprocess

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();

POST /api/sprite-lab/pack

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.

POST /api/sprite-lab/stitch

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.


Project endpoints

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.

POST /api/sprite-lab/projects

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.

GET /api/sprite-lab/projects

Auth: Bearer JWT. Response: { "ok": true, "projects": [...] }. Ordered by updated_at desc. 500 PROJECT_LIST_FAILED.

GET /api/sprite-lab/projects/:id

Auth: Bearer JWT. Response: { "ok": true, "project": {...} }. 404 PROJECT_NOT_FOUND (also for cross-user: RLS hides existence).

PATCH /api/sprite-lab/projects/:id

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.

DELETE /api/sprite-lab/projects/:id

Auth: Bearer JWT. Response: { "ok": true, "deleted": {...} }. Cascades through FK ON DELETE CASCADE. 404 PROJECT_NOT_FOUND.


Batch endpoints

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.

POST /api/sprite-lab/batch

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.

GET /api/sprite-lab/batch/:batch_id/stream

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


Tilesets, iconpacks, tilemaps

POST /api/sprite-lab/tileset/generate

Generates a tileset (NxN grid of tiles) with chroma key + autotile rules

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.

POST /api/sprite-lab/iconpack/generate

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.

POST /api/sprite-lab/tilemap/save

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.

POST /api/sprite-lab/tilemap/export

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.


Provider endpoints

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.

POST /api/sprite-lab/nb2

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.

POST /api/sprite-lab/gpt-image-2 β€” ASYNC (Wave 5 post-audit hotfix 2026-05-17)

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. See server/lib/gen-jobs-worker.js for the worker contract; migration 005_wave5_gen_jobs_async.sql for the schema (REUSE of public.jobs with two new columns processor_picked_at + error_json); migration 006_wave5_drop_byok_key.sql for the BYOK retirement cleanup.

GET /api/sprite-lab/jobs/:id β€” NEW (Wave 5 post-audit hotfix)

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:

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_present surfaces the presence without the value. Legacy payload_json.byok_key (pre-2026-05-19 rows the migration missed) is also stripped defensively.

POST /api/providers/gpt-image-2/generate

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.

POST /api/sprite-lab/providers/grok/generate

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


Validation

POST /api/sprite-lab/validate-similarity

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


Recent AI Models Roadmap updates

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


Library + Versions + Providers (Wave 3 GA)

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

Library (Track G β€” C7-11 + C7-12)

GET /api/sprite-lab/assets

List the caller's assets. RLS-scoped; soft-deleted rows excluded.

Auth: Bearer JWT.

Query parameters:

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"

GET /api/sprite-lab/assets/search

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.

GET /api/sprite-lab/assets/:id

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.

PATCH /api/sprite-lab/assets/:id

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.

DELETE /api/sprite-lab/assets/:id

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.

POST /api/sprite-lab/assets/bulk-delete

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.

POST /api/sprite-lab/assets/bulk-tag

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

POST /api/sprite-lab/assets/bulk-export

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 (Track I β€” C7-14)

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 the tilemap/save precedent). Within an owned asset, a missing / wrong-version-id returns 404 VERSION_NOT_FOUND.

GET /api/sprite-lab/assets/:id/versions

List versions ordered by created_at DESC.

Response (200): { "ok": true, "versions": [{ "id": "...", "version_number": 7, "pinned": false, "metadata_json": {...}, "bytes": 12345, "created_at": "..." }, ...] }.

GET /api/sprite-lab/assets/:id/versions/:version_id

Read a single version row.

Errors: 400 PARAMS_REQUIRED; 404 VERSION_NOT_FOUND; 500 VERSION_GET_FAILED.

POST /api/sprite-lab/assets/:id/versions/:version_id/restore

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.

POST /api/sprite-lab/assets/:id/versions/:version_id/pin

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 /api/sprite-lab/assets/:id/versions/:version_id

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.

Providers extended (Track H β€” C7-NEW-08 / C7-NEW-09)

POST /api/sprite-lab/providers/imagen4/generate

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 fast as not production-ready for Spriteoven sprite-sheet prompts (ignores chroma + frame-strip structure). standard PASSES quality but exceeds the cost threshold for Tier 1; reclassified as Tier 2 candidate Ciclo 8+. ultra not 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 & admin

GET /api/auth/me

Auth: Bearer JWT. Returns { "id": "...", "email": "...", "created_at": "..." }.

POST /api/email/test

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.

GET /api/sprite-lab/status

Auth: none. Returns build hash + provider availability flags. Use this for health checks and "which model can I use right now" decisions.


Error reference

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.

Rate limiting

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:

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.


Changelog

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