Home Playground Docs hub

Guide G-04 · Platform-wide

Uploading files the platform way

Verified against local · 2026-09-23 · cdn-service 0.1.0 (workspace HEAD)
Journey. As a developer, I want my app to store files (avatars, voice
recordings, documents), share them safely, and clean them up, without
building storage infrastructure.

The CDN Service is the platform's single front door for files. Every other service that consumes files — Speech, Media Editing, RAG ingestion — takes a path that refers to a file stored here. This guide covers the full lifecycle: upload, the already-exists conflict, read-back, delegation via time-limited tokens, and deletion. Verified against the local stack (CDN :40040); the shared dev subdomain works identically.

Prerequisites

subdomain).

in TOKEN — CDN file endpoints require both (obtain one via guide G-01; the full example below fetches one via the QA sign-in shortcut).

Node (built-in fetch/FormData, Node ≥ 18).

Concepts (only what this journey needs)

(uploads/avatar.png). The service manages the physical location; other services consume the same path via their source_path-style fields.

user's id (sub). Only the owner (or an admin role) can download or delete them via the authenticated endpoints.

overwrite=true fails with 409 — the platform never silently replaces content. With the flag, the replace is atomic.

system, mint a time-limited token: it binds the download to one path and expiry. The caller still presents the usual key pair and a valid JWT — the token scopes which file the call may stream.

Step 1 — Upload a file

Uploads are multipart/form-data with two fields: path (the tenant-relative destination) and file (the bytes):

bash
curl -s -o /dev/null -w "HTTP %{http_code}\n" \
  -X POST "${BASE_URL}/v3/cdn/files/upload" \
  -H "Authorization: Bearer ${TOKEN}" \
  -H "x-api-key: ${INAYA_API_KEY}" \
  -H "x-client-app-key: ${INAYA_APP_KEY}" \
  -F "path=demo/hello.txt" \
  -F "file=@./hello.txt;type=text/plain"
python
import os, requests

res = requests.post(
    f"{os.environ['BASE_URL']}/v3/cdn/files/upload",
    headers={
        "Authorization": f"Bearer {os.environ['TOKEN']}",
        "x-api-key": os.environ["INAYA_API_KEY"],
        "x-client-app-key": os.environ["INAYA_APP_KEY"],
    },
    data={"path": "demo/hello.txt"},
    files={"file": ("hello.txt", open("./hello.txt", "rb"), "text/plain")},
)
print("HTTP", res.status_code)
javascript
import { readFileSync } from "node:fs";

const form = new FormData();
form.set("path", "demo/hello.txt");
form.set("file", new Blob([readFileSync("./hello.txt")], { type: "text/plain" }));

