Compute Jobs
/v1/jobs is one queue for generation work: you ask for a price and an ETA first, submit with an Idempotency-Key, then follow the job by polling, by a Server-Sent Events stream or by a signed webhook. Every job answers in the same shape whatever runs it, and a job that fails is refunded.
Capabilities
- Quote before you pay:
POST /v1/jobs/quotereturns the price (credits and USD), whether the job can run now, an ETA range and your queue position, for free - Safe retries: the
Idempotency-Keyheader makes a repeated submit return the original job and never charge twice - Three ways to follow a job: poll
GET /v1/jobs/{id}, streamGET /v1/jobs/{id}/events, or receivejob.*webhooks - Processing region control:
region_policykeeps a job in the EU, or in the EU and Switzerland - Scheduled start:
run_atholds a job until a time up to 30 days ahead
Base URL: https://apis.fotohub.appAuthentication: Authorization: Bearer fh_live_your_api_key (see Authentication)
Endpoints
| Method | Path | Charged | API key | Description |
|---|---|---|---|---|
| POST | /v1/jobs/quote | no | yes | Price, availability, ETA and processing regions of a job not yet submitted |
| POST | /v1/jobs | yes | yes (write key) | Submit a job; 202 with the job id and its quote |
| GET | /v1/jobs | no | yes | Your jobs, newest first, cursor pagination |
| GET | /v1/jobs/{job_id} | no | yes | One job |
| POST | /v1/jobs/{job_id}/cancel | no | yes (write key) | Cancel; any refund is reported in the answer |
| POST | /v1/jobs/{job_id}/retry | yes | yes (write key) | A new job from a failed or cancelled one |
| POST | /v1/jobs/{job_id}/redo/quote | no | yes | Price and ETA of running a finished job again at another speed |
| POST | /v1/jobs/{job_id}/redo | yes | yes (write key) | A new job from a finished job at another speed |
| GET | /v1/jobs/{job_id}/events | no | yes | Server-Sent Events: status, progress, preview, done |
| GET | /v1/console/webhooks/job-secret | no | yes (write key) | The secret that signs per-job webhooks |
| POST | /v1/console/webhooks/job-secret/rotate | no | yes (write key) | Replace that secret |
| POST | /v1/job-graphs/quote | no | yes | Total and per-node quote of a job graph |
| POST | /v1/job-graphs | credits | no (403) | Submit a job graph (browser sessions only for now) |
| GET | /v1/job-graphs/{graph_id} | no | yes | A graph and its nodes |
| POST | /v1/job-graphs/{graph_id}/cancel | no | yes (write key) | Cancel every node that has not started |
| POST | /v1/schedules | no | no (403) | Create a schedule (browser sessions only for now) |
| GET | /v1/schedules | no | yes | Your schedules |
| DELETE | /v1/schedules/{schedule_id} | no | yes (write key) | Delete a schedule |
Keys. Every route takes an fh_live_* key. Routes that change something need a key that can write: a read-only key gets 403 FORBIDDEN with the message This API key is read-only. Storage bucket keys cannot run jobs (403 FORBIDDEN, Storage bucket keys cannot run jobs.).
Availability. The job queue is enabled for every plan. If it is switched off for your account the routes answer 403 with FEATURE_DISABLED (or PLAN_REQUIRED when a plan is needed). Nothing is charged.
Kinds and models
kind must be one of:
| Kind | What it does |
|---|---|
image.generate | Generate images from a prompt |
image.edit | Edit an image |
image.layers | Split one image into separate RGBA layers |
video.generate | Generate a video |
audio.music | Generate music |
model takes a public model name. These names are accepted:
| Model | Family |
|---|---|
seedream-5-0-260128 | image |
ida-q-image-2 | image |
gpt-image-1 | image |
fotohub-image-edit | image (FOTOhub AI editing model) |
fotohub-image-pro | image (FOTOhub AI) |
luma-ray-3-2 | video |
Any other name is refused with 422 INVALID_REQUEST (field: "model", Unknown model.); an unknown kind with field: "kind", Unknown job kind. Whether a model can run a kind right now is answered by the quote: call POST /v1/jobs/quote before you build against a pair.
params carries the model's own parameters (for example the prompt, and num_images). Inputs are URLs to files, never the bytes themselves.
Submit a job
curl -X POST https://apis.fotohub.app/v1/jobs \
-H "Authorization: Bearer fh_live_your_api_key" \
-H "Idempotency-Key: order-1042-hero" \
-H "Content-Type: application/json" \
-d '{
"kind": "image.generate",
"model": "seedream-5-0-260128",
"params": {"prompt": "A ceramic mug on a linen tablecloth, soft morning light", "num_images": 2},
"region_policy": "eu_only"
}'Request body
Unknown fields are refused (422 INVALID_REQUEST, with the offending field).
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
kind | string (1-64) | yes | One of the kinds above | |
model | string (1-128) | yes | A public model name from the table above | |
params | object | no | {} | Model parameters. At most 64 KiB of JSON (params is too large: pass storage URLs, not media.) |
params.num_images | integer 1-64 | no | 1 | The number of outputs; it is also the number of units you are charged for |
speed | "standard" | "boost" | "idle" | no | "standard" | API keys can use standard only (see Speed) |
region_policy | "any" | "eu_only" | "eu_ch" | no | your stored preference (any unless you changed it) | Where the job may run (see Processing regions) |
deadline | ISO 8601 datetime | no | Must be in the future. Not allowed with idle speed | |
idle_window_h | 6 | 12 | 24 | no | 12 with idle | Idle speed only |
run_at | ISO 8601 datetime | no | Hold the job until then (at most 30 days ahead: run_at is too far ahead (30 days at most).). A time in the past means now. The job reads as scheduled until it starts | |
webhook_url | string (max 2048) | no | An https URL that receives this job's job.* events, signed (see Webhooks) | |
notify | object (max 2 KiB of JSON) | no | {"progress": true} adds job.progress webhook events |
URLs inside params. Every string in params that is a network address (scheme://... or //...) is checked before anything is charged: it must be a public https URL. A refused URL answers 400 with code URL_BLOCKED, the JSON path of the value in field (for example params.image_url) and the message <field> must be a public https URL. At most 64 URLs per job, and params may nest at most 6 levels deep (both 400 INVALID_INPUT).
Idempotency-Key. Required for API keys (422 INVALID_REQUEST, field: "Idempotency-Key", The Idempotency-Key header is required for API keys.). 1-200 printable ASCII characters, no spaces.
- The same key with the same body returns the original job with
"replay": trueand the headerIdempotent-Replayed: true. Nothing is charged again. - The same key with a different body:
409 IDEMPOTENCY_CONFLICT. - The same key while the first request is still running:
409 IDEMPOTENCY_CONFLICTwithRetry-After: 2and the messageA request with this Idempotency-Key is still being processed. Retry shortly.
Response 202 Accepted
{
"id": "6f0c2c4e-1d7b-5a43-9a1e-0b8f3f6a2d11",
"status": "queued",
"quote": {
"available": true,
"price": {"credits": 8.0, "usd": 0.07},
"eta": {"start_s": 4, "done_p50_s": 21, "done_p90_s": 38, "approximate": false},
"position": 3,
"speed": "standard"
},
"replay": false
}The numbers above are an example. quote is the price and ETA the job was accepted at. API-key jobs are paid from the USD wallet (price.usd).
Quote
POST /v1/jobs/quote takes the same body as the submit without run_at, webhook_url and notify (kind, model, params, speed, region_policy, deadline, idle_window_h). It is free, needs no Idempotency-Key and never creates a job.
{
"available": true,
"price": {"credits": 8.0, "usd": 0.07},
"eta": {"start_s": 4, "done_p50_s": 21, "done_p90_s": 38, "approximate": false},
"position": 3,
"processing_regions": ["eu", "ch"],
"speed": "standard"
}| Field | Description |
|---|---|
available | true when the job can be accepted now |
price.credits | The price in credits |
price.usd | The USD price charged to an API key's wallet (filled for API-key callers) |
eta | Seconds until the job starts, and until it is done at the median (done_p50_s) and 90th percentile (done_p90_s). approximate: true when the estimate is older than 15 seconds. null when unavailable |
position | Your queue position if you submit now |
processing_regions | Where the job could run: eu, ch, us |
reason | Only when available is false: a code from the error table, for example CAPACITY_UNAVAILABLE or MODEL_DISABLED. No ETA is given then |
Prices are not fixed on this page: the quote is the price for your account. The USD rates per model are listed by GET /v1/pricing.
The job object
GET /v1/jobs/{job_id}, the done event of the stream and every webhook carry the same object:
{
"id": "6f0c2c4e-1d7b-5a43-9a1e-0b8f3f6a2d11",
"kind": "image.generate",
"model": "seedream-5-0-260128",
"status": "succeeded",
"progress": 100,
"stage": null,
"position": null,
"eta": null,
"created_at": "2026-10-09T08:12:03.118Z",
"started_at": "2026-10-09T08:12:06.402Z",
"finished_at": "2026-10-09T08:12:27.950Z",
"outputs": [
{"url": "https://s1.fotohub.app/storage/v1/object/sign/...", "mime": "image/png", "width": 2048, "height": 2048, "duration_s": null}
],
"error": null,
"charge": {"credits": 0.0, "usd": 0.07, "refunded_credits": 0.0, "refunded_usd": 0.0},
"processing_region": "eu",
"speed": "standard"
}| Field | Description |
|---|---|
status | queued, scheduled (waiting for run_at), waiting (a graph node waiting for its parents), running, succeeded, failed, cancelled |
progress | 0-100 |
stage | A short stage name while running, or null |
position / eta | Queue position and ETA while the job waits or runs, else null |
outputs | Only when succeeded: url (on FOTOhub storage or static.fotohub.app), mime, width, height, duration_s |
error | {code, message} when the job failed or was cancelled, else null |
charge | What was charged and refunded (credits, usd, refunded_credits, refunded_usd), or null |
processing_region | Where it ran: eu, ch, us, or null |
List
GET /v1/jobs?status=running,queued&kind=image.generate&limit=20&cursor=...
| Query | Description |
|---|---|
status | Comma-separated public statuses (queued, scheduled, waiting, running, succeeded, failed, cancelled) |
kind | One kind |
limit | 1-100, default 20 |
cursor | next_cursor from the previous page |
Response: {"data": [<job>, ...], "next_cursor": "<string or null>"}. An unknown status, kind or a malformed cursor is 422 INVALID_REQUEST. A job that is not yours, or does not exist, is 404 JOB_NOT_FOUND.
Cancel, retry, redo
Cancel
POST /v1/jobs/{job_id}/cancel (no body):
{"id": "6f0c2c4e-...", "status": "cancelled", "cancel_requested": false, "refund": {"usd": 0.07}, "already_finished": false}cancel_requested: true means the job was already running and stops at the next safe point; already_finished: true means there was nothing left to cancel. refund lists the amounts returned (credits and/or usd), or is null.
Retry
POST /v1/jobs/{job_id}/retry (no body) creates a new job from a failed or cancelled one, with a new quote and a new charge, at standard speed. The answer is the submit 202 plus "retry_of": "<old job id>". Retrying the same job again returns that first retry (replay: true) and does not charge twice. A job in any other status, or a node of a job graph, answers 409 RETRY_NOT_ALLOWED.
Redo at another speed
POST /v1/jobs/{job_id}/redo/quote prices running a finished or failed job again:
| Field | Type | Description |
|---|---|---|
speeds | array of standard / boost / idle (1-3) | Optional; all three by default |
idle_window_h | 6 | 12 | 24 | Only when idle is asked for |
Response: {"id", "current_speed", "speeds": {"standard": <quote>, ...}}, each a quote as above. For API keys boost and idle come back available: false with reason: "FEATURE_DISABLED". When the original inputs are no longer available the answer is 422 INVALID_INPUT (The original inputs of this job are no longer available, so it cannot be redone.).
POST /v1/jobs/{job_id}/redo with {"speed": "standard"} (and Idempotency-Key) creates the new job: 202 plus "redo_of". API keys can only redo at standard (403 FEATURE_DISABLED, feature: "speed"). Refusals: 409 REDO_NOT_ALLOWED, 409 REDO_SAME_SPEED (the job already ran at that speed), 409 REDO_GRAPH_NODE.
Speed
speed accepts standard, boost and idle, but API-key jobs run at standard only: boost and idle are refused with 403 FEATURE_DISABLED, feature: "speed" and the message Priority and idle speed are not available for API wallet billing yet. Nothing is charged.
Processing regions
region_policy | The job may run in |
|---|---|
any | EU, Switzerland or the United States |
eu_only | EU only |
eu_ch | EU or Switzerland |
The quote lists the processing_regions that could take the job, and the finished job reports the processing_region it used. With a narrow policy a job may be refused with 503 REGION_UNAVAILABLE when no capacity in the allowed regions can take it; nothing is charged.
Events (SSE)
GET /v1/jobs/{job_id}/events streams text/event-stream:
| Event | Data |
|---|---|
status | {id, status, position, eta}, sent when one of them changes |
progress | {id, progress, stage} |
preview | {id, url}: a live preview image, when the model produces one |
done | The full job object; the stream ends. {id, status: "unknown"} if the job disappeared |
timeout | {id, poll: "/v1/jobs/<id>"} after 15 minutes; the stream ends, continue by polling |
A : keep-alive comment is sent every 15 seconds. You may open 30 streams a minute and hold 5 at once; past that the answer is 429 RATE_LIMITED (Too many open event streams (at most 5 at once). Close one, or poll GET /v1/jobs/{id}.).
Webhooks
Set webhook_url on the submit to receive this job's events:
| Event | When |
|---|---|
job.queued | Accepted into the queue |
job.started | Started running |
job.progress | Every 10 percentage points, only when you sent notify: {"progress": true} |
job.succeeded | Finished with outputs |
job.failed | Failed |
job.refunded | A charge was returned |
job.cancelled | Cancelled |
The body is the standard envelope with the job object as data:
{
"event": "job.succeeded",
"event_id": "6f0c2c4e-1d7b-5a43-9a1e-0b8f3f6a2d11:job.succeeded",
"timestamp": "2026-10-09T08:12:28.104Z",
"data": { "id": "6f0c2c4e-...", "status": "succeeded", "outputs": [ ... ] },
"attempt": 1
}Headers: X-FotoHub-Signature (HMAC-SHA256 hex of the raw body, no prefix), X-FotoHub-Event, and X-FotoHub-Delivery, which equals event_id (<job_id>:<event>, plus :<step> for job.progress). Use it to drop duplicates: delivery is at least once, with up to 3 attempts.
The signing secret is your job-webhook secret, separate from the secrets of registered endpoints:
curl https://apis.fotohub.app/v1/console/webhooks/job-secret \
-H "Authorization: Bearer fh_live_your_api_key"
# {"secret": "<64 hex characters>"}It is created on first use. POST /v1/console/webhooks/job-secret/rotate replaces it ({"secret": "..."}) and the old one stops signing immediately. Both need a write key; a storage outage answers 503 with {"detail": "Job webhook secret is temporarily unavailable."}. Verify the signature exactly as for registered webhooks.
webhook_url passes the same checks as a registered endpoint when you submit (422 INVALID_REQUEST, field: "webhook_url"), and again right before each delivery; a URL that no longer passes is skipped.
Job graphs
A job graph runs several jobs as one unit: a node can wait for others (after) or take another node's output as an input (inputs), and when a node fails, the nodes that depend on it are cancelled and refunded.
API keys
Submitting a graph is not available to API keys yet: POST /v1/job-graphs answers 403 FEATURE_DISABLED (Job graphs bill credits and are not available for API keys yet.) before anything is charged. Quoting, reading and cancelling work with a key.
POST /v1/job-graphs/quote:
| Field | Type | Description |
|---|---|---|
nodes | array, 1-32 | Each {key, kind, model, params, inputs?, after?} |
nodes[].key | string ^[A-Za-z0-9_-]{1,32}$ | Unique within the graph |
nodes[].inputs | object | {"<param>": "<node_key>.outputs[<i>].url"} (or .path): the parent's output is placed into this node's params |
nodes[].after | array of keys | Wait for these nodes without taking their output |
speed | standard | boost | Default standard. Send either speed or speeds, not both |
speeds | array of standard / boost / idle | One quote per speed: {"speeds": {"standard": <quote>, ...}} |
region_policy | any | eu_only | eu_ch | As for a single job |
bulk | boolean | Default false |
A graph quote answers {available, total_credits, nodes: [{key, credits, eta, available}], processing_regions, speed, eta_total, reason?}. eta_total follows the longest chain of dependent nodes. Duplicate keys, an input that names a missing node, a node waiting for itself or a cycle answer 422 INVALID_REQUEST (field: "nodes").
GET /v1/job-graphs/{graph_id} returns {id, status, nodes: [{key, id, status, progress, error?, charged_credits, refunded_credits, refund_pending_credits}], quote: {total_credits, nodes: [{key, credits}]}, created_at, finished_at, charged_credits, refunded_credits, refund_pending_credits}. Each node is also a job you can read with GET /v1/jobs/{id}.
POST /v1/job-graphs/{graph_id}/cancel cancels every node that has not started: {id, status, cancelled: [...], cancel_requested: [...], refund: {credits}}. A graph that is not yours is 404 JOB_NOT_FOUND (No such graph.).
Schedules
Schedules start a job or a graph on a cron or once at a set time.
API keys
Creating a schedule is not available to API keys yet: POST /v1/schedules answers 403 FEATURE_DISABLED before anything is stored. Listing and deleting work with a key.
GET /v1/schedules returns {"data": [...]}, each:
{
"id": "b7d4...",
"name": "Nightly catalogue refresh",
"cron": "0 2 * * *",
"run_once_at": null,
"timezone": "Europe/Warsaw",
"enabled": true,
"next_run_at": "2026-10-10T00:00:00Z",
"last_run_at": "2026-10-09T00:00:00Z",
"last_job_id": "6f0c...",
"last_graph_id": null,
"last_error": null,
"created_at": "2026-10-01T10:00:00Z",
"body": {"kind": "image.generate", "model": "seedream-5-0-260128", "params": {"prompt": "..."}}
}A schedule has exactly one of cron (five fields: minute hour day month weekday; at most once every 5 minutes) and run_once_at (between one minute and a year ahead). Each run is charged when it starts, like a submit; a run that is refused (for example for lack of balance) is skipped and its code is recorded in last_error. At most 50 enabled schedules per account.
DELETE /v1/schedules/{schedule_id} answers {"id": "...", "deleted": true}, or 404 JOB_NOT_FOUND (No such schedule.).
Rate limits
Per account, per minute:
| Bucket | Limit |
|---|---|
| Submit, cancel, retry and redo of jobs | 60 |
| Job and redo quotes | 120 |
Job reads (GET /v1/jobs, GET /v1/jobs/{id}) | 600 |
| Event streams opened | 30 (and 5 open at once) |
| Graph submits and cancels | 20 |
| Graph quotes | 60 |
| Graph reads | 300 |
| Schedule writes / reads | 20 / 120 |
Over a limit: 429 RATE_LIMITED with Retry-After (seconds to the next minute).
Errors
Every error of these routes has the same body:
{
"error": {"code": "INSUFFICIENT_CREDITS", "message": "Not enough credits or balance for this job."},
"charged": false
}charged is false when nothing was taken, true when a charge stands (it is then refunded by the job's own life cycle), "pending" or "unknown" when the outcome could not be confirmed (see SUBMIT_UNCONFIRMED). Extra fields sit inside error next to code and message.
| HTTP | Code | Meaning / extra fields |
|---|---|---|
| 400 | URL_BLOCKED | A URL in params is not a public https URL. field |
| 400 | INVALID_INPUT | params nests too deeply, or holds more than 64 URLs |
| 401 | UNAUTHENTICATED | No valid credential |
| 402 | INSUFFICIENT_CREDITS | Not enough balance. required_usd when known |
| 403 | PLAN_REQUIRED | The wallet is empty: API access needs a funded wallet. Top up to continue. topup_url |
| 403 | FEATURE_DISABLED | An option your key cannot use (feature: "speed"), job graphs or schedules with a key |
| 403 | FORBIDDEN | A read-only key on a write route, or a storage bucket key |
| 404 | JOB_NOT_FOUND | Not yours, or does not exist |
| 409 | IDEMPOTENCY_CONFLICT | The key was used for a different request, or the first request is still running |
| 409 | RETRY_NOT_ALLOWED | The job cannot be retried |
| 409 | REDO_NOT_ALLOWED, REDO_SAME_SPEED, REDO_GRAPH_NODE | The job cannot be redone that way |
| 422 | INVALID_REQUEST | A field is missing, unknown or out of range. field |
| 422 | INVALID_BINDING | A graph input binding cannot be resolved |
| 422 | INVALID_INPUT | Redo: the original inputs are gone |
| 429 | RATE_LIMITED | See Rate limits. Retry-After |
| 429 | TOO_MANY_ACTIVE | Too many of your jobs are running or queued. running, queued, limit_running, limit_queued; Retry-After: 30 |
| 429 | QUEUE_FULL | The queue for this job is full. Retry-After: 60 |
| 429 | BOOST_LIMIT | All priority runs for today are used |
| 503 | CAPACITY_UNAVAILABLE | Nothing can run the job now. processing_regions, eta_done_p90_s; Retry-After: 30 |
| 503 | REGION_UNAVAILABLE | Nothing in your allowed regions can run it now. Retry-After: 60 |
| 503 | MODEL_DISABLED | The model is not available (or not priced) now. Retry-After: 300 |
| 503 | FABRIC_DISABLED | Job processing is paused. Retry-After: 60 |
| 503 | TEMPORARILY_UNAVAILABLE | Retry shortly. Retry-After: 5 |
| 503 | SUBMIT_UNCONFIRMED | The submit could not be confirmed. idempotency_key; retry with the same key |
| 500 | INTERNAL_ERROR | Processing failed |
SUBMIT_UNCONFIRMED. Retry with the same Idempotency-Key: it returns the job if it was created and never charges twice. For an API key the message is We could not confirm that the job was created. Retry with the same Idempotency-Key to get it; if it was not created the charge is returned automatically within minutes. and charged is "pending".
Failed jobs. A job that fails carries error.code, for example CONTENT_REJECTED (The content was rejected by the safety filter.), INVALID_INPUT, OUTPUT_TOO_LARGE, CANCELLED_BEFORE_START or INTERNAL_ERROR, and its charge is refunded (charge.refunded_usd, and a job.refunded webhook).
Related
- Webhooks: envelope and signature verification
- Billing & Pricing: the USD wallet and
GET /v1/pricing - Errors

