# Jobs

> Submit a task as a job, follow its state, read its result, and cancel or resume it.

Submitting a task creates a **job**. A job runs in the background; you learn its outcome by
polling, by waiting on the submit call, or by a [webhook](https://docs.refabric.com/task-apis/calling-tasks/webhooks).

## Submit

```bash
curl -s -X POST "$REFABRIC_API/tasks/image.generate" \
  -H "Authorization: Key $REFABRIC_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"prompt": "a navy jacket in this fabric", "references": [{"image": "art:x1", "use_case": "fabric"}]}'
```

```json
HTTP 202
X-Refabric-Credits: <credits held>
X-Refabric-Credit-Type: <credit type>

{ "job_id": "j_…", "lifecycle": "queued",
  "status_url": "https://api.refabric.com/v1/jobs/j_…",
  "result_url": "https://api.refabric.com/v1/jobs/j_…/result",
  "cancel_url": "https://api.refabric.com/v1/jobs/j_…/cancel" }
```

The three URLs are absolute. Use them as given rather than building them yourself.

### Request headers

| Header | Meaning |
|---|---|
| `Idempotency-Key` | makes retries safe ([Idempotency](https://docs.refabric.com/api-reference/platform/idempotency)) |
| `Prefer: wait=N` | wait up to `N` seconds (capped at `limits.prefer_wait.max_seconds` of `GET /v1/meta`) for the result ([Sync mode](https://docs.refabric.com/task-apis/calling-tasks/synchronous)) |
| `X-Refabric-Start-Timeout: <seconds>` | deadline for the job to **start** ([Start deadline](#start-deadline)) |
| `X-Request-ID` | your own correlation id; echoed back and logged with ours |
| `Refabric-Version` | pin a dated API version ([Versioning](https://docs.refabric.com/api-reference/platform/versioning)) |

Query parameter `webhook_url=https://…` asks for a callback when the job ends ([Webhooks](https://docs.refabric.com/task-apis/calling-tasks/webhooks)).

### Credits

Submitting **holds** the estimated cost: `X-Refabric-Credits` is the amount held and
`X-Refabric-Credit-Type` its credit type (values at `GET /v1/vocab/credit_type`). When the job
ends the hold is settled against what was actually delivered; a failed or cancelled job releases what
it did not use. Check the
cost first with `POST /v1/tasks/{name}/estimate`, which takes the same body and runs nothing.
Every task's price per option is on [Pricing](https://docs.refabric.com/task-apis/pricing); what you spent is [Usage](https://docs.refabric.com/api-reference/platform/tasks/read-your-usage).

## States

```
queued ──▶ running ──▶ succeeded
   │          ├──────▶ failed
   └──────────┴──────▶ cancelled
```

A job has two fields, not one: `lifecycle` says **where** it is, `outcome` says **how it ended**
and is `null` until then.

| `lifecycle` | `outcome` | Meaning |
|---|---|---|
| `queued` | `null` | accepted, waiting to start |
| `running` | `null` | working |
| `terminal` | `succeeded` | finished; the result is ready |
| `terminal` | `failed` | ended with an `error` ([Errors](https://docs.refabric.com/task-apis/errors/task-errors#errors-inside-a-job)) |
| `terminal` | `cancelled` | cancelled by you (or by us, with a reason) |

New non-terminal lifecycles may be added; treat anything but `terminal` as "not finished yet". The
submit answer's `lifecycle` is the job's lifecycle at that moment (`queued`, or `running` after a
`Prefer: wait` that ran out). Live list: `GET /v1/vocab/lifecycle`.

## Read a job

```bash
curl -s "$REFABRIC_API/jobs/j_…" -H "Authorization: Key $REFABRIC_API_KEY"
```

```json
{ "job_id": "j_…", "task": "image.generate", "lifecycle": "running", "outcome": null,
  "progress": { "done": 1, "total": 4, "phase": "" },
  "metrics": { "queued_at": "…", "started_at": "…", "duration_ms": null }, "error": null,
  "api_key_id": "key_8f3a…",
  "charge": { "credits": <credits>, "credit_type": "<credit type>", "state": "reserved" } }
```

`api_key_id` is the key that started the job (`null` for work started in the Refabric app).
`charge` is what the job costs: while it runs the credits are `reserved` (the most it can cost);
once it has ended they are `charged` (what it cost) or `refunded` (it cost nothing) (live list: `GET /v1/vocab/charge_state`). `charge` is `null`
for a task that holds no credits.

`progress` counts outputs: `total` is how many the job planned (`null` until known), `done` how
many it has delivered so far; a succeeded job's `done` is what it delivered. `phase` is a short label of the job's progress.

A failed job's `error` is the same error object every error answer uses
([Errors](https://docs.refabric.com/task-apis/errors/task-errors#errors-inside-a-job)):

```json
{ "job_id": "j_…", "task": "image.generate", "lifecycle": "terminal", "outcome": "failed",
  "error": { "code": "processing_failed", "type": "processing_failed",
             "message": "The job ran and failed.", "field": null, "retryable": true,
             "request_id": null } }
```

A cancelled job's `error` is `{"code": "cancelled", "type": "conflict", "retryable": false, …}`
— never `null`.

(Full field list: the OpenAPI spec.) Poll every few seconds at most; for jobs longer than a minute
prefer a webhook.

### Event log (`?logs=1`)

```bash
curl -s "$REFABRIC_API/jobs/j_…?logs=1" -H "Authorization: Key $REFABRIC_API_KEY"
```

adds `events` to the same answer — what happened to the job, in order:

```json
{ "job_id": "j_…", "lifecycle": "terminal", "outcome": "succeeded", …,
  "events": [
    { "at": "2026-10-01T10:00:00Z", "phase": "queued",    "message": "Accepted and queued." },
    { "at": "2026-10-01T10:00:02Z", "phase": "running",   "message": "Started." },
    { "at": "2026-10-01T10:00:09Z", "phase": "delivered", "message": "Delivered a file.", "file": "art:…" },
    { "at": "2026-10-01T10:00:12Z", "phase": "warning",   "message": "…", "field": "back" },
    { "at": "2026-10-01T10:00:12Z", "phase": "succeeded", "message": "Finished." } ] }
```

| Field | Meaning |
|---|---|
| `at` | when (ISO-8601 UTC) |
| `phase` | `queued` · `running` · `delivered` · `warning` · `succeeded` · `failed` · `cancelled` (new values may appear — [Conventions](https://docs.refabric.com/api-reference/platform/conventions#enums)) (live list: `GET /v1/vocab/lifecycle`) (live list: `GET /v1/vocab/outcome`) |
| `message` | one sentence; a `failed` event carries the job's `error.message`, a `warning` its warning's |
| `file` | on `delivered`: the file, as in the result (`art:…`, or the record a record-making task made) |
| `field` | on `warning` / `failed`: the input or output the event is about, when there is one |

Deliveries are listed in the result's order, not by arrival.
While a job runs it lists the files delivered so far; a task that makes a record (a moodboard, a
shoot) lists its record once the job succeeded.

## Result

```bash
curl -s "$REFABRIC_API/jobs/j_…/result?limit=100" -H "Authorization: Key $REFABRIC_API_KEY"
```

```json
{ "job_id": "j_…",
  "files": [
    { "file": "art:x2", "url": "https://…", "media_type": "image/png",
      "task": "image.rotate_views", "job_id": "j_…", "created_at": "2026-09-28T10:01:10Z" },
    { "file": "art:x3", "url": "https://…", "media_type": "image/png",
      "task": "image.rotate_views", "job_id": "j_…", "created_at": "2026-09-28T10:01:08Z" } ],
  "has_more": false,
  "summary": { "requested": 3, "delivered": 2,
               "warnings": [ { "code": "output_not_produced", "field": "right",
                               "message": "One output of this job could not be produced; `field` names which." } ] } }
```

- `files` — what the job produced, each file once, in the one file shape ([Files](https://docs.refabric.com/task-apis/files-and-media)). Only
  THIS job's files.
  - **Documented order.** Files come in the order the task documents for its outputs (for example
    `image.rotate_views`: back · left · right; `image.repose`: the order of `poses.items`), not the
    order they finish. `created_at` may therefore
    go backwards between two files.
  - **Paged.** `limit`'s default and maximum are in the OpenAPI spec. When there are more,
    `has_more` is `true` and `next_cursor` is a string: pass it as `?cursor=` for the next page.
    The last page has `has_more: false` and no `next_cursor`. A cursor this listing did not
    give answers `422 invalid_request` with `field: "cursor"`.
  - A task that makes a **record** (a moodboard, a fabric, a range plan, a brand kit) lists ONLY
    that record's file — the same object `GET /v1/files/{ref}` returns; its images are
    `data.items[].url`, never separate files. Its result is always one page.
- `summary` — present only when the task has counts or warnings to say; a single-file task's
  result has **no `summary` key**. The same words for every task, each present only when the task has it:
  `requested` (outputs asked for), `delivered` (outputs produced) and `warnings[]`
  (`{code, field, message}`, codes from the error catalogue with type `warning` —
  [Errors](https://docs.refabric.com/task-apis/errors/task-errors#warnings)). A missing output is a warning `output_not_produced` whose `field`
  names it, and is not charged; a job that produced none failed instead. The task's
  `outputSchema` documents which words it uses.
- Before the job finishes: `409 conflict`, `code: result_not_ready`.
- A job that **failed**: `409 conflict`, `code: job_failed` — "This job failed; its reason is the
  job read's `error`." Read `GET /v1/jobs/{id}`. A **cancelled** job: `409 conflict`,
  `code: cancelled`.
- The webhook payload and a `Prefer: wait` answer are this same object, **first page**: when their
  `has_more` is `true`, read the rest here with `?cursor=<next_cursor>`.

## Start deadline

```http
X-Refabric-Start-Timeout: 30
```

If the job has not **started** within that many seconds of submission (for example because the
job queue is busy), it fails with `code: start_timeout`, releases its credit
hold, and the response for it carries `X-Refabric-Start-Timeout-Type: user` (the deadline was
yours, not a server fault). The deadline covers only the wait before running, not the run.

## Cancel

```bash
curl -s -X PUT "$REFABRIC_API/jobs/j_…/cancel" -H "Authorization: Key $REFABRIC_API_KEY"
```

```json
HTTP 202
{ "job_id": "j_…", "lifecycle": "running" }
```

Answers `202`: cancellation is requested and the job moves to `outcome: cancelled` shortly (its
`error` then has `code: cancelled`). `lifecycle` is where the job is at that moment. Cancelling a
job that already ended also answers `202` (`lifecycle: terminal`) and changes nothing — read the
job to see how it ended. Work already delivered before the cancel may still be charged. `404` if
no job of yours has that id. Rate limited like every public endpoint.

## Resume

A job that waits for your decision is resumed with
`POST /v1/jobs/{id}/resume` and `{node_id, action, params}`:

```json
HTTP 202
{ "job_id": "j_…", "node_id": "shoot", "action": "select", "lifecycle": "running" }
```

`202`: the resume is sent and the job continues shortly. `lifecycle` is where the job is
at that moment — the same word the cancel answer uses, never `status`. A job that already ended
answers `400`.

## List jobs

```bash
curl -s "$REFABRIC_API/jobs?task=image.generate&lifecycle=terminal&limit=50" \
  -H "Authorization: Key $REFABRIC_API_KEY"
```

```json
{ "items": [ { "job_id": "j_…", "task": "image.generate", "lifecycle": "terminal",
               "outcome": "succeeded", "queued_at": "…" } ],
  "has_more": true, "next_cursor": "…" }
```

Newest first.
Filters: `task` (a task name from `GET /v1/tasks`; an unknown name answers
`422 invalid_option`), `lifecycle` (`queued` · `running` · `terminal`; live list: `GET /v1/vocab/lifecycle`), `api_key_id` (only the jobs
started with this key — its id `key_…` from [API keys](https://docs.refabric.com/setting-up/get-your-api-key), never the secret; a key that
is not one of yours answers `404 not_found`). `limit` (default and maximum in the OpenAPI spec), `cursor` = the previous
page's `next_cursor`. Pagination: [Conventions](https://docs.refabric.com/api-reference/platform/conventions#pagination).

## Python: submit and wait

```python
import time

def run(session, api, task, body, key, wait=30, poll=5):
    r = session.post(f"{api}/tasks/{task}", json=body, timeout=wait + 45,
                     headers={"Idempotency-Key": key, "Prefer": f"wait={wait}"})
    r.raise_for_status()
    if r.status_code == 200:
        return r.json()
    handle = r.json()
    while True:
        job = session.get(handle["status_url"]).json()
        if job["lifecycle"] == "terminal":
            break
        time.sleep(poll)
    if job["outcome"] != "succeeded":
        raise RuntimeError(job.get("error"))
    return session.get(handle["result_url"]).json()
```
