loralume

API reference

Everything the site can do, your code can do: train subjects, render images, run the image tools, build and edit 3D models, and manage your gallery. One key, one header, plain JSON.

Authentication

Create a key at Account → API keys and send it as a Bearer token on every request:

Authorization: Bearer lm_live_…

A key can do everything your account can do — including spend Sparks — except manage keys or change credentials; those need a web login. Keys are shown once at creation, stored hashed, and die the moment you revoke them. At most 10 active keys.

Sparks & billing

Paid operations debit your Sparks wallet at request time and refund automatically if the work fails. Costs are stated per endpoint below; the ones to know:

Render2⚡ per SDXL image · 4⚡ per FLUX image at 1024×1024, 25 steps — scales with size and steps; a reference image (pose / sketch / depth) adds 1⚡
Image toolsupscale 1⚡ · enhance by output size · remove background 1⚡ · face 2⚡ · colorize 4⚡ · repaint 4⚡
3D studiocreate a model 40⚡ · regenerate an area 4⚡ · every other edit 2⚡
TrainingSDXL 200⚡ · FLUX 1000⚡ · FLUX rush 1400⚡

Insufficient balance returns 402 with {need, have} in the detail.

Quickstart

Render your subject

curl -s https://loralume.com/api/me/subjects/SUBJECT_ID/generate \
  -H "Authorization: Bearer $LORALUME_KEY" \
  -H "Content-Type: application/json" \
  -d '{"prompt": "Nora as an astronaut, golden hour", "quantity": 2}'
# => {"request_ids": [...], "sparks_debited": 8, ...}
# poll /api/me/subjects/SUBJECT_ID/room until your request_ids
# carry a generated_image_id

Turn a picture into a 3D model

curl -s https://loralume.com/api/me/images/IMAGE_ID/to-3d \
  -H "Authorization: Bearer $LORALUME_KEY" \
  -H "Content-Type: application/json" \
  -d '{"title": "Rocket", "target_faces": 40000}'
# => {"id": "MODEL_ID", "status": "queued", ...}   (40⚡)
# poll /api/me/models/MODEL_ID until status is completed,
# then download /api/me/models/MODEL_ID/file (.glb)

Fix its skin from code

# the atlas as PNG
curl -s -H "Authorization: Bearer $LORALUME_KEY" \
  https://loralume.com/api/me/models/MODEL_ID/texture -o atlas.png
# edit it in anything, then save it back as a new version (2⚡)
curl -s https://loralume.com/api/me/models/MODEL_ID/paint \
  -H "Authorization: Bearer $LORALUME_KEY" -F file=@atlas.png

Conventions

Base URLhttps://loralume.com
FormatJSON in and out unless an endpoint says multipart or binary. Errors are {"detail": ...}; validation errors carry a detail array.
Async workRenders, trainings and 3D builds return immediately with an id. Poll the endpoint named in the route's notes; completed and failed/rejected are terminal, and failures refund automatically.
Versions3D edits never overwrite: each one creates a new model whose parent_model_id points at its source.
OwnershipEvery id is scoped to your account; another customer's id is a plain 404.

Receiving webhooks

Instead of polling, register an endpoint and Loralume POSTs JSON to it when work finishes:

{
  "event": "model.completed",
  "created_at": "2026-08-24T14:03:22+00:00",
  "data": {"model_id": "…", "op": "create", "status": "completed",
           "faces": 40000, "has_texture": true, "sparks_debited": 40}
}

Every delivery is signed. Verify it with the secret from registration:

# X-Loralume-Signature: sha256=<hex>
import hmac, hashlib
expected = "sha256=" + hmac.new(secret.encode(), raw_body,
                                hashlib.sha256).hexdigest()
ok = hmac.compare_digest(request.headers["X-Loralume-Signature"], expected)

Answer with any 2xx within 10 seconds. Anything else is retried with backoff (up to 5 tries over ~2.5 hours); an endpoint that fails 50 deliveries in a row is switched off and shows why in disabled_reason.

Generation & gallery

