--- api_version: 2026-05 schema: async-job-v0.4 last_updated: 2026-05-03 source: docs/api.md --- # Runfra API Reference **Base URL:** `https://api.runfra.com` **API version:** `0.4` --- ## Getting Started Runfra is an async batch image generation API. You submit a job, the platform generates candidates in parallel, scores and filters them, and returns the best result. No SDK required — just HTTP. ### Five-minute quickstart **Step 1 — Get credentials** - **Testing (browser session):** Open your [dashboard](https://runfra.com/dashboard) and copy the Bearer token from the API Access panel. It expires in ~1 hour. - **Production (server-to-server):** Go to [Dashboard → API Keys](https://runfra.com/dashboard/api-keys), create a key, and copy the raw value immediately — it is shown once. **Step 2 — Create a job** Two submission modes — `prompt` for a single scene, `items[]` to generate multiple distinct prompts in one request: ```bash # Single-prompt (one scene, multiple candidates) — with an API key curl -X POST https://api.runfra.com/v1/jobs \ -H "X-API-Key: rfa_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "prompt": "a red panda sitting on a wooden bridge, studio ghibli style", "batch_size": 4, "quality_mode": "strict" }' ``` ```bash # Multi-prompt (one image per prompt, in a single request) curl -X POST https://api.runfra.com/v1/jobs \ -H "X-API-Key: rfa_your_key_here" \ -H "Content-Type: application/json" \ -d '{ "items": [ { "prompt": "a red panda on a bridge, studio ghibli style" }, { "prompt": "a fox in a snowy forest, golden hour" }, { "prompt": "a whale diving underwater, photorealistic" } ] }' ``` ```bash # Single-prompt with a Bearer token (testing / dashboard session) curl -X POST https://api.runfra.com/v1/jobs \ -H "Authorization: Bearer YOUR_SUPABASE_ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "prompt": "a red panda sitting on a wooden bridge, studio ghibli style", "batch_size": 4, "quality_mode": "strict" }' ``` Response `201 Created`: ```json { "job_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "status": "queued" } ``` **Step 3 — Poll for the result** ```bash curl https://api.runfra.com/v1/jobs/a1b2c3d4-e5f6-7890-abcd-ef1234567890/result \ -H "X-API-Key: rfa_your_key_here" ``` Returns `202 Accepted` while generating. Returns `200 OK` when done. - **Single-prompt job:** read `best_result_url` for the Top Pick image. - **Multi-prompt job:** read `items[].result_url` — one URL per submitted prompt. `best_result_url` is `null` for multi-mode. Image files are retained for 30 days. ### img2img quickstart Image-to-image requires a JWT (Bearer token). Upload a reference image first, then submit a job with `pipeline_mode="img2img"`. ```bash # Step 1 — upload reference (JWT-only, no API key) curl -X POST https://api.runfra.com/v1/uploads/input-image \ -H "Authorization: Bearer YOUR_SUPABASE_ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "image": "" }' # → { "input_storage_key": "user-uuid/image-uuid.png" } ``` ```bash # Step 2 — create job curl -X POST https://api.runfra.com/v1/jobs \ -H "Authorization: Bearer YOUR_SUPABASE_ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "prompt": "a red panda in the style of studio ghibli", "pipeline_mode": "img2img", "input_storage_key": "user-uuid/image-uuid.png", "width": 512, "height": 512 }' ``` img2img is SDXL-only, JWT-only, and output is capped at 768×768 px. The upload preserves the image's original aspect ratio — the worker aligns it to the output size just before inference using cover + center-crop. --- ## Authentication Two auth modes are accepted. Most endpoints accept either; `/v1/me/*` routes require Bearer JWT only. ### API Key (production / server-to-server) ``` X-API-Key: rfa_your_key_here ``` - Format: `rfa_` followed by 40 lowercase hex characters (44 characters total). - The raw value is shown **once** at creation (or rotation) and never stored — save it immediately. - `key_prefix` (first 12 chars, e.g. `rfa_ab12cd34ef`) is safe to display. - Optional `expires_at`; expired keys are rejected with `401`. - Revocation takes effect immediately. | Key type | Billing | |---|---| | `customer` | Production key linked to a billable user. Credits deducted atomically; refunded on platform failure. The only type available via self-service. | | `developer` | Internal / CI key. Billing bypassed. Not self-serve — internal provisioning only. | ### Bearer JWT (dashboard / session testing) ``` Authorization: Bearer ``` Obtained from `supabase.auth.getSession()` in the frontend. **Short-lived (~1 hour).** Required for `/v1/me/*` routes and for `/v1/uploads/input-image`. Do not commit to a repo or use in production code. --- ## Models Three models are available. Pass the model name (or any alias) in `model_name`. ### stable-diffusion-xl-base-1.0 (SDXL) Default model. Photorealistic and general-purpose. - **Default inference steps:** 20 - **Guidance scale:** configurable (default ~7.5) - **Aliases:** `sdxl`, `sdxl-base`, `stable-diffusion-xl` - **Credits per image:** 1 (≤512px) · 2 (≤768px) · 3 (>768px, e.g. 1024) ### flux-schnell Fast distilled flow-matching model. Good for abstract / artistic / creative. - **Default inference steps:** 4 (distilled — fewer steps are intentional) - **Guidance scale:** forced to `0.0` (model design — request value ignored) - **Negative prompts:** not supported — `negative_prompt` and `shared_negative_prompt` are silently ignored - **Aliases:** `flux`, `flux_schnell` - **Credits per image:** 2 (≤512px) · 3 (≤768px) · 4 (>768px, e.g. 1024) ### flux-dev (RunFra Pro) Higher-fidelity FLUX variant. Best for premium quality where cost matters less than result. - **Default inference steps:** 20 (28 also available via the `quality` step preset) - **Guidance scale:** configurable (default `3.5`) - **Negative prompts:** not supported — silently ignored - **Aliases:** `flux-dev`, `flux_dev` - **Display name:** `RunFra Pro` - **Credits per image:** 5 (≤512px) · 9 (≤768px) · 14 (>768px, e.g. 1024) ### Pricing table — credits per image Credits are determined by model and the longest side of the output (`max(width, height)`): | Model | ≤ 512 px | ≤ 768 px | > 768 px (e.g. 1024×1024) | |---|---|---|---| | `stable-diffusion-xl-base-1.0` | 1 credit | 2 credits | 3 credits | | `flux-schnell` | 2 credits | 3 credits | 4 credits | | `flux-dev` (RunFra Pro) | 5 credits | 9 credits | 14 credits | **Total job cost** = `credits_per_image × effective_batch`. Single mode: `effective_batch = batch_size`. Multi mode: `effective_batch = items.length`. Cost is frozen at job creation; refunds return the exact stored amount. **Cost examples (600-credit pack):** - SDXL 512×512 (1 cr/img) = 600 images - SDXL 1024×1024 (3 cr/img) = 200 images - FLUX Schnell 1024×1024 (4 cr/img) = 150 images - RunFra Pro 1024×1024 (14 cr/img) ≈ 42 images --- ## Async Job Lifecycle Create job → poll `/result` until `200` → read `best_result_url` (single-prompt) or `items[].result_url` (multi-prompt). | Status | Meaning | |---|---| | `queued` | Accepted; no worker has started yet | | `running` | At least one task claimed by a worker | | `succeeded` | All tasks done; result available | | `failed` | Platform error; credits refunded automatically | | `cancelled` | Cancelled before completion | | When | Field to use | |---|---| | Job is `running` | `preview_best_url` — rolling best-so-far, non-null while running | | Succeeded + `selection_finalized = true` | `best_result_url` — authoritative Top Pick; production-safe | | After finalization, style preference | `aesthetic_best_url` — highest aesthetic-score candidate | | All accepted candidates | `result_urls` — array of all quality-passed image URLs | **Always check `selection_finalized = true` before using `best_result_url`.** It is `null` until finalization is complete. ```bash JOB_ID="a1b2c3d4-e5f6-7890-abcd-ef1234567890" while true; do STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ -H "X-API-Key: rfa_your_key_here" \ "https://api.runfra.com/v1/jobs/$JOB_ID/result") if [ "$STATUS" == "200" ]; then curl -s -H "X-API-Key: rfa_your_key_here" \ "https://api.runfra.com/v1/jobs/$JOB_ID/result" break fi sleep 3 done ``` Typical generation time is 10–30 seconds per task. --- ## Endpoints ### POST /v1/uploads/input-image Upload a reference image for an img2img job. **Requires Bearer JWT.** API keys and anonymous access are rejected (`403`). - **Body:** `{ "image": "" }` - **Accepted formats:** PNG, JPEG, WebP. - **Limits:** max 10 MB decoded; max longest side 1024 px; max area 4096×4096 (decompression-bomb guard). - **Normalization:** API converts to RGB PNG at original proportional size (no center-crop). The worker aligns to the job's `width × height` just before inference using cover + center-crop. - **Response (201):** `{ "input_storage_key": "user-uuid/image-uuid.png" }` — pass verbatim as `input_storage_key` in `POST /v1/jobs`. - **Rate limit:** 30 req/min/user. - **Errors:** `401` missing/invalid JWT · `403` non-JWT caller · `413` >10 MB · `422` bad base64 / format / dimensions · `500` storage failure. ### POST /v1/jobs Create a new batch generation job. Accepts either auth mode. Credits deducted atomically; refunded on platform failure. `developer` keys bypass billing. Two submission modes — set exactly one of `prompt` or `items`. #### Parameters | Field | Type | Required | Default | Notes | |---|---|---|---|---| | `prompt` | string | one of `prompt`/`items` | — | 1–2000 chars. Single-prompt mode. | | `items` | array | one of `prompt`/`items` | — | Array of `PromptItem`. Multi-prompt mode. Min 1. | | `negative_prompt` | string | no | null | (Single mode) max 2000 chars. Ignored for `flux-schnell` and `flux-dev`. | | `model_name` | string | no | `stable-diffusion-xl-base-1.0` | See Models. | | `width` | integer | no | 1024 | 512–1024 px | | `height` | integer | no | 1024 | 512–1024 px | | `num_inference_steps` | integer | no | model default | 1–100. SDXL/flux-dev: 20. flux-schnell: 4. | | `guidance_scale` | float | no | model default | 0.0–20.0. Forced to 0.0 for `flux-schnell`. Defaults to 3.5 for `flux-dev`. | | `batch_size` | integer | no | 1 | (Single mode) 1–100. Tier cap: anonymous = 4, free = 8, paid = 100. | | `quality_mode` | string | no | `"strict"` | `strict` \| `soft` \| `off`. | | `return_all_candidates` | boolean | no | false | Include rejected candidates in result. | | `notify_on_complete` | boolean | no | false | Email on terminal state. Requires verified email. | | `pipeline_mode` | string | no | `"txt2img"` | `txt2img` or `img2img`. | | `input_storage_key` | string | conditional | null | Required when `img2img`. Must be omitted for `txt2img`. | | `strength` | float | no | `0.8` | (img2img only) 0.4–0.9. Lower = closer to reference. | | `crop_anchor` | string | no | `"center"` | (img2img only) `center`/`top`/`bottom`/`left`/`right`/`top-left`/etc. | | `consistency_mode` | string | no | `"off"` | `off` or `story`. | | `shared_prompt` | string | no | null | Prefix prepended to every task. Story mode only. | | `shared_negative_prompt` | string | no | null | Negative prefix merged per task. Story mode only. Ignored for `flux-schnell`. | | `base_seed` | integer | no | null | Per-task seed = `base_seed + task_index`. Story mode only. | `PromptItem` fields (multi-prompt mode): `prompt` (required, 1–2000 chars), `negative_prompt` (optional), `seed` (optional, 0–4294967295). #### img2img constraints When `pipeline_mode="img2img"`: - **Auth:** Bearer JWT required. API keys are rejected with `403`. - **Model:** locked to `stable-diffusion-xl-base-1.0` regardless of `model_name`. - **Size:** `width` and `height` each between 384 and 768 (multiples of 64), `width × height ≤ 768×768`. Default 512×512. - **`input_storage_key`:** required — upload the reference image first via `POST /v1/uploads/input-image`. #### Response (201) ```json { "job_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "status": "queued", "seeds": [2847391024, 1093847562, 7391820475, 4820193847] } ``` `seeds` length = `batch_size` (single) or `items.length` (multi). #### Errors | Code | Reason | |---|---| | `401` | Missing or invalid API key / JWT | | `402` | Insufficient credits | | `422` | Invalid field (both `prompt` and `items` set, neither set, empty `items`, empty item prompt, out-of-range value, batch limit exceeded) | | `500` | Failed to enqueue tasks | ### GET /v1/jobs/{job_id} Full job status, metadata, per-task progress. Either auth mode. JWT users and customer API keys can only access their own jobs. Key fields in the response: - `status`: `queued` | `running` | `succeeded` | `failed` | `cancelled` - `selection_finalized`: `false` while running, `true` once Top Pick is selected. **Check before reading `best_result_url`.** - `best_result_url`: authoritative Top Pick. **Null until `selection_finalized = true`.** - `preview_best_url`: rolling best-so-far during generation. Null once finalized. - `aesthetic_best_url`: Style Pick. Non-null only after finalization. - `result_urls`: array of all accepted candidate URLs. - `accepted_count`: authoritative. - `quality_score` / `quality_passed` / `is_best_effort`. - `task_progress`: array of per-task progress, only when `status = "running"`. - `input_mode`: `"single"` or `"multi"`. - `prompt_count`, `notify_on_complete`, `items` (multi-mode only — `null` for single-mode). - `pipeline_mode`: `"txt2img"` or `"img2img"`. - `input_image_signed_url`: short-lived signed URL (1 hour); non-null only for `img2img` and only for the job owner. `items[]` entry fields (multi-mode): `task_index`, `prompt`, `status`, `result_url`, `seed`, `error_message`. `task_progress` item: `{ task_index, stage, pct, message, last_heartbeat_at }`. `result_meta` is a legacy auxiliary blob. Always prefer top-level fields. Only read `result_meta` for fields with no top-level equivalent: `candidates`, `user_selected_index`, `model_best_index`, `aesthetic_best_index`. ### GET /v1/jobs/{job_id}/result Lightweight polling endpoint. `200` when terminal; `202` while running. | `status` | HTTP | |---|---| | `queued` / `running` | `202` | | `succeeded` / `failed` / `cancelled` | `200` | `cancelled` returns `200` (not `202`) because `202` implies "retry later" which is wrong for a job that will never proceed. For multi-prompt jobs, `items[]` is the authoritative per-item result source. Legacy fields (`best_result_url`, `result_urls`, `candidates`) are still returned for backwards compatibility — `best_result_url` may be `null`. For `selection_finalized`, `preview_best_url`, and `task_progress` use `GET /v1/jobs/{job_id}` instead. ### POST /v1/jobs/{job_id}/select Record the user's preferred candidate. Used for model feedback. Either auth mode. Body: `{ "candidate_index": <0-based index> }`. Response: `{ job_id, candidate_index, model_best_index, agreement_model }`. Errors: `404` not found · `409` job not yet completed or no accepted candidates · `422` index out of range. ### GET /v1/me/jobs List the authenticated user's jobs, paginated. **JWT only.** Query: `page` (default 1), `per_page` (default 20). Returns `{ jobs[], total, page, per_page, has_more }`. ### GET /v1/me/jobs/{job_id} Same shape as `GET /v1/jobs/{job_id}`. **JWT only.** `403` if the job belongs to another user. ### DELETE /v1/me/jobs/{job_id} Delete a job and all associated data. **JWT only.** `204` on success, `403` for non-owners. ### GET /v1/me/credits Current credit balance. **JWT only.** Response: `{ "credits": , "is_paid": }`. At 1024×1024: SDXL ≈ `credits ÷ 3` images, FLUX Schnell ≈ `credits ÷ 4` images, RunFra Pro (`flux-dev`) ≈ `credits ÷ 14` images. ### POST /v1/billing/checkout Create a Stripe Checkout session. **JWT only.** Body: `{ "package_id": "starter" | "creator" | "studio" }`. | `package_id` | Pack name | Price | Credits | SDXL 1024px capacity | Effective price | |---|---|---|---|---|---| | `starter` | Starter Batch | $5 | 600 | up to ~200 images | ~$0.025/image | | `creator` | Creator Batch | $12 | 1,600 | up to ~533 images | ~$0.0225/image | | `studio` | Pro Batch | $24 | 3,600 | up to ~1,200 images | ~$0.020/image | Response: `{ "checkout_url": "https://checkout.stripe.com/..." }`. Credits granted via webhook on payment success. Failed jobs are refunded — credits are only consumed by successful output. ### Pricing Runfra uses prepaid credits shared across Dashboard, API, n8n, and MCP. There is no separate API surcharge. Generation cost = `image_count × model_resolution_rate` (see model rate table). Effective per-image price depends on credit pack, model, resolution, and batch size — there is no fixed USD-per-image rate. --- ## API Key Management All management endpoints require **Bearer JWT** — sign-in only. `customer`-type keys only. - **GET /v1/me/api-keys** — List all of your keys. - **POST /v1/me/api-keys** — Create. Body: `{ name, description?, expires_at? }`. Response includes the **raw key (shown once)**. - **POST /v1/me/api-keys/{key_id}/revoke** — Revoke immediately. Idempotent. Row kept for audit. - **DELETE /v1/me/api-keys/{key_id}** — Same as `/revoke`. Returns `204`. - **POST /v1/me/api-keys/{key_id}/rotate** — Revoke + reissue in one operation. Inherits `name`/`description`/`expires_at`. **Not atomic** — if the new key creation fails after the old key is revoked, manually create a replacement. Errors (all management endpoints): `401` invalid JWT · `404` not yours · `500` DB error. --- ## Quality Filtering & Best-Effort Delivery Every candidate passes a heuristic gate (brightness, contrast, sharpness) and a CLIP prompt-alignment check. | `quality_passed` | `is_best_effort` | Meaning | |---|---|---| | `true` | `false` | At least one image passed. Normal delivery. | | `false` | `true` | Generated, but none passed after retries. Best available is delivered. **Credits not refunded.** | | `false` | `false` | No images generated (platform crash, safety block, timeout). **Credits refunded.** | --- ## Content Policy All prompts are screened at submission. Rejected requests consume **no credits**. Always blocked: - CSAM - Sexual violence / non-consensual content - Explicit pornographic / sexual content - Graphic gore, dismemberment, or torture Blocked response: `HTTP 422 { "detail": "This request violates our content policy. Please revise the prompt and try again." }`. Moderation pipeline: 1. Rule-based regex filter (always active; fail-closed for CSAM / sexual violence). 2. AI classifier (OpenAI Moderation API, when `OPENAI_API_KEY` set; fail-open on service error). 3. Output safety gate (CLIP zero-shot at the worker after generation; fail-closed by default). Layers 1 and 2 fire at submission and reject before credits are consumed. Layer 3 fires after generation — unsafe images are silently dropped and credits refunded. --- ## Error Reference ```json { "detail": "Invalid or inactive API key" } // 401 { "detail": "Invalid or expired token" } // 401 { "detail": "Not enough credits. Purchase more to continue." } // 402 { "detail": "Access denied" } // 403 { "detail": "Job not found" } // 404 { "detail": "Cannot select a candidate while job is '...'" } // 409 { "detail": "This request violates our content policy. Please revise the prompt..." } // 422 { "detail": "candidate_index N is out of range ..." } // 422 { "detail": "Failed to create job" } // 500 ``` --- ## Rate Limits | Endpoint | Limit | |---|---| | `POST /v1/jobs` | 20 req/min | | `GET /v1/jobs/{id}` | 120 req/min | | `GET /v1/jobs/{id}/result` | 120 req/min | | `GET /v1/jobs/{id}/candidates` | 60 req/min | | `POST /v1/jobs/{id}/select` | 30 req/min | | `POST /v1/billing/checkout` | 5 req/min | Identity resolution order: `X-API-Key` → `sub` claim from Bearer JWT → client IP. Exceeding the limit returns `429 Too Many Requests` with a `Retry-After` header. --- ## Security Model | Auth type | Job access | Billing | |---|---|---| | Bearer JWT | Own jobs only | Credits deducted atomically; refunded on platform failure | | Customer API key | Own jobs only | Same credit formula, billed to key owner | | Developer API key | All jobs (broad access) | No billing; bypass is per-key and explicit | **CORS:** restricted to an explicit allowed-origins list (`CORS_ORIGINS` env). Wildcard `*` is never returned. **Anonymous playground proxy:** frontend anonymous requests go through `/api/anon/...` server-side; `RUNFRA_ANON_KEY` lives only in server env and never reaches the browser bundle. **Webhook security:** Stripe webhook (`POST /v1/billing/webhook`) verifies `Stripe-Signature` using `STRIPE_WEBHOOK_SECRET`. Invalid signatures rejected `400`. Credit grants are idempotent. --- ## Image Retention Policy Generated files are kept for **30 days** after job creation. Job metadata (prompt, status, accepted count, credits) is retained indefinitely. | `storage_state` | Meaning | URLs returned? | |---|---|---| | `active` | Files available | Yes | | `pending_delete` | Scheduled for deletion | Yes — download now | | `deleted` | Files removed; metadata only | No — all URL fields null | | `delete_failed` | Storage deletion failed; will retry | Yes | Job-response expiry fields: `files_expire_at` (ISO timestamp or null), `storage_state`, `files_deleted_at` (ISO timestamp or null). When `storage_state === "deleted"`, the API nulls out all URL fields. `pending_delete` and `delete_failed` still return live URLs. --- ## What Runfra is NOT To prevent incorrect assumptions when an AI agent reads this file: - **Not OpenAI-compatible.** No `/v1/images/generations` endpoint. Use `POST /v1/jobs` + polling. - **No custom LoRA upload.** No support for user-supplied weights. - **No fixed per-image USD price.** Pricing is `credits × model × resolution`. Do not assume a flat per-image dollar rate. - **No webhooks for jobs.** Job completion is delivered via polling (or, for signed-in users, an optional `notify_on_complete` email). - **No SLAs from this docs file.** Production deployments should consult the dashboard and contract terms.