Skip to main content

Jobs

Every request that returns a job_id — companion chat, retarget, media tagging, and generation — records a job. These endpoints are how you look those jobs back up, whichever API produced them.

  • Auth: Bearer key with the job:read scope. This scope is granted to every API key by default, so no extra setup is needed.

Each job carries the endpoint it came from in endpoint_key, so a single list shows chat turns and custom GPU workflow runs side by side:

typeendpoint_keyProduced by
companion_chatchat.companionPOST /companion/chat
companion_retargetchat.companion_retargetPOST /companion/retarget
companion_media_tagmedia.companion_tagPOST /companion/media-tag
generateworkflow.<workflow-slug>The retired POST /generate/:slug (older jobs only)
voicevoice.<slug>POST /voice/:slug
llmtemplate.<slug>POST /:prefix/:slug (LLM endpoint)
hostedmodel.<slug> or workflow.<slug>POST /:prefix/:slug (hosted endpoint)

type is where the job ran, so a template whose model is a hosted model or a workflow runs as a hosted job while its endpoint_key stays template.<slug>. Older template jobs may read generate. A hosted endpoint that runs a ComfyUI workflow is keyed workflow.<slug>; one that runs a self-hosted model is keyed model.<slug>. To group jobs by what you called, use category. It is always the prefix of the endpoint_key:

categoryJobs
templateEvery template.<slug> endpoint call, whatever the template's model is.
workflowEvery workflow.<slug> call: GPU workflows and ComfyUI workflows.
modelEvery model.<slug> call: hosted endpoints that run a self-hosted model.
nullChat, retarget, media tagging and voice.

List jobs​

GET /api/v1/jobs

Query parameters​

ParameterTypeNotes
limitinteger1–100. Default 50.
offsetintegerDefault 0.
typestringOne of companion_chat, companion_retarget, companion_media_tag, generate, voice, llm, hosted.
categorystringtemplate, workflow or model. See the table above.
endpoint_keystringOnly jobs of this endpoint, for example template.text-to-image. The keys used before 2026-09-29 (llm., generate., hosted.) still match the endpoint they became.
decisionstringpass or reject. Only companion jobs carry a verdict; the async job types never match.
statusstringComma-separated: queued, moderating, generating, completed, rejected, failed.
api_key_idstringOnly jobs submitted with this API key. Use it to split usage per key.
searchstringPrefix match on job_id, up to 64 characters.
fromstringISO 8601. Inclusive lower bound on created_at.
tostringISO 8601. Exclusive upper bound on created_at.

Every generation job is content-moderated twice, and the status says where it is:

queued → moderating → generating → moderating → completed | rejected | failed

The first moderating checks your inputs before anything is generated; the second checks what was generated before it is returned. The job detail tells them apart with moderation_phase (input or output), present while the status is moderating or rejected. A job that moderation turns down finishes as rejected — see Content moderation.

generating was previously called processing. The filter still accepts processing and treats it as generating, so existing reconciliation polls keep working. Responses always carry generating, so move your comparisons over when you can.

Example​

curl "https://api.phantomrouter.ai/api/v1/jobs?status=queued,moderating,generating&limit=20" \
-H "Authorization: Bearer $PHANTOM_KEY"

Response​

{
"jobs": [
{
"job_id": "9f1c2e4a-7b3d-4c8e-9a1f-2d5b6c7e8f90",
"type": "generate",
"endpoint_key": "workflow.portrait",
"category": "workflow",
"status": "generating",
"api_key_id": "vJ8kQ2mXpL4nR7tY",
"api_key_name": "production",
"created_at": "2026-07-28T10:15:00.000Z",
"completed_at": null
},
{
"job_id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
"type": "companion_chat",
"endpoint_key": "chat.companion",
"category": null,
"status": "completed",
"is_prohibited": false,
"api_key_id": "vJ8kQ2mXpL4nR7tY",
"api_key_name": "production",
"created_at": "2026-07-28T10:14:12.000Z",
"completed_at": "2026-07-28T10:14:14.310Z"
}
],
"total": 2,
"limit": 20,
"offset": 0
}

is_prohibited is absent on generate, voice, llm and hosted jobs — they never produce a moderation verdict — and null while a moderated job is still pending. An absent field means "not applicable"; a null one means "not decided yet".

api_key_id names the key the job was submitted with, and api_key_name is the name you gave that key. Issue one key per environment or per customer, and you can attribute every job — and every charge on the matching ledger row — to the caller that made it. Both fields are null for a job started from the dashboard, and api_key_name alone is null once the key has been deleted.

