Platform API quickstart
Four calls: create a subject for your end-user, host a site for them from a file map, patch it, then mint a token their own browser can use directly. The full reference (every field, every error) is on the Platform API reference.
Before you start
$UPLOADTF_API_KEY: your platform key (prefixedutf_…), from the API screen. Pass it asx-api-keyorAuthorization: Bearer. Every call below uses the former.- Requires a plan with API access. A subject token (
utfs_…, minted in step 4) authenticates fine but is REJECTED on every endpoint here except the ones it's scoped for. This whole flow runs on your platform key. - Base URL:
https://upload.tf/api/platform/v1.
1. Create a subject
A subject is one of YOUR end-users, never an upload.tf account of its own. Creation is idempotent on externalId: call this again for the same user and you get the same subject back (200, not a duplicate 201).
POST /subjects
curl -s -X POST https://upload.tf/api/platform/v1/subjects \ -H "x-api-key: $UPLOADTF_API_KEY" \ -H "Content-Type: application/json" \ -d '{"externalId":"user-42","label":"Jordan (user-42)"}'Example response
{ "success": true, "data": { "id": "5f2c1e2a-6b3d-4a7c-9e2f-1a2b3c4d5e6f", "ownerId": "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9", "externalId": "user-42", "label": "Jordan (user-42)", "status": "active", "moderationStatus": "active", "createdAt": "2026-08-08T12:00:00.000Z", "updatedAt": "2026-08-08T12:00:00.000Z" } }2. Create a site from a file map
Send { files: [{ path, contentBase64 }] } instead of a .zip when your own server already has the generated files in memory, which is an LLM website builder's typical shape. Address the subject as ext:<externalId>, your own opaque id, so you never have to parse the create response just to make the next call.
POST /subjects/{subject}/sites
curl -s -X POST https://upload.tf/api/platform/v1/subjects/ext:user-42/sites \ -H "x-api-key: $UPLOADTF_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "files": [ { "path": "index.html", "contentBase64": "'"$(base64 < index.html)"'" } ] }'Example response
{ "success": true, "data": { "site": { "id": "a1b2c3d4e5", "name": "swift-otter-42", "url": "https://swift-otter-42.upload.tf", "fileName": "index.html", "kind": "site", "sizeBytes": 1842, "fileCount": 1, "createdAt": "2026-08-08T12:00:05.000Z", "expiresAt": null, "chatEnabled": false, "subjectId": "5f2c1e2a-6b3d-4a7c-9e2f-1a2b3c4d5e6f" } } }3. Apply a multi-file patch
Add, replace, or delete several files in one call, addressed by the site's IMMUTABLE id (data.site.id from step 2, never its name, because the name can be renamed later and would silently 404 a stale reference).
PATCH /sites/{pageId}
curl -s -X PATCH https://upload.tf/api/platform/v1/sites/a1b2c3d4e5 \ -H "x-api-key: $UPLOADTF_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "changes": [ { "op": "put", "path": "about.html", "contentBase64": "'"$(base64 < about.html)"'" }, { "op": "delete", "path": "old.html" } ] }'Example response
{ "success": true, "data": { "files": [ { "path": "index.html", "sizeBytes": 1842, "contentType": "text/html", "isEntry": true }, { "path": "about.html", "sizeBytes": 913, "contentType": "text/html", "isEntry": false } ], "fileCount": 2, "totalBytes": 2755 } }4. Mint a scoped browser token
A subject token (utfs_...) is safe to hand to that one end-user's own browser: it can only ever act as this one subject, and by default is read-only (cap defaults to ["site:read"] when omitted). Pin it to the page you just created with pag so the token can't touch this subject's other sites. It is returned exactly once, so store it client-side immediately.
POST /subjects/{subject}/tokens
curl -s -X POST https://upload.tf/api/platform/v1/subjects/ext:user-42/tokens \ -H "x-api-key: $UPLOADTF_API_KEY" \ -H "Content-Type: application/json" \ -d '{"cap":["site:read"],"ttlSeconds":600,"pag":"a1b2c3d4e5"}'Example response
{ "success": true, "data": { "token": "utfs_9f8e7d6c5b4a...", "expiresAt": 1786276800, "ttlSeconds": 600, "subjectId": "5f2c1e2a-6b3d-4a7c-9e2f-1a2b3c4d5e6f", "capabilities": ["site:read"] } }
Gotcha: deriving a site or subdomain name from user input
If you let end-users choose (or you derive from their handle) a custom site name, upload.tf validates it with the same validateSubdomainName check every other public slug goes through (lib/subdomain.ts). Two blocklists apply, and a platform deriving names from real user input WILL hit them eventually:
- RESERVED_NAMES: infrastructure-shaped labels (
www,api,admin, …). - PHISHING_FRAGMENTS, a substring blocklist against brand-impersonation phishing: any name CONTAINING
login,account,secure,billing,verify, or dozens of other credential-harvest/brand terms is refused, even as part of a longer name (e.g.jordan-secure-portalis rejected because it containssecure).
A rejected name in POST /subjects/{subject}/sites returns a 400. Either surface that error to the end-user and let them pick another name, or omit name entirely and let upload.tf generate one: the create response's data.site.name is always available afterward. Note name is platform-key-only in the first place: a subject token supplying it is refused with 400 regardless of the value.
Next: the full Platform API reference for every field and error shape, or webhooks to get notified instead of polling.

