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 | generate.<workflow-slug> | POST /generate/:slug |
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. |
decision | string | pass or reject. Only companion jobs carry a verdict; generation jobs never match. |
status | string | Comma-separated: queued, processing, completed, 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. |
Example
curl "https://api.phantomrouter.ai/api/v1/jobs?status=queued,processing&limit=20" \
-H "Authorization: Bearer $PHANTOM_KEY"
Response
{
"jobs": [
{
"job_id": "9f1c2e4a-7b3d-4c8e-9a1f-2d5b6c7e8f90",
"type": "generate",
"endpoint_key": "generate.portrait",
"status": "processing",
"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",
"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 jobs — generation never produces 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,processing 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": "generate.portrait",
"status": "completed",
"created_at": "2026-07-28T10:15:00.000Z",
"completed_at": "2026-07-28T10:15:42.000Z",
"result": {
"created_at": "2026-07-28T10:15:00.000Z",
"completed_at": "2026-07-28T10:15:42.000Z",
"outputs": { "image": "https://…/out.png?X-Amz-Signature=…" },
"workflow_slug": "portrait"
}
}
Media URLs are signed fresh on every read and expire after 15 minutes. Fetch the artifact soon after reading, or re-request the job for a new link — don't cache the URL itself.
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. |
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. |
CHARGE_FAILED | The job never started; the charge failed. | No — top up the account first. |
QUEUE_FAILED | The job could not be queued. | Yes. |
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.
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,processingfor every job still in flight. Prefer the list form for reconciliation: one request covers the whole backlog instead of one request per job.
Only generate jobs 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.