const res = await fetch(`${process.env.BASE_URL}/v3/cdn/files/upload`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.TOKEN}`,
    "x-api-key": process.env.INAYA_API_KEY,
    "x-client-app-key": process.env.INAYA_APP_KEY,
  },
  body: form,
});
console.log("HTTP", res.status);

Expected response — success is a bare 201 (empty body):

HTTP 201

PITFALL

Multipart field order does not matter, but the field names do — path and file exactly. A JSON body here returns 400, and a 415 means you sent the wrong Content-Type: let your HTTP client generate the multipart boundary, never set Content-Type manually.

Step 2 — The already-exists conflict, and overwrite

Upload the same path again and the platform refuses to guess:

bash
echo "1. Same path, no overwrite (expected 409):"
curl -s -o /tmp/g04_conflict.json -w "   HTTP %{http_code}\n" \
  -X POST "${BASE_URL}/v3/cdn/files/upload" \
  -H "Authorization: Bearer ${TOKEN}" \
  -H "x-api-key: ${INAYA_API_KEY}" \
  -H "x-client-app-key: ${INAYA_APP_KEY}" \
  -F "path=demo/hello.txt" \
  -F "file=@./hello-v2.txt;type=text/plain"
cat /tmp/g04_conflict.json; echo

echo "2. Same path, overwrite=true (expected 201):"
curl -s -o /dev/null -w "   HTTP %{http_code}\n" \
  -X POST "${BASE_URL}/v3/cdn/files/upload?overwrite=true" \
  -H "Authorization: Bearer ${TOKEN}" \
  -H "x-api-key: ${INAYA_API_KEY}" \
  -H "x-client-app-key: ${INAYA_APP_KEY}" \
  -F "path=demo/hello.txt" \
  -F "file=@./hello-v2.txt;type=text/plain"

Expected output:

1. Same path, no overwrite (expected 409):
   HTTP 409
File already exists; set overwrite=true to replace it
2. Same path, overwrite=true (expected 201):
   HTTP 201

This is the 409 pattern from G-03: deliberate, never silent. Use overwrite=true when re-sending is expected (re-exports, regenerated documents); surface the conflict to the user when overwriting would destroy something a human chose.

Step 3 — Read the file back

The authenticated download requires the owner's JWT — other users get 403:

bash
curl -s -o /tmp/hello-back.txt -w "HTTP %{http_code}\n" \
  "${BASE_URL}/v3/cdn/files/private/download?path=demo/hello.txt" \
  -H "Authorization: Bearer ${TOKEN}" \
  -H "x-api-key: ${INAYA_API_KEY}" \
  -H "x-client-app-key: ${INAYA_APP_KEY}"
diff ./hello-v2.txt /tmp/hello-back.txt && echo "content matches"

Expected output:

HTTP 200
content matches

Step 4 — Delegate access with a time-limited token

For browsers, third parties, or other services, mint a token that carries its own expiry and signature:

bash
TOKEN_RESP=$(curl -s -X POST "${BASE_URL}/v3/cdn/files/token" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer ${TOKEN}" \
  -H "x-api-key: ${INAYA_API_KEY}" \
  -H "x-client-app-key: ${INAYA_APP_KEY}" \
  -d '{"path": "demo/hello.txt"}')
echo "$TOKEN_RESP"
DL_TOKEN=$(echo "$TOKEN_RESP" | python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])')

# The token scopes the download to this file; caller credentials are still required
curl -s -o /tmp/hello-token.txt -w "HTTP %{http_code}\n" \
  "${BASE_URL}/v3/cdn/files/download?token=${DL_TOKEN}" \
  -H "Authorization: Bearer ${TOKEN}" \
  -H "x-api-key: ${INAYA_API_KEY}" \
  -H "x-client-app-key: ${INAYA_APP_KEY}"
diff ./hello-v2.txt /tmp/hello-token.txt && echo "content matches"

Expected output — the token response carries the expiry as a Unix timestamp, and the tokenized download streams the file when the caller presents the usual credentials:

json
{ "token": "…", "expires_at": 1790205431 }
HTTP 200
content matches

NOTE

The default TTL is configurable per deployment (INAYA_LABS_CDN_TOKEN_TTL_IN_SECONDS). The token scopes the download to one file until it expires — but caller credentials (key pair + JWT) are still required, so treat it as a capability on top of identity, not an anonymous link. Issue short TTLs for anything sensitive.

Step 5 — Delete the file

Cleanup uses the path itself (DELETE, wildcard route):

bash
curl -s -o /dev/null -w "HTTP %{http_code}\n" -X DELETE \
  "${BASE_URL}/v3/cdn/files/demo/hello.txt" \
  -H "Authorization: Bearer ${TOKEN}" \
  -H "x-api-key: ${INAYA_API_KEY}" \
  -H "x-client-app-key: ${INAYA_APP_KEY}"

Expected response — 204, and a later authenticated read of the same path returns 403 ("no ownership record") — deletion removes both the bytes and the ownership record.

Full working example

A complete upload → conflict → overwrite → download → delete cycle (cdn_lifecycle.sh) — it fetches a user JWT via the QA shortcut first (local/QA environments only; production uses the G-01 flows):

bash
#!/usr/bin/env bash
set -euo pipefail

printf 'hello from the CDN guide\n' > ./hello.txt
printf 'hello v2\n' > ./hello-v2.txt

# 0. Obtain a user JWT (QA shortcut — see G-01 for production flows)
TOKEN=$(curl -sf -X POST "${AUTH_URL}/v3/auth/qa/login" \
  -H "Content-Type: application/json" \
  -H "x-api-key: ${INAYA_API_KEY}" \
  -H "x-client-app-key: ${INAYA_APP_KEY}" \
  -d "{\"device_id\": \"cdn-demo-device\", \"locale\": \"en-US\", \"tenant_id\": \"${TENANT_ID}\", \"username\": \"cdn-demo\"}" \
  | python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])')

AUTH=(-H "Authorization: Bearer ${TOKEN}"
      -H "x-api-key: ${INAYA_API_KEY}"
      -H "x-client-app-key: ${INAYA_APP_KEY}")

# 1. Upload
curl -sf -X POST "${BASE_URL}/v3/cdn/files/upload" "${AUTH[@]}" \
  -F "path=demo/hello.txt" -F "file=@./hello.txt;type=text/plain" \
  && echo "uploaded"

# 2. Conflict, then overwrite
code=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
  "${BASE_URL}/v3/cdn/files/upload" "${AUTH[@]}" \
  -F "path=demo/hello.txt" -F "file=@./hello-v2.txt;type=text/plain")
[[ "$code" == "409" ]] && echo "conflict surfaced: 409"
curl -sf -X POST "${BASE_URL}/v3/cdn/files/upload?overwrite=true" "${AUTH[@]}" \
  -F "path=demo/hello.txt" -F "file=@./hello-v2.txt;type=text/plain" \
  && echo "overwritten"

# 3. Read back
curl -sf "${BASE_URL}/v3/cdn/files/private/download?path=demo/hello.txt" \
  "${AUTH[@]}" -o /tmp/hello-back.txt && diff ./hello-v2.txt /tmp/hello-back.txt \
  && echo "read-back matches"

# 4. Delete
curl -sf -X DELETE "${BASE_URL}/v3/cdn/files/demo/hello.txt" "${AUTH[@]}" \
  && echo "deleted"

(AUTH_URL is the Auth Service base URL — http://127.0.0.1:40004 on the local stack; TENANT_ID is your tenant's id.)

Try it live

Open the Playground → Infrastructure → CDN console — it exercises the same upload/download/delete cycle against the live CDN Service with a file picker.

Copy agent prompt

Agent prompt — copy and hand to your agent
You are working with the Inaya Labs CDN Service at ${BASE_URL}. The tenant key
pair is in INAYA_API_KEY / INAYA_APP_KEY env vars; obtain a user JWT via the QA
login from guide G-01 if TOKEN is not already set. Steps: (1) Upload
./hello.txt to path "demo/hello.txt" via POST /v3/cdn/files/upload (multipart
fields "path" and "file"); expect 201. (2) Upload again unchanged and report
the 409; then re-upload with ?overwrite=true and expect 201. (3) Read back via
GET /v3/cdn/files/private/download?path=demo/hello.txt and confirm the content
round-trips. (4) Mint a download token via POST /v3/cdn/files/token {"path":
"demo/hello.txt"} and verify GET /v3/cdn/files/download?token=<token> streams
the file when called with your key pair and JWT (the token scopes which file).
(5) Delete via DELETE /v3/cdn/files/demo/hello.txt; expect 204. Never log the
JWT; report status codes per step.

API reference

Next journeys