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
- The CDN Service is reachable (local stack
:40040, or the shared dev
subdomain).
- Your tenant key pair in
INAYA_API_KEY/INAYA_APP_KEY, and a user JWT
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).
curl, or any HTTP client. Snippets also show Python (usesrequests) and
Node (built-in fetch/FormData, Node ≥ 18).
Concepts (only what this journey needs)
- Paths, not URLs. You store and reference files by a tenant-relative path
(uploads/avatar.png). The service manages the physical location; other services consume the same path via their source_path-style fields.
- Ownership follows the uploader. Files are recorded against the uploading
user's id (sub). Only the owner (or an admin role) can download or delete them via the authenticated endpoints.
- Conflicts are explicit. Re-uploading an existing path without
overwrite=true fails with 409 — the platform never silently replaces content. With the flag, the replace is atomic.
- Sharing uses scoped tokens. To hand a file to another component of your
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):
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"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)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 201PITFALL
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:
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 201This 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:
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 matchesStep 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:
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:
{ "token": "…", "expires_at": 1790205431 }HTTP 200
content matchesNOTE
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):
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):
#!/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
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
- Errors, retries & idempotency (G-03) — the retry discipline that makes uploads safe to repeat.
- Calling services from an AI agent (G-02) — the CDN's MCP tools include token generation for agent-driven sharing.