guides

Use the media library from your app

Your app has a file — a sting, a lower-third still, a music bed, a pre-recorded segment — and it needs to end up on air. This guide is the whole path: get the bytes into TRaX, watch them become usable, put them on a studio's canvas, and take them away again.

Which host. These endpoints are on dev only for now: https://api-dev.traxstreaming.live/v1 with a dev key. This page will say so when they reach production.

The shape to hold in your head is three steps, not one:

  1. POST /v1/media — tell us what is coming. You get back an asset id and a plan for pushing the bytes.
  2. You upload the bytes — straight to storage, not through this API.
  3. POST /v1/media/{assetId}/complete — tell us they landed.

Step 3 is the one that gets skipped, and skipping it is the most expensive mistake on this page. An upload that stops after step 2 has produced nothing: the asset sits in UPLOADING forever, it counts against your user's storage, and it can be attached to nothing. If you take one thing from this guide, take that.

Before you start

  • An API key holding media:read to browse and media:write to upload or delete. media:write includes read. Create one under Developer → API keys in your account. Neither scope is in the default set — tick them when you create the key. media:read hands out a signed download URL for the owner's files, so a key created without choosing scopes does not carry it.
  • To put a file on a studio, you also need sources:write.

Set them up once:

export TRAX_API_KEY=sk_live_...
export TRAX_API=https://api-dev.traxstreaming.live/v1

Uploading a small file

curl -s -X POST $TRAX_API/media \
  -H "Authorization: Bearer $TRAX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "filename": "sting.mp4", "contentType": "video/mp4", "byteSize": 4210688 }'
{
  "assetId": "0f8fad5b-d9cb-469f-a165-70867728950e",
  "mode": "single",
  "uploadUrl": "https://s3.../video/0f8fad5b...?X-Amz-Signature=...",
  "objectKey": "video/0f8fad5b-d9cb-469f-a165-70867728950e",
  "kind": "video"
}

byteSize must be the real size. It is checked against the remaining storage allowance before a URL is issued, which is why an upload that cannot fit is refused here rather than after you have spent a user's data plan on it.

PUT the file at uploadUrl, then complete:

curl -s -X PUT "$UPLOAD_URL" \
  -H "Content-Type: video/mp4" \
  --data-binary @sting.mp4

curl -s -X POST $TRAX_API/media/$ASSET_ID/complete \
  -H "Authorization: Bearer $TRAX_API_KEY"

No body on /complete for a single-file upload. The response is the asset.

uploadUrl is a credential, not an address: anyone holding it can write to that object until it expires. Keychain, not log file, not crash report.

Uploading a big file

Read mode before anything else. Over a threshold, POST /v1/media returns no uploadUrl at all — it returns a plan:

{
  "assetId": "1c2d...",
  "mode": "multipart",
  "partBytes": 67108864,
  "parts": [
    { "partNumber": 1, "offset": 0,         "byteSize": 67108864, "url": "https://s3.../?partNumber=1&..." },
    { "partNumber": 2, "offset": 67108864,  "byteSize": 67108864, "url": "https://s3.../?partNumber=2&..." },
    { "partNumber": 3, "offset": 134217728, "byteSize": 22020096, "url": "https://s3.../?partNumber=3&..." }
  ],
  "kind": "video"
}

Why it exists: every request to our storage host crosses a CDN edge that caps a single request body at 100 MB, and it enforces that at the edge. A 150 MB PUT does not slow down — it dies with a 413 after about two megabytes, from a server that is not ours. Parts are sized to stay under that cap, so what limits a file is your user's storage allowance, not the network.

Then:

  • PUT each part's slice at its own URL. Slice by the plan's offset and byteSize — never by a part size of your own. The server may have grown the part size to keep the plan under the storage layer's part-count ceiling, so the plan is the only correct description of how the file is cut.
  • Upload parts in any order, a few at a time. Three in flight is a good number; more mostly buys congestion. Give each part two or three retries — a failed part is a failed part, not a failed upload.
  • Keep the ETag response header each PUT returns. Send them back on /complete.
curl -s -X POST $TRAX_API/media/$ASSET_ID/complete \
  -H "Authorization: Bearer $TRAX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "parts": [
        { "partNumber": 1, "etag": "\"d41d8cd98f00b204e9800998ecf8427e\"" },
        { "partNumber": 2, "etag": "\"a1b2c3d4e5f60718293a4b5c6d7e8f90\"" },
        { "partNumber": 3, "etag": "\"0f1e2d3c4b5a69788796a5b4c3d2e1f0\"" }
      ] }'

Order does not matter — the server sorts, because parts finish out of order when several are in flight. Completeness does. A partial list is an error, not a partial completion.

If you cannot read the ETag header, which is the normal case in a browser unless the storage host exposes it through CORS, send no body at all. The server recovers every ETag from storage itself. That fallback is why a missing ETag is an inconvenience rather than a dead end.

/complete is idempotent. If the response never arrives, call it again — never re-upload.

Watching it become usable

