GET /v1/jobs/:jobId
Poll the unified envelope for any long-running operation — content generation, ingest, clones, influencer creation.
/v1/jobs/{jobId}- Auth
- Bearer
- Scope
- jobs:read
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 for the 202-then-poll pattern and recommended polling cadence.
jobIdstring (job_<ULID>)requiredThe jobId returned from the originating 202 response.
If-None-MatchstringoptionalETag from a previous response. Returns 304 if the job has not advanced.
Example request
curl https://api.layers.com/v1/jobs/job_01HXA1NHKJZXPV8R7Q6WSM5BCD \
-H "Authorization: Bearer lp_live_01HX9Y6K7EJ4T2_4QZpN..."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));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)Response
{
"jobId": "job_01HXA1NHKJZXPV8R7Q6WSM5BCD",
"kind": "content_generate",
"status": "running",
"stage": "generating_visuals",
"progress": 0.42,
"startedAt": "2026-04-18T19:20:11Z"
}{
"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 }
]
}
}{
"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 }
}
}{ "error": { "code": "NOT_FOUND", "message": "Unknown jobId." } }Status values
runningstateoptionalWork in progress. Keep polling.completedterminaloptionalSuccess. result is populated. Sticky.failedterminaloptionalFatal error. error is populated with a stable code. Sticky.canceledterminaloptionalYou called cancel and it took effect. Sticky.
Terminal states never flip back to running. Once you see one, stop polling.
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.
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
- Jobs — the 202 → poll pattern
POST /v1/jobs/:jobId/cancel— best-effort cancel- Error codes — the full taxonomy
error.codedraws from