:::tip Reconciling with webhooks Webhooks carry the fast path — see Webhooks for how to verify one. For reconciliation, poll ?status=queued,moderating,generating once on a timer rather than polling each job individually — one request covers everything still in flight. :::

Get a job​

GET /api/v1/jobs/{job_id}

The result object is shaped by the job type.

Companion chat / retarget​

{
"job_id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
"type": "companion_chat",
"endpoint_key": "chat.companion",
"status": "completed",
"created_at": "2026-07-28T10:14:12.000Z",
"completed_at": "2026-07-28T10:14:14.310Z",
"result": {
"is_prohibited": false,
"chat_id": "chat-42"
}
}

Media tagging​

{
"job_id": "5d6e7f80-1a2b-4c3d-9e8f-7a6b5c4d3e2f",
"type": "companion_media_tag",
"endpoint_key": "media.companion_tag",
"status": "completed",
"created_at": "2026-07-28T09:02:00.000Z",
"completed_at": "2026-07-28T09:02:03.120Z",
"result": {
"is_prohibited": false,
"media_url": "https://example.com/photo.jpg",
"media_kind": "image"
}
}

Generation​

{
"job_id": "9f1c2e4a-7b3d-4c8e-9a1f-2d5b6c7e8f90",
"type": "generate",
"endpoint_key": "workflow.portrait",
"status": "completed",
"created_at": "2026-07-28T10:15:00.000Z",
"completed_at": "2026-07-28T10:15:42.000Z",
"moderation": { "input": "pass", "output": "pass" },
"phases": [
{
"phase": "queued",
"started_at": "2026-07-28T10:15:00.000Z",
"ended_at": "2026-07-28T10:15:00.400Z",
"duration_ms": 400
},
{
"phase": "moderating_input",
"started_at": "2026-07-28T10:15:00.400Z",
"ended_at": "2026-07-28T10:15:02.100Z",
"duration_ms": 1700
},
{
"phase": "generating",
"started_at": "2026-07-28T10:15:02.600Z",
"ended_at": "2026-07-28T10:15:31.000Z",
"duration_ms": 28400
},
{
"phase": "moderating_output",
"started_at": "2026-07-28T10:15:31.000Z",
"ended_at": "2026-07-28T10:15:41.800Z",
"duration_ms": 10800
},
{
"phase": "finalizing",
"started_at": "2026-07-28T10:15:41.800Z",
"ended_at": "2026-07-28T10:15:42.000Z",
"duration_ms": 200
}
],
"result": {
"created_at": "2026-07-28T10:15:00.000Z",
"completed_at": "2026-07-28T10:15:42.000Z",
"moderation": { "result": "pass" },
"nsfw": false,
"outputs": {
"image": {
"media_id": "6a0c2d1e-4f3b-4b1a-9f2c-8d7e6a5b4c30",
"status": "ready",
"kind": "image",
"model": "phantom/portrait",
"content_type": "image/png",
"size_bytes": 482913,
"filename": "ComfyUI_00001_.png",
"job_id": "9f1c2e4a-7b3d-4c8e-9a1f-2d5b6c7e8f90",
"created_at": "2026-07-28T10:15:42.000Z",
"expires_at": null,
"url": "https://…/out.png?X-Amz-Signature=…",
"url_expires_at": "2026-07-28T10:30:42.000Z"
}
},
"workflow_slug": "portrait"
}
}

Each artifact in outputs is a media object. Its url is signed fresh on every read and dies at url_expires_at (15 minutes) — fetch the artifact soon after reading, or re-request the job for a new link; store the media_id, never the URL. An artifact whose media was deleted or expired keeps its id and reports "status": "deleted" or "expired" with url: null.

A completed job with image or video outputs also carries nsfw, with no opt-in. It is true when any generated image or video output is labelled NSFW, otherwise false — including when moderation supplied no label, so false does not prove a check ran. The label is informational and never a rejection on its own. Audio-only, text-only, pending, rejected and failed jobs omit it. The generation poll response and the completion webhook carry the same field beside outputs.

moderation holds one decision per content-moderation phase that has ended — input for what you sent, output for what was generated — each pass, reject or unavailable, and a reason when a phase rejected the job. It is absent until the first phase ends and never says which field was decided against. result.moderation is the endpoint's own moderation result (result, and reason on a rejection), present once the job has ended unless the endpoint turned it off. See Content moderation.

