# 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 API integration 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.

<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_..."
    ```
  </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 waitBeforeNextPoll();
      }
    }
    ```
  </Tab>

  <Tab value="Python">
    ```python
    import httpx

    def poll_job(job_id: str, api_key: str):
        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:
                    wait_before_next_poll()
                    continue
                etag = r.headers.get("etag")
                job = r.json()
                if job["status"] != "running":
                    return job
                wait_before_next_poll()
    ```
  </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_7d18b9a1..."],
      "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`                                                    |
| `project_ingest_website`   | `fetching` → `extracting` → `persisting`                                                                                          |
| `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`                                                                                         |
| `marketing_bootstrap`      | `project_create` → `ingest_website` → `sdk_app_create` → `layer_provision` → `influencer_create` → `first_content` → `finalizing` |
| `ad_optimizer_run`         | `fetching_metrics` → `scoring` → `planning_actions` → `applying_actions` → `finalizing`                                           |
| `project_keywords_refresh` | `extracting_keywords` → `expanding_candidates` → `scoring` → `finalizing`                                                         |

There is no `tiktok_lease` job kind. Lease requests use their own status endpoint instead of the jobs API. See Request leased accounts.

## Polling pattern [#polling-pattern]

Poll with jitter and back off while a job remains `running`. Paired with `If-None-Match`, `304` responses are effectively free.

## 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
