# XFiles — File Management as a Service > API-first file management. Upload files, serve via CDN, auto-generate image variants. > Plus a platform for search, live updates, chat, notifications and multiplayer. XFiles is a file management service. You create projects, get API keys, and use the REST API to upload, manage, and serve files through CloudFront CDN. Image variants (resized WebP) are generated the first time each width is requested, stored in S3, and cached at the CloudFront edge. Bundles — whole folders a build produces, versioned, such as a game's levels or a sound library — are stored and served on Cloudflare (see Bundles). The platform (PART 3) is a second service for the same projects, with the same API keys: typo-tolerant search, live updates over WebSockets with presence, chat, in-app and web push notifications, and multiplayer rooms with an authoritative server. Browsers use short-lived tokens your server mints with the project key. ## Quick Links - Docs (signed in): https://xfiles.dev/dashboard/docs — this file is the complete reference, and public - Dashboard: https://xfiles.dev/dashboard - Bundle sync script: https://xfiles.dev/xfiles-sync.mjs - Platform API: https://platform.xfiles.dev (PART 3) - Push service worker, to serve from your own site: https://xfiles.dev/xfiles-sw.js --- # PART 1: REST API ## Authentication All `/api/v1/*` endpoints require a Bearer token: Authorization: Bearer xf_AbC12dEfGhIjKlMnOpQrStUvWxYz9012 Keys are created per-project in the dashboard (the project menu at the top of the sidebar → New project). Format: `xf_` + 32 random characters (35 total). Only the SHA-256 hash is stored — the raw key is shown once at creation. Lost or leaked a key? Rotate it on the dashboard (Keys & access → Rotate key): a new key is shown once and the old one answers 401 from that moment — nothing caches it. Give the new key to everything that used the old one (servers, CI, xfiles-sync). URLs, tokens, files and bundles already handed out keep working: they are signed with the CDN key, not the API key. ## Core Concepts - **Project**: A namespace with its own API key and files. Storage is metered against the owning user's credit balance — no per-project cap. - **File**: An uploaded object stored in S3, served through CloudFront CDN. - **Visibility**: `"public"` (permanent CDN URL) or `"private"` (signed CDN URL, 5 min expiry). - **Entity**: Optional polymorphic attachment (`type`, `id`, `role`, `position`) for linking files to your domain objects. - **Image Variants**: Opt-in resized WebP copies at predefined widths (100, 300, 400, 600, 800, 1000, 1200px). Each width is generated the first time it is requested, stored in S3 and cached by the CDN, so each variant is built at most once. - **Variant Visibility**: Independent from file visibility. A private original can have public variants (e.g. watermarked thumbnails). ## Upload Flow (3 steps) 1. **Intent** — POST to get a presigned S3 upload URL (locks content-type, size, metadata) 2. **Upload** — PUT the file directly to S3 via the presigned URL 3. **Confirm** — POST to verify the file exists in S3 and mark it confirmed Files never pass through the application server. Uploads go directly to S3 via presigned URLs. Downloads are always served through CloudFront CDN. Bundles are the exception: they are stored and served on Cloudflare (see Bundles). ## API Reference Base URL: https://xfiles.dev ### POST /api/v1/files/upload-intent Get a presigned upload URL. The URL cryptographically locks Content-Type, Content-Length, and all metadata so the client cannot tamper with anything declared at intent time. Request body: { "filename": "photo.jpg", // Required "contentType": "image/jpeg", // Required — must be whitelisted "size": 204800, // Required — bytes, max 500MB "visibility": "public", // Optional — "public" | "private" (default: "private") "ownerUserId": "user_123", // Optional — track who uploaded "entity": { // Optional — attach to a domain object "type": "product", // Required if entity provided "id": "prod_abc123", // Required if entity provided "role": "gallery", // Optional — avatar, logo, gallery, document, etc. "position": 0 // Optional — display order (0 = primary, default: 0) }, "transformations": { // Optional — image variant behavior "image": { "enabled": true, // Enable variant generation (default: false) "visibility": "public" // "public" | "private" (default: "private") } } } Response: { "fileId": "abc123def456", "uploadUrl": "https://s3.amazonaws.com/...?X-Amz-Signature=...", "key": "files/originals/public/proj_id/abc123def456/photo-k9x2m7.jpg", "expiresIn": 900 } ### PUT {uploadUrl} Upload the file directly to S3. Content-Type and Content-Length must match exactly. curl -X PUT "UPLOAD_URL" -H "Content-Type: image/jpeg" --data-binary @photo.jpg ### POST /api/v1/files/confirm Verify the file exists in S3 and mark it as confirmed. Safe to retry: confirming a file that is already confirmed answers the same file again. If the stored object's size differs from the declared size, it is discarded and the call answers 409 — upload it again. Request body: { "fileId": "abc123def456" } Response: { "file": { "id": "abc123def456", "filename": "photo-k9x2m7.jpg", "originalFilename": "photo.jpg", "contentType": "image/jpeg", "size": 204800, "visibility": "public", "uploadStatus": "confirmed", "ownerUserId": "user_123", "entity": { "type": "product", "id": "prod_abc123", "role": "gallery", "position": 0 }, "transformations": { "image": { "enabled": true, "visibility": "public" } }, "createdAt": "2026-03-12T10:00:00.000Z" } } ### GET /api/v1/files List all confirmed files. No filters required — call with just your API key to get all files. Always returns 20 files per page (hardcoded, not configurable — paginate with `page`). Every file includes its entity attachment data (or null). All filters are optional and can be combined. When filtering by entity, results are sorted by position ascending (position 0 first). **Each file includes its CDN `url` and (for transform-enabled images) a `variants[]` array inline** — so you can render a whole grid/gallery from this ONE response. Do NOT call `POST /{id}/url` per file; that's an N+1 round-trip storm. Only use `/{id}/url` to refresh a PRIVATE file's signed URL after it expires (public URLs are permanent — see Caching below). **`url` can be null.** Once the account has been out of credit for more than 7 days (or has never had credit), private originals and private variants come back with `"url": null` until a top-up, and the top-level `privateLinks` says why — see "Private links at zero" below. Public originals and public variants always keep their URLs. Render a placeholder for a null url. Optional query params: entityType, entityId, role, ownerUserId, visibility, variantVisibility, page (default 1) Examples: GET /api/v1/files — all files, page 1 GET /api/v1/files?page=3 — all files, page 3 GET /api/v1/files?entityType=product&entityId=prod_abc — files for a product GET /api/v1/files?visibility=public&ownerUserId=user_123 — public files by owner Response (every file carries url + variants — no per-file lookups needed): { "files": [ { "id": "abc123", "filename": "sneaker-front-k9x2m7.jpg", "originalFilename": "sneaker-front.jpg", "contentType": "image/jpeg", "size": 204800, "visibility": "public", "uploadStatus": "confirmed", "ownerUserId": "user_123", "entity": { "type": "product", "id": "prod_abc", "role": "gallery", "position": 0 }, "transformations": { "image": { "enabled": true, "visibility": "public" } }, "url": "https://cdn.example.com/files/originals/public/proj/abc123/sneaker-front-k9x2m7.jpg", "variants": [ { "width": 100, "url": "https://cdn.example.com/files/variants/public/proj/abc123/sneaker-front-k9x2m7_w100.webp" }, { "width": 300, "url": "..." }, { "width": 400, "url": "..." }, { "width": 600, "url": "..." }, { "width": 800, "url": "..." }, { "width": 1000, "url": "..." }, { "width": 1200, "url": "..." } ], "createdAt": "2026-03-12T10:00:00.000Z" } ], "total": 42, "page": 1, "totalPages": 3, "privateLinks": { "paused": false } } Non-image files (and images with transforms disabled) include `url` but no `variants`. ### Private links at zero When the account's balance reaches zero, uploads stop at once, but private links — signed URLs for private originals and private variants — keep being issued for 7 more days, then pause until credit is added. Every response that carries URLs (list, get, url, restore) says which, in `privateLinks`: { "paused": false } the account has credit { "paused": false, "reason": "out_of_credit", at zero, inside the 7 days: "outOfCreditSince": "2026-11-04T09:00:00.000Z", links still signed — log pausesAt "pausesAt": "2026-11-11T09:00:00.000Z" } { "paused": true, "reason": "out_of_credit", after: every private url is null "outOfCreditSince": "2026-11-04T09:00:00.000Z", "pausedAt": "2026-11-11T09:00:00.000Z", "message": "Private file links are paused: this account is out of credit. Add credits at /dashboard/billing and they come back at once." } - While paused, list, get and restore keep answering (metadata, entity, everything) with `"url": null` on each private original and `{ "width": 400, "url": null }` for each private variant. Originals and variants are judged separately: a private file's public variants keep their URLs, and a public file's private variants are null. - POST /{id}/url answers 402 for a private file (body: "error" plus the same privateLinks); a public file still gets its permanent URL. - An account that never had credit has no grace: paused from the start, both dates null. - A top-up that takes the balance above zero brings every link back on the next request. Signed URLs already handed out work until they expire (5 minutes). - Branch on privateLinks.paused, never on a 402 from list or get: those do not answer 402. Private bundles follow the same 7 days (see Private bundles). ### Large files (over 100MB) For files over 100MB, upload-intent returns a `multipart` object INSTEAD of `uploadUrl`. Branch on which field is present — do not check the size yourself, the threshold can change. { "fileId": "abc123", "key": "files/originals/private/proj/abc123/video-k9x2m7.mp4", "expiresIn": 21600, "multipart": { "uploadId": "2~abc...", "partSize": 10485760, "parts": [ { "partNumber": 1, "url": "https://..." }, ... ] } } PUT each slice of the file to its part URL, keep the ETag each response returns, then: ### POST /api/v1/files/complete Assemble a multipart upload and confirm it in one call. Replaces /confirm for large files. Request body: { "fileId": "abc123", "uploadId": "2~abc...", "parts": [ { "partNumber": 1, "etag": ""abc123"" }, ... ] } The assembled size is verified against what you declared at intent. A mismatch discards the object and returns 400 — a part URL cannot pin content length the way a single PUT can. ### GET /api/v1/files/{id} Get file details + CDN URLs. Public files get permanent URLs, private get signed URLs. Raster images with transforms enabled include variant URLs at all predefined widths. Private urls are null while private links are paused (see Private links at zero). Response: { "file": { ... }, "url": "https://cdn.example.com/files/originals/public/.../photo.jpg", "variants": [ { "width": 100, "url": "https://cdn.example.com/files/variants/public/.../photo_w100.webp" }, { "width": 300, "url": "..." }, { "width": 400, "url": "..." }, { "width": 600, "url": "..." }, { "width": 800, "url": "..." }, { "width": 1000, "url": "..." }, { "width": 1200, "url": "..." } ], "privateLinks": { "paused": false } } ### PATCH /api/v1/files/{id} Update visibility, variant settings, owner, or position. Changing visibility moves the S3 object between public/private paths. Changing variant visibility purges existing variants. Request body (all optional): { "visibility": "public", "ownerUserId": "user_456", "position": 2, "transformations": { "image": { "enabled": true, "visibility": "public" } } } ### DELETE /api/v1/files/{id} Delete a single file. The stored object and every variant are removed and the file disappears from all listings immediately. The record is retained for 30 days so the delete can be undone — see POST /api/v1/files/{id}/restore. Response: { "success": true, "restorable": true } ### DELETE /api/v1/files Bulk delete by entity filters. At least one filter required. Reversible per file, exactly like deleting one. Query params: entityType, entityId, role Response: { "deleted": 5, "restorable": true } ### POST /api/v1/files/{id}/restore Undo a delete, within 30 days. Variants are NOT restored — they are regenerated on demand the next time a variant URL is requested. Works whatever the balance; while private links are paused the private urls in the answer are null. Response: { "file": { ... }, "url": "...", "variants": [ ... ], "privateLinks": { "paused": false } } Returns 410 if the 30-day window has passed and the stored copy is gone. ### GET /api/v1/files/deleted List deleted files that can still be restored. Paginated (20 per page). No URLs are returned — the objects are gone until restored. Response: { "files": [ { "id": "abc123", "restorableUntil": "2026-04-11T10:00:00.000Z", ... } ], "total": 3, "page": 1, "totalPages": 1, "restoreWindowDays": 30 } ### POST /api/v1/files/{id}/url Refresh the CDN URLs for ONE file. The list and GET endpoints already return URLs inline, so you only need this to re-sign a PRIVATE file's URL after the 5-minute expiry. Public files return permanent CDN URLs (no need to refresh); private files return signed URLs valid for 5 min. Response (public): { "url": "https://cdn.example.com/files/originals/public/.../photo.jpg", "variants": [{ "width": 100, "url": "..." }, ...] } Response (private): { "url": "https://cdn.example.com/...?Signature=...&Expires=...", "variants": [{ "width": 100, "url": "https://cdn.example.com/...?Signature=..." }, ...], "expiresIn": 300, "privateLinks": { "paused": false } } Private file while private links are paused (see Private links at zero) → 402: { "error": "Private file links are paused: this account is out of credit. Add credits at /dashboard/billing and they come back at once.", "privateLinks": { "paused": true, "reason": "out_of_credit", "outOfCreditSince": "…", "pausedAt": "…", "message": "…" } } A public file is never refused: its URL is permanent (private variants of it come back null). ## Bundles A bundle is a named, versioned set of files that a build produces — a game's levels and maps, a library of sounds, an asset pack, a static site. You publish a whole folder at once, everybody downloads it, and later you publish its next version. Content is stored by its SHA-256 and never overwritten, so an unchanged file is never uploaded again: once per project across every public bundle and version, and once per bundle for a private one. Every content URL is fixed: content and manifests never change and are cached for a year. ### Files or bundles? Files are what your users upload, one at a time, into your app. Bundles are what your build produces and everybody downloads. If it comes out of a build folder, it is a bundle. Files Bundles For one upload: an avatar, an invoice a whole folder: maps, sounds, a site Belongs to a record in your app, and its uploader your release: a name and versions Changing it replace or delete the file publish a version; roll back by making an older one live Images thumbnails at any width, on demand served exactly as built Private a signed URL per file, for minutes one token for the whole bundle, per player, up to a day Repeats each upload is stored unchanged content stored once, across all public versions and bundles; a private bundle keeps its own Served from AWS (CloudFront) Cloudflare, its own address Billed storage and downloads storage (each piece once) and downloads, same prices Neither is for data your app or game edits or players produce — settings, saves, progress, scores. Those are records in your own database; bundles are files you publish. ### Concepts - **Bundle**: a name in a project, e.g. "sounds" or "afterbloom". Names: lowercase letters, digits, ".", "_", "-", starting with a letter or digit, up to 64 characters, not "b", "c" or "v". Created by its first version. Public or private, decided by its first version and fixed after that. - **Version**: 1, 2, 3 … per bundle, never reused. Starts as a draft; you list its files and upload what is missing; committing it writes its manifest. A committed version never changes. - **Manifest**: a version's list of files — path → [sha256, size, contentType] — plus contentBase. Written once, at commit. Its URL is fixed for ever. - **Pointer** (live.json): names the live version. Making a version live, or rolling back, moves only the pointer. Readers see the change within 30 seconds. - **Content address**: a file's URL is the manifest's contentBase + the file's SHA-256. - **Paths** are what your code looks files up by. Relative, "/"-separated, up to 512 characters, no empty, "." or ".." segments. Bundle URLs are on their own host (for example https://cdn.xfilescdn.dev), not the one files use. **Always take URLs from what XFiles returns** — the API, xfiles-sync --json, the pointer, the manifest — and never build them from parts. The one thing you add: for a private bundle, "?" + the access token's query on the end of every URL (see Private bundles). ### Organising a studio's content **One project for all your games.** Public content is stored once per project: a sound or texture used by three games is uploaded and stored once if the games share a project, three times if they do not. Use separate projects only for content that must be billed or owned separately. **One bundle per game for what is built with it** — levels, maps, textures: afterbloom/ → bundle "afterbloom" levels/01.json maps/east/12_7.webp ui/atlas.png A game either follows the live version (reads the pointer; you roll back by making an older version live) or pins one (stores a manifest URL; nothing changes until you ship a new URL). **A shared library bundle for what many games use** — sounds, music, fonts: sounds/ → bundle "sounds", used by every game sfx/jump.ogg sfx/explosion-big.ogg music/menu.ogg Which sound plays where is not in any bundle. It is a setting in each game's own database, holding a path from the library — here, settings.sounds: { "sounds": { "jump": "sfx/explosion-big.ogg", "menu": "music/menu.ogg" } } Changing a setting takes effect at once — no publish. Adding or changing sounds is a new version of "sounds"; every game picks it up within 30 seconds, and a bad change is undone by making the previous version live. The game resolves names through the live manifest: const live = await fetch(SOUNDS_LIVE_URL).then((r) => r.json()); const library = await fetch(live.manifest).then((r) => r.json()); const urlOf = (path) => { const entry = library.files[path]; // [sha256, size, contentType] return entry ? library.contentBase + entry[0] : null; }; const url = urlOf(settings.sounds.jump); // settings come from your game's database if (url) playSound(url); // null: the live library no longer has that path A setting can name a path the live library no longer has — a sound removed in a newer version. Plan for it: a default sound, silence, or a warning in your admin screen. The admin screen can check every setting against Object.keys(library.files) before saving. SOUNDS_LIVE_URL is the bundle's liveUrl from GET /api/v1/bundles/sounds — store it in your game's config once. A setting may instead hold a SHA-256 (library.contentBase + sha256): that sound then stays the same even if the library's file at that path is replaced — for as long as any version of the library still lists it. Versions are kept unless deleted; protect a version to make sure its sounds stay. Names centralise; hashes pin. A dropdown of every available sound is the manifest's file list: Object.keys(library.files). **Drafts for testers** in a private bundle (e.g. "afterbloom-next"): only people your server gives a token can read it. Visibility is fixed per bundle, so release by publishing the same folder to the public bundle. Its content is uploaded once more: a private bundle's content is kept apart from everything else, which is what keeps it off every public address. **Audio**: Opus in .ogg (audio/ogg) or .weba (audio/webm) is about a tenth of WAV's size. If you support older Safari, ship .mp3 (audio/mpeg) too and pick per browser. Name the files .ogg, .weba or .mp3: other extensions (".opus") are uploaded by xfiles-sync as application/octet-stream. ### Publishing with the sync script One command hashes a folder, uploads only what is new, commits the version and makes it live: curl -O https://xfiles.dev/xfiles-sync.mjs XFILES_API_KEY=xf_... node xfiles-sync.mjs ./build/sounds --bundle sounds --json Needs Node 18+, nothing else. Run it on your machine or in CI — never in a browser. --bundle bundle to publish to (required) --message "" stored in the manifest --no-live commit, but leave the live version where it is --protect commit as protected: cannot be deleted until unprotected --private make a NEW bundle private --dry-run say what would upload; upload and commit nothing --force publish a new version even if the folder matches the live one --json one JSON object on stdout when done; progress on stderr --concurrency parallel uploads (default 16) --include-hidden include files and folders whose names start with "." --url XFiles to talk to (default $XFILES_URL or https://xfiles.dev) node xfiles-sync.mjs --access [--ip
] [--expires ] print a private bundle's manifest URL with a token, to try it by hand Content types come from extensions (the table under "Content types" below); anything else is application/octet-stream. Exit code 0 on success; anything else means nothing was committed. An interrupted run can be run again: content that already arrived is skipped. The draft the interrupted run left behind is abandoned 7 days after it was started (see What is kept). **A folder that matches the live version publishes nothing.** If every file — path, content and type — is exactly what the live version holds, the run stops before creating anything, exits 0, and --json answers the live version with "unchanged": true: { "bundle": "sounds", "version": 2, "visibility": "public", "manifest": "…/b/sounds/v/2.json", "sha256": "364bbb2a…", "live": true, "protected": false, "files": 3, "new": 0, "reused": 3, "newBytes": 0, "totalBytes": 10133, "unchanged": true } So it is safe to run on every build. --force publishes a new version anyway — to give it a new message, or to publish it protected (the live version is not protected by an unchanged run). A --dry-run reports the same: "unchanged": true, "upload": 0. --json prints the commit result: { "bundle": "sounds", "version": 2, "visibility": "public", "manifest": "https://cdn.xfilescdn.dev/files/assets/public//b/sounds/v/2.json", "sha256": "364bbb2a…", "live": true, "protected": false, "files": 3, "new": 1, "reused": 2, "newBytes": 2113, "totalBytes": 10133 } "new" counts files whose content no other committed version lists — what changed. ### Publishing from your own server (the API) The sync script is these calls; use them directly to publish from a tool or a server — for example a sound editor adding a sound to the library. Every call needs Authorization: Bearer ; JSON in, JSON out. Base URL: https://xfiles.dev **1. (optional) Plan** — what would upload. Changes nothing, and works before the bundle exists. POST /api/v1/bundles/sounds/plan { "files": [ { "path": "sfx/jump.ogg", "sha256": "4d0c54bb…", "size": 2010, "contentType": "audio/ogg" }, … ] } → 200 { "files": 3, "missing": [ { "sha256": "4d0c54bb…", "size": 2010 }, … ], "missingBytes": 10030 } **2. Start a draft.** Creates the bundle on first use. POST /api/v1/bundles/sounds/versions { "visibility": "public" } optional. For a new bundle: "public" (default) or "private". For an existing one, leave it out — naming the other visibility answers 409. → 201 { "bundle": "sounds", "version": 1, "visibility": "public", "status": "draft" } **3. List the version's files**, up to 1,000 per call (call again for more; listing a path again replaces it). For every piece of content this project has never stored, you get an upload URL: POST /api/v1/bundles/sounds/versions/1/files { "files": [ { "path": "sfx/jump.ogg", "sha256": "4d0c54bb…", "size": 2010, "contentType": "audio/ogg" }, … ] } → 200 { "added": 3, "fileCount": 3, "missing": [ { "sha256": "4d0c54bb…", "size": 2010, "url": "https://…r2.cloudflarestorage.com/files/assets/public//c/4d0c54bb…?X-Amz-…", "headers": { "Content-Type": "audio/ogg", "Cache-Control": "public, max-age=31536000, immutable", "x-amz-checksum-sha256": "TQxUu93u…", "If-None-Match": "*" } }, … ] } "type" is accepted as another name for "contentType". sha256 is the 64 hex characters of the SHA-256 of the file's bytes. A draft keeps accepting files until it is committed, so a listing can be split over many calls — or resumed after an interruption, with the same version number. **4. Upload** each missing piece: PUT its bytes to "url" with exactly the "headers" given. PUT (headers as given; body = the file's bytes) → 200 stored → 412 already stored — treat as success → 400 BadDigest — the bytes do not match the sha256 you listed URLs last 15 minutes; for any that ran out, list the same files again for fresh ones. **5. Commit.** Every file must be stored; the manifest is written once; by default the version goes live. POST /api/v1/bundles/sounds/versions/1/commit { "message": "First sounds", "live": true, "protect": false } all optional → 200 { "bundle": "sounds", "version": 1, "visibility": "public", "manifest": "https://cdn.xfilescdn.dev/files/assets/public//b/sounds/v/1.json", "sha256": "6c705728…", "live": true, "protected": false, "files": 3, "new": 3, "reused": 0, "newBytes": 10030, "totalBytes": 10030 } Committing a committed version again answers the same object (and makes it live if asked) — safe to retry. A shortcut, POST /api/v1/bundles/{name}/versions/{n}/publish, commits and makes live in one call and answers { "bundle": }. **Interrupted?** Either list the same files on the same draft again — anything already uploaded is not handed out again — and commit, or start a new draft. Keep the draft's number from step 2; if it was lost, GET /api/v1/bundles/{name} lists it among "versions" with "status": "draft" and "deletedAt": null. A draft not committed within 7 days of being started is abandoned; deleting it (DELETE …/versions/{n}) abandons it at once. Either way it can no longer be committed, and its number is not reused. ### Publishing only when something changed Through the API a commit always makes a new version (xfiles-sync skips for you). To skip identical publishes, compare your files with the live version's manifest first — the same check xfiles-sync makes: // api(): your helper that calls XFiles with the API key and parses the JSON. // A 404 means the bundle does not exist yet — that is a first publish. const { bundle } = await api("GET", "/api/v1/bundles/sounds"); const live = bundle.live && await fetch(bundle.live.manifestUrl).then((r) => r.json()); const same = live && Object.keys(live.files).length === mine.length && mine.every((f) => { const entry = live.files[f.path]; // [sha256, size, contentType] return entry && entry[0] === f.sha256 && entry[2] === f.contentType; }); if (same) return; // nothing to publish mine is your list of { path, sha256, size, contentType }. bundle.live is null while no version is live. For a private bundle, fetch bundle.live.manifestUrl + "?" + query, with a query from POST …/access (see Private bundles). ### Content types Bundles accept the types under Allowed Content Types below, plus application/octet-stream, application/wasm and image/ktx2. With the API you say each file's type; xfiles-sync derives it from the extension: .ogg audio/ogg .weba audio/webm .mp3 audio/mpeg .wav audio/wav .json application/json .xml application/xml .txt text/plain .csv text/csv .html text/html .css text/css .js .mjs text/javascript .png image/png .jpg .jpeg image/jpeg .gif image/gif .webp image/webp .avif image/avif .svg image/svg+xml .tif .tiff image/tiff .ktx2 image/ktx2 .glb model/gltf-binary .gltf model/gltf+json .wasm application/wasm .mp4 video/mp4 .webm video/webm .woff font/woff .woff2 font/woff2 .ttf font/ttf .otf font/otf .pdf application/pdf .zip application/zip .gz application/gzip .bin and anything else application/octet-stream A file is served with the type it was listed with. ### Reading a bundle From your game or site — no API key, plain GETs: GET the pointer, e.g. https://cdn.xfilescdn.dev/files/assets/public//b/sounds/live.json { "bundle": "sounds", "version": 1, "manifest": "https://cdn.xfilescdn.dev/files/assets/public//b/sounds/v/1.json", "sha256": "6c705728…", "updatedAt": "2026-10-31T18:11:41Z" } GET { "format": "xfiles-manifest", "formatVersion": 1, "bundle": "sounds", "version": 1, "visibility": "public", "createdAt": "2026-10-31T18:11:40Z", "message": "First sounds", "protected": false, "contentBase": "https://cdn.xfilescdn.dev/files/assets/public//c/", "totals": { "files": 3, "bytes": 10030 }, "files": { "music/menu.ogg": ["9e12eb79…", 5010, "audio/ogg"], "sfx/explosion-big.ogg": ["7054cda3…", 3010, "audio/ogg"], "sfx/jump.ogg": ["4d0c54bb…", 2010, "audio/ogg"] } } GET the file itself Follow the pointer to always get the live version; store a manifest URL to pin one. The pointer's sha256 is the SHA-256 of the manifest's bytes as fetch returns them (decompressed), so a reader that already holds a manifest with that hash can skip downloading it again. Public and private bundles lay out their URLs differently (private ones have no "/b/"), which is one more reason never to build a URL: take it from the pointer, the manifest, the API or --json. Responses: content and manifests "public, max-age=31536000, immutable" with their content type (cache them for ever — a URL never changes); the pointer "public, max-age=30". Range requests (206), HEAD and If-None-Match (304) work. JSON is served gzip- or brotli-compressed to a GET that accepts it. Every response, errors included, has Access-Control-Allow-Origin: *. ### Private bundles Nothing — files, manifests or pointer — is served without a token for that one bundle. Your server asks for one per player (with your API key) and hands the URLs to the player's client: POST /api/v1/bundles/afterbloom-next/access { "expiresIn": 7200, "ip": "203.0.113.7" } both optional → 200 { "visibility": "private", "query": "Policy=…&Key-Pair-Id=…&Signature=…", "liveUrl": "https://cdn.xfilescdn.dev/files/assets/private//afterbloom-next/live.json?Policy=…", "manifestUrl": "https://cdn.xfilescdn.dev/files/assets/private//afterbloom-next/v/1.json?Policy=…", "contentBase": "https://cdn.xfilescdn.dev/files/assets/private//afterbloom-next/c/", "expiresAt": "2026-10-31T20:12:26.330Z", "ip": "203.0.113.7/32" } - expiresIn: seconds, 60 to 86400 (one day), default 7200. - ip: an IPv4/IPv6 address or CIDR range; the token then works from there alone, so a token copied out of a player's browser is useless anywhere else. It is checked against the player's public address as the bundle host sees it — pass the address your server saw the player's request come from (behind a proxy or load balancer, from the header it sets, such as X-Forwarded-For). A server running on your own machine sees 127.0.0.1 or ::1, and a token bound to that never works. A player on a network with both IPv4 and IPv6 may reach your server over one and the bundle host over the other; leave ip out unless you are sure. A token with the wrong address answers the same 403 as a missing or expired one. - Append "?" + query to every URL of the bundle: manifestUrl and liveUrl already have it; the "manifest" inside the pointer and every file — contentBase + sha256 — need it added. - manifestUrl is the version that was live when the token was made; read liveUrl to follow the live version as it changes. - Ask for a new token before expiresAt; a token opens one bundle and nothing else. - Without a valid token every request answers 403 {"error":"forbidden"}. - For a public bundle the same call answers its plain URLs with "query": "". - **Out of credit:** tokens keep being issued for 7 days after the balance reaches zero (the same 7 days private file links get), and each answer then carries "pausesAt" — when they stop unless credit is added. After it the call answers 402 {"error": "Private bundles are paused: …", "outOfCreditSince": "…"}; a top-up opens them again at once. Tokens already handed out work until their expiresAt (at most a day). Public bundles are never paused. Log pausesAt on your server. ### Managing versions GET /api/v1/bundles → { "bundles": [ , … ] } GET /api/v1/bundles/{name} → { "bundle": } POST /api/v1/bundles/{name}/live { "version": 2 } → { "bundle": } POST /api/v1/bundles/{name}/versions/{n}/protect → { "bundle": } (DELETE to unprotect) DELETE /api/v1/bundles/{name}/versions/{n} → { "bundle": } DELETE /api/v1/bundles/{name} → { "deleted": true } is: { "name": "sounds", "visibility": "public", "liveUrl": "https://cdn.xfilescdn.dev/…/b/sounds/live.json", "contentBase": "https://cdn.xfilescdn.dev/…/c/", "live": { "version": 1, "manifestUrl": "https://cdn.xfilescdn.dev/…/b/sounds/v/1.json" }, "versions": [ { "version": 2, "status": "committed", "live": false, "protected": false, "message": "New jump", "fileCount": 3, "totalBytes": 10133, "newFiles": 1, "newBytes": 2113, "manifestUrl": "https://cdn.xfilescdn.dev/…/b/sounds/v/2.json", "manifestSha256": "364bbb2a…", "createdAt": "…", "committedAt": "…", "deletedAt": null, "restorableUntil": null }, … ], "createdAt": "…" } - "status" is "draft" (still accepting files) or "committed" (has a manifest; never changes). A version with "deletedAt" set is deleted, whatever its status. Only a committed version can be made live or protected. - Making a version live is also how you roll back. Versions are numbered for ever; "v3" or "3" both work in a path. - A protected version cannot be deleted, and a bundle with one cannot be deleted. - The live version cannot be deleted: make another live first, or delete the whole bundle. - A deleted committed version can be made live again until its "restorableUntil", 7 days after deletion. A deleted draft is gone: it cannot be committed or made live, and its "restorableUntil" is null. A deleted bundle stops being served at once and comes back if its name is used again within 7 days. - The same actions are on the dashboard's Bundles page. ### What is kept, and for how long - Committed versions: for ever, unless deleted. - Drafts never committed: abandoned 7 days after they were started (they can no longer be committed), then removed 7 days later. - Deleted versions and bundles: 7 days, then removed. - Content: removed 7 days after no remaining version lists it — never while one does. - Deleting the project deletes all its bundles at once. ### Limits - API: 1,000 files per files call; 50,000 files per version; 100 MB per file. - Accepted content types: see Content types above. - Downloads: only GET and HEAD. 1,200 requests per IP address per minute on bundles; private bundles also 300 per token and 600 per IP address. Past a limit: 429 with Retry-After: 60, CORS and {"error":"rate_limited","retryAfter":60}. Wait, then carry on. ### Errors API errors are JSON: { "error": "" }, sometimes with more fields. 400 bad request: a bad name, path, sha256, size, version number or body — e.g. "\"Bad Name!\" is not a bundle name. Use lowercase letters, digits, …" 401 {"error":"Invalid API key"} 402 out of credit: starting a version and listing files are refused until the balance is topped up. Private-bundle tokens keep coming for 7 days, then this too (see Private bundles). Reading public bundles and downloads keep working. 403 {"error":"This account is suspended","code":"suspended"} — every call with the account's keys, until the suspension is lifted; the keys then work unchanged. Bundles already public keep being served. 404 {"error":"No bundle called \"nope\"."} / {"error":"sounds has no version 99."} 409 conflicts — read "error": "sounds is public. A bundle's visibility is set when it is created — publish to a new name instead." "1 file has not been uploaded. List them again to get upload URLs, upload them, then commit." (with "missingCount" and "missing": [sha256, …] — list those files again, upload, commit) "v1 is live. Make another version live first, or delete the whole bundle." "sounds has 1 protected version(s). Unprotect them before deleting it." "sounds v3 is committed and cannot change. Start a new version." "sounds v3 was abandoned. Start a new version." (a deleted or expired draft) 413 too many files in a call or version, or a file over 100 MB 415 {"error":"files[0].contentType \"application/x-msdownload\" is not a type bundles accept."} 503 some content is being cleaned up from storage; "retryAfter": 60 — wait, then list again Downloads from the bundle host: 403 {"error":"forbidden"} for a missing file, a missing or invalid token, or a path outside bundles; 405 for anything but GET/HEAD; 429 as above. ### Billing Storage and downloads, at the same prices as files. Content shared between versions or bundles is stored, and billed, once. Every download is counted exactly — cached or not, a Range request by the part sent; a file is counted at its stored size even when it travels compressed (JSON does — manifests and pointers; audio, images and other binary files do not). At a zero balance: publishing stops at once; private bundles keep opening for 7 days (an email at zero says until when, another when they pause), then stop until a top-up; public bundles keep being served, and their downloads are billed against the balance. ## Upload Security The presigned URL cryptographically locks: - Content-Type — must match the declared type - Content-Length — must match the declared size - Visibility — cannot be tampered with - Project ID — cannot assign to a different project - File ID — cannot reassign to a different record - Expiry — URL valid for 15 minutes only After upload, the confirm endpoint calls HeadObject on S3 to verify the file actually exists before marking it confirmed. Stale pending records are cleaned up by a cron job. ## Allowed Content Types Images: image/jpeg, image/png, image/gif, image/webp, image/svg+xml, image/avif, image/tiff, image/bmp, image/ico Documents: application/pdf, application/msword, application/vnd.openxmlformats-*, text/plain, text/csv, text/html, text/css, text/javascript, application/json, application/xml Archives: application/zip, application/gzip, application/x-tar Audio: audio/mpeg, audio/wav, audio/ogg, audio/webm Video: video/mp4, video/webm, video/ogg Fonts: font/woff, font/woff2, font/ttf, font/otf Adobe: application/postscript (.ai, .eps), application/illustrator, image/vnd.adobe.photoshop (.psd, .psb), application/x-photoshop, application/x-indesign (.indd), application/vnd.adobe.indesign-idml-package (.idml), application/vnd.adobe.aftereffects.project (.aep), application/vnd.adobe.premiere (.prproj), application/vnd.adobe.xd (.xd), image/x-adobe-dng (.dng) 3D models: model/gltf-binary (.glb), model/gltf+json (.gltf) Max file size: 500MB ## Filename Sanitization Input filenames are sanitized: lowercase, special chars removed, spaces → hyphens, 6-char unique suffix appended, base name capped at 100 chars. Original filename preserved in DB. Example: "Best Ever Photo (2024).JPG" → "best-ever-photo-2024-a7x9k2.jpg" ## S3 Path Structure files/originals/{public|private}/{projectId}/{fileId}/{sanitizedFilename} files/variants/{public|private}/{projectId}/{fileId}/{baseName}_w{width}.{format} ## Error Codes - 400 — Invalid request (missing fields, invalid content type, etc.) - 401 — Missing or invalid API key - 402 — Out of credits: upload-intent, confirm, complete and new bundle versions answer it when balance ≤ 0; POST /{id}/url for a private file, and private bundle access, once the balance has been ≤ 0 for 7 days (see Private links at zero). List and get never answer 402. - 403 — A browser Origin the project does not allow; or {"error":"This account is suspended","code":"suspended"}: every call with the account's keys is refused until the suspension is lifted, then the same keys work again. Public files and bundles already published keep being served. - 404 — File not found - 500 — Server error ## Prices Prepaid credit, drawn down as it is used. https://xfiles.dev/api/pricing serves the same rates as JSON. Files and bundles: - Storage: $0.05 per GB per month, debited daily - Bandwidth: $0.20 per GB transferred - Image variants: $0.50 per 1,000 new variants — each width of an image, the first time it is built The platform (PART 3) — each UTC day billed once it is over, as one line in Billing: - Search: $0.10/1k searches — Each search, from a browser or your server; a multi-search counts each query in it. - Search: $0.10/1k documents/mo — Documents kept in your collections, charged by the day: a day costs the monthly rate divided by the days in that month. - Live updates: $1.00/1M events — Each event published on a channel — by your server, by a browser, and the ones chat and notifications publish to deliver live. - Live updates: $1.00/1M connection-minutes — Every minute a WebSocket stays open — at least one per connection. - Chat: $0.01/1k messages — Each message sent in a conversation. - Notifications: $0.10/1k notifications — Each notification, to each user — in-app and web push included, however many devices. - Multiplayer: $0.10/1k player-minutes — Each player's minutes in a match, counted when it ends. A connection is billed by its minutes (at least one), a match by its players' minutes, a search document while it is kept, a push as part of its notification. Amounts below a cent are carried, not rounded away, until they add up to one. ## Billing model Pure prepaid credits, no subscriptions, no free tier. Users top up via Stripe Checkout from /dashboard/billing. Storage (debited daily), bandwidth (debited from the CDN's logs) and new image variants (daily) draw down the balance, at the Prices above. Bundles are billed the same way: their storage daily, their downloads hourly from the bundle server's own logs. The platform (PART 3) is billed per UTC day once it is over. Reaching $0 immediately blocks new uploads (intent, confirm, complete) and new bundle versions until the user adds credits. Private links — private files' signed URLs and private-bundle tokens — keep being issued for 7 more days, then pause until a top-up (see Private links at zero, and Bundles); the platform keeps working for the same 7 days, then answers 402 payment_required until a top-up (at once for an account that never had credit). Nothing stored is deleted, public files and bundles keep being served — and billed, into a negative balance the next top-up pays off first — and links already handed out work until they expire. A warning email is sent when the balance crosses the user-configured low-balance threshold (default $5); another when it reaches zero, if the account has private files or bundles or has used the platform in the last 30 days, saying the day they pause; and one when they do. --- # PART 2: Next.js Integration ## Setup Add your API key to .env.local (server-side only — never expose to browser): XFILES_API_KEY=xf_AbC12dEfGhIjKlMnOpQrStUvWxYz9012 NEXT_PUBLIC_XFILES_URL=https://xfiles.dev Create a base fetch helper: // lib/xfiles.ts const API_KEY = process.env.XFILES_API_KEY!; const BASE_URL = process.env.NEXT_PUBLIC_XFILES_URL!; export async function xfiles(path: string, options: RequestInit = {}) { const res = await fetch(`${BASE_URL}${path}`, { ...options, headers: { Authorization: `Bearer ${API_KEY}`, "Content-Type": "application/json", ...options.headers, }, }); if (!res.ok) { const error = await res.json().catch(() => ({})); throw new Error(error.error || `XFiles API error: ${res.status}`); } return res.json(); } ## File Service One service file for all operations: // lib/file-service.ts import { xfiles } from "@/lib/xfiles"; // ── Upload ────────────────────────────────────────────── export async function uploadFile( file: File, entity: { type: string; id: string; role: string; position?: number }, opts?: { visibility?: "public" | "private"; transforms?: { enabled: boolean; visibility?: "public" | "private" }; }, ) { const intent = await xfiles("/api/v1/files/upload-intent", { method: "POST", body: JSON.stringify({ filename: file.name, contentType: file.type, size: file.size, visibility: opts?.visibility ?? "private", entity, ...(opts?.transforms && { transformations: { image: opts.transforms }, }), }), }); await fetch(intent.uploadUrl, { method: "PUT", headers: { "Content-Type": file.type }, body: file, }); const { file: confirmed } = await xfiles("/api/v1/files/confirm", { method: "POST", body: JSON.stringify({ fileId: intent.fileId }), }); return confirmed; } // ── List by entity — sorted by position; each file already includes // url + variants inline, so this is your one call to render a gallery. // Returns 20 per page; pass `page` to paginate (no per-page limit param). export async function listFiles(opts: { entityType?: string; entityId?: string; role?: string; page?: number; }) { const params = new URLSearchParams(); if (opts.entityType) params.set("entityType", opts.entityType); if (opts.entityId) params.set("entityId", opts.entityId); if (opts.role) params.set("role", opts.role); if (opts.page) params.set("page", String(opts.page)); const query = params.toString(); return xfiles(`/api/v1/files${query ? `?${query}` : ""}`); } // ── Refresh ONE file's URLs. The list/GET endpoints already return URLs, // so you only need this to re-sign a PRIVATE file after its 5-min expiry. export async function getFile(fileId: string) { return xfiles(`/api/v1/files/${fileId}`); } // ── Update position ───────────────────────────────────── export async function updateFilePosition(fileId: string, position: number) { return xfiles(`/api/v1/files/${fileId}`, { method: "PATCH", body: JSON.stringify({ position }), }); } // ── Delete single file ────────────────────────────────── export async function deleteFile(fileId: string) { return xfiles(`/api/v1/files/${fileId}`, { method: "DELETE" }); } // ── Delete all files by entity ────────────────────────── export async function deleteByEntity(entityType: string, entityId: string) { const params = new URLSearchParams({ entityType, entityId }); return xfiles(`/api/v1/files?${params}`, { method: "DELETE" }); } ## Real-World Example: E-Commerce Product Gallery Entity mapping: type "product", id product.id, role "gallery", position 0/1/2... ### Create product with images // app/api/products/route.ts import { NextResponse } from "next/server"; import { db } from "@/lib/db"; import { products } from "@/lib/db/schema"; import { uploadFile, listFiles, getFile } from "@/lib/file-service"; import { desc } from "drizzle-orm"; export async function POST(req: Request) { const formData = await req.formData(); const name = formData.get("name") as string; const price = Number(formData.get("price")); const images = formData.getAll("images") as File[]; const [product] = await db.insert(products).values({ name, price }).returning(); const uploaded = await Promise.all( images.map((file, i) => uploadFile( file, { type: "product", id: product.id, role: "gallery", position: i }, { visibility: "public", transforms: { enabled: true, visibility: "public" } }, ), ), ); return NextResponse.json({ product, images: uploaded }); } ### List products with thumbnails export async function GET() { const allProducts = await db.select().from(products).orderBy(desc(products.createdAt)); const result = await Promise.all( allProducts.map(async (product) => { // One list call per product — the files already carry url + variants inline, // so there is NO per-file lookup. (Better still: cache the gallery on the // product row — see "Caching" below — and skip XFiles entirely on reads.) const { files } = await listFiles({ entityType: "product", entityId: product.id, role: "gallery", }); const thumb = files[0] ?? null; const thumbnailUrl = thumb?.variants?.find((v: any) => v.width === 400)?.url ?? thumb?.url ?? null; return { ...product, thumbnailUrl }; }), ); return NextResponse.json({ products: result }); } ### Get, update, and delete product // app/api/products/[id]/route.ts import { NextResponse } from "next/server"; import { db } from "@/lib/db"; import { products } from "@/lib/db/schema"; import { eq } from "drizzle-orm"; import { listFiles, deleteByEntity } from "@/lib/file-service"; type Params = { params: Promise<{ id: string }> }; // GET — product with all gallery images sorted by position export async function GET(_req: Request, { params }: Params) { const { id } = await params; const [product] = await db.select().from(products).where(eq(products.id, id)); if (!product) return NextResponse.json({ error: "Not found" }, { status: 404 }); // One call. Each file already carries url + variants[] — no per-file lookups. const { files: images } = await listFiles({ entityType: "product", entityId: id, role: "gallery" }); return NextResponse.json({ product, images }); } // DELETE — delete product and all its images in one call export async function DELETE(_req: Request, { params }: Params) { const { id } = await params; await deleteByEntity("product", id); await db.delete(products).where(eq(products.id, id)); return NextResponse.json({ success: true }); } ### Display components // components/ProductCard.tsx — thumbnail from 400w variant import Image from "next/image"; export function ProductCard({ product }: { product: any }) { return (
{product.thumbnailUrl ? ( {product.name} ) : (
No image
)}

