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:readscope. 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:
type | endpoint_key | Produced by |
|---|---|---|
companion_chat | chat.companion | POST /companion/chat |
companion_retarget | chat.companion_retarget | POST /companion/retarget |
companion_media_tag | media.companion_tag | POST /companion/media-tag |
generate | workflow.<workflow-slug> | The retired POST /generate/:slug (older jobs only) |
voice | voice.<slug> | POST /voice/:slug |
llm | template.<slug> | POST /:prefix/:slug (LLM endpoint) |
hosted | model.<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:
category | Jobs |
|---|---|
template | Every template.<slug> endpoint call, whatever the template's model is. |
workflow | Every workflow.<slug> call: GPU workflows and ComfyUI workflows. |
model | Every model.<slug> call: hosted endpoints that run a self-hosted model. |
null | Chat, retarget, media tagging and voice. |
List jobs
GET /api/v1/jobs
Query parameters
| Parameter | Type | Notes |
|---|---|---|
limit | integer | 1–100. Default 50. |
offset | integer | Default 0. |
type | string | One of companion_chat, companion_retarget, companion_media_tag, generate, voice, llm, hosted. |
category | string | template, workflow or model. See the table above. |
endpoint_key | string | Only 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. |
decision | string | pass or reject. Only companion jobs carry a verdict; the async job types never match. |
status | string | Comma-separated: queued, moderating, generating, completed, rejected, failed. |
api_key_id | string | Only jobs submitted with this API key. Use it to split usage per key. |
search | string | Prefix match on job_id, up to 64 characters. |
from | string | ISO 8601. Inclusive lower bound on created_at. |
to | string | ISO 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:
phase | Covers |
|---|---|
queued | Accepted, waiting for a worker. |
moderating_input | Your text and media inputs, checked before anything is generated. |
generating | From the engine accepting the job until the generated content is checked. |
moderating_output | The generated content, checked before it is returned. |
finalizing | The 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_code | Meaning | Retry? |
|---|---|---|
PROVIDER_BUSY | Capacity is throttled right now. | Yes, after a short backoff. |
PROVIDER_UNAVAILABLE | The rendering service is down or unreachable. | Yes, after a short backoff. |
JOB_TIMEOUT | The job ran past its time limit. | Yes, or simplify the request. |
CAPACITY_TIMEOUT | The job waited too long for capacity; it never ran. | Yes, later. |
JOB_CANCELLED | The job was cancelled before it finished. | No — resubmit if unintended. |
RENDER_FAILED | The engine rejected the request. | No — fix the inputs first. |
OUTPUT_NOT_STORED | The engine produced an output we could not store. | Yes. |
OUTPUT_TRUNCATED | The output hit its length limit before it finished. | No — request a smaller output. |
CHARGE_FAILED | The job never started; the charge failed. | No — top up the account first. |
QUEUE_FAILED | The job could not be queued. | Yes. |
MODERATION_UNAVAILABLE | Content moderation could not reach a decision. | Yes, after a short backoff. |
INTERNAL_ERROR | Something 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
| Column | Notes |
|---|---|
job_id, type, endpoint_key, status | Identity and routing. |
is_prohibited | Empty for generation jobs and for jobs still pending. |
created_at, completed_at, duration_ms | duration_ms is empty until the job finishes. |
reason | Why a companion job was rejected. |
chat_id, media_url, media_kind | Companion request context. |
workflow_slug, provider_job_id, gpu_execution_time_ms | Generation provenance. |
error_code, error | Failure detail. |
api_key_id, api_key_name | Which 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:
- Webhook — pass
webhook_urlwhen 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. - Polling —
GET /api/v1/jobs/{job_id}for one job, orGET /api/v1/jobs?status=queued,moderating,generatingfor 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.
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.