phases is how long the job spent in each phase, in the order it passed through them:

phaseCovers
queuedAccepted, waiting for a worker.
moderating_inputYour text and media inputs, checked before anything is generated.
generatingFrom the engine accepting the job until the generated content is checked.
moderating_outputThe generated content, checked before it is returned.
finalizingThe completion write and the webhook — or, on a rejection, the media delete.

The phase the job is in now has ended_at and duration_ms null. A phase the job never reached is absent, so a job rejected at the input phase lists three phases, and a companion chat job — which runs inside its own request — lists only queued. The two moderation phases are timed apart: they answer different questions.

Failed jobs​

A failed job carries error — one sentence saying what to do next — and error_code, which is what you branch on. The message is deliberately generic: it describes the class of failure, not the internals of whichever engine ran the job.

error_codeMeaningRetry?
PROVIDER_BUSYCapacity is throttled right now.Yes, after a short backoff.
PROVIDER_UNAVAILABLEThe rendering service is down or unreachable.Yes, after a short backoff.
JOB_TIMEOUTThe job ran past its time limit.Yes, or simplify the request.
CAPACITY_TIMEOUTThe job waited too long for capacity; it never ran.Yes, later.
JOB_CANCELLEDThe job was cancelled before it finished.No — resubmit if unintended.
RENDER_FAILEDThe engine rejected the request.No — fix the inputs first.
OUTPUT_NOT_STOREDThe engine produced an output we could not store.Yes.
OUTPUT_TRUNCATEDThe output hit its length limit before it finished.No — request a smaller output.
CHARGE_FAILEDThe job never started; the charge failed.No — top up the account first.
QUEUE_FAILEDThe job could not be queued.Yes.
MODERATION_UNAVAILABLEContent moderation could not reach a decision.Yes, after a short backoff.
INTERNAL_ERRORSomething else went wrong on our side.Yes, then contact support.

Quote the job_id if you contact support: we hold the underlying cause against that id.

Rejected jobs​

A rejected job carries the same two fields with error_code: "CONTENT_REJECTED", and moderation says which phase rejected it and why. It has no outputs: nothing rejected by moderation is retained. The response never says which input or output was rejected — see Content moderation.

Request inputs, webhook URLs, and internal provenance are never returned.

Export jobs as CSV​

GET /api/v1/jobs/export

from and to are required and the window cannot exceed 366 days. to is exclusive — pass the start of the day after your last desired date to include that final day. The response is streamed, so a wide window starts returning rows immediately.

A window holding more than 500,000 jobs is rejected with a 400 before any data is written — narrow the range and export in several requests. The export is never silently truncated: a body that downloads to completion is the complete export for that window.

curl "https://api.phantomrouter.ai/api/v1/jobs/export?from=2026-07-01T00:00:00Z&to=2026-08-01T00:00:00Z" \
-H "Authorization: Bearer $PHANTOM_KEY" \
-o jobs.csv

Columns​

ColumnNotes
job_id, type, endpoint_key, statusIdentity and routing.
is_prohibitedEmpty for generation jobs and for jobs still pending.
created_at, completed_at, duration_msduration_ms is empty until the job finishes.
reasonWhy a companion job was rejected.
chat_id, media_url, media_kindCompanion request context.
workflow_slug, provider_job_id, gpu_execution_time_msGeneration provenance.
error_code, errorFailure detail.
api_key_id, api_key_nameWhich API key submitted the job. Empty for dashboard jobs.

The file is UTF-8 with a byte-order mark so spreadsheet apps decode it correctly, and cells that would otherwise be interpreted as formulas are escaped.

Following a job to completion​

There is no progress stream. A job's result reaches you two ways, and both report the same terminal state:

  1. Webhook — pass webhook_url when you submit. This is the fast path: the callback fires as soon as the job finishes, so nothing has to poll. Every callback is signed — verify the signature before you act on the payload.
  2. Polling — GET /api/v1/jobs/{job_id} for one job, or GET /api/v1/jobs?status=queued,moderating,generating for every job still in flight. Prefer the list form for reconciliation: one request covers the whole backlog instead of one request per job.

Only the async types — generate, voice, llm and hosted — are ever non-terminal by the time you hold a job_id. The companion endpoints return their result in the HTTP response itself, so their jobs are already finished.

tip

Back your polling interval off as the job runs. A generation still running after two minutes will not finish in the next two seconds, and a fixed 1s poll only spends your rate limit.