{product.name}

${product.price}

); } // components/ProductGallery.tsx — full gallery with variant selection "use client"; import Image from "next/image"; import { useState } from "react"; export function ProductGallery({ images }: { images: any[] }) { const [selected, setSelected] = useState(0); const current = images[selected]; if (!images.length) return null; function getImageUrl(img: any, targetWidth: number) { const WIDTHS = [100, 300, 400, 600, 800, 1000, 1200]; const w = WIDTHS.find((v) => v >= targetWidth) ?? 1200; return img.variants?.find((v: any) => v.width === w)?.url; } return (
{current.originalFilename}
{images.map((img, i) => ( ))}
); } ## Caching Image URLs (recommended) Public CDN URLs are PERMANENT (content-addressed by file id + width), so cache them and skip XFiles on reads entirely. The pattern most apps use: when a parent object's images change (on create/update), fetch its gallery ONCE and store a compact image array on the parent row (e.g. a Postgres `jsonb` column). Render pages from that cache — never call XFiles on a page view. Recommended cached shape — built straight from a `listFiles()` item (every item already has `url` + `variants`, so this is a pure map, no extra requests): type CachedImage = { id: string; alt: string; // use originalFilename url: string; // original (full-res) CDN URL variants: { width: number; url: string }[]; contentType: string; }; const toCachedImage = (f: any): CachedImage => ({ id: f.id, alt: f.originalFilename, url: f.url, variants: f.variants ?? [], contentType: f.contentType, }); // On product create/update — one list call, then persist to a jsonb column: const { files } = await listFiles({ entityType: "product", entityId: product.id, role: "gallery" }); await db.update(products) .set({ images: files.map(toCachedImage) }) .where(eq(products.id, product.id)); IMPORTANT — only cache PUBLIC URLs. PRIVATE files return SIGNED URLs that expire in 5 minutes; never persist those. For private files, read the URL fresh per request (the list returns it) or refresh a single one via `POST /{id}/url`. ## Tips - The list and GET endpoints return `url` + `variants` inline — render a whole grid from ONE call. Never loop `POST /{id}/url` per file; that's an N+1 round-trip storm. - Pick the variant nearest your render width (e.g. `variants.find(v => v.width >= target)`); fall back to `url` (the original) only when no variant fits. - Public URLs are permanent — cache them (see above). Private signed URLs expire in 5 min — don't persist them. - A private `url` (or a private variant's) is null while the account's private links are paused — out of credit for over 7 days. Render a placeholder rather than a broken image, and check `privateLinks.paused` to say why. - SVGs are vector graphics — they don't get variants and are served directly as originals. - Variants are made of JPEG, PNG, WebP, GIF, AVIF and TIFF images up to 50MB, named with their extension. Anything else is served as the original: `transformations.image.enabled` comes back false at upload, and PATCH answers 422 if you ask to turn variants on. - Deleting a file, or making a public file private, also removes it from the CDN within minutes. - Use `unoptimized` on Next.js Image components since images are already optimized by XFiles. - When listing files by entity, results are sorted by position ascending — position 0 is the primary image. - Use `deleteByEntity` when deleting a parent object to clean up all attached files in one call. --- # PART 3: The Platform — search, live updates, chat, notifications, multiplayer A second service beside the files API, for what apps, sites and games need around their data. Same projects, same API keys. Platform (HTTP): https://platform.xfiles.dev WebSocket: wss://platform.xfiles.dev/v1/realtime - **Search** — typo-tolerant full-text search with filters, facets, sorting, geo, pinned and hidden results, and analytics (Algolia-style; Typesense underneath). Optional search over the project's files. - **Live updates** — one WebSocket per browser tab: channels, presence, typing, and events that a reconnecting client catches up on without gaps or duplicates. - **Chat** — direct and group conversations; messages arrive in order and never twice, with read positions, unread counts and attachments. - **Notifications** — an in-app list that updates live, and web push to browsers that allowed it, even with the site closed. - **Multiplayer** — rooms, seats, matchmaking, and an authoritative server that validates every move. Everything works with plain fetch and WebSocket from any language. The TypeScript SDK (@xfiles/sdk) is not on npm yet — don't install it. ## How it fits: the key, tokens, spaces Your server uses the project key (`xf_…`, the files API's key) for everything. Browsers never see the key: your server mints a short-lived token for the signed-in user, and the browser sends that token as `Authorization: Bearer `. A token is bound to one project, one space and one user, with the scopes your server chose — a browser can't widen it. ### Mint a token (your server) // app/api/xfiles-token/route.ts (Next.js) — your pages call this export async function POST() { const user = await currentUser(); // your own auth const res = await fetch(`${process.env.XFILES_PLATFORM_URL}/v1/auth/tokens`, { method: "POST", headers: { Authorization: `Bearer ${process.env.XFILES_API_KEY}`, "Content-Type": "application/json" }, body: JSON.stringify({ userId: user.id, name: user.name, // shown in presence scopes: ["search", "realtime", "messages", "notifications", "games"], channels: { "orders:*": "s" }, // s subscribe · p publish · r presence }), }); return Response.json(await res.json(), { status: res.status }); // { token, expiresAt } } # .env.local XFILES_API_KEY=xf_AbC12dEfGhIjKlMnOpQrStUvWxYz9012 XFILES_PLATFORM_URL=https://platform.xfiles.dev `POST /v1/auth/tokens` (project key only, from a server — a request with an `Origin` header is refused): { "userId": "ana", // omit for an anonymous token; ≤128 of A-Z a-z 0-9 . _ : @ - "space": "site-42", // default: the X-XFiles-Space header, else "" "scopes": ["search", "realtime"], // default: all five for a user; search + realtime for anonymous "groups": ["team-7"], // ≤50; for restricted search documents (_access: "group:team-7") "channels": { "orders:*": "s", "chat:lobby": "spr" }, // ≤50 grants; patterns end in * or are exact "name": "Ana", // display name for presence; never used to authorise "ttlSeconds": 3600 // user: 30 s – 24 h (default 1 h); anonymous: ≤ 90 days (default 24 h) } → 201 { "token": "eyJ…", "expiresAt": "2026-11-04T13:00:00.000Z" } Scopes: `search`, `realtime`, `messages`, `notifications`, `games`. A token without `userId` is anonymous: search and realtime only, can subscribe to `public:*` and granted channels, never publish or join presence. Mint a new token before `expiresAt` (a minute early is good); on the WebSocket, send `auth` again with it — no reconnect needed. `GET /v1/auth/whoami` (any credential) says what it resolves to — useful when wiring up. ### Spaces One project can serve many sites or tenants. Put a `space` (e.g. a site id) in each token: that user's search results, channels, conversations, notifications and rooms never see another space's. Your server picks a space per request with the header `X-XFiles-Space: site-42` (none = the empty space ""). ≤128 of A-Z a-z 0-9 . _ - ### Origins - The project key sent from a browser (any request with an Origin header) works only from the project's allowed origins (dashboard → Keys & access) and is refused if there are none. Keep the key on your server. - Tokens are checked against the allowed origins when the project has a list. - Rotating the project key ends every token minted with the old key within a minute, and closes WebSockets opened with either within 90 seconds (4401). Removing an origin closes WebSockets from it the same way (4403) and stops push to browsers that subscribed there. - The platform serves a project only while its account is in good standing. Out of credit for more than 7 days (or no credit ever): the key and every token answer 402 `payment_required` and WebSockets close (4402) until a top-up. A suspended account: 403 `suspended` and 4403 until the suspension is lifted. Nothing is deleted, keys don't change. ## Errors and limits Errors: `{ "error": "sentence", "code": "stable_code", "details"?: [...] }` - 400 `bad_request` — not valid; `details: [{ path, message }]` - 401 `unauthorized` — no credential, a wrong or expired one, or the key was rotated - 402 `payment_required` — the account has been out of credit for more than 7 days (or never had credit): paused until a top-up - 403 `forbidden` — missing scope or channel grant, origin not allowed, a key-only route - 403 `suspended` — the account is suspended: every key and token, until that is lifted - 404 `not_found` — also what exists but isn't yours (a conversation you're not in) - 409 `conflict` — room full, match started, message deleted - 413 `payload_too_large` — body over 256 KB (8 MB on …/documents) - 429 `rate_limited` — wait `Retry-After` seconds - 503 `unavailable` / `feature_disabled` — a dependency is down, or the feature isn't on Limits: search 200 req/s per project and 20/s per user; 1–10 searches per request; 1,000 documents per write. Chat: 5 messages/s per user, text 10,000 chars, data 16 KB, 10 attachments. Realtime: 30 frames/s per connection, 100 subscriptions per connection, 5,000 connections per project, frames 64 KB, published data 32 KB. Games: 200 rooms per project, 20 commands/s per player, 2 KB each. Every response has `X-Request-Id`. ## Search ### 1. Define a collection (your server, once — and whenever it changes) PUT https://platform.xfiles.dev/v1/search/collections/products Authorization: Bearer xf_… { "visibility": "public", // "public" (anonymous ok) | "users" (signed in) | "restricted" (per document) "fields": [ { "name": "title", "type": "string", "weight": 5 }, { "name": "description", "type": "string", "weight": 1 }, { "name": "brand", "type": "string", "facet": true }, { "name": "price", "type": "float", "facet": true, "sort": true }, { "name": "inStock", "type": "bool", "filter": true }, { "name": "publishedAt", "type": "date", "sort": true }, { "name": "location", "type": "geo" } ], "customRanking": ["publishedAt:desc"], // tie-breakers after relevance "synonyms": [{ "synonyms": ["tv", "television"] }] } → { collection, created, rebuilding } Field types: string, string[], int, float, bool, date, geo, int[], float[]. Roles: `searchable` (default for strings), `weight` 1–10, `facet`, `filter`, `sort`, `locale`, `infix`. Other settings: `defaultSort`, `typo` (`maxTypos` 0–2), `prefix` (search as you type, on), `stopwords`, `maxPageSize`. Default visibility is `restricted`. Only declared fields are indexed; other fields are stored and returned. A change that needs it rebuilds the index in the background — searches keep working. Collection names: lowercase letters, digits, `-` and `_`, ≤64. ### 2. Send documents whenever your data changes (your server) POST https://platform.xfiles.dev/v1/search/collections/products/documents { "documents": [{ "id": "p1", "title": "Wireless keyboard", "brand": "Acme", "price": 49, "inStock": true, "publishedAt": "2026-10-01T00:00:00Z", "location": { "lat": 44.43, "lng": 26.1 } }], "version": 1762257600000 } → { written, stale } - Up to 1,000 per request; all or nothing. `id` required (≤128 chars, no spaces). - `version` (or `_version` per document) — send your row's updatedAt in ms. An older version never overwrites a newer one, so retries and out-of-order writes are safe. Without it, arrival time is used. - Searchable within a second or two. A write is accepted once stored; the index catches up even if the engine was briefly down. - Restricted collections: `_access: ["public" | "user:" | "group:", …]` per document. A document without `_access` is visible to the key only. - Delete: `POST …/documents/delete { "ids": ["p1"] }` or `DELETE …/documents/p1`. - Reconcile: `GET …/documents?after=&limit=1000` walks every stored document in id order. Keep it in step with your database: after your transaction commits, send the changed rows with `version: row.updatedAt`; retry on failure (duplicates are harmless). For changes that must never be lost, write an outbox row in your transaction and send from a job. ### 3. Query (browser with a token, or your server) POST https://platform.xfiles.dev/v1/search/collections/products/query Authorization: Bearer { "query": "wireless keybaord", // typos are fine; ≤512 chars "filters": { "brand": ["Acme", "Globex"], "price": { "gte": 10, "lte": 100 }, "inStock": true }, "facets": ["brand", "price"], "sort": "price:asc", // "field:asc" | "field:desc" | "_relevance" (default) "page": 1, "limit": 20, // limit 1–250 "fields": ["title", "price"] // optional: only these } → { "hits": [{ "id": "p1", "document": {...}, "highlights": { "title": { "snippet": "Wireless keyboard", "matched": ["wireless"] } }, "score": … }], "total": 1, "page": 1, "pages": 1, "limit": 20, "facets": { "brand": { "Acme": 1 } }, "facetStats": { "price": { "min": 49, "max": 49, "avg": 49, "sum": 49 } }, "tookMs": 3 } More options: `facetQuery: { field, query }` (search facet values), `maxFacetValues`, `near: { field, lat, lng }` (sort by distance), `highlight`, `typos` (fewer than the collection allows), `prefix`, `analytics: false` (for keystrokes before the settled query). Keys only: `allSpaces: true`. Several searches in one round trip: `POST /v1/search/query { "searches": [{ "collection": "products", "query": "…" }, …] }` → `{ results }`. Search-as-you-type: debounce ~150 ms, abort the previous request, `analytics: false` until the user pauses. ### Filters (data, never strings) { "category": "electronics", "inStock": true } equals { "brand": ["Acme", "Globex"] } any of { "price": { "gte": 10, "lte": 100 } } range { "or": [ { "brand": "Acme" }, { "rating": { "gte": 4 } } ] } or { "location": { "near": { "lat": 44.43, "lng": 26.1, "km": 5 } } } within 5 km Operators: eq ne in nin gt gte lt lte between near. Dates: ISO strings or epoch ms. Only fields declared `filter`, `facet` or `sort` (and geo fields) can be filtered. ≤4 levels, 50 conditions, 100 values. ### Pinned and hidden results, analytics, file search - `POST …/rules { "query": "keyboard", "match": "exact" | "contains", "pinned": [{ "id": "p9", "position": 1 }], "hidden": ["p4"] }`; `GET …/rules`, `DELETE …/rules/:id`. - `GET …/stats?days=7` → `{ counts, topQueries, topQueriesWithoutResults }`. - `PUT /v1/search/files` indexes the project's files into the `_files` collection (name, tags and description from metadata, type, size, owner, attached record; text of TXT, Markdown, CSV and JSON up to 1 MB). Any search token finds public files; a private file is found only with its `ownerUserId`'s token (or the key). ### Search endpoints PUT/GET/DELETE /v1/search/collections/:name settings (key) GET /v1/search/collections list (key) POST /v1/search/collections/:name/documents upsert ≤1000 (key) POST /v1/search/collections/:name/documents/delete delete ≤1000 (key) GET/DELETE /v1/search/collections/:name/documents/:id (key) GET /v1/search/collections/:name/documents browse (key) POST /v1/search/collections/:name/reindex rebuild from stored documents (key) GET/POST /v1/search/collections/:name/rules, DELETE …/rules/:id (key) GET /v1/search/collections/:name/stats (key) POST /v1/search/collections/:name/query (key or token with "search") POST /v1/search/query multi-search (key or token) PUT /v1/search/files file search on (key) ## Live updates (realtime) ### Connect (browser) const socket = new WebSocket("wss://platform.xfiles.dev/v1/realtime"); socket.onopen = () => socket.send(JSON.stringify({ type: "auth", token })); // within 5 s; never in the URL socket.onmessage = (m) => { const f = JSON.parse(m.data); if (f.type === "ready") socket.send(JSON.stringify({ type: "sub", id: "1", channel: "orders:42" })); if (f.type === "evt") console.log(f.channel, f.event, f.data); }; After `ready` you are subscribed to `user:` already (notifications and chat invitations arrive there). Reconnect with backoff when the socket closes, then authenticate and subscribe again. ### Channels user: that user only published by the platform and your key public: any token in the space, anonymous published by your key, or tokens granted "p" conversation: the conversation's members published by the chat API anything else tokens granted "s" ("orders:*") published by tokens granted "p", or your key Names: ≤190 of letters, digits and : . _ @ - . Channels are per project and per space. Losing the right to a channel ends the subscription (`unsubscribed { channel, reason }`). ### Publish from your server POST https://platform.xfiles.dev/v1/realtime/publish Authorization: Bearer xf_… { "channel": "orders:42", "event": "shipped", "data": { "carrier": "DHL" }, "persist": true } → 202 { "eventId": "…", "seq": 17, "delivered": 3 } A browser with a `p` grant publishes on the socket: `{ "type": "pub", "id": "2", "channel", "event", "data" }`. ### Delivery and catching up - Ordinary events are best effort: a disconnected or too-slow client misses them. - `persist: true` events are kept 7 days with a per-channel sequence number (`seq`). Track the last `seq` you saw; after a reconnect, subscribe with `{ "type": "sub", "id", "channel", "since": lastSeq }` — the missed events replay, then live ones follow, with no gap and no duplicate (≤500 replayed; `truncated: true` in the ack means page the rest from `GET /v1/realtime/channels/:channel/history?since=`). First subscribe with `"resume": true` to get the current `seq` in the ack. - Chat messages and notifications are stored in their own lists; re-fetch after a reconnect. - Nothing is exactly-once: every event has an `eventId` for de-duplication. ### Presence and typing Subscribe with `"presence": true, "state": { … }` (a grant with `r`, or a conversation you are in): you get `presence { channel, sync }`, then `{ joins, leaves, updates }`. Update your state with `{ "type": "presence", "channel", "state" }`. A user who drops stays present 3 s (a reload is not a leave). Typing: `{ "type": "typing", "channel", "typing": true }`, relayed as `typing { channel, userId, typing }`. Your server reads presence with `GET /v1/realtime/presence/:channel`. ### Frames client → server auth { token } · sub { id, channel, since?, resume?, presence?, state? } · unsub { id, channel } pub { id, channel, event, data? } · presence { channel, state } · typing { channel, typing } · ping {} server → client ready { connectionId, userId, space, heartbeatMs } · ack { id, … } · error { id?, code, message } evt { channel, event, data, eventId, seq?, from, ts } · presence { … } · typing { … } gap { dropped } · unsubscribed { channel, reason } · reconnect { afterMs } · pong {} `from` is "server" or `{ userId, name }` (stamped by the platform; can't be forged). `gap`: you were too slow and events were dropped — re-sync persisted channels. `reconnect`: the server is restarting; reconnect after `afterMs`. Close codes: 1012 restart · 1013 too slow · 4400 bad frame · 4401 token invalid or expired, or the key rotated (mint a new one) · 4402 the account is out of credit (reconnect slowly; it works again after a top-up) · 4403 not allowed (an origin you removed, or the account suspended, too — don't retry) · 4408 no auth in time · 4429 too many frames. ## Chat // browser, with the user's token const call = (path, body) => fetch(`https://platform.xfiles.dev${path}`, { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, body: JSON.stringify(body), }).then((r) => r.json()); const { conversation } = await call("/v1/conversations", { kind: "direct", members: ["bob"] }); // subscribe to `conversation:${conversation.id}` on the socket for live messages await call(`/v1/conversations/${conversation.id}/messages`, { text: "hello", clientId: crypto.randomUUID() }); await call(`/v1/conversations/${conversation.id}/read`, {}); - `direct` is one conversation per pair: creating it again returns the same one (200). `group` has an owner (the creator), an optional `title` and up to 1,000 members. - Messages take the next `seq` in the conversation; that is their order. - Send a `clientId` (a UUID) and retry failures with the same one: never posted twice. - After a reconnect, or when you see a gap in `seq`, fetch `GET …/messages?after=`. - Unread = `lastSeq - lastReadSeq`; sending marks your own message read. - Attachments: upload with the files API (from your server, `ownerUserId` = the sender), then send `attachments: [fileId]`. A user may attach their own files and public files. Members get a private file's URL from `GET …/attachments/:fileId/url` (valid 15 min). - Your server (project key) can create conversations between anyone, add members, and post as a user (`senderId`) or as the system (no `senderId`). - Members who weren't online get one notification per burst of unread messages, not per message. Events on `conversation:`: `message.created` (the message), `message.updated`, `message.deleted` `{ id, seq }`, `read` `{ userId, seq }`, `members`. On `user:`: `conversation.added` `{ conversationId }`. GET /v1/conversations the user's, latest first: ?limit=30&before= POST /v1/conversations { kind, members, title?, metadata?, createdBy? (key) } GET /v1/conversations/:id { conversation, members: [{ userId, role, lastReadSeq }] } POST /v1/conversations/:id/members { userIds } — group owner or key DELETE /v1/conversations/:id/members/:userId owner, key, or yourself (leave) GET /v1/conversations/:id/messages ?before= | ?after=, limit ≤200 → { messages, lastSeq } POST /v1/conversations/:id/messages { text?, data?, attachments?, clientId?, senderId? (key) } POST /v1/conversations/:id/read { seq? | messageId? } → { lastReadSeq, unread } GET /v1/conversations/:id/attachments/:fileId/url PATCH /v1/messages/:id { text } — the sender DELETE /v1/messages/:id the sender or a group owner; keeps its place, loses its content A conversation you are not in answers 404. ## Notifications ### Send (your server, project key only) POST https://platform.xfiles.dev/v1/notifications { "userIds": ["ana"], // ≤1,000 "type": "order.shipped", // users can turn types off; lowercase . _ - ≤64 "title": "Your order shipped", // ≤200 "body": "It arrives Thursday.", // ≤1,000 "url": "/orders/42", // a path, or https on one of your allowed origins "data": { "orderId": 42 }, "dedupeKey": "order-42-shipped" // the same key for the same user = one notification } → { "created": [{ "id": "…", "userId": "ana", "push": true }] } Each notification goes to the user's in-app list at once (a `notification` event on `user:`) and, if they allowed it, as a push to each of their browsers. ### In-app (browser, user token) GET /v1/notifications?unread=true&limit=30&before= → { notifications, unread } POST /v1/notifications/:id/read POST /v1/notifications/read-all GET/PUT /v1/notifications/preferences { preferences: { "order.shipped": { inApp, push }, "*": { inApp, push } } } Live: `notification` (the notification) and `notification.read` `{ id }` or `{ all: true }` — read state stays in step across tabs. Read notifications are deleted after 90 days. ### Web push (browser) 1. Download the service worker — https://xfiles.dev/xfiles-sw.js — and serve it from YOUR site at `/xfiles-sw.js` with `Cache-Control: no-cache`. (A push subscription belongs to the site that made it; each site serves its own copy.) It shows the notification and opens its `url` on click — only links on your own site. 2. From a click (never on page load), subscribe: async function enablePush(token) { if (!("serviceWorker" in navigator) || !("PushManager" in window)) return; if ((await Notification.requestPermission()) !== "granted") return; const reg = await navigator.serviceWorker.register("/xfiles-sw.js"); await navigator.serviceWorker.ready; const { publicKey } = await fetch("https://platform.xfiles.dev/v1/notifications/vapid-public-key").then((r) => r.json()); const sub = (await reg.pushManager.getSubscription()) ?? (await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: base64UrlToBytes(publicKey) })); const { endpoint, keys } = sub.toJSON(); await fetch("https://platform.xfiles.dev/v1/notifications/subscriptions", { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, body: JSON.stringify({ endpoint, keys }), }); } const base64UrlToBytes = (s) => Uint8Array.from(atob(s.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(s.length / 4) * 4, "=")), (c) => c.charCodeAt(0)); 3. On every page load, if `Notification.permission === "granted"` and a subscription exists, POST it again (it can change, and so can the signed-in user). 4. On sign-out: `POST /v1/notifications/subscriptions/remove { "endpoint": sub.endpoint }`, then `sub.unsubscribe()`. Devices: `GET /v1/notifications/subscriptions`, `DELETE /v1/notifications/subscriptions/:id`. Expired subscriptions are dropped automatically. iOS delivers web push only to sites added to the home screen. ## Multiplayer Rooms are made and found over HTTP; the game is played over the same WebSocket. The server runs the game and validates every command against the state it changes — clients propose, the server decides. GET /v1/games { games: [{ key, sync, tickRate, minPlayers, maxPlayers }] } POST /v1/games/rooms { game, visibility: "private" | "public", maxPlayers?, config?, invite?: [userIds], ownerUserId? (key) } GET /v1/games/rooms?game=tictactoe open public rooms in your space GET /v1/games/rooms/:id POST /v1/games/rooms/:id/join { inviteCode? } → { room, seat } POST /v1/games/rooms/:id/leave POST /v1/games/quickmatch { game, config? } — a seat in an open public room, or a new one GET /v1/games/matches/:id the record: players, result, times (its players or the key) A private room opens to its invited users and to its invite code (10 characters, shown to the owner). Then, on the socket: → { "type": "game.join", "id": "j1", "room": "", "inviteCode"?: "…" } → { "type": "game.ready", "id": "r1", "room", "ready": true } → { "type": "game.start", "id": "s1", "room" } the owner, once everyone is ready → { "type": "game.cmd", "room", "seq": 1, "cmd": { … } } seq must increase; no ack → { "type": "game.resync", "id", "room" } · { "type": "game.leave", "id", "room" } ← game.room { room } · game.state { tick, state } · game.reject { seq, reason } · game.over { result } ← lockstep games: game.sync { tick, state } · game.tick { tick, commands: [{ seat, command }] } · game.checksum { tick, value } Sync models: `events` (fresh state after each accepted command — turn-based), `snapshot` (each player's own view every few ticks — fog of war), `lockstep` (every tick's ordered commands plus checksums — for many units). A dropped player keeps their seat 30 s and gets the full state on rejoin. Idle lobbies close after 10 minutes. Games available now: `tictactoe` (events), `skirmish` (snapshot), `skirmish-lockstep` (lockstep); new games are added to the platform by XFiles. ## The dashboard Each project's Platform section shows every feature: live connections and channels, a test event and a test notification, conversations, notifications by type, rooms and matches, a 30-day chart per feature, and for search: collections, test queries, pinned and hidden results, top queries and queries without results. ## Pricing Paid from the same prepaid balance as files. Each UTC day is billed once it is over, as one line in Billing (the dashboard shows this month so far): - Search: $0.10/1k searches — Each search, from a browser or your server; a multi-search counts each query in it. - Search: $0.10/1k documents/mo — Documents kept in your collections, charged by the day: a day costs the monthly rate divided by the days in that month. - Live updates: $1.00/1M events — Each event published on a channel — by your server, by a browser, and the ones chat and notifications publish to deliver live. - Live updates: $1.00/1M connection-minutes — Every minute a WebSocket stays open — at least one per connection. - Chat: $0.01/1k messages — Each message sent in a conversation. - Notifications: $0.10/1k notifications — Each notification, to each user — in-app and web push included, however many devices. - Multiplayer: $0.10/1k player-minutes — Each player's minutes in a match, counted when it ends. A connection is billed by its minutes (at least one), a match by its players' minutes, a document while it is kept, a push as part of its notification. The dashboard's charts show the same daily counts, per project. At a zero balance the platform keeps working for 7 days, then pauses until a top-up (402 payment_required) — at once for an account that never had credit. ## Platform tips - Keep the key on your server; give browsers tokens from one route of yours, and fetch a new one a minute before `expiresAt`. - Use one space per tenant/site instead of prefixing ids; isolation is enforced for you. - Send `version: updatedAt` with search documents so late or retried writes can't overwrite newer data. - For events a reconnecting client must not miss, `persist: true` and resubscribe with `since`; for chat, rely on `seq` and `?after=`. - Ask for push permission only from a click, and re-send the subscription on page load.