Media
Every artifact a Phantom endpoint generates — an image, a rendered clip, a video — is stored for you
and given a durable media_id. Endpoints that take an image, video or audio input accept only a
media_id of something generated within the platform or uploaded by an administrator; URLs are not accepted as media inputs.
- Auth: Bearer key with the
media:readscope for listing and resolving,media:deletefor deletion. Both are granted to every API key by default.
The media object
Every artifact you meet in the API is the same media object — the value under an output's
public name in a job poll or a webhook delivery, and the body of GET /media/{media_id}:
{
"media_id": "6a0c2d1e-4f3b-4b1a-9f2c-8d7e6a5b4c30",
"status": "ready",
"kind": "image",
"model": "phantom/sdxl-realistic-basic",
"content_type": "image/png",
"size_bytes": 482913,
"duration_seconds": null,
"filename": "ComfyUI_00001_.png",
"job_id": "9f1c2e4a-7b3d-4c8e-9a1f-2d5b6c7e8f90",
"created_at": "2026-09-01T10:15:42.000Z",
"expires_at": null,
"url": "https://…/phantom-media/…/6a0c2d1e-….png?X-Amz-Signature=…",
"url_expires_at": "2026-09-01T10:30:42.000Z"
}
| Field | Meaning |
|---|---|
media_id | The durable handle. Store this; pass it into later calls. |
status | ready, or deleted / expired once the artifact is gone (see below). |
kind | image, video or audio — what a media input is checked against. |
model | The model that generated it, as the id a template call selects a model by: phantom/<slug> for a GPU workflow, otherwise the provider's model id. null for an upload. |
duration_seconds | Playback length of a video or audio artifact, read from the file. null for an image, or when it could not be read. |
url | A presigned download link. It stops working at url_expires_at; resolve the id again for a new one. |
url_expires_at | When url dies. 15 minutes from a poll or a media read, 30 minutes from a webhook delivery. |
expires_at | When the artifact itself is removed by retention; null when it is kept forever. |
URLs expire; the media_id does not. Store the id and either pass it into a later call or
resolve it again when you need a fresh link.
Where it comes from
A completed job carries one under each artifact output's public name. A many output publishes a
list in the same order the artifacts were produced. Scalar outputs (text, numbers) are unchanged.
{
"job_id": "9f1c2e4a-7b3d-4c8e-9a1f-2d5b6c7e8f90",
"status": "completed",
"outputs": {
"image": {
"media_id": "6a0c2d1e-4f3b-4b1a-9f2c-8d7e6a5b4c30",
"status": "ready",
"kind": "image",
"model": "phantom/sdxl-realistic-basic",
"url": "https://…?X-Amz-Signature=…",
"url_expires_at": "2026-09-01T10:30:42.000Z",
"…": "…"
},
"caption": "a lighthouse at dusk"
}
}
The webhook delivery carries the same outputs.
Once the artifact is gone
A deleted or expired artifact keeps its place in the job's outputs, keeps its media_id, and says
why. There is no link to give:
{
"media_id": "6a0c2d1e-4f3b-4b1a-9f2c-8d7e6a5b4c30",
"status": "deleted",
"kind": "image",
"url": null
}
The same two verdicts are what GET /media/{media_id} answers 410 with.
External media
An endpoint can be configured to answer with a fixed response instead of running. A media output
of such a response is a link the operator supplied rather than an artifact stored here. It arrives
as the same object with status ready, but nothing to store or expire:
{
"media_id": null,
"status": "ready",
"kind": "image",
"model": "phantom/sdxl-realistic-basic",
"content_type": null,
"size_bytes": null,
"duration_seconds": null,
"filename": null,
"job_id": "9f1c2e4a-7b3d-4c8e-9a1f-2d5b6c7e8f90",
"url": "https://cdn.example.com/sample.png",
"url_expires_at": null
}
media_id is null, so it cannot be passed into a later call or fetched from GET /media, and
url does not expire. Treat media_id: null with status: "ready" as "use the link as given".
Use a media_id as an input
Pass the id where the endpoint's schema declares an image, video, audio, combined image/video, or
any-kind media input. The schema marks these with "format": "uuid" and
"x-media-ref": "media_id":
curl -X POST https://api.phantomrouter.ai/api/v1/ai/describe \
-H "Authorization: Bearer $PHANTOM_KEY" \
-H "Content-Type: application/json" \
-d '{ "inputs": { "photo": "6a0c2d1e-4f3b-4b1a-9f2c-8d7e6a5b4c30" } }'
Media created under any account can be used as an input, not only your own. Reading, listing and deleting media stay limited to your account. The id is checked before anything is charged:
| Situation | Response |
|---|---|
| Unknown id | 404 |
| Deleted, expired, or still being stored | 400 with the reason |
| Wrong kind for the input (audio → image) | 400 with both kinds named |
| A URL instead of an id | 400 — generate or upload the asset first |
Uploading media
An administrator can create a key with the media:upload scope from the client's
API key panel. Uploads enter that key's own client library. Customer self-service keys
cannot request this scope.
One call carries the file, and GET /api/v1/media/{media_id} is both the poll and the
result:
curl -X POST https://api.phantomrouter.ai/api/v1/media/uploads \
-H "Authorization: Bearer $PHANTOM_KEY" \
-F "file=@photo.png;type=image/png"
Send multipart/form-data with one file part. Add a filename part to file the media
under a different name. One file per request, up to 300 MB. The response is 202:
{
"media_id": "6a0c2d1e-4f3b-4b1a-9f2c-8d7e6a5b4c30",
"kind": "image",
"status": "moderating",
"retry_after_ms": 5000,
"moderation": {
"provider": "omnifence",
"status": "pending",
"job_id": "omnifence-job-id",
"is_prohibited": null,
"nsfw": null,
"reason": null
}
}
Every upload passes mandatory Omnifence moderation. Images, videos and audio each go to their own moderation endpoint; video moderation does not imply a separate audio-transcript check. The file is unavailable in the library and cannot be used as an input until moderation passes.
Poll the media
GET /api/v1/media/{media_id} answers the same status block while the check runs.
Wait retry_after_ms, then read it again. The admin console does this for you.
Once the check passes, the same read answers the normal media object, with the stored decision alongside it:
{
"media_id": "6a0c2d1e-4f3b-4b1a-9f2c-8d7e6a5b4c30",
"status": "ready",
"kind": "image",
"url": "https://…",
"moderation": {
"provider": "omnifence",
"status": "completed",
"job_id": "omnifence-job-id",
"is_prohibited": false,
"nsfw": true,
"reason": null
}
}
nsfw is an informational label, independent of rejection. It is true or false
when Omnifence supplies it, or null when unavailable (including audio or an account
with the NSFW check disabled). A missing classification is never treated as false.
The stored moderation result also appears when listing the media.
Two statuses are terminal and mean the media never becomes usable:
status | Meaning |
|---|---|
rejected | Omnifence prohibited the content. The moderation block says why. |
failed | The check could not complete, or ran past 30 minutes. Upload again. |
A key that holds media:upload may read its own uploads without also holding
media:read. Abandoned, rejected and failed uploads are removed by the retention
sweep. The bytes reach storage through this API, so the object behind a media_id is
the object that was moderated.
An upload with the same bytes as one you already uploaded returns the earlier
media_id and its current status. The file is not stored or moderated again. This does
not apply when the earlier upload is deleted, expired or failed.
Request errors
| Status | Code | When |
|---|---|---|
400 | INVALID_REQUEST | No file part, an unsupported content type, or a filename containing / or \ |
403 | FORBIDDEN | The key does not hold media:upload |
413 | PAYLOAD_TOO_LARGE | The file is larger than the endpoint accepts |
List media
GET /api/v1/media?kind=image&limit=50&cursor=…
Returns your live media, newest first. kind is optional (image, video, audio); limit is
1–200 (default 50); next_cursor is null on the last page.
{
"media": [
{
"media_id": "6a0c2d1e-4f3b-4b1a-9f2c-8d7e6a5b4c30",
"status": "ready",
"kind": "image",
"model": "phantom/sdxl-realistic-basic",
"content_type": "image/png",
"size_bytes": 482913,
"duration_seconds": null,
"filename": "ComfyUI_00001_.png",
"job_id": "9f1c2e4a-7b3d-4c8e-9a1f-2d5b6c7e8f90",
"created_at": "2026-09-01T10:15:42.000Z",
"expires_at": null,
"url": "https://…?X-Amz-Signature=…",
"url_expires_at": "2026-09-01T10:30:42.000Z"
}
],
"next_cursor": null
}
Get one media item
GET /api/v1/media/{media_id}
Returns the media object, with a freshly signed url.
404— the id is unknown, or belongs to another account.410 Gone— the media was deleted (MEDIA_DELETED) or its retention expired (MEDIA_EXPIRED). It did exist, and it is not coming back.
Delete media
DELETE /api/v1/media/{media_id}
Answers 204. The id stops resolving at once and the object is removed. Deleting again also answers
204. Any job that produced the artifact shows it as "status": "deleted" with url: null in its
outputs from then on. A queued job that references the id fails when it is dispatched, and is
refunded.
Retention
Media is kept forever unless a retention window is set. When one is, each item's expires_at
is measured from its created_at; expired media answers 410 and is removed the same way deleted
media is. Ask us to set a window for your account if you want media cleared automatically.
Storage pricing
Storage is billed per GiB-month from a daily snapshot: once a day the live bytes you hold are
recorded and priced at your rate for that day (bytes / GiB × rate / days in the month). Media
created and deleted between two snapshots costs nothing. Accrued storage is settled onto your
balance as a "Media storage" ledger entry once it crosses the platform minimum charge, and the
usage summary shows the bytes stored and the amount accrued so far even before that.
The default rate is $0.05 per GiB-month.