Amba

Media

Read media assets + upload user-owned media (avatars, user content).

Client uploads are scoped under <projectId>/user/<appUserId>/<ms>_<id>_<name> in the storage path — they can't collide with admin-uploaded assets. <name> is a URL-safe form of the upload's filename (its last path component, accents dropped, every character other than letters, digits, ., _ and - replaced by _, the extension kept); the asset's filename stays exactly as sent.

How signed URLs work

Every upload and private-download URL Amba returns carries a freshly minted, single-purpose token — not a long-lived link persisted in your database. The token is:

  • Per-request. A new one is minted each time you register an upload or request a download URL. You don't store URLs; you mint them on demand.
  • Scoped to one object + one operation. The token is bound to the exact storage object and to a single verb — an upload token can only PUT that one object, a download token can only GET it. It can't be replayed against a different file, another user's object, or the opposite operation.
  • Short-lived. Upload URLs default to ~10 minutes, download URLs to ~1 hour. Both are capped at a hard maximum of 24 hours. When a URL expires, you mint another.
  • Effectively revocable by expiry. Because URLs are short-lived and minted per request rather than persisted, no stale, indefinitely-valid link ever circulates — you don't maintain a list of live URLs to invalidate.

You can tune the lifetime per request with expires_in_seconds (see Signing an upload / download URL). The bytes are served from https://<project>.cdn.amba.host/...; storage credentials are never exposed to the client.

Endpoints

MethodPathDescription
GET/client/media/:assetIdFetch a single asset (public columns).
GET/client/mediaPaginated asset list; optional ?folder_id=.
POST/client/media/uploadRegister an upload; returns upload_url.
POST/client/media/presignMint a short-lived signed upload (PUT) or download (GET) URL for an object you own.
POST/client/media/commitConfirm an upload arrived; returns the asset.
GET/client/media/catalogs/:slugRead a public asset catalog by slug (no session required).
DELETE/client/media/:assetIdDelete one of your assets and its stored file.

Reads return assets whose upload finished. An upload is pending from registration until its file arrives; until then GET /client/media/:assetId answers 404, the list leaves it out, a download presign answers 404, and it counts toward no media usage. A GET by id that finds the file commits the asset. A pending upload whose file never arrives is deleted by the daily cleanup once it is 24 hours old. See When an upload counts.

GET /client/media/:assetId

Response 200

{
  "data": {
    "id": "…",
    "filename": "…",
    "mime_type": "image/png",
    "storage_url": "…",
    "thumbnail_url": null,
    "alt_text": null,
    "metadata": {}
  }
}

Errors

  • 404 NOT_FOUND.

Try it:

GET/client/media/%7B%7BassetId%7D%7D
client auth
curl -X GET 'https://api.amba.dev/v1/client/media/%7B%7BassetId%7D%7D'
Loading auth… Configure auth in the settings drawer (top-right) to run this request.

Curl:

curl -X GET "${BASE_URL}/client/media/{assetId}" \
  -H "X-Api-Key: ${CLIENT_API_KEY}" \
  -H "Authorization: Bearer ${SESSION_TOKEN}"

GET /client/media

Query

ParamDefaultDescription
limit50
offset0
folder_id—Filter.

Response 200

{
  "data": [{ "id": "…", "filename": "…", "storage_url": "…" }],
  "total": 42,
  "offset": 0,
  "limit": 50
}

Try it:

GET/client/media
client auth
curl -X GET 'https://api.amba.dev/v1/client/media'
Loading auth… Configure auth in the settings drawer (top-right) to run this request.

Curl:

curl -X GET "${BASE_URL}/client/media" \
  -H "X-Api-Key: ${CLIENT_API_KEY}" \
  -H "Authorization: Bearer ${SESSION_TOKEN}"

POST /client/media/upload

Request

