> ## Documentation Index
> Fetch the complete documentation index at: https://docs.impellabs.tech/llms.txt
> Use this file to discover all available pages before exploring further.

# Jobs

> The 202 pattern, the five job states, cancellation, and what happens when an outcome is genuinely unknown.

Work that will not finish inside an HTTP request answers **202 Accepted** and
hands you a job to poll. Documents, extractions, reports and knowledge ingestions
all do this, and they all do it the same way — one helper produces the response,
so three subsystems cannot each invent a slightly different answer.

## The 202

```http theme={null}
HTTP/1.1 202 Accepted
Location: /api/v2/jobs/job_…
Retry-After: 2

{
  "id": "ext_…",
  "object": "extraction",
  "status": "processing",
  "job_id": "job_…",
  "job": { "…": "the full job payload" }
}
```

* **`Location`** points at the job. A caller told to poll should not have to
  construct the URL.
* **`Retry-After`** is a hint, not a promise — but without one, the first thing
  every integration writes is a `while True` with no sleep in it.
* **`status: "processing"`** is the **resource's** word, and it is deliberately
  not one of the job's five states. The resource and the job are two things, and
  the job's own status is one dereference away under `job_id`.

## Five states, and only these moves

```
queued ──▶ running ──▶ completed
  │  ▲        │
  │  └────────┤  retryable failure: another attempt, same job_id
  │           ├──▶ failed
  └───────────┴──▶ cancelled
```

`completed`, `failed` and `cancelled` are **terminal and have no outgoing
edges**. A stale retry delivered after a job finished cannot move it back to
`running`.

Two distinctions that deliberately are **not** extra states:

| Question                                   | Where the answer is                                                                                               |
| ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------- |
| "Will it be retried?"                      | `status: "queued"` with `attempts < max_attempts` — which is the truthful answer anyway: the work is queued again |
| "I cancelled it, why is it still running?" | `status: "running"` with `cancel_requested: true`                                                                 |

`running → queued` is a real edge and it is the retry. An internal retry does not
create a new public job — same `job_id`, and the attempt count is where the retry
lives.

## Polling one

```bash theme={null}
curl "$IMPEL_API/jobs/job_…" -H "Authorization: Bearer $IMPEL_KEY"
```

Needs `jobs:read`.

```json theme={null}
{
  "id": "job_…",
  "object": "job",
  "type": "extraction.run",
  "status": "running",
  "cancel_requested": false,
  "progress": { "current": 3, "total": 12 },
  "attempts": 1,
  "max_attempts": 3,
  "result": null,
  "error": null,
  "created_at": "…", "started_at": "…", "finished_at": null,
  "request_id": "req_…",
  "trace_id": "trc_…",
  "parent_job_id": null,
  "resource": { "type": "extraction", "id": "ext_…" }
}
```

`result` is populated only when `status` is `completed`.

`error` is present for `failed` and `cancelled` and **absent otherwise** —
including for a job that is `queued` again after a retryable failure. Reporting an
error on a job that is about to run again would tell you your work had failed when
it has not finished.

## Cancelling

```bash theme={null}
curl -X POST "$IMPEL_API/jobs/job_…/cancel" -H "Authorization: Bearer $IMPEL_KEY"
```

Needs `jobs:cancel`, which is separate from `jobs:read` so a read-only monitoring
integration cannot stop work.

Idempotent, including on a job that has already finished.

<Note>
  Cancellation is a **request**, not a guarantee. A provider call already in
  flight cannot be interrupted, so you may see `status: "running"` with
  `cancel_requested: true` for a while.

  There is no `cancelling` state, because a `cancelling` state promises hard
  cancellation and the platform does not have it.
</Note>

## `POST /jobs` creates nothing today

The endpoint is routed and demands `jobs:create`, and creation is allowlisted
twice: a job type must be **registered** *and* marked **publicly creatable**.
Nothing is currently marked publicly creatable, so this endpoint accepts no type.

```json theme={null}
{ "error": { "code": "…", "message": "'document.process' is not a job type this endpoint can create." } }
```

The way to create a job is the domain endpoint — `POST /documents`,
`/extractions`, `/reports`, `/knowledge/ingestions` — each of which returns a job
alongside its resource. `POST /jobs` exists for types that will have no domain
resource of their own.

<Note>
  "No such type" and "that type is not yours to create" give the same answer.
  Telling them apart would let a caller enumerate the platform's internal job
  types by trying names.
</Note>

## Listing

```bash theme={null}
curl "$IMPEL_API/jobs?limit=50&offset=0" -H "Authorization: Bearer $IMPEL_KEY"
```

Needs `jobs:read`.

<Warning>
  `/jobs` is the one endpoint on the surface that pages by **`offset`** and
  returns **no cursor at all**. The response carries `has_more` rather than
  `next_cursor`.

  Every other paged endpoint is cursor-based. See
  [Overview](/v2/overview#what-it-is-honest-about-not-being-finished).
</Warning>

## Not found means not found

A guessed `job_id`, a job in another workspace, and an id that was never issued
all answer **404**. A 403 would confirm the guess.

## When the outcome is genuinely unknown

This is the part worth reading before you build a retry loop.

A job step can call one of your own tools — a booking, an order, a charge. If that
call **times out**, the platform does not know whether it landed. There are two
tempting answers and both are wrong: retrying might place a second order, and
recording a failure might tell a customer their order did not go through when it
did.

So the platform does a third thing.

<Steps>
  <Step title="The ledger row stays `pending`, and stays there">
    Not `failed`. A `failed` row is one a retry may re-run, and re-running a
    charge is the outcome this whole mechanism exists to prevent.

    Nothing expires a `pending` row on a timer. A row disappearing is the
    platform forgetting the one fact that stops it acting twice.
  </Step>

  <Step title="The next identical call is refused without reaching your system">
    Not retryable. The same call with the same arguments does not go out again.
  </Step>

  <Step title="The customer is told it could not be confirmed">
    The model is instructed, in these words:

    > *"This did not come back in time and it may or may not have gone through.
    > Do NOT try it again. Do NOT tell them it worked and do NOT tell them it
    > failed. Say plainly that you could not confirm it and that someone will
    > check and come back to them."*

    An ambiguous outcome has to forbid three things at once: a retry, a claim of
    success, and a claim of failure. A model handed "it failed" will apologise
    for an order that exists; handed "it worked" it will confirm one that does
    not.
  </Step>

  <Step title="A human or a reconciler settles it">
    The only way out of `pending` is somebody finding out what actually
    happened — asking the far system what it has. Once settled as *it happened*,
    a later identical call replays the recorded result rather than acting again.
    Settled as *it did not*, the key is freed and the call may be retried.
  </Step>
</Steps>

<Warning>
  **Do not build a client-side retry that treats a timeout as a failure.** For a
  safe, read-only call that is fine. For anything that writes to your systems, a
  timeout is not a failure — it is an unknown, and the platform is deliberately
  refusing to guess on your behalf.
</Warning>

## Idempotency

`POST /jobs` sets **`Idempotent-Replay: true`** on a replay. A replayed 202 is
the same body and the same status code — a replay is not a different outcome.

The key may also be sent in the body as `idempotency_key`, because a caller using
a generated client that cannot set headers is not a caller who should have to give
up idempotency. See [Idempotency](/v2/idempotency).