GET/api/me/flagsReturns the per-user feature-flag mask the client reads once at sign-in.
Response{new_ia: bool, is_grandfathered: bool (has >=1 legacy pack artifact), show_account_history_link: bool}
Sparksfree
Errorsnone beyond auth
NotesAdditive contract: new flags append, existing flags are never renamed. Intended to be cached client-side.
GET/api/me/workshopOne-call payload for the signed-in home page: subject tiles, Cast tiles, and the last 12 generations.
Response{tiles: [{id, kind ('subject'), name, subject_type, tier ('sdxl'|'flux'), trained_at, lora_ready, photo_count (null in MVP), order_count, recent_thumb_urls (<=4), approx_cost_per_portrait (Sparks), is_developing}], resume: null-or-{subject_id, subject_name, draft_prompt, updated_at} (always null in MVP), recent_across_subjects: [{id, subject_id, subject_name, prompt, seed, filename, order_id, created_at}] (<=12), total_subjects, casts: [CastSummary — see GET /api/me/casts], total_casts}
Sparksfree
Errorsnone beyond auth
NotesThe route is patched at import time to include casts/total_casts. approx_cost_per_portrait is derived live from the pricing service (2 Sparks for sdxl tier, 4 for flux at the default 1024x1024/25-step render). thumb URLs point at /api/orders/{order_id}/gallery/{image_id} and require the auth cookie.
GET/api/me/history/ordersLegacy bundle-era order receipts for grandfathered accounts.
Response[{order_id, created_at, delivered_at, product_slug, product_name, total_cents, image_count, subject_id, subject_name, artifact_id}] newest first
Sparksfree
Errorsnone beyond auth
NotesReturns [] for wallet-mode-only accounts; the UI hides the nav link via /flags.show_account_history_link. product name/slug prefer the artifact's frozen copy so they survive product deletions.
GET/api/me/subjects/{subject_id}/roomFull Subject Room payload: summary, in-flight order, recent generation batches, matching theme packs, and downloadable files.
Requestpath: subject_id
Response{subject: {id, name, subject_type, tier, trained_at, lora_ready, order_count, approx_cost_per_portrait, trigger_word, subject_ref_url}, in_progress_order: null-or-{order_id, is_wallet_mode, status}, recent_batches (<=20): [{prompt, order_id, first_created_at, last_created_at, generations: [{id, generated_image_id, order_id, thumb_url, seed, status, rejection_reason, created_at, completed_at}]}], theme_packs: [{id, slug, name, description, subject_types, tier, min_subjects, max_subjects, allows_minors}], files: {lora_ready, lora_size_bytes, lora_download_url, legacy_pack_artifact_id, legacy_pack_name, legacy_pack_delivered_at, legacy_pack_image_count, legacy_pack_order_id}, default_model_slug ('flux-dev'|'sdxl-base'), default_width: 1024, default_height: 1024, default_steps: 25}
Sparksfree
Errors404 if subject missing, soft-deleted, or not owned by the caller
NotesThis is also the poll target after POST /subjects/{sid}/generate: match your request_ids inside recent_batches[].generations; a generation is done when generated_image_id/thumb_url is set, and 'rejected' (with rejection_reason) is terminal. Batches group requests by prompt within 5-minute windows.
POST/api/me/subjects/{subject_id}/generateQueues 1-4 portrait renders of one trained subject against its own model (wallet mode).
RequestJSON: prompt (str 1..1500, required), width (int <=2048, default 1024), height (int <=2048, default 1024), steps (int 1..100, default 25), draft (bool, default false), quantity (int 1..4, default 1)
Response{order_id, order_created: bool, request_ids: [str] (one per image), rejected: bool, rejection_reason, sparks_debited: int (total), balance_after, rendered_prompt}
SparksPer image: ceil(model_base x steps/25 x megapixels), min 1; draft halves it. Model is picked by subject tier: sdxl-base (base 1.2) or flux-dev (base 4.0). At the defaults that is 2 Sparks/image for sdxl subjects and 4 for flux; quantity multiplies.
Errors404 subject not found/not owned; 409 subject has no trained portrait yet; 402 wallet mode not enabled for this account, or insufficient Sparks (detail: {need, have}); 500 no active product for the tier
NotesAsync: images are not in the response. Poll GET /api/me/subjects/{sid}/room and match request_ids in recent_batches; done when generated_image_id is set, 'rejected' is terminal. A prompt-safety rejection is NOT an HTTP error — it returns 200 with rejected=true, sparks_debited=0, and nothing queued. Subject names in the prompt are substituted with trigger tokens server-side (rendered_prompt shows the result). Reuses/creates a wallet-mode order per subject; order_created says whether one was minted.
GET/api/me/subjects/{subject_id}/portrait-modelDownloads the subject's trained portrait model as a .safetensors attachment.
Requestpath: subject_id. Auth may also be supplied as ?token= (get_current_user_flex) because a browser <a href> cannot send headers.
ResponseBinary application/octet-stream attachment named {subject_name}_portrait.safetensors
Sparksfree
Errors404 subject not found/not owned; 409 portrait model not trained yet; 410 model file missing on disk
NotesCustomer-facing copy calls this the 'portrait model' download; the URL is also surfaced as files.lora_download_url in the Subject Room payload.
GET/api/me/compose/modelsReturns the curated model list for the compose page, with live per-image prices.
Response[{slug, name, tier ('flux'|'sdxl'), description, cost_per_image_approx: int}] — 4 models: flux-premium, realvis-standard, juggernaut-standard, epicrealism-standard
Sparksfree to call. cost_per_image_approx is computed from the pricing service at the 1024x1024/25-step/1-LoRA reference render: 4 Sparks for flux-premium, 2 for each sdxl model.
Errorsnone beyond auth
NotesPrices are derived, not hardcoded, so the picker always matches what /compose actually debits.
POST/api/me/composeQueues a free-form render with a chosen model and up to 4 of the user's trained subjects blended in.
RequestJSON: model_slug (required, one of the 4 compose slugs), lora_ids (list[str] of owned subject ids, max 4, default []), lora_strengths (optional positional list, one entry per lora_id; null entries use default 0.85, values clamped to 0.5..1.0), prompt (str 1..1500), negative_prompt (str <=500, default ''), width/height (int 512..1536, default 1024), steps (int 10..100, default 25), quantity (int 1..8, default 1), face_detail (bool, default false), reference_image_id (optional id of an owned gallery image), reference_mode ('pose'|'sketch'|'depth', default 'pose'), reference_strength (0.2..1.0; per-mode defaults: pose 0.8, sketch 0.55, depth 0.5), pose_image_id/pose_strength (legacy aliases for the reference fields), draft (bool), seed (optional int), seed_locked (bool), guidance (optional float 1.0..20.0, CFG on SDXL / distilled guidance on FLUX)
Response{order_id, request_ids: [str] (one per image), rejected: bool, rejection_reason, sparks_debited: int (total), balance_after, rendered_prompt}
SparksPer image: ceil(model_base x steps/25 x megapixels x lora_mult x draft_mult), min 1, where model_base is 4.0 (flux-premium) or 1.2 (the three sdxl models), lora_mult = 1 + 0.15 per subject past the first, draft_mult = 0.5. At defaults: 4 Sparks/image flux, 2 sdxl. A reference image adds a flat +1 Spark per image.
Errors400 unknown model, more than 4 lora_ids, lora_strengths length mismatch, or invalid reference_mode; 404 subject not owned/found or reference image not found (also returned when the reference belongs to someone else); 409 a chosen subject is still training, or was trained for a different tier than the model; 410 reference image file no longer on the server; 402 insufficient Sparks (detail: {need, have, already_queued}); 500 compose backing pack missing
NotesAsync: poll GET /api/me/compose/history (match request_id) or GET /api/me/gallery. Mints a fresh wallet-mode order per submit. Prompt-safety rejection returns 200 with rejected=true and no debit. seed_locked + seed pins the batch's starting point but each image i uses seed+i (a locked batch still varies). All lora_ids must be the same tier as the model. The reference picture itself is never reused — only the extracted skeleton/line-work/depth hint.
GET/api/me/compose/historyLists the user's most recent compose renders with full metadata.
Requestquery: limit (int, default 24, clamped 1..96)
Response[{id, order_id, filename, thumb_url, prompt, seed, width, height, model_slug, model_name, lora_ids, lora_names, sparks_debited, is_public, created_at, request_id (the compose request that produced it, for matching a submit response)}] newest first
Sparksfree
Errorsnone beyond auth
NotesOnly surfaces compose-originated images (orders whose pack slug starts with 'compose-') and only finished ones; use it to poll a POST /compose submission via request_id. For the whole archive use /api/me/gallery.
GET/api/me/galleryPaginated, filterable archive of every image the user has generated or uploaded.
Requestquery: limit (default 48, clamped 1..96), offset (default 0), favorites (bool), model (compose model slug, theme-pack slug, or '__no_pack__' for legacy no-pack orders), subject_id, q (case-insensitive prompt substring), folder_id, unfiled (bool — only images with no folder)
Response{items: [{id, order_id, thumb_url, prompt, seed, width, height, model_slug, model_name, lora_ids, lora_names, is_favorite, is_public, source ('compose'|'pack'|'upload'), available (bool — file still on disk), folder_id, created_at}], total, limit, offset, has_more}
Sparksfree
Errorsnone beyond auth
NotesNewest first. available=false means the row exists but the file is gone — render a placeholder, don't hotlink the thumb. folder_id and unfiled are mutually exclusive filters (folder_id wins).
GET/api/me/gallery/facetsCounts that drive the gallery filter bar — only models and subjects the user actually owns images for.
Response{total: int, favorites: int, models: [{key (slug or '__no_pack__'), label, count}] sorted by count desc, subjects: [{key (subject_id), label (name), count}] sorted by count desc}
Sparksfree
Errorsnone beyond auth
NotesFeed each facet's key back into /api/me/gallery's model / subject_id params. '__no_pack__' labels as 'Earlier orders'.
DELETE/api/me/gallery/{image_id}Permanently deletes one of the user's own images — the DB row, the PNG, and cached thumbnails.
Requestpath: image_id
Response{deleted: image_id}
Sparksfree
Errors404 image not found or not owned
NotesIrreversible. Spend records survive: the originating request row and any image-op rows are kept with their references to the deleted image nulled out.
POST/api/me/images/{image_id}/upscaleQueues an image operation (upscale, generative enhance, background removal, face detail, or colorize) on an image the user owns.
Requestpath: image_id. JSON: mode ('fast'|'enhance'|'remove_bg'|'face'|'colorize'|'repaint', default 'enhance'), scale (int, default 2; must be 2 or 4 for fast/enhance; forced to 1 for the non-resizing modes), hint (str <=400, colorize only — what the customer remembers about the real colours)
ResponseImageOp: {id, op, mode, scale, status, source_image_id, result_image_id, result_url, sparks_debited, error, progress_pct, progress_msg, created_at, completed_at}
Sparksfast: 1 flat. remove_bg: 1 flat. face: 2 flat. colorize: 4 flat. enhance: ceil(0.6 x output megapixels), min 1 — e.g. a 1024x1024 source is 3 Sparks at 2x and 10 at 4x. Output area is capped at 24 MP.
Errors400 invalid mode, scale not 2/4 for fast/enhance, or output would exceed 24 MP; 404 image not found/not owned; 410 source file no longer on the server; 409 image has no recorded dimensions; 402 insufficient Sparks (message includes need/have)
NotesAsync: returns status 'queued'. Poll GET /api/me/image-ops/{id}; progress_pct/progress_msg update while running; the op is finished when result_image_id and completed_at are set, failed when error is non-null. The result is written as a new gallery image in the SOURCE image's order, next to the original. Repaint needs a brushed mask, so start it via POST /images/{id}/repaint instead of this JSON route.
POST/api/me/images/{image_id}/repaintRepaints (inpaints) the brushed area of an owned image — replace it per the prompt, or just remove it.
Requestpath: image_id. multipart/form-data: mask (file, required — an image the size of the source, white where the picture should change, black where it must not; max 8 MB; auto-resized to source dimensions if needed), prompt (text field, default '' — blank means 'just take it out')
ResponseImageOp (same shape as /upscale): {id, op, mode: 'repaint', scale: 1, status, source_image_id, result_image_id, result_url, sparks_debited, error, progress_pct, progress_msg, created_at, completed_at}
Sparks4 flat per repaint
Errors400 empty mask, unreadable mask, or nothing brushed (all-black mask); 413 mask over 8 MB; 404 image not found/not owned; 410 source file gone; 409 image has no recorded dimensions; 402 insufficient Sparks
NotesAsync: poll GET /api/me/image-ops/{id} exactly as for /upscale — done when result_image_id + completed_at set, failed when error set. No debit happens if the mask is rejected. Result lands in the source image's order in the gallery.
GET/api/me/image-ops/{op_id}Reads one image operation's status — the poll target for upscale/enhance/remove_bg/face/colorize/repaint.
Requestpath: op_id
ResponseImageOp: {id, op, mode, scale, status, source_image_id (nullable — freed if the source was deleted), result_image_id, result_url, sparks_debited, error, progress_pct, progress_msg, created_at, completed_at}
Sparksfree
Errors404 op not found or not owned
NotesTerminal when completed_at/result_image_id is set (success) or error is non-null (failure); until then progress_pct/progress_msg carry live worker progress.
GET/api/me/image-opsLists the user's recent image operations, newest first.
Requestquery: limit (int, default 20, clamped 1..100)
Response[ImageOp] — same per-item shape as GET /api/me/image-ops/{op_id}
Sparksfree
Errorsnone beyond auth
Notessource_image_id can be null on older ops whose source picture was deleted; the op row is retained because it carries the spend record.
POST/api/me/castsCreates a Cast — a named, reusable group of 2-4 trained subjects for group photos.
RequestJSON: name (str 1..64), cast_type ('family'|'couple'|'friends'|'custom'), subject_ids (list[str], 2..4 unique owned subject ids, all trained, all the same tier)
Response201 Created. Cast: {id, name, cast_type, member_count, tier, members: [{subject_id, name, subject_type, tier, position (0..3), role, trigger_word, lora_ready, subject_ref_url}], created_at, updated_at}
Sparksfree
Errors400 wrong member count or duplicate subject_ids; 404 a subject is missing/not owned; 409 a subject is still training, or members mix tiers
NotesMember order is preserved; position 0 is the primary. All members must share one tier ('sdxl' or 'flux') because a Cast renders on one model.
GET/api/me/castsLists the user's Casts as compact grid tiles, most recently updated first.
Response[{id, name, cast_type, member_count, tier, member_names, member_thumb_urls, approx_cost_per_portrait (Sparks), recent_thumb_urls (<=4), updated_at}]
Sparksfree
Errorsnone beyond auth
Notesapprox_cost_per_portrait is priced live for a 1024x1024/25-step render with N member models (see POST /casts/{id}/generate for the formula).
GET/api/me/casts/{cast_id}Reads one Cast in full, including its ordered member list.
Requestpath: cast_id
ResponseCast — same shape as POST /api/me/casts response
Sparksfree
Errors404 cast not found, soft-deleted, or not owned
NotesMembers whose subject was hard-deleted are silently skipped.
PATCH/api/me/casts/{cast_id}Renames a Cast, changes its type, or replaces its member set.
RequestJSON (all optional): name (str 1..64), cast_type ('family'|'couple'|'friends'|'custom'), subject_ids (list[str] 2..4 — replaces the ENTIRE member list, same validation as create)
ResponseCast — updated full shape
Sparksfree
Errors404 cast not found/not owned; 400 bad member count or duplicates; 404/409 per-subject validation as on create
Notessubject_ids is a full replacement, not a merge; position/roles are reassigned in the order given.
DELETE/api/me/casts/{cast_id}Soft-deletes a Cast.
Requestpath: cast_id
Response204 No Content
Sparksfree
Errors404 cast not found or not owned
NotesSoft delete: member rows are kept so historical orders can still reconstruct who was in the Cast. The Cast disappears from listings.
GET/api/me/casts/{cast_id}/roomFull Cast Room payload: the Cast, its in-flight order, recent generation batches, matching theme packs, and render defaults.
Requestpath: cast_id
Response{cast: Cast (full shape), in_progress_order: null-or-{order_id, is_wallet_mode, status}, recent_batches (<=20, same batch shape as the Subject Room), theme_packs: [ThemePack shape filtered to this Cast's size/tier/types], default_model_slug ('flux-premium'|'realvis-standard'), default_width: 1024, default_height: 1024, default_steps: 25}
Sparksfree
Errors404 cast not found/not owned; 409 the Cast is incomplete (a member's subject was removed) — edit the Cast to fix it
NotesPoll target after POST /casts/{id}/generate: match request_ids in recent_batches[].generations; done when generated_image_id is set, 'rejected' is terminal.
POST/api/me/casts/{cast_id}/generateQueues 1-4 group renders of a Cast, with a per-face regional refine pass for every non-primary member.
Requestpath: cast_id. JSON: prompt (str 1..1500), negative_prompt (str <=500, default ''), width (int <=2048, default 1024), height (int <=2048, default 1024), steps (int 1..100, default 25), draft (bool, default false), quantity (int 1..4, default 1)
Response{order_id, order_created: true, request_ids: [str] (one per image), rejected: bool, rejection_reason, sparks_debited: int (total), balance_after, rendered_prompt}
SparksPer image: ceil(model_base x steps/25 x megapixels x (1 + 0.15 x (members - 1))), min 1; draft halves it. Model is fixed by tier: realvis-standard (base 1.2) for sdxl Casts, flux-premium (base 4.0) for flux. At defaults: sdxl Casts cost 2 Sparks/image at any size 2-4; flux Casts cost 5 (2 members) or 6 (3-4 members).
Errors404 cast not found/not owned; 409 the Cast is incomplete (fewer than 2 live members); 402 insufficient Sparks (detail: {need, have, already_queued}); 500 cast backing pack missing
NotesAsync: poll GET /api/me/casts/{cast_id}/room and match request_ids in recent_batches, or watch /api/me/gallery. Mints a fresh wallet-mode order per submit. Prompt-safety rejection returns 200 with rejected=true and no debit. Member names in the prompt are substituted with trigger tokens (rendered_prompt shows the result); every member's model is applied at strength 0.85.
POST/api/me/compose/{image_id}/publishToggle whether one of your compose renders is shown on the public Explore page.
RequestJSON: {public: bool}
Response{id, is_public_example}
Sparksfree
Errors404 if the image is not yours

Sparks wallet

GET/api/me/walletReturns the signed-in user's current Sparks balance with per-bucket breakdown, subscription state, and daily-streak info.
ResponseWalletOut: total (int), topup (int), subscription (int), promo (int), subscription_tier_id (str|null), subscription_period_end (str|null), streak_days (int), daily_grant_last_at (str|null)
Sparksfree
Errors401 if unauthenticated
NotesFirst call auto-creates the wallet row if none exists. total is the sum of the topup/subscription/promo buckets.
POST/api/me/wallet/claim-dailyClaims the 20-Spark daily grant; every 7th consecutive streak day also pays a 200-Spark bonus.
ResponseDailyClaimOut: granted (bool), sparks (int), streak_days (int), streak_bonus (int), next_available_at (str)
Sparksfree to call — credits +20 Sparks; +200 streak bonus on day 7 and every 7 days thereafter
Errors401 if unauthenticated
NotesIdempotent per 20-hour window: calling again inside the window returns granted=false with next_available_at telling the client when to re-enable the claim button.
GET/api/me/wallet/transactionsPaginated view of the user's Sparks ledger, newest first.
Requestquery: limit (int, default 50, min 1, max 200); before (str, optional — created_at ISO timestamp cursor of the last row seen)
ResponseTransactionsPage: items (list of TransactionOut: id (str), delta (int), bucket (str), kind (str), reference_id (str|null), reference_type (str|null), memo (str|null), balance_after (int, sum of the three bucket snapshots), created_at (str ISO)), has_more (bool), next_cursor (str|null)
Sparksfree
Errors400 if `before` is not a parseable ISO timestamp; 401 if unauthenticated
NotesCursor pagination on created_at (not offset), so ledger inserts during pagination don't skip rows. To fetch the next page, pass next_cursor as `before`; next_cursor is null on the last page.
GET/api/me/wallet/packages no authLists the Sparks top-up packages available for purchase (used on the pricing page).
Responselist of SparkPackageOut: id (str), slug (str), display_name (str), sparks (int), price_cents (int), bonus_pct (int), sort_order (int)
Sparksfree
Errorsnone beyond standard 5xx
NotesReturns only active packages, ordered by sort_order then price_cents. No auth dependency — safe to call anonymously.
GET/api/me/wallet/tiers no authLists the monthly subscription tiers (used on the pricing page).
Responselist of SubscriptionTierOut: id (str), slug (str), display_name (str), monthly_sparks (int), price_cents (int), queue_priority (int), max_private_loras (int), hd_default (bool), commercial_license (bool), api_key_allowed (bool), sort_order (int)
Sparksfree
Errorsnone beyond standard 5xx
NotesReturns only active tiers, ordered by sort_order then price_cents. No auth dependency — safe to call anonymously.
POST/api/me/wallet/purchase-intentCreates a Stripe PaymentIntent for a one-time Sparks top-up pack; the client confirms it with Stripe.js using the returned client_secret.
RequestPurchaseIntentIn: package_id (str — id of an active package from GET /api/me/wallet/packages)
ResponsePurchaseIntentOut: client_secret (str, Stripe PaymentIntent client secret), amount_cents (int), sparks (int — Sparks the pack grants on successful payment)
Sparksfree to call — the pack costs its price_cents in USD and grants its sparks amount
Errors404 if package_id does not match an active package; 401 if unauthenticated
NotesAsync completion: Sparks are granted only by the server-side Stripe webhook (payment_intent.succeeded), never on client-side confirmation. After confirming payment, poll GET /api/me/wallet (or /transactions) until the credit appears — the ledger credit is the terminal state. A Stripe customer is created lazily for the user on first purchase.
POST/api/me/wallet/subscribe-checkoutCreates a Stripe Checkout session for a monthly subscription tier and returns the hosted checkout URL to redirect the user to.
RequestSubscribeCheckoutIn: tier_id (str — id of an active tier from GET /api/me/wallet/tiers), success_url (str), cancel_url (str)
ResponseSubscribeCheckoutOut: checkout_url (str — redirect the user here)
Sparksfree to call — the tier bills price_cents/month and grants monthly_sparks each billing cycle
Errors404 if tier_id does not match an active tier; 409 if the tier has no stripe_price_id (not yet wired to Stripe); 400 if tier_id is "free"; 401 if unauthenticated
NotesAsync completion: the monthly Sparks grant is deferred to the Stripe invoice.paid webhook. After the user returns via success_url, poll GET /api/me/wallet until subscription_tier_id and subscription_period_end are set — that is the terminal state. A Stripe customer is created lazily if the user has none. The free tier cannot be checked out.

Training

POST/api/trainCreates a new wallet-mode LoRA training order (wizard Step 1) for a person, pet, or object subject.
RequestJSON body (CreateTrainingIn): subject_type (string, must match ^(person|pet|object)$), name (string, 1-120 chars), trigger_word (optional string, max 32 chars; auto-generated pronounceable 6-char token if omitted), retrain_of_subject_id (optional string)
Response201 Created (TrainingOrderOut): order_id (string), status (string, starts as "awaiting_images"), wizard_current_step (int), subject_type (string), trigger_word (string), wallet_balance (int, Sparks), needs_verification (bool), verification_id (string|null)
Sparksfree at this step — no debit until submit
Errors503 if wallet mode is disabled globally (LORALUME_WALLET_MODE_ENABLED); 403 if the account is not enrolled in wallet mode; 409 if the user is at their private-model slot cap (message includes used/cap counts)
NotesThe order sits in status "awaiting_images" until photos are uploaded and submit is called. needs_verification=true means a person subject requires a passed identity verification before submit will succeed. The slot-cap check here is a courtesy; submit re-checks and is the real gate. NOTE: the module docstring lists photo-upload routes (POST /api/train/{oid}/upload, DELETE and PATCH /api/train/{oid}/images/{iid}) but they are NOT implemented in this file — upload is not documentable from this source.
GET/api/train/{order_id}/analysisReturns dataset analysis for an order's uploaded photos: counts, diversity, identity checks, and improvement suggestions (wizard Step 2).
RequestPath param: order_id. No body.
Response200 (AnalysisOut): dataset_size (int), accepted_count (int), quality_score (int|null, currently always null pending analyzers), diversity_score (float|null), diversity_axes (dict|null), identity_ratio (float|null), face_outlier_image_ids (list[string]), pending_scans (int), pending_captions (int), suggestions (list[string])
Sparksfree
Errors404 if the order does not exist or is not owned by the caller
NotesAnalysis is populated asynchronously by background analyzer tasks — poll this endpoint; pending_scans and pending_captions both reaching 0 means analysis is complete. Suggestions include a floor warning when fewer than 15 photos are uploaded and an outlier warning when identity_ratio < 0.6.
POST/api/train/{order_id}/planPreviews the exact Sparks cost of a training submission for the chosen base models without committing or charging anything (wizard Step 3 preview).
RequestPath param: order_id. JSON body (PlanIn): base_models (list[string], 1-4 items, e.g. ["flux-dev","sdxl-base"]), priority (bool, default false), advanced (optional dict)
Response200 (PlanOut): jobs_preview (list of {base_model (string), preset (string), sparks_cost (int)}), total_sparks (int), wallet_after (int; balance minus total when affordable, otherwise unchanged balance), insufficient (bool), shortfall (int, Sparks missing; 0 when affordable)
Sparksfree to call; previewed per-job costs: SDXL presets (sdxl_face_v1) 200 Sparks, FLUX (flux_face_v1) 1000 Sparks, FLUX rush (flux_face_v1_rush, priority=true on a FLUX model) 1400 Sparks
Errors404 if the order does not exist or is not owned by the caller; 400 if no training preset exists for the (subject_type, base_model) pair
NotesPure calculation — nothing is mutated or debited. priority=true only changes the preset (and cost) for FLUX models (flux_face_v1 → flux_face_v1_rush); for SDXL models priority affects queue position only and the cost stays 200. Supported base models per the preset map: flux-dev, flux-schnell, juggernaut-xl, realistic-vis, dreamshaper, sdxl-base, sdxl-lightning, pony-v6 (same set for person, pet, and object subjects).
POST/api/train/{order_id}/submitDebits Sparks and enqueues one training job per chosen base model, starting the actual training (wizard Step 3 commit).
RequestPath param: order_id. JSON body (SubmitIn): base_models (list[string], 1-4 items), priority (bool, default false), advanced (optional dict), ownership_ack (bool, required — must be true)
Response200 (SubmitOut): order_id (string), jobs (list of SubmittedJobOut: {job_id (string), base_model (string), training_preset (string), sparks_debited (int)}), wallet_balance (int, post-debit)
Sparksdebited per job by preset: sdxl_face_v1 = 200, flux_face_v1 = 1000, flux_face_v1_rush = 1400 (FLUX + priority). Each job is debited separately with idempotency key train:{job_id}; if any job's debit fails, all jobs and all debits in the request are rolled back
Errors404 order not found/not owned; 409 order not in "awaiting_images" state; 409 order is bundle-mode (use /api/orders/{id}/submit instead); 400 ownership_ack missing; 400 fewer than 5 accepted images; 409 private-model slot cap reached (skipped for re-trains that already own a subject); 403 person subject without completed identity verification; 402 insufficient Sparks (detail object includes need and have)
NotesAsync operation: jobs are inserted with status "queued" and the order moves to "ready_to_process"; poll GET /api/train/{order_id}/epochs for progress and results. Job statuses "queued", "claimed", and "running" are in-flight; any other status is terminal and epoch artifacts appear on completion. A Subject record is auto-created on first submit (re-trains reuse the existing subject and slot).
GET/api/train/{order_id}/epochsLists every epoch checkpoint produced by each training job on the order, with sample image URLs, for choosing the best result.
RequestPath param: order_id. No body.
Response200 (EpochsPageOut): order_id (string), jobs (list of JobEpochGroup: {job_id (string), base_model (string), training_preset (string), status (string), artifacts (list of EpochOut: {id (string), epoch_index (int), step_count (int), sample_urls (list[string] — directly renderable HTTP URLs), user_picked (bool), size_bytes (int), created_at (string, ISO 8601)})})
Sparksfree
Errors404 if the order does not exist or is not owned by the caller
NotesThis is the status/polling endpoint after submit. artifacts is empty until the training worker delivers checkpoints; each job's status field reflects progress ("queued"/"claimed"/"running" are in-flight). sample_urls point at the artifact-samples endpoint below.
GET/api/train/{order_id}/artifacts/{artifact_id}/samples/{filename}Serves a single sample image (PNG) generated from an epoch checkpoint so the customer can compare epochs visually.
RequestPath params: order_id, artifact_id, filename. No body. Use the sample_urls returned by GET /api/train/{order_id}/epochs — do not construct filenames by hand.
Response200: raw image file, Content-Type image/png
Sparksfree
Errors404 if the order/artifact is not found or not owned by the caller, or if the filename is not among the artifact's recorded sample files
NotesFilenames are validated against the artifact's recorded sample paths; arbitrary filenames return 404.
POST/api/train/{order_id}/jobs/{job_id}/pick-epochSelects the winning epoch checkpoint for a training job; that checkpoint becomes the subject's usable LoRA model.
RequestPath params: order_id, job_id. JSON body (PickEpochIn): epoch_index (int, 0-3 inclusive)
Response200 (PickEpochOut): delivery_id (string; empty string if no delivery record exists yet), subject_id (string|null), lora_filename (string), picked_at (string, ISO 8601)
Sparksfree
Errors404 if the order or job is not found/not owned, or if no artifact exists at the given epoch_index ("no such epoch")
NotesThe picked artifact is attached to the order's Subject as its LoRA file; all sibling (non-picked) epoch checkpoints are marked eligible for garbage collection 90 days out. Calling again with a different epoch_index re-picks: the new choice is kept and the previous pick joins the GC-eligible siblings.

Subjects

GET/api/me/subjectsList all of the user's trained subjects, including delivered LoRA orders not yet linked to a subject (shown with pseudo-id `order-{order_id}`).
RequestNo parameters.
ResponseArray of SubjectOut: id (str), name (str), reference_filename (str|null), verified_at (str|null, ISO), created_at (str|null, ISO), order_count (int), subject_type (str, default "person"), tier (str, "sdxl"|"flux"), version_label (str|null), parent_subject_id (str|null), lora_filename (str|null), lora_size_bytes (int|null), is_minor (bool), ready (bool — true when a LoRA file exists), is_self_subject (bool), has_verification (bool), verification_id (str|null), age_bracket (str|null), family_role (str|null).
Sparksfree
ErrorsNone beyond auth failures.
NotesEntries with id of the form `order-{order_id}` are delivered-order tiles, not real Subject rows — route their detail views to the order page, not subject endpoints. Soft-deleted subjects are excluded.
POST/api/me/subjectsCreate a new subject from a reference photo; the photo is safety-checked by the guard model before the subject is saved.
Requestmultipart/form-data: file (required, image, max 20 MiB), name (str, required, trimmed to 120 chars), subject_type (str, default "person"), verification_id (str, optional), is_self (bool, default false), age_bracket (str, optional; one of infant|toddler|child|teen|adult|senior), family_role (str, optional; one of father|mother|parent|son|daughter|child|sibling|partner|spouse|self|friend|grandparent|pet|subject|other).
Response201 with SubjectOut (same shape as list endpoint).
Sparksfree
Errors400 empty name, invalid age_bracket/family_role, photo rejected by guard, or no visible face; 400 verification status not "passed", verification is account-holder's own, or verification name mismatch; 403 is_self without a completed account verification, or person subject without verification_id (body includes error "subject_verification_required" and a verify_url), or verification belongs to another user; 404 verification_id not found; 413 file over 20 MiB; 503 guard model unavailable.
NotesPerson subjects require verification: is_self=true reuses the account holder's verification; otherwise a per-subject verification_id whose face scan passed and whose for_subject_name matches the given name (case-insensitive). Non-person subjects (pets, real estate, etc.) skip verification. Accepted photo MIME types recorded: image/jpeg, image/png, image/webp (others stored as image/jpeg). Defaults applied server-side: family_role "self" for self-subjects, "pet" for pet subjects; age_bracket "adult" for self-subjects. A trigger word is auto-generated from the name. Guard check runs synchronously in the request — nothing to poll.
GET/api/me/subjects/{subject_id}Fetch one subject by id.
RequestPath: subject_id (str).
ResponseSubjectOut (same shape as list endpoint).
Sparksfree
Errors404 if not found, not owned by the caller, or soft-deleted.
NotesDoes not resolve the pseudo `order-{order_id}` ids returned by the list endpoint.
PATCH/api/me/subjects/{subject_id}Rename a subject and/or update its age bracket and family role.
RequestJSON body (all fields optional; only supplied fields change): name (str, 1–120 chars), age_bracket (str, max 16 chars; empty string clears, otherwise infant|toddler|child|teen|adult|senior), family_role (str, max 32 chars; empty string clears, otherwise father|mother|parent|son|daughter|child|sibling|partner|spouse|self|friend|grandparent|pet|subject|other).
ResponseUpdated SubjectOut.
Sparksfree
Errors400 invalid age_bracket or family_role; 404 if not found or not owned by the caller.
NotesRenaming does not change the trigger word — use the trigger endpoints for that.
DELETE/api/me/subjects/{subject_id}Soft-delete a subject; order history is preserved but the LoRA file and all photos are permanently removed from storage.
RequestPath: subject_id (str).
ResponseJSON: ok (true), soft_deleted (true), preserved_orders (int), message (str).
Sparksfree
Errors404 if not found, not owned by the caller, or already deleted.
NotesIrreversible for files: the .safetensors LoRA, reference photos, and training photo pool are deleted from disk immediately and cannot be recovered. The subject row is only soft-deleted so past order galleries remain viewable; it disappears from the list endpoint.
GET/api/me/subjects/{subject_id}/photoDownload the subject's primary reference photo.
RequestPath: subject_id (str). Auth accepted via Authorization header or ?token= query parameter.
ResponseThe image file (FileResponse) with the photo's stored MIME type.
Sparksfree
Errors404 if the subject is not found/not owned, or the file is missing on disk.
NotesThe ?token= query form exists so the URL can be used directly in <img> tags.
GET/api/me/subjects/{subject_id}/photosList all reference photos attached to a subject.
RequestPath: subject_id (str).
ResponseArray of SubjectPhotoOut: id (str), filename (str), is_primary (bool), added_at (str, ISO). Primary photo sorts first, then oldest-first.
Sparksfree
Errors404 if the subject is not found or not owned by the caller.
POST/api/me/subjects/{subject_id}/photosAdd an additional reference photo to a subject; the photo is safety-checked by the guard model.
Requestmultipart/form-data: file (required, image, max 20 MiB). Path: subject_id (str).
Response201 with SubjectPhotoOut: id, filename, is_primary (false for new photos), added_at (ISO).
Sparksfree
Errors400 photo rejected by guard or no clearly visible face; 404 subject not found or not owned; 413 file over 20 MiB; 503 guard model unavailable.
NotesFilename collisions are deduped server-side (suffix _2, _3, …) — read the returned filename rather than assuming the uploaded name. New photos are never primary; use the /primary endpoint to promote. Guard check is synchronous — nothing to poll.
POST/api/me/subjects/{subject_id}/photos/{photo_id}/primaryMake the given photo the subject's primary reference photo.
RequestPath: subject_id (str), photo_id (str). No body.
ResponseJSON: ok (true).
Sparksfree
Errors404 if the subject or the photo (within that subject) is not found.
NotesAll other photos are demoted; the subject's denormalized reference_filename/sha256/mime are updated to the new primary.
DELETE/api/me/subjects/{subject_id}/photos/{photo_id}Delete one reference photo from a subject.
RequestPath: subject_id (str), photo_id (str).
ResponseJSON: ok (true).
Sparksfree
Errors404 if the subject or photo is not found; 409 if it is the subject's only photo ("delete the subject instead").
NotesDeleting the primary photo auto-promotes the oldest remaining photo to primary. The file is removed from disk.
GET/api/me/subjects/{subject_id}/photos/{photo_id}/fileDownload a specific reference photo by id.
RequestPath: subject_id (str), photo_id (str). Auth accepted via Authorization header or ?token= query parameter.
ResponseThe image file (FileResponse) with the photo's stored MIME type.
Sparksfree
Errors404 if the subject or photo is not found, or the file is missing on disk.
NotesThe ?token= query form exists so the URL can be used directly in <img> tags.
GET/api/me/subjects/subjects/{subject_id}/trigger-suggestionGet the trigger word that would be auto-generated for this subject, plus a celebrity-safe alternative if the name is high-risk.
RequestPath: subject_id (str).
ResponseTriggerSuggestionOut: name (str), suggested_trigger (str), is_high_risk (bool), safer_alternative (str).
Sparksfree
Errors404 if the subject is not found or not owned by the caller.
NotesThe doubled "/subjects/subjects/" path segment is literal — the route path adds "/subjects/" on top of the router prefix.
PATCH/api/me/subjects/subjects/{subject_id}/triggerSet the subject's trigger word (normalized server-side), optionally forcing the celebrity-safe variant.
RequestJSON body: trigger_word (str, required), use_safe_suffix (bool, default false — when true and the given word doesn't already end in "LL", the safe variant is generated instead).
ResponseJSON: trigger_word (str) — the normalized trigger actually stored, which may differ from the submitted value.
Sparksfree
Errors404 if the subject is not found or not owned by the caller.
NotesThe doubled "/subjects/subjects/" path segment is literal. The submitted word is passed through the server's trigger normalizer, so always read the returned trigger_word. Existing order-subject links are updated to the new trigger as well.

3D studio

POST/api/me/images/{image_id}/to-3dTurns one of your generated images into a textured 3D model (GLB).
RequestJSON body (ToModelIn): title (string, optional, max 200 chars); target_faces (int, default 40000, min 1000, max 200000). Path: image_id of an image you own.
ResponseModel3DOut: id, op, status, title, source_image_id, parent_model_id, folder_id, faces (int), vertices (int), has_texture (bool), size_bytes (int), sparks_debited (int), error, params (dict), progress_pct, progress_msg, file_url (null until completed), created_at, completed_at.
Sparks40 (MODEL_3D_SPARKS, op=create); charged up front, refunded on failure
Errors404 image not found (or not yours); 402 insufficient Sparks (message includes needed vs. held amounts)
NotesAsync: queues a model_3d job and returns immediately. Poll GET /api/me/models/{id}; progress_pct/progress_msg update while running. status=completed is terminal (file_url becomes /api/me/models/{id}/file); on failure the error field is set and the debit is refunded.
POST/api/me/models/{model_id}/editRuns a mesh edit on an existing model, producing a new model version.
RequestJSON body (EditIn): op (required; one of smooth, simplify, fill_holes, clean, erase); strength (int, default 8, 1–40; smooth only: Taubin iterations); target_faces (int, default 8000, 200–500000; simplify only); point (list of 3 floats, model coordinates; required for erase); radius (float, default 0.06, >0 and ≤0.5; erase only — a fraction of the bounding-box diagonal, not an absolute distance).
ResponseModel3DOut (new child model; parent_model_id points at the edited model).
Sparks2 (MODEL_EDIT_SPARKS) for every edit op; charged up front, refunded on failure
Errors400 op not in the allowed list, or erase without a 3-element point; 404 model not found; 409 parent model not yet completed; 402 insufficient Sparks
NotesAsync: creates a new Model3D and queues a job. Poll GET /api/me/models/{new_id}; status=completed is terminal, failure sets error and refunds. Op semantics from the code: smooth=Taubin (no shrink), simplify=texture-aware quadric collapse, fill_holes=close small boundary loops, clean=drop disconnected floaters/repair non-manifold edges, erase=delete geometry within radius of the picked point then close the hole (removes attached artefacts clean cannot).
POST/api/me/models/{model_id}/paintSaves a browser-painted texture atlas as a new version of the model.
Requestmultipart/form-data: file (required; PNG only, magic-byte checked; max 64 MB).
ResponseModel3DOut (new child model, op=paint).
Sparks2 (MODEL_EDIT_SPARKS — paint is billed at the edit rate)
Errors404 model not found; 409 parent model not yet completed; 413 file over 64 MB; 400 file is not a PNG; 402 insufficient Sparks
NotesAsync, but no GPU work — the worker only swaps the uploaded image into the GLB. The PNG is written to disk before the job is queued. Poll GET /api/me/models/{new_id}; completed is terminal.
POST/api/me/models/{model_id}/meshUploads an edited GLB mesh (from the in-studio Sculpt tool or an external tool like Blender) as a new version of the model.
Requestmultipart/form-data: op (form field, default "sculpt"; must be "sculpt" or "upload"); file (required; binary .glb, max 128 MB). The GLB is validated: must have a glTF header, a JSON chunk, and mesh[0].primitives[0] with POSITION data.
ResponseModel3DOut with status=completed immediately; faces, vertices, has_texture, size_bytes are read from the uploaded GLB.
Sparks2 (MODEL_EDIT_SPARKS)
Errors400 op not sculpt/upload, or file is not a usable .glb; 404 model not found; 409 parent model not yet completed; 413 file over 128 MB; 402 insufficient Sparks (the uploaded file is deleted before the error is returned)
NotesSynchronous — the only 3D write path with no worker job. The file arrives finished, so it is validated, stored, and charged in the request; no polling needed. File is written before the debit.
POST/api/me/models/{model_id}/regenRegenerates a brushed area of the model's texture with a FLUX Fill diffusion pass.
Requestmultipart/form-data: prompt (form field, string, default ""; stored truncated to 400 chars); file (required; PNG mask in ATLAS space, white where brushed; max 32 MB).
ResponseModel3DOut (new child model, op=regen).
Sparks4 (MODEL_REGEN_SPARKS — a full FLUX Fill sampler run, priced like an image repaint)
Errors404 model not found; 409 parent model not yet completed; 400 parent has no texture, or mask is not a PNG; 413 mask over 32 MB; 402 insufficient Sparks
NotesAsync GPU job (unlike the free-ish Patch flow). Mask is saved to disk before queuing. Poll GET /api/me/models/{new_id}; status=completed is terminal, failure sets error and refunds.
GET/api/me/models/{model_id}/textureDownloads the model's base-colour texture atlas so an editor can paint on it.
RequestPath: model_id. No body or query parameters.
ResponseRaw image bytes of the GLB's baseColorTexture (media type from the GLB, typically image/png). Header: Cache-Control: private, max-age=60.
Sparksfree
Errors404 model not found, model has no stored file, or model has no texture to edit; 410 the file is gone from disk
NotesExtracted server-side straight from the GLB's BIN chunk; exists because model-viewer cannot hand the atlas back to the page.
GET/api/me/models/{model_id}/fileDownloads the model itself as a .glb file.
RequestPath: model_id. No body or query parameters.
ResponseBinary GLB (Content-Type: model/gltf-binary), served as an attachment named "{title}.glb" (or "model.glb" if untitled).
Sparksfree
Errors404 model not found or it has no stored file yet; 410 the file is gone from disk
NotesThis is the URL surfaced in Model3DOut.file_url once status=completed.
GET/api/me/modelsLists your 3D models, newest first.
RequestQuery: limit (int, default 50, clamped to 1–200); folder_id (string, optional — filter to one folder).
ResponseJSON array of Model3DOut.
Sparksfree
Errorsnone beyond auth
GET/api/me/models/{model_id}Fetches one model, including live job progress — the polling endpoint for all async 3D operations.
RequestPath: model_id. No body or query parameters.
ResponseModel3DOut. progress_pct/progress_msg come from the associated job while it runs; file_url is set only when status=completed.
Sparksfree
Errors404 model not found (or not yours)
DELETE/api/me/models/{model_id}Deletes one model and its file; models derived from it are kept and detached.
RequestPath: model_id. No body.
ResponseModelDeleteOut: {deleted: "<model_id>"}.
Sparksfree
Errors404 model not found
NotesChild models have their parent_model_id set to null rather than being deleted, so removing an early draft does not remove work cut from it. The on-disk file is unlinked best-effort.

Sharing & Explore

POST/api/me/models/{id}/publishShare a finished 3D model on the public Explore page, or take it back off.
RequestJSON: {public: bool}
Responsethe model, with is_public updated
Sparksfree
Errors404 not yours; 409 model not finished
NotesWhile shared, the GLB is downloadable by anyone via the public URL below.
GET/api/public/explore-models no authPublic feed of shared 3D models.
Requestquery: limit (max 48)
Response[{id, title, faces, has_texture, creator_handle, file_url, created_at}]
Sparksfree
GET/api/public/m/{id}/file no authA shared model's GLB — no auth while its owner keeps it public.
Responsebinary model/gltf-binary
Sparksfree
Errors404 not shared; 410 file gone

Printing & restoring

GET/api/me/models/{id}/stlThe model as an STL for 3D printing, watertight-merged and scaled to real millimetres.
Requestquery: size_mm (10-400, default 100) — the longest axis
Responsebinary model/stl; X-Loralume-Printable header says watertight or check-in-slicer
Sparksfree
Errors404 not yours or not finished; 410 file gone
NotesUV-seam vertices are merged on the way out; slicers assume millimetres, hence the explicit scale.
POST/api/me/images/{id}/upscale (mode: restore)One-click old-photo restore: colorizes when the photo is monochrome or sepia, refreshes the face if there is one, sharpens 2x.
RequestJSON: {mode: 'restore', scale: 1, hint?: string — colour memories, passed to the colorizer}
Responsean image op; poll /api/me/image-ops/{id} until completed/failed
Sparks6
Errors402 not enough Sparks
NotesDetection is automatic: colour photos skip the colorize stage but still get the face and sharpen passes. The result is a NEW image beside the original.

Folders

POST/api/me/foldersCreate a new gallery folder (and its hidden backing order that uploads will attach to).
RequestJSON body FolderIn: name (string, 1-120 chars; whitespace trimmed)
ResponseFolderOut: id (str), name (str), image_count (int), created_at (ISO str), updated_at (ISO str), preview_urls (list[str], up to 4 newest-image thumbnail paths of the form /api/orders/{order_id}/gallery/{image_id})
Sparksfree
Errors422 validation (name length); 500 "upload storage is not configured" if the internal placeholder product is missing
GET/api/me/foldersList all of the caller's folders, most recently updated first.
Responselist[FolderOut] (same fields as create: id, name, image_count, created_at, updated_at, preview_urls)
Sparksfree
Errorsnone beyond auth
GET/api/me/folders/{folder_id}Fetch one folder the caller owns.
Requestpath param folder_id
ResponseFolderOut: id, name, image_count, created_at, updated_at, preview_urls
Sparksfree
Errors404 "folder not found" if it doesn't exist or belongs to someone else
PATCH/api/me/folders/{folder_id}Rename a folder.
Requestpath param folder_id; JSON body FolderIn: name (string, 1-120 chars; trimmed)
ResponseFolderOut with the new name and refreshed updated_at
Sparksfree
Errors404 folder not found / not owned; 422 validation
DELETE/api/me/folders/{folder_id}Delete a folder; the images inside are kept and become loose (unfoldered) library images.
Requestpath param folder_id
Response{ok: true, images_kept: int} — images_kept is how many images had their folder_id cleared
Sparksfree
Errors404 folder not found / not owned
NotesNon-destructive to images by design: only the folder label is removed, never the pictures.
POST/api/me/folders/{folder_id}/imagesUpload image files into a folder; each becomes a normal gallery image that every image operation works on.
Requestmultipart/form-data, field `files`: up to 40 files per request; each file max 40 MB; accepted content types image/png, image/jpeg, image/webp, image/gif, image/bmp, image/tiff; longest edge max 8192 px; everything is re-encoded to PNG (alpha preserved for RGBA/LA/P sources)
ResponseUploadedOut: folder_id (str), uploaded (list[str] of new image ids), skipped (list of {name, reason} for files rejected per-file)
Sparksfree
Errors400 if more than 40 files in one request; 404 folder not found / not owned. Per-file problems (unsupported type, >40 MB, empty, unreadable, edge >8192 px) do NOT fail the request — they appear in `skipped` with a reason.
NotesThe original filename is stored as the image's prompt (truncated to 300 chars) so uploads are searchable. Each upload is created with guard_verdict="pending" and the same asynchronous moderation scan as generated images is enqueued per file; the scan runs in the background and its failure to enqueue never loses the upload.
POST/api/me/folders/{folder_id}/runRun one image operation (upscale/enhance, fast upscale, remove background, face detail, colorize, repaint) across a whole folder or a selected subset, with an optional dry run that prices the batch first.
Requestpath param folder_id; JSON body RunIn: mode (str, default "enhance"), scale (int, default 2; forced to 1 for modes that take no scale), image_ids (list[str] or null — null means the whole folder; explicit list is de-duplicated), dry_run (bool, default false). Hard cap: at most 100 images per batch (oldest first).
ResponseRunOut: folder_id, mode, scale, dry_run, eligible (int), total_sparks (int), balance_after (int or null; null on dry runs), queued (list[str] of per-image operation ids), skipped (list of {image_id, reason})
SparksCharged per image via the standard image-op pricing: fast = 1, remove_bg = 1, face = 2, colorize = 4, repaint = 4; enhance/upscale = ceil(1.2 × output_megapixels × 0.5), minimum 1, where output_megapixels = (src_w × scale) × (src_h × scale) / 1024². scale must be 2 or 4 for upscale pricing. Images whose scaled output would exceed 24 MP are skipped, not charged. dry_run=true costs nothing and returns the exact total_sparks the real run would charge.
Errors400 if image_ids is an explicitly empty list; 404 folder not found or no matching images in it; 409 if (non-dry-run) nothing in the selection is eligible; 402 if the wallet balance is below the batch total (message states needed vs. held Sparks). Individual images can also land in `skipped` for: no recorded size, file missing on disk, output over 24 MP, or a per-image queue failure (its HTTP error detail is echoed as the reason).
NotesAsynchronous: the call only queues work. The whole bill is checked against the wallet up front before anything is debited, then each eligible image is submitted through the identical single-image operation path (same validation, pricing, debit, and refund-on-failure). `queued` contains the per-image operation ids to poll via the single-image operation status endpoint; this file does not define that endpoint or its terminal statuses. Use dry_run=true first to show the user the total before spending.
POST/api/me/folders/{folder_id}/moveFile existing images the caller owns (generated or uploaded) into this folder.
Requestpath param folder_id; JSON body MoveIn: image_ids (list[str]). Only the first 500 ids are processed; the rest are silently ignored.
Response{ok: true, moved: int} — moved counts only images that existed and belong to the caller
Sparksfree
Errors404 folder not found / not owned. Ids that don't exist or belong to another user are skipped silently (not moved, no error) rather than revealing their existence.

Pricing

GET/api/pricing/generation-costs no authReturns a Sparks cost table for every curated model at three canonical settings so customers can compare FLUX vs SDXL vs Schnell pricing.
Requestnone (no parameters)
ResponseGenerationCostsOut: { models: ModelPricingRow[] { model_slug: str, display_name: str, family: 'flux'|'sdxl', base: float, cost_1mp_25steps: int (1024², 25 steps, 1 LoRA, no draft), cost_hd_50steps: int (1536², 50 steps), cost_draft: int (1024², 25 steps, draft on) }, formula: str — 'cost = model_base × (steps / 25) × megapixels × (1 + (lora_count-1) × 0.15) × (0.5 if draft else 1) × quantity' }
Sparksfree to call. Model base rates it reports (Sparks per 1024²/25-step/1-LoRA image): flux-dev 4.0, flux-schnell 1.5, juggernaut-xl 1.2, realistic-vis 1.2, dreamshaper 1.2, sdxl-base 1.2, sdxl-lightning 0.8, pony-v6 1.4; compose aliases flux-premium 4.0, realvis-standard 1.2, juggernaut-standard 1.2, epicrealism-standard 1.2
Errorsnone beyond transport; endpoint takes no input
NotesRows are emitted for every slug in MODEL_BASE, including the /compose alias slugs (flux-premium, realvis-standard, juggernaut-standard, epicrealism-standard), which fall back to family 'sdxl' and their slug as display name. Per-image costs are ceil()-rounded with a 1-Spark minimum. Responses are cached 5 minutes at the nginx layer.
GET/api/pricing/training-costs no authReturns the fixed Sparks price (and USD equivalent) for each LoRA training preset.
Requestnone (no parameters)
ResponseTrainingCostRow[]: { preset: str, display_name: str, sparks: int, usd_equiv: float (sparks / 100; $1 = 100 Sparks parity) }
Sparksfree to call. Prices reported: sdxl_face_v1 = 200 Sparks ($2.00), flux_face_v1 = 1000 Sparks ($10.00), flux_face_v1_rush = 1400 Sparks ($14.00)
Errorsnone beyond transport; endpoint takes no input
NotesDisplay names describe the presets: SDXL Face LoRA (rank 128, ~30 min on a 24 GB GPU), FLUX Face LoRA (rank 32, ~85 min on H100), FLUX Face LoRA (rush, ~50 min on H200). Cached 5 minutes at nginx.
GET/api/pricing/image-ops no authQuotes what every image operation (upscale, enhance, face enhance, background removal, colorize) would cost for a source image of the given size.
Requestquery: width: int (required), height: int (required); both clamped server-side to 1..8192
ResponseImageOpCostRow[]: { mode: 'fast'|'enhance'|'face'|'remove_bg'|'colorize', label: str, scale: int (1, 2, or 4), sparks: int, resizes: bool (true when scale > 1), note: str }
Sparksfree to call. Prices quoted: fast upscale (2x or 4x) = 1 Spark flat; enhance upscale = ceil(1.2 × output_megapixels × 0.5), minimum 1, priced by OUTPUT area (a 4x of 1 MP is 16 MP of sampling); face enhance = 2; remove_bg = 1; colorize = 4
Errors422 if width or height is missing or not an integer (FastAPI validation)
NotesUpscale rows whose output would exceed 24 megapixels (UPSCALE_MAX_OUTPUT_MP) are omitted from the response entirely — the job would be refused, so it is not offered. The UI is expected to fetch this rather than hardcode prices.
POST/api/pricing/estimate no authComputes the exact Sparks cost for a specific generation request; powers the live cost meter on the /generate page.
RequestJSON body (EstimateIn): model_slug: str (required); width: int (required, >0, ≤4096); height: int (required, >0, ≤4096); steps: int (default 25, 1..100); lora_count: int (default 1, 0..9); draft: bool (default false); quantity: int (default 1, 1..16); reference_image: bool (default false); pose_transfer: bool (default false, retained for older callers — treated identically to reference_image)
ResponseEstimateOut: { total_sparks: int, per_image_sparks: int, breakdown: { reference_image_sparks: int, model_base: float, steps_multiplier: float, megapixels: float, lora_multiplier: float, draft_multiplier: float, quantity: int } }
Sparksfree to call. Quote formula: cost = model_base × (steps/25) × megapixels × (1 + max(0, lora_count−1) × 0.15) × (0.5 if draft) × quantity, ceil-rounded per image, minimum 1 Spark per image; steps clamped at 100 (4.0× max). reference_image or pose_transfer adds a flat 1 Spark (REFERENCE_SPARKS) per image on top
Errors400 if the pricing service rejects the parameters (ValueError from compute_cost, e.g. non-positive dimensions); 422 for Pydantic validation failures (out-of-range fields, missing required fields)
NotesAn unknown model_slug does NOT error — it silently falls back to flux-dev pricing (base 4.0) with a server-side warning, so a typo'd slug quotes at the premium rate. The first LoRA is free; each additional LoRA adds 15%. This is a quote only — nothing is debited; the same surcharge logic is applied at debit time so the quoted and charged prices match.

Webhooks

GET/api/me/webhooksList your webhook endpoints.
Response[{id, url, active, disabled_reason, last_success_at, created_at, secret_hint}]
Sparksfree
Errorsnone beyond auth
POST/api/me/webhooksRegister an endpoint to receive events. The signing secret is returned once, at creation.
RequestJSON: {url} — must be a public http(s) URL; private, loopback and link-local addresses are rejected
Response{id, url, secret (whsec_…, shown once), secret_hint, ...}
Sparksfree
Errors400 unusable or private URL; 409 more than 5 active endpoints
NotesEvents: ping, render.completed/rejected, image_op.completed/failed, model.completed/failed. Delivery is at-least-once with backoff (up to 5 tries over ~2.5h); endpoints failing 50 times in a row are switched off.
POST/api/me/webhooks/{id}/testQueue a ping event so you can verify your receiver and signature check.
Response{queued, event_id}
Sparksfree
Errors404 not yours
GET/api/me/webhooks/{id}/deliveriesRecent delivery attempts with errors, for debugging.
Requestquery: limit (max 100)
Response[{id, event, attempts, delivered_at, dead_at, last_error, created_at}]
Sparksfree
Errors404 not yours
DELETE/api/me/webhooks/{id}Remove an endpoint. Pending deliveries are dropped with it.
Response204
Sparksfree
Errors404 not yours

API keys

GET/api/me/api-keysLists the customer's active (non-revoked) MCP API keys, newest first.
RequestNo body or query parameters.
ResponseJSON array of ApiKeyOut: id (str), name (str), key_prefix (str), created_at (datetime), last_used_at (datetime | null). Revoked keys are excluded; the full key value is never included.
Sparksfree
Errors401 when the login JWT is missing/invalid (from get_current_user).
NotesSynchronous. Only the key_prefix is ever visible after creation — the full key is not retrievable from this endpoint.
POST/api/me/api-keysCreates a new MCP API key and returns the full key value exactly once.
RequestJSON body ApiKeyCreateIn: name (str, 1–64 chars, default "MCP key").
Response201 Created with ApiKeyCreateOut: id (str), name (str), key_prefix (str), created_at (datetime), last_used_at (datetime | null), key (str — the full key, shown once, never stored in plaintext).
Sparksfree
Errors402 Payment Required when mcp_access denies the account (detail carries the reason); 409 Conflict when the account already has 10 active keys ("at most 10 active API keys; revoke one first"); 401 without a valid JWT.
NotesSave the `key` field from the response immediately — it is returned only at creation and cannot be fetched again (subsequent listings show key_prefix only). Limit is MAX_ACTIVE_KEYS = 10 active keys per account; revoke one to free a slot.
DELETE/api/me/api-keys/{key_id}Revokes one of the customer's API keys.
RequestPath parameter: key_id (str). No body.
Response204 No Content on success (no response body).
Sparksfree
Errors404 Not Found when the key does not exist or belongs to another user ("key not found"); 401 without a valid JWT.
NotesIdempotent: deleting an already-revoked key still returns 204. Revocation sets revoked_at; the key stops appearing in listings and no longer counts toward the 10-key limit.

Questions or a missing endpoint? Write to support@loralume.com. — Loralume