FieldTypeRequired
filenamestringyes
mime_typestringyes
size_bytesnumberon free projects (the file's exact size in bytes)

On a free project the upload URL accepts at most size_bytes bytes and the size counts toward the 250 MB media budget.

Response 201

{
  "data": {
    "id": "…",
    "filename": "…",
    "storage_url": "https://{projectId}.cdn.amba.host/media/…",
    "uploaded_by": "<appUserId>"
  },
  "upload_url": "https://{projectId}.cdn.amba.host/media/…?upload=true",
  "upload_url_expires_at": "2026-05-28T12:05:00Z"
}

Uploading the file

PUT the raw file bytes to the returned upload_url:

  • Method is PUT; the request body is the file bytes.
  • Content-Type MUST match the mime_type registered in the upload call — a mismatch is rejected.
  • No Authorization or X-Api-Key header on the upload PUT — upload_url is already authorized.
  • Success is 200, 201 or 204. The file is then served from storage_url, and the asset is listed and returned by GET.
  • upload_url is short-lived (upload_url_expires_at); if it has expired, register the upload again to get a fresh one.
  • Maximum file size for the media bucket is 50 MB. Uploads with a mime_type outside the bucket allow-list return 400 MIME_NOT_ALLOWED.

Errors

  • 400 MIME_NOT_ALLOWED — the mime_type is not in the bucket's allow-list.
  • 400 SIZE_REQUIRED — a free project registered an upload without size_bytes.
  • 402 FREE_TIER_LIMIT_REACHED (details.limit: "media_storage") — the upload would take a free project past its media budget. Delete media to free space, or upgrade.
  • 404 BUCKET_NOT_FOUND — the named bucket doesn't exist.
  • 404 USER_NOT_FOUND — the signed-in user was deleted; a deleted user's session registers no asset.
  • 500 CREATE_FAILED.

Try it:

POST/client/media/upload
client auth
curl -X POST 'https://api.amba.dev/v1/client/media/upload'
Loading auth… Configure auth in the settings drawer (top-right) to run this request.

Curl (end-to-end):

# 1. Register the upload with the file's exact size in bytes
SIZE=$(wc -c < avatar.jpg | tr -d ' ')
RESP=$(curl -s -X POST "${BASE_URL}/client/media/upload" \
  -H "X-Api-Key: ${CLIENT_API_KEY}" \
  -H "Authorization: Bearer ${SESSION_TOKEN}" \
  -H 'Content-Type: application/json' \
  -d "{\"filename\":\"avatar.jpg\",\"mime_type\":\"image/jpeg\",\"size_bytes\":${SIZE}}")
 
# 2. Extract upload + storage URLs
UPLOAD_URL=$(echo "$RESP" | jq -r '.upload_url')
STORAGE_URL=$(echo "$RESP" | jq -r '.data.storage_url')
 
# 3. PUT the bytes (Content-Type matches mime_type; no auth header)
curl -X PUT "$UPLOAD_URL" -H 'Content-Type: image/jpeg' --data-binary @avatar.jpg
 
# 4. Fetch the asset
curl -L "$STORAGE_URL" -o downloaded.jpg

POST /client/media/presign

Mint a short-lived signed URL for a single object you own — either to PUT bytes (upload) or to GET a private object (download). This is the on-demand way to get a signed URL when you already know the object's bucket + key, rather than going through the upload-registration flow.

Request

FieldTypeRequiredDescription
bucketstringyesThe bucket the object lives in.
keystringyesThe object key. For PUT, must start with user/<your-user-id>/ — you can only upload under your own prefix.
method"GET" | "PUT"yesGET mints a download URL; PUT mints an upload URL.
content_typestringPUT only(PUT only, required) The upload's type. The upload must send this Content-Type, and the asset is served as it.
expires_in_secondsnumbernoURL lifetime. Defaults to ~600s for PUT, ~3600s for GET. Clamped to a max of 3600s (1h) for PUT and 86400s (24h) for GET.

Response 200

{
  "data": {
    "url": "https://<project>.cdn.amba.host/…?token=…",
    "expires_at": "2026-05-28T12:05:00Z",
    "asset_id": "4f6c…"
  }
}

asset_id is present on a PUT.

  • GET enforces ownership against the media_assets row — a key that isn't yours returns 403 FORBIDDEN (or 404 NOT_FOUND if no matching asset exists). A client can never sign a URL for another user's object.
  • PUT requires the key to be under your user/<your-user-id>/ prefix; otherwise 403 FORBIDDEN. The key must name a file, and none of its segments may be empty, . or .. (plain or percent-encoded, such as %2e%2e).
  • PUT requires content_type, which must be on the bucket's MIME allow-list when the bucket has one.
  • PUT registers the object as your asset and returns its asset_id. The asset is pending until the upload arrives, then it is listed by GET /client/media, readable by id, downloadable with a GET presign, deletable with DELETE /client/media/:assetId, counted as media storage, and erased when the user is deleted, exactly like an asset from the upload-registration flow. Presigning the same key again returns the same asset_id, records the new content_type and restarts the asset's retention.
  • On a free project, PUT returns 400 PRESIGN_PUT_NOT_AVAILABLE: register uploads with the SDK mode of this route (bucket, filename, mime_type, size_bytes) or POST /client/media/upload, so the size counts toward the media budget. GET works on every plan.
  • The returned url is already authorized by the embedded token — PUT/GET it directly with no Authorization or X-Api-Key header. After expires_at it stops working; mint a fresh one.

Errors

  • 400 INVALID_METHOD — method isn't "GET" or "PUT".
  • 400 INVALID_BODY — bucket / key missing or not strings, content_type missing on a PUT, or a PUT key that names no file, has an empty, . or .. segment, or contains ?, #, %, \ or a control character (each would make the asset's URL address a different object).
  • 400 MIME_NOT_ALLOWED — PUT with a content_type outside the bucket's MIME allow-list.
  • 403 FORBIDDEN — PUT key outside your prefix, or GET on another user's object.
  • 404 BUCKET_NOT_FOUND / 404 NOT_FOUND — bucket or asset doesn't exist.
  • 404 USER_NOT_FOUND — SDK mode and method: "PUT": the signed-in user was deleted; a deleted user's session gets no upload URL.
  • 409 KEY_IN_USE — PUT on a key another uploader's asset already uses.
  • 503 TENANT_UNAVAILABLE — storage still provisioning.

Curl (sign a 5-minute download URL):

curl -X POST "${BASE_URL}/client/media/presign" \
  -H "X-Api-Key: ${CLIENT_API_KEY}" \
  -H "Authorization: Bearer ${SESSION_TOKEN}" \
  -H 'Content-Type: application/json' \
  -d '{"bucket":"media","key":"user/<your-user-id>/avatar.jpg","method":"GET","expires_in_seconds":300}'

POST /client/media/commit

Confirm that an upload's file arrived. The SDK calls it after its PUT (storage.commit, and the last step of storage.upload). The asset's file must exist with a non-zero size; the response is the asset with its stored size_bytes. Committing an asset that is already committed answers 200 again.

Request

FieldTypeRequiredDescription
upload_idstringyesFrom the presign response. Equal to asset_id.
asset_idstringyesThe asset to commit; it must be your own upload.

Response 200

{
  "data": {
    "id": "…",
    "filename": "avatar.jpg",
    "mime_type": "image/jpeg",
    "storage_url": "https://{projectId}.cdn.amba.host/media/…",
    "size_bytes": 48213,
    "uploaded_by": "<appUserId>"
  }
}

Errors

  • 400 INVALID_INPUT — upload_id or asset_id missing, or different.
  • 404 NOT_FOUND — no live asset with this id belongs to the signed-in user.
  • 409 UPLOAD_NOT_FOUND — no file has arrived for this asset (or it is empty). The asset stays pending: PUT the file to its upload URL, then commit again. Once the upload URL has expired (about 10 minutes), register the upload again.
  • 503 UPLOAD_UNVERIFIED — the file could not be checked right now. Retry the commit.

DELETE /client/media/:assetId

Deletes one of the signed-in user's assets. The stored file is deleted before the response, so the asset's URL stops serving once cached copies expire (about 5 minutes at the edge for original files). From the delete on, the asset is gone from every read and stops counting toward media usage. Its hidden record is removed by a daily cleanup 7 to 8 days after the delete.

Response 200

{ "data": { "id": "…", "deleted": true } }

Errors

  • 404 NOT_FOUND — no live asset with this id belongs to the signed-in user.
  • 409 UPLOAD_WINDOW_OPEN — free projects only: the asset's upload URL can still write, so the delete waits. Retry after the Retry-After seconds.
  • 503 STORAGE_DELETE_FAILED — the stored file could not be deleted right now. The asset stays as it was; retry the delete.

Curl:

curl -X DELETE "${BASE_URL}/client/media/{assetId}" \
  -H "X-Api-Key: ${CLIENT_API_KEY}" \
  -H "Authorization: Bearer ${SESSION_TOKEN}"

Catalogs

A catalog is a curated, slug-addressable bundle of media assets, configured in the admin API. Your app reads one in a single call — typically at boot — to load a fixed set of images (onboarding illustrations, a sticker pack, home-screen heroes) in a stable order.

GET /client/media/catalogs/:slug

Resolve a public catalog by slug. Requires the client API key but no end-user session — this is public, curated content. Only catalogs published with is_public: true are readable; anything else returns 404 NOT_FOUND (a draft catalog never leaks its existence). The catalog JSON is client-cacheable (Cache-Control: private, max-age=300) — private because the catalog is scoped to your project, so it's cached on the calling device, never in a shared cache. The asset bytes at each url stay CDN-cached independently.

Response 200

{
  "data": {
    "slug": "onboarding-art",
    "name": "Onboarding Illustrations",
    "description": null,
    "metadata": {},
    "updated_at": "2026-06-05T12:00:00Z",
    "items": [
      {
        "media_id": "…",
        "position": 0,
        "caption": "Welcome screen",
        "metadata": {},
        "filename": "welcome.png",
        "mime_type": "image/png",
        "url": "https://{projectId}.cdn.amba.host/media/welcome.png",
        "thumbnail_url": null,
        "alt_text": null,
        "blurhash": null
      }
    ]
  }
}

Items are ordered by position. Each url is a stable, long-cacheable public asset URL.

SDK:

const catalog = await Amba.media.catalog('onboarding-art');
catalog.items.forEach((item) => render(item.url, item.caption));

Curl:

curl -X GET "${BASE_URL}/client/media/catalogs/onboarding-art" \
  -H "X-Api-Key: ${CLIENT_API_KEY}"