Skip to content

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/quote returns the price (credits and USD), whether the job can run now, an ETA range and your queue position, for free
  • Safe retries: the Idempotency-Key header makes a repeated submit return the original job and never charge twice
  • Three ways to follow a job: poll GET /v1/jobs/{id}, stream GET /v1/jobs/{id}/events, or receive job.* webhooks
  • Processing region control: region_policy keeps a job in the EU, or in the EU and Switzerland
  • Scheduled start: run_at holds 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 ​

MethodPathChargedAPI keyDescription
POST/v1/jobs/quotenoyesPrice, availability, ETA and processing regions of a job not yet submitted
POST/v1/jobsyesyes (write key)Submit a job; 202 with the job id and its quote
GET/v1/jobsnoyesYour jobs, newest first, cursor pagination
GET/v1/jobs/{job_id}noyesOne job
POST/v1/jobs/{job_id}/cancelnoyes (write key)Cancel; any refund is reported in the answer
POST/v1/jobs/{job_id}/retryyesyes (write key)A new job from a failed or cancelled one
POST/v1/jobs/{job_id}/redo/quotenoyesPrice and ETA of running a finished job again at another speed
POST/v1/jobs/{job_id}/redoyesyes (write key)A new job from a finished job at another speed
GET/v1/jobs/{job_id}/eventsnoyesServer-Sent Events: status, progress, preview, done
GET/v1/console/webhooks/job-secretnoyes (write key)The secret that signs per-job webhooks
POST/v1/console/webhooks/job-secret/rotatenoyes (write key)Replace that secret
POST/v1/job-graphs/quotenoyesTotal and per-node quote of a job graph
POST/v1/job-graphscreditsno (403)Submit a job graph (browser sessions only for now)
GET/v1/job-graphs/{graph_id}noyesA graph and its nodes
POST/v1/job-graphs/{graph_id}/cancelnoyes (write key)Cancel every node that has not started
POST/v1/schedulesnono (403)Create a schedule (browser sessions only for now)
GET/v1/schedulesnoyesYour schedules
DELETE/v1/schedules/{schedule_id}noyes (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:

KindWhat it does
image.generateGenerate images from a prompt
image.editEdit an image
image.layersSplit one image into separate RGBA layers
video.generateGenerate a video
audio.musicGenerate music

model takes a public model name. These names are accepted:

ModelFamily
seedream-5-0-260128image
ida-q-image-2image
gpt-image-1image
fotohub-image-editimage (FOTOhub AI editing model)
fotohub-image-proimage (FOTOhub AI)
luma-ray-3-2video

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 ​

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

FieldTypeRequiredDefaultDescription
kindstring (1-64)yesOne of the kinds above
modelstring (1-128)yesA public model name from the table above
paramsobjectno{}Model parameters. At most 64 KiB of JSON (params is too large: pass storage URLs, not media.)
params.num_imagesinteger 1-64no1The 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"noyour stored preference (any unless you changed it)Where the job may run (see Processing regions)
deadlineISO 8601 datetimenoMust be in the future. Not allowed with idle speed
idle_window_h6 | 12 | 24no12 with idleIdle speed only
run_atISO 8601 datetimenoHold 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_urlstring (max 2048)noAn https URL that receives this job's job.* events, signed (see Webhooks)
notifyobject (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": true and the header Idempotent-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_CONFLICT with Retry-After: 2 and the message A request with this Idempotency-Key is still being processed. Retry shortly.

Response 202 Accepted ​

json
{
  "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.

json
{
  "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"
}
FieldDescription
availabletrue when the job can be accepted now
price.creditsThe price in credits
price.usdThe USD price charged to an API key's wallet (filled for API-key callers)
etaSeconds 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
positionYour queue position if you submit now
processing_regionsWhere the job could run: eu, ch, us
reasonOnly 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:

json
{
  "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"
}
FieldDescription
statusqueued, scheduled (waiting for run_at), waiting (a graph node waiting for its parents), running, succeeded, failed, cancelled
progress0-100
stageA short stage name while running, or null
position / etaQueue position and ETA while the job waits or runs, else null
outputsOnly 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
chargeWhat was charged and refunded (credits, usd, refunded_credits, refunded_usd), or null
processing_regionWhere it ran: eu, ch, us, or null

List ​

GET /v1/jobs?status=running,queued&kind=image.generate&limit=20&cursor=...

QueryDescription
statusComma-separated public statuses (queued, scheduled, waiting, running, succeeded, failed, cancelled)
kindOne kind
limit1-100, default 20
cursornext_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):

json
{"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:

FieldTypeDescription
speedsarray of standard / boost / idle (1-3)Optional; all three by default
idle_window_h6 | 12 | 24Only 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_policyThe job may run in
anyEU, Switzerland or the United States
eu_onlyEU only
eu_chEU 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:

EventData
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
doneThe 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:

EventWhen
job.queuedAccepted into the queue
job.startedStarted running
job.progressEvery 10 percentage points, only when you sent notify: {"progress": true}
job.succeededFinished with outputs
job.failedFailed
job.refundedA charge was returned
job.cancelledCancelled

The body is the standard envelope with the job object as data:

json
{
  "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:

bash
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:

FieldTypeDescription
nodesarray, 1-32Each {key, kind, model, params, inputs?, after?}
nodes[].keystring ^[A-Za-z0-9_-]{1,32}$Unique within the graph
nodes[].inputsobject{"<param>": "<node_key>.outputs[<i>].url"} (or .path): the parent's output is placed into this node's params
nodes[].afterarray of keysWait for these nodes without taking their output
speedstandard | boostDefault standard. Send either speed or speeds, not both
speedsarray of standard / boost / idleOne quote per speed: {"speeds": {"standard": <quote>, ...}}
region_policyany | eu_only | eu_chAs for a single job
bulkbooleanDefault 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:

json
{
  "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:

BucketLimit
Submit, cancel, retry and redo of jobs60
Job and redo quotes120
Job reads (GET /v1/jobs, GET /v1/jobs/{id})600
Event streams opened30 (and 5 open at once)
Graph submits and cancels20
Graph quotes60
Graph reads300
Schedule writes / reads20 / 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:

json
{
  "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.

HTTPCodeMeaning / extra fields
400URL_BLOCKEDA URL in params is not a public https URL. field
400INVALID_INPUTparams nests too deeply, or holds more than 64 URLs
401UNAUTHENTICATEDNo valid credential
402INSUFFICIENT_CREDITSNot enough balance. required_usd when known
403PLAN_REQUIREDThe wallet is empty: API access needs a funded wallet. Top up to continue. topup_url
403FEATURE_DISABLEDAn option your key cannot use (feature: "speed"), job graphs or schedules with a key
403FORBIDDENA read-only key on a write route, or a storage bucket key
404JOB_NOT_FOUNDNot yours, or does not exist
409IDEMPOTENCY_CONFLICTThe key was used for a different request, or the first request is still running
409RETRY_NOT_ALLOWEDThe job cannot be retried
409REDO_NOT_ALLOWED, REDO_SAME_SPEED, REDO_GRAPH_NODEThe job cannot be redone that way
422INVALID_REQUESTA field is missing, unknown or out of range. field
422INVALID_BINDINGA graph input binding cannot be resolved
422INVALID_INPUTRedo: the original inputs are gone
429RATE_LIMITEDSee Rate limits. Retry-After
429TOO_MANY_ACTIVEToo many of your jobs are running or queued. running, queued, limit_running, limit_queued; Retry-After: 30
429QUEUE_FULLThe queue for this job is full. Retry-After: 60
429BOOST_LIMITAll priority runs for today are used
503CAPACITY_UNAVAILABLENothing can run the job now. processing_regions, eta_done_p90_s; Retry-After: 30
503REGION_UNAVAILABLENothing in your allowed regions can run it now. Retry-After: 60
503MODEL_DISABLEDThe model is not available (or not priced) now. Retry-After: 300
503FABRIC_DISABLEDJob processing is paused. Retry-After: 60
503TEMPORARILY_UNAVAILABLERetry shortly. Retry-After: 5
503SUBMIT_UNCONFIRMEDThe submit could not be confirmed. idempotency_key; retry with the same key
500INTERNAL_ERRORProcessing 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).