curl -s $TRAX_API/media/$ASSET_ID -H "Authorization: Bearer $TRAX_API_KEY"
{
  "assetId": "0f8fad5b-d9cb-469f-a165-70867728950e",
  "kind": "video",
  "filename": "sting.mp4",
  "status": "READY",
  "sizeBytes": 4210688,
  "durationSeconds": 8.4,
  "width": 1920,
  "height": 1080,
  "thumbnailUrl": "https://s3.../thumb/...",
  "downloadUrl": "https://s3.../video/...",
  "createdAt": "2026-08-23T00:12:00Z",
  "updatedAt": "2026-08-23T00:12:09Z"
}

Poll this until status reads READY. A large file is assembled and probed after /complete returns, so READY can lag it by a moment. Every second or two is plenty, and then stop.

sizeBytes is what actually landed in storage, not what you declared. The duration and dimensions come from probing the file; treat them as picker metadata rather than a guarantee, and expect 0 for a still image or a file we could not read.

thumbnailUrl and downloadUrl expire. Fetch them when you render and throw them away. A URL you saved in your database works all through testing and 403s a day later — this is the single most common way this API gets used wrong. thumbnailUrl is also best-effort even on a READY asset: audio files and most images do not have one, which is a kind icon on your side, not a failure.

Browsing the library

curl -s "$TRAX_API/media?limit=50" -H "Authorization: Bearer $TRAX_API_KEY"
{
  "data": [
    { "id": "0f8fad5b...", "filename": "sting.mp4", "kind": "video", "byteSize": 4210688,
      "status": "READY", "durationSeconds": 8.4, "width": 1920, "height": 1080,
      "thumbnailUrl": "https://s3.../thumb/..." }
  ],
  "nextCursor": "MGY4ZmFkNWI",
  "quotaUsedBytes": 418447360,
  "quotaLimitBytes": 2147483648
}

The library belongs to the user, not to a studio, so nothing here names one. Rows carry a thumbnail so you can draw a grid, and deliberately carry no download URL — reading a file is a different thing from knowing it exists, and list responses are what end up in logs. Read one asset when you need the file.

quotaUsedBytes / quotaLimitBytes ride along so you can show usage and refuse an over-large pick on the device, instead of discovering the ceiling when an upload comes back 402.

Putting it on a studio

Create a source and pass the asset id. Do not pass downloadUrl.

curl -s -X POST $TRAX_API/studios/$STUDIO/sources \
  -H "Authorization: Bearer $TRAX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Opening sting", "type": "video-file", "mediaAssetId": "'$ASSET_ID'" }'

Use the type that matches the file: video-file, image, audio-file, or the generic media.

The reason to pass an id rather than a link: the URL your phone can fetch and the URL the encoder can fetch are different signatures of the same object, and both expire. Pass the id and the server resolves the one the encoder needs and re-mints it whenever it goes stale. An asset-backed source keeps working next month; a source built from a pasted downloadUrl stops working tomorrow.

url and mediaAssetId are mutually exclusive. Sending both is a 400 rather than a precedence rule you would have to remember.

Cancelling and deleting

Two different operations, and they are not interchangeable.

Cancel an upload in flight — the user backed out, or it failed past retrying:

curl -s -X POST $TRAX_API/media/$ASSET_ID/abort \
  -H "Authorization: Bearer $TRAX_API_KEY"

Do this. An abandoned upload does not show up in GET /v1/media — that lists finished assets — and it still occupies the storage allowance, so an app that abandons uploads without aborting them slowly locks its own user out of uploading anything at all. There is a server-side sweeper, but it works in hours, not minutes.

/abort is idempotent, and it refuses a finished asset with 409. That is deliberate: a mistimed cancel must not become data loss.

Delete finished content:

curl -s -X DELETE $TRAX_API/media/$ASSET_ID \
  -H "Authorization: Bearer $TRAX_API_KEY"

Permanent — the file, its thumbnail, and the storage it held.

It is refused with 409 while any studio source still uses the asset, and the message says how many. Take that seriously: a source pointing at a deleted file shows a permanent "unavailable" card, and if that studio is live, the encoder's next read on the file fails and the input drops to a placeholder in the middle of the show. Delete the sources first.

What we accept

Kind Content types
video video/mp4, video/webm, video/quicktime
image image/jpeg, image/png, image/webp, image/gif
audio audio/mpeg, audio/wav, audio/ogg, audio/aac

contentType decides the asset's kind, which decides how it behaves on a canvas — a looping video, a still, an audio-only bed. Send the wrong one and you get an asset that misbehaves in a way nothing later will explain.

When it does not work

Response What happened
400 invalid_request An unsupported contentType, a byteSize of zero or less, a partial part list, or a mediaAssetId that is not a READY asset of yours. The message says which
402 storage_quota_exceeded The library is full, or this file is larger than any single upload this account may hold. Nothing is wrong with the request — free space or change the plan. The message names which ceiling
403 insufficient_scope The key lacks media:read (to browse) or media:write (to upload or delete). The requiredScope field names it
404 not_found No such asset, or it belongs to someone else. The two are deliberately indistinguishable
409 conflict You aborted an upload that already finished, or deleted an asset a studio source still uses
503 unavailable Writes are disabled on this deployment, or the service is briefly unreachable. Retry

Where to go next

  1. Drive the canvas — arrange the source you just added.
  2. Control the canvas over /v1 — put a library asset on the canvas, then move it.
  3. Control the mixer from your app — for an audio bed, the level is the next thing you want.
  4. The /v1 reference — every endpoint and scope.