Skip to main content

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:read scope for listing and resolving, media:delete for 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"
}
FieldMeaning
media_idThe durable handle. Store this; pass it into later calls.
statusready, or deleted / expired once the artifact is gone (see below).
kindimage, video or audio — what a media input is checked against.
modelThe 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_secondsPlayback length of a video or audio artifact, read from the file. null for an image, or when it could not be read.
urlA presigned download link. It stops working at url_expires_at; resolve the id again for a new one.
url_expires_atWhen url dies. 15 minutes from a poll or a media read, 30 minutes from a webhook delivery.
expires_atWhen 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:

SituationResponse
Unknown id404
Deleted, expired, or still being stored400 with the reason
Wrong kind for the input (audio → image)400 with both kinds named
A URL instead of an id400 — 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:

statusMeaning
rejectedOmnifence prohibited the content. The moderation block says why.
failedThe 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​

StatusCodeWhen
400INVALID_REQUESTNo file part, an unsupported content type, or a filename containing / or \
403FORBIDDENThe key does not hold media:upload
413PAYLOAD_TOO_LARGEThe 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.