# GET /v1/jobs/:jobId (/docs/api/reference/jobs/get-job)



<Endpoint method="GET" path="/v1/jobs/{jobId}" auth="Bearer" scope="jobs:read" phase="1" />

Every async endpoint on the Partner API returns a `jobId` and hands you off to this one to watch the work finish. One shape, one poll loop, one error taxonomy. Learn it once; every async call looks the same.

Responses are ETag-aware. Send `If-None-Match` with the last `etag` you saw and we return `304` when nothing has changed. That keeps you safe to poll tightly without paying for a re-serialize on every tick.

See the [Jobs concept](/docs/api/concepts/jobs) for the 202-then-poll pattern and recommended polling cadence.

<Parameters
  title="Path"
  rows="[
  { name: 'jobId', type: 'string (job_<ULID>)', required: true, description: 'The jobId returned from the originating 202 response.' },
]"
/>

<Parameters
  title="Headers"
  rows="[
  { name: 'If-None-Match', type: 'string', description: 'ETag from a previous response. Returns 304 if the job has not advanced.' },
]"
/>

## Example request [#example-request]

<Tabs items="['curl', 'TypeScript', 'Python']">
  <Tab value="curl">
    ```bash
    curl https://api.layers.com/v1/jobs/job_01HXA1NHKJZXPV8R7Q6WSM5BCD \
      -H "Authorization: Bearer lp_live_01HX9Y6K7EJ4T2_4QZpN..."
    ```
  </Tab>

  <Tab value="TypeScript">
    ```ts title="poll-job.ts"
    export async function pollJob(jobId: string, apiKey: string) {
      let delayMs = 2_000;
      let etag: string | undefined;

      for (;;) {
        const res = await fetch(`https://api.layers.com/v1/jobs/${jobId}`, {
          headers: {
            Authorization: `Bearer ${apiKey}`,
            ...(etag ? { "If-None-Match": etag } : {}),
          },
        });

        if (res.status === 304) {
          await sleep(delayMs);
          delayMs = Math.min(delayMs * 1.3, 10_000);
          continue;
        }

        etag = res.headers.get("etag") ?? undefined;
        const job = await res.json();
        if (job.status !== "running") return job;

        await sleep(delayMs + Math.random() * 500);
        delayMs = Math.min(delayMs * 1.3, 10_000);
      }
    }

    const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
    ```
  </Tab>

  <Tab value="Python">
    ```python
    import httpx, random, time

    def poll_job(job_id: str, api_key: str):
        delay = 2.0
        etag = None
        with httpx.Client() as client:
            while True:
                headers = {"Authorization": f"Bearer {api_key}"}
                if etag:
                    headers["If-None-Match"] = etag
                r = client.get(
                    f"https://api.layers.com/v1/jobs/{job_id}",
                    headers=headers,
                )
                if r.status_code == 304:
                    time.sleep(delay)
                    delay = min(delay * 1.3, 10.0)
                    continue
                etag = r.headers.get("etag")
                job = r.json()
                if job["status"] != "running":
                    return job
                time.sleep(delay + random.random() * 0.5)
                delay = min(delay * 1.3, 10.0)
    ```
  </Tab>
</Tabs>

## Response [#response]

<Response status="200" description="Running — partial state with advisory progress and stage.">
  ```json
  {
    "jobId": "job_01HXA1NHKJZXPV8R7Q6WSM5BCD",
    "kind": "content_generate",
    "status": "running",
    "stage": "generating_visuals",
    "progress": 0.42,
    "startedAt": "2026-04-18T19:20:11Z"
  }
  ```
</Response>

<Response status="200" description="Completed — result shape depends on kind. `stage` and `progress` are always emitted.">
  ```json
  {
    "jobId": "job_01HXA1NHKJZXPV8R7Q6WSM5BCD",
    "kind": "content_generate",
    "status": "completed",
    "stage": "finalizing",
    "progress": 1.0,
    "startedAt": "2026-04-18T19:20:11Z",
    "finishedAt": "2026-04-18T19:23:47Z",
    "result": {
      "contentContainerIds": ["cnt_01HXAJK..."],
      "mediaAssets": [
        { "assetId": "asset_01HXAJ...", "kind": "video", "durationMs": 14800 }
      ]
    }
  }
  ```
</Response>

<Response status="200" description="Failed — error.code is stable; error.data is stable per code. `stage`/`progress` reflect the last reached checkpoint.">
  ```json
  {
    "jobId": "job_01HXA1NHKJZXPV8R7Q6WSM5BCD",
    "kind": "content_generate",
    "status": "failed",
    "stage": "generating_visuals",
    "progress": 0.42,
    "startedAt": "2026-04-18T19:20:11Z",
    "finishedAt": "2026-04-18T19:22:02Z",
    "error": {
      "code": "MODERATION_BLOCKED",
      "message": "Safety check rejected the generated caption.",
      "data": { "flag": "violence", "retryAfterMs": null }
    }
  }
  ```
</Response>

<Response status="304" description="No change since If-None-Match. Keep polling." />

<Response status="404" description="Job does not exist in your organization.">
  ```json
  { "error": { "code": "NOT_FOUND", "message": "Unknown jobId." } }
  ```
</Response>

## Status values [#status-values]

<Parameters
  title="status"
  rows="[
  { name: 'running', type: 'state', description: 'Work in progress. Keep polling.' },
  { name: 'completed', type: 'terminal', description: 'Success. result is populated. Sticky.' },
  { name: 'failed', type: 'terminal', description: 'Fatal error. error is populated with a stable code. Sticky.' },
  { name: 'canceled', type: 'terminal', description: 'You called cancel and it took effect. Sticky.' },
]"
/>

Terminal states never flip back to `running`. Once you see one, stop polling.

## Stages by kind [#stages-by-kind]

`stage` is an advisory, human-readable label safe to display in a UI. The vocab is frozen per `kind` — renaming a stage is a breaking change.

| Kind                      | Stages (in order)                                                                    |
| ------------------------- | ------------------------------------------------------------------------------------ |
| `project_ingest_github`   | `cloning` → `analyzing` → `generating_sdk_patch` → `opening_pr` → `finalizing`       |
| `content_generate`        | `planning` → `generating_visuals` → `assembling` → `finalizing`                      |
| `content_regenerate`      | `planning` → `generating_visuals` → `assembling` → `finalizing`                      |
| `content_clone_from_post` | `analyzing_source` → `planning` → `generating_visuals` → `assembling` → `finalizing` |
| `influencer_create`       | `generating_identity` → `rendering_reference` → `persisting`                         |
| `appstore_ingest`         | `scraping` → `summarizing` → `persisting`                                            |

There is no `tiktok_lease` job kind. Leased account provisioning is a manual ops step at Layers, not an async workflow. See [Request leased accounts](/docs/api/guides/request-leased-accounts).

## Polling cadence [#polling-cadence]

Two seconds with a bit of jitter is a sane starting interval so many clients do not hit the same second. Back off to ten seconds after the first thirty seconds of `running`. Paired with `If-None-Match`, 304 responses are effectively free.

Do not poll faster than once per second. You will not learn anything useful and you will eat into your reads budget.

## See also [#see-also]

* [Jobs](/docs/api/concepts/jobs) — the 202 → poll pattern
* [`POST /v1/jobs/:jobId/cancel`](/docs/api/reference/jobs/cancel-job) — best-effort cancel
* [Error codes](/docs/api/operational/errors) — the full taxonomy `error.code` draws from
