# xplatform — full documentation > Every page, in reading order. Individual pages are available as raw > markdown under /docs/raw/, and an index is at /docs/llms.txt. --- > From a new account to your first successful API call, in about two minutes. # Quickstart Two minutes from nothing to a working call. You need an account and a project — if you have one open in the console, you already have everything below. ## 1. Your base URL and key Every request goes to the same base and carries the same kind of key: ```bash export BASE="https://" export KEY="sk_live_…" # the secret key from your project page ``` Your project page shows both. The key is displayed **once**, when it is issued — if you have lost it, issue another; keys are free and revoking the old one takes a click. Two kinds exist and the difference matters: | | Where it goes | What it can do | |---|---|---| | `sk_…` **secret** | On your server, only | Everything | | `pk_…` **publishable** | In your app's frontend | Sign users in, and nothing else | A publishable key is safe in a browser *because* it can't reach the management APIs. A secret key in a browser is a breach, so we make it impossible rather than discouraged — the endpoints that matter answer no preflight at all. ## 2. Your first call ```bash curl "$BASE/v1/files" -H "authorization: Bearer $KEY" ``` ```json { "files": [], "total": 0, "page": 1, "totalPages": 0, "nextCursor": null } ``` That's a working integration. An empty list is the correct answer for a new project, and the response shape is the one you'll get with a thousand files in it. If you got `401`, the key is wrong or revoked. If you got nothing at all, `$BASE` is unset — the most common first mistake, and the reason it is spelled out above. ## 3. Upload something Uploading is three calls, and the middle one doesn't touch us at all: ```bash # a) Ask where to put it curl -X POST "$BASE/v1/files/upload-intent" \ -H "authorization: Bearer $KEY" -H "content-type: application/json" \ -d '{"filename":"hello.txt","contentType":"text/plain","size":11}' ``` ```bash # b) Send the bytes straight to storage, using the url that came back curl -X PUT "" -H "content-type: text/plain" --data-binary "hello world" ``` ```bash # c) Tell us it landed curl -X POST "$BASE/v1/files/confirm" \ -H "authorization: Bearer $KEY" -H "content-type: application/json" \ -d '{"id":""}' ``` The bytes never pass through us, which is why a 500 MB upload costs you one request rather than a timeout. [Upload files](/docs/files-api/upload-files) has the full shape. ## 4. Sign someone in Auth is enabled per project, because it gives you a Postgres database of your own — open **Auth** on your project page and turn it on. Then: ```bash curl -X POST "$BASE/v1/auth/sign-up/email" \ -H "authorization: Bearer $KEY" -H "content-type: application/json" \ -d '{"email":"ada@example.com","password":"correct-horse-battery","name":"Ada"}' ``` A session cookie comes back, and that user now exists in a database no other project can reach. [Auth](/docs/auth-api) covers social login, passkeys, two-factor, organizations and SSO — all of it on the same key. ## What to read next - [Files API](/docs/files-api) — every endpoint, one page each - [Auth](/docs/auth-api) — sessions, tokens, and the rest of it - [Usage and billing](/docs/platform/usage-and-billing) — what any of this costs Nothing here assumes a client library. There is no SDK yet, so this HTTP API is the whole interface, and every example above is a request you can paste into a terminal. --- > Sign-up, sign-in, sessions and JWTs for your app's users, with your users in a database of their own. # Auth Your users, their passwords, their sessions and the tokens your API verifies — without you running any of it. ```bash Signing someone up curl -X POST https:///v1/auth/sign-up/email \ -H "Authorization: Bearer pk_live_…" \ -H "Content-Type: application/json" \ -d '{"email":"ada@example.com","password":"…","name":"Ada Lovelace"}' ``` ```json The response { "token": "onSnRF8wZMuCs03Li7lcKJT2tLTvHjQO", "user": { "id": "NrzmwycxFVpxrD8RSZqieT4iqpW24W9M", "email": "ada@example.com", "emailVerified": false, "name": "Ada Lovelace" } } ``` A `Set-Cookie` comes back with it. That cookie *is* the session — httpOnly, Secure, SameSite=Lax, and namespaced to your project so a browser can hold sessions for several apps at once without them colliding. ## Your users are in their own database Every project gets its own Postgres database. Not a schema, not a shared table with a project column — a separate database, in a region you choose. This is the part worth understanding, because it is what you are buying. Isolation by convention means every query in our codebase has to remember a `where` clause, and one that forgets is a breach. Isolation by database means two customers' rows are never in the same query scope, so there is no clause to forget. What follows from it: - **Your data is yours to point at.** One project, one database, one connection. - **Nobody else's traffic is your problem.** Separate compute per project. - **Point-in-time recovery is per project**, not per platform. The cost is honest and worth naming: a database that has been idle scales to zero, so the first sign-in after a quiet period pays a wake-up of a second or two. Steady traffic never sees it. ## Both key kinds work here Auth is the one API you can call from a browser, because signing in happens in one and there is nowhere else for it to happen. | Key | Use it | Origin check | |---|---|---| | `pk_…` publishable | In your app's frontend | Enforced — the request must come from an origin you registered | | `sk_…` secret | On your server | Not applicable — a server sends no `Origin`, and the key is the authentication | A publishable key in a browser is safe *because* of the origin check. Register your app's origins in the console; exact matches only, no wildcards. A project with no registered origins accepts no browser traffic at all, which is the right default for one whose owner has not named any. Cross-origin calls from a registered origin allow credentials, so send `credentials: "include"` from `fetch` or the session cookie will not stick. ## Verifying tokens without calling us `GET /v1/auth/token` returns a short-lived JWT. Your API verifies it locally against our JWKS, so we are not a network hop on every request you serve. ```json The claims { "sub": "NrzmwycxFVpxrD8RSZqieT4iqpW24W9M", "sid": "h0bsPL9wBPaRfvslAcU2q0w8VPeF4wJu", "org_id": null, "role": null, "email": "ada@example.com", "email_verified": false, "iss": "https://", "exp": 1786977488 } ``` Signed with **EdDSA/Ed25519**, fifteen minute expiry, keys at `GET /v1/auth/jwks`. The signing keys live in *your* project's database, so your tokens are signed with a keypair nobody else holds — a token minted for another project cannot verify against yours. **`org_id` and `role` are in the payload from today, before organizations ship.** They are null until then. This is deliberate: writing your API against these claims now means it keeps working when teams arrive, and retrofitting them after you have built a flat user model is the part that hurts. ## Settings Some of how auth behaves is your decision, not ours. Read and change it with a secret key: ```bash curl https:///v1/auth/settings -H "Authorization: Bearer sk_live_…" ``` ```json { "requireEmailVerification": false, "disableSignUp": false, "minPasswordLength": 8 } ``` | Setting | Default | What it does | |---|---|---| | `requireEmailVerification` | `false` | Refuse a session until the address is confirmed | | `disableSignUp` | `false` | Stop new account creation; existing users still sign in | | `minPasswordLength` | `8` | The shortest password you accept, from 8 to 64 | `PATCH` changes only what you send, so one setting can move without restating the rest: ```bash curl -X PATCH https:///v1/auth/settings \ -H "Authorization: Bearer sk_live_…" \ -H "Content-Type: application/json" \ -d '{"disableSignUp":true}' ``` **Changes take effect on the next request**, not after a cache expires. A misspelled setting is **refused**, not ignored — a setting that reads as saved and does nothing is worse than an error: ```json { "error": "Unknown setting: requireEmailVerifcation. Known settings are requireEmailVerification, disableSignUp, minPasswordLength." } ``` **Turning on `requireEmailVerification` affects users you already have.** Anyone who never confirmed their address stops being able to sign in until they do. That is the point of the setting, and worth knowing before you flip it to see what it does. `disableSignUp` is not a suspension. Sign-in, sessions and tokens keep working; only account creation stops — for an app that provisions users elsewhere, or one you are closing to new signups. ## Email Three flows send mail, and all of them go out under **your project's name** rather than ours — in the subject line and in the From column, which is the half people actually read: ``` From: "Acme" Subject: Confirm your email for Acme ``` The sending address stays ours, because it is the domain verified with our mail provider. Only the name is yours. Someone signing up for your app has never heard of us, and their inbox should not be where they find out. | Flow | Trigger | What the person gets | |---|---|---| | Confirm your email | Automatic on sign-up | A link that marks the address verified | | Reset your password | `POST /v1/auth/request-password-reset` | A single-use link, valid for an hour | | Confirm account deletion | `POST /v1/auth/delete-user` with no password | A link that deletes the account | **Verification does not block signing in.** A new user gets the confirmation mail and can use your app straight away; `emailVerified` on the user record tells you whether they have confirmed, and what you do about it is your product's decision, not ours. If you want the harder rule — no session until confirmed — say so and we will make it a per-project setting. **The reset flow is enumeration-resistant.** An address that does not exist gets the same `200` and the same message as one that does: ```json { "status": true, "message": "If this email exists in our system, check your email for the reset link" } ``` **`redirectTo` must be an origin you registered.** A reset link that could land anywhere is an open redirect with a valid session attached, so an unregistered origin is refused with `INVALID_REDIRECT_URL`. Add your app's origins in the console. That last flow is worth knowing about if you use social login: a user with no password and a session older than a day cannot confirm a deletion by password, because they have none. Calling `delete-user` without one sends the confirmation mail instead, so they can still leave. ## Social login Google and GitHub, **with your own OAuth app**. That last part is the design, not a limitation. The consent screen names the application asking for access, so if we ran one app for the whole platform your users would be told that *xplatform* wanted their email — from a company they have never heard of, at the exact moment people abandon a sign-up. Your app, your name on the screen. **1. Register this redirect URL** with the provider: ``` https:///v1/auth/callback/google ``` It points at us, because the token exchange happens here. Getting this wrong is the most common way an OAuth setup fails, and it fails on the provider's site where we can't tell you anything useful — so the console shows the exact string to paste. **2. Give us the credentials**, from your server: ```bash curl -X PUT https:///v1/auth/providers/google \ -H "Authorization: Bearer sk_live_…" \ -H "Content-Type: application/json" \ -d '{"clientId":"…","clientSecret":"…"}' ``` **3. Send people to it:** ```bash curl -X POST https:///v1/auth/sign-in/social \ -H "Authorization: Bearer pk_live_…" \ -H "Content-Type: application/json" \ -d '{"provider":"google","callbackURL":"https://yourapp.com/welcome"}' ``` ```json { "url": "https://accounts.google.com/o/oauth2/v2/auth?..." } ``` Redirect the browser there. They come back signed in, with the same session cookie a password sign-in produces. ### What you can read back `GET /v1/auth/providers` lists what's configured: ```json [ { "provider": "google", "clientId": "123.apps.googleusercontent.com", "callbackUrl": "https:///v1/auth/callback/google" } ] ``` **Client secrets are never returned.** They're encrypted at rest and there is no endpoint that reads one back — you can replace a secret, not retrieve it. `PUT` replaces the pair whole; there's no partial update, because a rotated secret and a swapped app look identical from our side and a half-update would let you believe you'd changed something you hadn't. `DELETE /v1/auth/providers/google` disconnects it. **Accounts already linked through it are left alone** — someone who signed up with Google keeps their user record and everything attached to it. Disconnecting stops new sign-ins; it doesn't delete people. A provider you haven't configured answers `404 PROVIDER_NOT_FOUND` rather than showing your users a button that fails after they've left your site. ## Two-factor TOTP and recovery codes. No SMS — it costs money per message, is the weakest common second factor, and its main appeal is familiarity rather than security. ```bash curl -X POST https:///v1/auth/two-factor/enable \ -H "Authorization: Bearer pk_live_…" -b "session cookie" \ -H "Content-Type: application/json" -d '{"password":"…"}' ``` Back comes a `totpURI` for a QR code and ten single-use backup codes. **The issuer in the authenticator app is your project's name**, so somebody adding a code for their Acme account sees Acme. Once it's verified, a password sign-in stops returning a session and returns this instead: ```json { "twoFactorRedirect": true, "twoFactorMethods": ["totp"] } ``` Send them to your second-factor screen, then `POST /v1/auth/two-factor/verify-totp` with the six digits. That call returns the session. **The TOTP secret and the backup codes are encrypted at rest**, under your project's own signing secret — a key no other project holds. ## Passkeys Sign in with a fingerprint, a face, or a security key. No password to phish. **Passkeys are bound to your domain, not ours**, and that shapes the setup. A browser only accepts a credential whose relying party is your own site, so we derive it from the first browser origin you register: ```json { "rp": { "name": "Acme", "id": "app.acme.com" } } ``` The consequence to know before you build on it: **a passkey works on the domain it was created for and nowhere else.** A project serving two unrelated domains can have passkeys on one of them. That's WebAuthn's rule rather than ours, and there is no way around it — the browser enforces it, not our server. A project with no registered origins can't use passkeys at all, which is already true of every other browser flow. The flow is the standard WebAuthn one, and the client SDK does the hard part: ``` GET /v1/auth/passkey/generate-register-options → options with your rp POST /v1/auth/passkey/verify-registration → stores the credential POST /v1/auth/sign-in/passkey → a session ``` ## Organizations Workspaces inside *your* app — a company, a team, a tenant of yours. Not to be confused with your own xplatform organization; these live in your project's database and we never see them. ```bash curl -X POST https:///v1/auth/organization/create \ -H "Authorization: Bearer pk_live_…" -b "session cookie" \ -H "Content-Type: application/json" \ -d '{"name":"Acme Engineering","slug":"acme-eng"}' ``` Whoever creates one is its `owner`. The other roles are `admin` and `member`: | | organization | members | invitations | |---|---|---|---| | `owner` | update, delete | add, update, remove | send, cancel | | `admin` | update | add, update, remove | send, cancel | | `member` | read only | read only | read only | A `member` attempting any of it gets a **403**, which we checked rather than assumed. ### It finally fills in `org_id` The JWT has carried `org_id` and `role` since the first day this service existed, and until now both were always null. That was deliberate — a consumer written against those claims last month keeps working today without touching their code, which is the thing that bites teams who add the claim afterwards. ```json { "sub": "Nrzmwy…", "org_id": "2kbhx7…", "role": "owner", "sid": "…" } ``` `org_id` is the **active** organization on the session, because one person can belong to several and switch without their identity changing. Set it with `POST /v1/auth/organization/set-active`; creating an organization makes it active automatically. `role` is read from the membership at the moment the token is minted, not copied onto the session at sign-in — so demoting an admin takes effect on their next token rather than their next sign-in. ### Invitations `POST /v1/auth/organization/invite-member` emails the person a link. **The page it lands on is yours** — we deliver the mail, your app renders the accept screen and calls `accept-invitation`. Point it somewhere with the `invitationUrl` setting; the invitation id is appended: ``` invitationUrl = https://app.acme.com/join → https://app.acme.com/join/ ``` Unset, it guesses at `/accept-invitation` on your first registered origin, so invitations work before you have configured anything. **An invitation whose email fails still returns 200.** The invitation row is written before the mail is attempted and the send happens after, so a bounced or refused message does not roll it back. `GET /v1/auth/organization/list-invitations` gives you the id, and the link is just your URL plus that id — so a re-send is always possible without creating a second invitation. ## Enterprise SSO Your enterprise customers sign in with their own Okta, Entra or any OIDC or SAML 2.0 identity provider — and set it up themselves, without emailing anyone a certificate. The shape is three deep, so it is worth naming: we host the service, you run an application on it, and *your* customer is a company with a directory. An SSO provider belongs to one of **your** organizations. **1. Declare the identity provider's hosts.** Registering one makes our server fetch the provider's discovery document, so the hosts have to be trusted in advance — otherwise `sso/register` would be "make your server request any URL I name": ```bash curl -X PATCH https:///v1/auth/settings \ -H "Authorization: Bearer sk_live_…" -H "Content-Type: application/json" \ -d '{"ssoOrigins":["https://bigcorp.okta.com"]}' ``` **Every origin the discovery document resolves to must be listed, not just the issuer.** Providers routinely put their token and JWKS endpoints on other hosts — Google's issuer is `accounts.google.com` while its token endpoint is `oauth2.googleapis.com`, and registration fails with `discovery_untrusted_origin` naming the exact URL it refused. These origins are trusted for SSO endpoints only. They never gain the right to make browser requests against your API. **2. An organization owner or admin registers the provider:** ```bash curl -X POST https:///v1/auth/sso/register \ -H "Authorization: Bearer pk_live_…" -b "session cookie" \ -H "Content-Type: application/json" -d '{ "providerId": "bigcorp-okta", "issuer": "https://bigcorp.okta.com", "domain": "bigcorp.com", "organizationId": "org_…", "oidcConfig": { "clientId": "…", "clientSecret": "…", "discoveryEndpoint": "https://bigcorp.okta.com/.well-known/openid-configuration" } }' ``` A plain `member` gets **403** — registering an identity provider decides who can enter the organization, so it takes an owner or an admin. **3. Users sign in by email domain:** ```bash curl -X POST https:///v1/auth/sign-in/sso \ -H "Authorization: Bearer pk_live_…" \ -H "Content-Type: application/json" \ -d '{"email":"someone@bigcorp.com","callbackURL":"https://yourapp.com/"}' ``` Back comes a `url` to redirect to. Anyone arriving through that provider **joins the linked organization automatically**, as a `member` — the point of enterprise SSO is that staff arrive already belonging somewhere. ### SAML Same registration call with `samlConfig` instead — the certificate and entry point come from the IdP, so nothing is fetched and no origins need declaring. Give the IdP administrator your service provider metadata: ``` GET /v1/auth/sso/saml2/sp/metadata?providerId=bigcorp-saml ``` It carries the ACS URL they need, which is ours: ``` http:///v1/auth/sso/saml2/sp/acs/bigcorp-saml ``` IdP-initiated sign-in works without extra setup — the callback handles both the POST from the IdP and the browser's follow-up GET. ## Directory sync (SCIM) SSO answers "can this person sign in". SCIM answers "who exists at all" — a company that hires someone on Monday expects them to have an account before they first open your app, and expects it gone the day they leave. Neither works if people only appear when they sign in. **1. Issue the directory a credential.** From your server, naming the organization and the administrator doing it: ```bash curl -X POST https:///v1/auth/scim-connections \ -H "Authorization: Bearer sk_live_…" -H "Content-Type: application/json" \ -d '{"organizationId":"org_…","actorId":"user_…"}' ``` ```json { "token": "ba_scim_credential_…", "scimBaseUrl": "https:///v1/scim//v2" } ``` The token is returned **once** and stored only as a keyed digest — it can be rotated, never read back. `actorId` must be an `owner` or `admin` of that organization; a plain member gets **403**, because a directory credential can create and deactivate anybody in it. **2. Give your enterprise customer's IT team the base URL and token.** They paste both into Okta, Entra or whatever they run. Authentication is `Authorization: Bearer `. Note the base URL carries your project id rather than authenticating by API key. SCIM reserves the `Authorization` header for the directory's own token — a directory can't send two — so the project moves into the path. It isn't a secret; the token is what authorizes anything. **3. That's it.** The directory drives the rest: | | | |---|---| | `POST /Users` | Someone joins — a real user account, before they ever sign in | | `PATCH /Users/:id` | `active: false` when they leave, which ends their sessions | | `DELETE /Users/:id` | Removes the directory's resource | | `POST /Groups` | Groups and memberships | | `GET /Users?filter=…` | Equality filters on `userName`, `externalId`, `emails.value` | **Deleting a SCIM resource does not delete the person.** Their account, their data and anything you attached to it survive; the directory is disclaiming management, not issuing an erasure. Use the delete-user API for that. **Provisioning creates no way to sign in.** A provisioned person still needs SSO, a password or a passkey — SCIM says who exists, not how they authenticate. Pair it with an SSO provider on the same organization and the two halves meet. Discovery endpoints (`/ServiceProviderConfig`, `/Schemas`, `/ResourceTypes`) are public, so a directory can inspect what's supported before you hand it a token. ### Letting groups grant roles By default a directory group is synced and grants nothing — a group is a fact about someone else's directory until you say what it should mean in your product. Say it with an explicit map: ```bash curl -X PATCH https:///v1/auth/settings \ -H "Authorization: Bearer sk_live_…" -H "Content-Type: application/json" \ -d '{"scimRoleMap":{"directory-group-admins":"admin"}}' ``` The key is the group's `externalId`, falling back to its display name. Prefer the `externalId`: a directory administrator can rename a group, and if a bare name granted access then a rename would be a privilege change. **Only `admin` and `member` can be granted this way.** `owner` is refused — the owner can delete the organization and remove everyone in it, and that is not a decision to hand to whoever administers an identity provider. Two things this deliberately will not do: **It never touches an owner.** However the groups change, an owner stays an owner. A directory cannot lock a customer out of their own organization. **Losing a group demotes, it does not remove.** Someone dropped from every mapped group becomes a `member` rather than losing their membership, because an administrator moving people between groups should not delete their access to their work. Within those rules the directory owns the role: promote a synced user by hand and the next sync will put them back where the directory says they belong. If you change the map itself, existing users are reconciled on the directory's next request for them. ## Removing users Two ways, because they are two different actions. **The person deletes their own account.** Signed in, with their password: ```bash curl -X POST https:///v1/auth/delete-user \ -H "Authorization: Bearer pk_live_…" \ -H "Content-Type: application/json" \ -b "session cookie" \ -d '{"password":"…"}' ``` A password or a session less than a day old is required — this is irreversible, and a stale cookie on a shared laptop should not be enough. Users who signed in with a social provider have no password, so they need to sign in again first. **You delete somebody's account.** From your server, with a secret key — for a support request, an erasure mail, or clearing out a migration: ```bash curl -X DELETE https:///v1/auth/users/ \ -H "Authorization: Bearer sk_live_…" ``` ```json { "id": "zafSejoiTBShUIhggLMox0Tyz4qKrSrV", "email": "alan@example.com", "deleted": true } ``` Both are **hard deletes**. The account goes, and its sessions and linked providers go with it. There is no undo and no recycle bin — if you need one, keep your own record before calling. `GET /v1/auth/users` lists them so you can decide what to remove: ```json { "users": [ { "id": "NrzmwycxFVpxrD8RSZqieT4iqpW24W9M", "email": "ada@example.com", "name": "Ada Lovelace", "emailVerified": false, "createdAt": "2026-08-17T14:22:39.655Z", "lastSeenAt": "2026-08-17T14:25:41.491Z" } ], "nextCursor": null } ``` `lastSeenAt` is the last time a session of theirs was used, or null if they have never signed in. It is there so *you* can decide what counts as dormant — we do not bill on it. Page with `?limit=` and `?before=`. **`/v1/auth/users` takes a secret key only, and answers no CORS.** Listing and deleting other people's accounts is exactly what must not be reachable from a browser, so there is no preflight response for it — a secret key pasted into a frontend bundle still cannot make these calls. This also matters for your bill: auth is charged per user account stored, so an account you delete stops costing you at the next nightly count. ## What is here today Email and password, social login with Google and GitHub, passkeys, two-factor by TOTP, organizations with roles and invitations, enterprise SSO by OIDC and SAML, SCIM directory sync, sessions, JWT issuance with JWKS, user management, and transactional email. The build order is complete. What comes next is driven by what customers ask for. The token payload already has room for the fourth of those. ## While a project is being set up A project whose auth database is still being created answers **503** with a `Retry-After`. Provisioning takes a few seconds and happens once. ```json { "error": "Auth isn't ready for this project yet." } ``` The same 503 covers a suspended project, deliberately — the response does not tell a browser which of our internal states a project is in. ## Rate limits Two limits apply, and they answer different questions. **Per API key: 600 requests a minute**, shared with the rest of the platform. Every response carries `X-RateLimit-Remaining` so you can slow down before being told to; a refusal adds `Retry-After`. This bounds what your project as a whole can do. **Per end user, per endpoint**, which is what protects your users from each other. Sign-in is limited to **3 attempts every 10 seconds from one IP address**, so somebody guessing passwords against one of your accounts is held to a crawl while everyone else signs in normally. Other endpoints get 100 requests a minute. A refusal is a **429** with `X-Retry-After` in seconds. ```json { "message": "Too many requests. Please try again later." } ``` The counters live in your project's own database, so your traffic and another customer's never share one. This is a throttle rather than a lockout — no account is ever disabled by failed attempts, because that turns a nuisance into a way to lock your users out of their own accounts. --- > Every endpoint, one page each, with the request and response it actually returns. # Files API Base URL: `/v1/files`. One page per operation — the request, the response, and the errors that operation can actually produce. Every example here uses two shell variables. Set them once and the rest of these pages paste straight into a terminal: ```bash export BASE="https://" export KEY="sk_live_…" ``` Both are on your project page in the console. New here? Start with the [quickstart](/docs/start/quickstart). **There is no SDK yet.** This HTTP API is the whole interface, so everything here is a request you can make with `curl`, `fetch`, or whatever your language uses. Nothing below assumes a client library, and no example calls one. ```bash Authentication curl "$BASE/v1/files" \ -H "authorization: Bearer $KEY" ``` The key identifies the project. There is no project id to send and no default project: an unresolvable key is refused before anything else happens, with the same message whether it is unknown, revoked or malformed. ```json Rejected { "error": "Invalid API key" } ``` Revoking a key or suspending a project takes effect within a minute. ## Concepts Five words appear throughout, and everything else follows from them. - **File** — an object in storage, served through the CDN. - **Visibility** — `public` gives a permanent URL; `private` gives one signed for five minutes. It is a path prefix rather than a flag, so changing it moves the object. - **Entity** — an optional attachment (`type`, `id`, `role`, `position`) linking a file to one of your own objects, so you can ask for "this product's gallery" instead of keeping file ids yourself. - **Image sizes** — resized WebP copies at fixed widths, built the first time each is requested and then kept. Each is built at most once, ever. - **Variant visibility** — independent of the original. A private document can have public thumbnails. The widths are fixed: ``` 100 300 400 600 800 1000 1200 ``` Every resizable image comes back with all seven as a `srcset`. Hand it to an `` and let the browser choose — it knows the viewport and the pixel ratio, and its choice is what decides which sizes ever get built. ## Operations Grouped by what you are doing, not by how many calls it takes. Uploading is one operation and three calls; deleting is one operation whether you name a file or an entity. | Operation | Calls | |---|---| | [Upload files](/docs/files-api/upload-files) | `POST /upload-intent` → `PUT` → `POST /confirm` | | [Get files](/docs/files-api/get-files) | `GET /v1/files`, `GET /v1/files/{id}` | | [Update files](/docs/files-api/update-files) | `PATCH /v1/files/{id}` | | [Delete files](/docs/files-api/delete-files) | `DELETE /v1/files/{id}`, `DELETE /v1/files` | | [Restoring](/docs/files-api/restoring) | `GET /v1/files/deleted`, `POST /v1/files/{id}/restore` | | [Signed URLs](/docs/files-api/signed-urls) | `POST /v1/files/{id}/url` | Create, read, update, delete — named for what you do to a file rather than what the acronym calls it — plus the undo for delete, and one supporting operation for the URLs a private file needs re-signing on. ## This is a server-side API It needs a **secret** key — `sk_…` — and secret keys belong on a server. That is not a recommendation you could ignore carefully. A publishable key (`pk_…`) is refused outright: ```json 403 { "error": "This is a publishable key. Files is a server-side API — use a secret key (sk_…)." } ``` The reason is that there is nothing here a browser needs. **Serving files takes no key at all** — a public URL is permanent and a private one is signed, both answered by the CDN. So the only thing a browser would use a key *for* is this management API, which can delete a project's files. A credential that ships inside an app is public the moment it deploys, and those two facts do not belong in the same key. There is also no CORS on these routes, which means a browser cannot call them even if a secret key ends up in a bundle by mistake. That is deliberate: a protection that works without anyone reading a warning is worth more than one that does not. If your frontend needs to upload, have it ask your own backend, and let that call [upload-intent](/docs/files-api/upload-files) with the secret key. The presigned URL that comes back **is** safe to hand to the browser — it is scoped to one object, one content type, one length, and fifteen minutes. ``` browser → your server → POST /v1/files/upload-intent (sk_…) browser ← your server ← { uploadUrl } browser → storage PUT the bytes (no key) browser → your server → POST /v1/files/confirm (sk_…) ``` Reading is simpler still: your server fetches the file's `url` and hands it over. The CDN serves it with **no key involved at all** — permanent if the file is public, signed for five minutes if it is private. ## Scopes A key carries what it may do, as `service:action`. Files understands two: | Scope | Covers | |---|---| | `files:read` | List, fetch, re-sign a private URL | | `files:write` | Upload, confirm, update, delete | A new key is **unrestricted** by default — `*`, meaning everything the project can do including services that ship later. That is what almost every integration wants, and a key listing today's services would quietly fail against tomorrow's. Narrow one when it is going somewhere that only needs to look: a static site build, an analytics job, a contractor's machine. Write implies read, because a key that can replace a file and not read it back is a shape nobody wants. ```json 403 — scoped too narrowly { "error": "This key is not scoped for files:write." } ``` Scopes narrow; they never widen. A publishable key carrying `files:write` is still refused, because the kind is checked first and files is server-side only. ## Rate limits **600 requests a minute per key.** Every response says where you stand, so you can slow down before being refused rather than after: ```bash Any response x-ratelimit-limit: 600 x-ratelimit-remaining: 412 x-ratelimit-reset: 1786952640 ``` Past the limit the answer is `429`, with `Retry-After` in seconds: ```json 429 { "error": "Too many requests. Slow down and try again shortly." } ``` The window is fixed and one minute long, and `x-ratelimit-reset` is the unix second it rolls over. A caller that waits for `Retry-After` is never refused twice for the same burst. Two things worth knowing: **Serving files is not counted.** The limit is on this API — listing, uploading, changing, deleting. The bytes themselves come from the CDN, which needs no key and has no per-key limit, so a page rendering a thousand images makes zero requests against it. **It is per key, not per project.** A key doing bulk work cannot starve the one your application is using — which is a reason to give a migration script or a nightly job its own key rather than sharing. ## Errors Every failure is `{ "error": "…" }` with a meaningful status. | Status | Means | |---|---| | `400` | The request is wrong; the message says how | | `401` | Missing, malformed, unknown or revoked key | | `403` | A publishable key, or one not scoped for what you asked | | `429` | Over the rate limit; `Retry-After` says how long to wait | | `404` | No such file — or it belongs to another project | | `500` | Ours. The message is deliberately generic | `404` covering both "gone" and "not yours" is deliberate: telling one project that another's file id exists is a leak, so the two are indistinguishable. A `500` never carries the underlying message. Internal errors can contain a connection string or a signed URL, so the detail stays in our logs and you get a flat *That request couldn't be completed.* ## See also - [The file object](/docs/files-api/file-object) — every field, once - [Limits and formats](/docs/files-api/limits) — sizes, accepted types, widths --- > One operation, three calls — get a URL, send the bytes, confirm they landed. # Upload files Uploading is one operation and three calls. The file goes from the client straight to storage; only the decision passes through us, which is why there is a step before and a step after. ``` 1. POST /v1/files/upload-intent → a presigned URL your server, sk_… 2. PUT {uploadUrl} → the bytes anywhere, no key 3. POST /v1/files/confirm → we check they landed your server, sk_… ``` Steps 1 and 3 are [server-side only](/docs/files-api). Step 2 is not: the presigned URL is safe to hand to a browser, which is the entire reason the flow has three steps rather than one. ## POST /v1/files/upload-intent Creates a pending record and returns a URL valid for fifteen minutes. The signature covers content type, content length, visibility, project and file id — so nothing declared here can be changed by whoever holds the URL. That is the reason this is not a single call. ```json Request body { "filename": "photo.jpg", // Required — the original name "contentType": "image/jpeg", // Required — locked into the signature "size": 204800, // Required — locked into the signature "visibility": "public", // Optional — default "private" "ownerUserId": "user_123", // Optional — who uploaded it "entity": { // Optional — attach to your own object "type": "product", // Required with the entity "id": "prod_abc123", // Required with the entity "role": "gallery", // Optional — what it is for "position": 0 // Optional — order, 0 first }, "transformations": { // Optional — image sizes "image": { "enabled": true, // Default false "visibility": "public" // Default "private" } } } ``` ```json Response { "fileId": "abc123def456", "uploadUrl": "https://…?X-Amz-Signature=…", "key": "files/originals/public/proj/abc123def456/photo-k9x2m7.jpg", "expiresIn": 900 } ``` ## Large files come back split Above 100 MB there is no `uploadUrl`. You get a `multipart` block instead — one signed URL per part, all of them at once, valid for six hours: ```json Response for a large file { "fileId": "abc123def456", "key": "files/originals/private/proj/abc123def456/big-k9x2m7.mp4", "expiresIn": 21600, "multipart": { "uploadId": "2~xY9…", "partSize": 10485760, "parts": [ { "partNumber": 1, "url": "https://…" }, { "partNumber": 2, "url": "https://…" } ] } } ``` **Check which one you got rather than checking the size yourself.** The threshold is ours to move, and a client that hardcoded it breaks the day we do. Upload each part with a `PUT` — in parallel if you like — and keep the `ETag` header each one returns. A part that fails is one part to retry, not the whole file, which is the entire reason this exists. Then assemble them: ```json POST /v1/files/complete { "fileId": "abc123def456", "uploadId": "2~xY9…", "parts": [ { "partNumber": 1, "etag": "\"a1b2…\"" }, { "partNumber": 2, "etag": "\"c3d4…\"" } ] } ``` Part numbers may arrive in any order — you uploaded them concurrently, so demanding an order would be demanding you undo that. Completing replaces the `confirm` step; the response is the same confirmed file. Only the server can assemble a multipart upload, and that is what keeps it safe: holding part URLs lets a client write parts and nothing else. The object does not exist until you call complete, and complete checks it — **an assembled size that does not match what was declared is discarded**, because a part URL cannot pin a length the way a single PUT's signature does: ```json 400 { "error": "That upload declared 125829120 bytes and assembled to 20971520. It has been discarded." } ``` Abandoned parts are billed like any other storage, so they are aborted after seven days. Finishing or failing promptly costs nothing; walking away costs a week of whatever you uploaded. ## PUT {uploadUrl} Straight to storage. Content type and length must match what was declared — that is what the signature covers. ```bash Sending the bytes curl -X PUT "$UPLOAD_URL" \ -H "content-type: image/jpeg" \ --data-binary @photo.jpg ``` In a browser, use `XMLHttpRequest` if you want a progress bar. `fetch` cannot report upload progress, so a large file shows nothing until it finishes — which is indistinguishable from a hang. ```ts With progress const xhr = new XMLHttpRequest(); xhr.open("PUT", uploadUrl); xhr.setRequestHeader("content-type", file.type); xhr.upload.onprogress = (event) => { if (event.lengthComputable) setSent(event.loaded); }; xhr.send(file); ``` ## POST /v1/files/confirm Marks the record confirmed, creates its entity attachment, and makes the file visible to every other call. This asks storage directly whether the object exists. A client reporting success is not evidence of success, and a record claiming a file exists when it does not is worse than no record at all. ```json Request body { "fileId": "abc123def456" } ``` ```json Response { "file": { "id": "abc123def456", "originalFilename": "photo.jpg", "uploadStatus": "confirmed", "url": "https://cdn…/files/originals/public/…", "variants": [{ "width": 100, "url": "…" }], "srcset": "…100w, …300w, …", "entity": { "type": "product", "id": "prod_abc123", "role": "gallery", "position": 0 } } } ``` Confirming twice is harmless — the second call returns the same file. ## If you never confirm The file appears in no listing, and an unconfirmed record is swept up an hour later along with anything that did reach storage. An abandoned upload costs nothing and leaves nothing behind. ## Errors ```json 400 — type not accepted { "error": "Content type not allowed" } ``` ```json 400 — too large { "error": "That file is 95.4 MB. The limit is 50 MB for images, so every image can have its smaller versions built." } ``` ```json 400 — missing fields { "error": "filename, contentType, and size are required" } ``` ```json 400 — confirmed too early { "error": "The file is not in storage yet. Upload it to the presigned URL, then confirm." } ``` Also `400` for a `visibility` that isn't `public` or `private`, and for an `entity` missing its `type` or `id`. See [limits](/docs/files-api/limits). --- > List and filter, or fetch one by id. Every result carries its URLs already. # Get files Two calls, one job: reading files back out. Every result carries its `url`, `variants[]` and `srcset` inline, so a whole gallery is one request rather than one per file. ## GET /v1/files Confirmed files, newest first — or ordered by `position` when you filter by entity, so position `0` is the primary image and the array renders as it stands. ```bash Request curl "$BASE/v1/files?entityType=product&entityId=prod_abc123&role=gallery" \ -H "authorization: Bearer $KEY" ``` All parameters are optional and combine with AND. | Parameter | Matches | |---|---| | `entityType` | The attached object's type | | `entityId` | The attached object's id | | `role` | The file's role on that object | | `ownerUserId` | The end user who uploaded it | | `visibility` | `public` or `private` | | `variantVisibility` | `public` or `private` | | `page` | 1-based; 20 per page | ```json Response { "files": [ { "id": "E7iVnWA1LiEpu3u8dRTVK", "originalFilename": "sneaker front.jpg", "contentType": "image/jpeg", "size": 204800, "visibility": "public", "url": "https://cdn…/files/originals/public/…", "variants": [{ "width": 100, "url": "…" }], "srcset": "…100w, …300w, …", "entity": { "type": "product", "id": "prod_abc123", "role": "gallery", "position": 0 } } ], "total": 260, "page": 1, "totalPages": 13 } ``` Twenty per page, fixed. It is not a parameter, which is deliberate: a caller cannot ask for ten thousand rows and neither can a bug. A page past the end returns an empty array with the real `total`, not a `404`. ## Walking a large project `page` counts rows to skip, which the database has to do one at a time — fine at page 2, expensive at page 500. For anything that walks a whole project, use the cursor instead: ```bash Everything, a page at a time curl "$BASE/v1/files?cursor=$CURSOR" -H "authorization: Bearer $KEY" ``` Each response carries `nextCursor`, and its absence means you have reached the end — so "keep going while there is a cursor" is the whole loop: ```json Response { "files": [ … ], "total": 259, "nextCursor": "MjAyNi0wOC0xN1QxMTowMDo0Ni4wMDBafGFiYzEyMw" } ``` Treat it as opaque. It happens to be base64 today; anything you decode from it is not a promise. Filtering by `role` alone is a scan across every entity in the project rather than a lookup — legitimate when you want every `avatar`, but pair it with `entityType` when you mean one kind of thing. ## GET /v1/files/{id} One file, when you kept the id yourself. ```json Response { "file": { "…": "the file object" }, "url": "https://cdn…/files/originals/public/…", "variants": [{ "width": 100, "url": "…" }], "srcset": "…100w, …300w, …" } ``` The URLs appear both inside `file` and at the top level; read them from `file`, the outer copies are the same strings kept for older callers. **Do not loop this over a listing.** If you have the list, you have these URLs already — that is the N+1 the inline URLs exist to prevent. ## Errors ```json 404 { "error": "File not found" } ``` The same `404` covers a file that does not exist and one belonging to another project. Telling you which would confirm that someone else's id is real. --- > Change visibility, ownership, position or transform settings — and what moves when you do. # Update files ## PATCH /v1/files/{id} Every field is optional; send only what changes. ```json Request body { "visibility": "private", // Moves the object — see below "ownerUserId": "user_456", // Or null to clear it "position": 2, // Order within its entity role "transformations": { "image": { "enabled": true, "visibility": "public" // Changing this purges existing sizes } } } ``` ```json Response { "file": { "…": "the file in its new state, with fresh URLs" } } ``` ## Visibility moves the object Visibility is a path prefix, not a flag, because the prefix is what the CDN's signing policy keys on. A flag would leave the edge serving the file exactly as before — the failure you would least want to discover. So changing it **moves the object**, and two things follow: - **The URL changes.** Any public URL you cached is now a `404`. This is the one case that breaks the "public URLs are permanent" rule, and it breaks it because you asked. - **It is not instant at the edge.** An already-cached copy can still be served for a while. Treat public → private as *no longer distributed* rather than *immediately unreachable*; if it must be unreachable now, [delete it](/docs/files-api/delete-files). ## Variant visibility purges the sizes Variants live under their own prefix too, so switching `transformations.image.visibility` deletes the ones that exist. They rebuild on demand under the new prefix, the first time each is requested. Leaving them would be bytes nobody can reach and everybody pays for. ## Errors ```json 400 { "error": "Visibility must be 'public' or 'private'" } ``` ```json 404 { "error": "File not found" } ``` ## What you cannot change - **A file's contents.** Upload a new one and delete the old. - **Its filename.** Derived at upload and fixed; `originalFilename` is what you were displaying anyway. - **Its project.** Files belong to the project that uploaded them. --- > One file, or everything attached to one of your objects. # Delete files Both calls stop the file serving immediately and remove every size built from it. **They are reversible for 30 days.** The stored copy is kept and the record survives alongside it, so a file can be brought back exactly as it was — see [restoring a deleted file](/docs/files-api/restoring). After 30 days the copy expires and the deletion becomes permanent. ## DELETE /v1/files/{id} ```bash Request curl -X DELETE "$BASE/v1/files/abc123def456" \ -H "authorization: Bearer $KEY" ``` ```json Response { "success": true } ``` Deleting something already deleted returns `404`, so retrying after a timeout is safe — you cannot delete the wrong thing by repeating yourself. ## DELETE /v1/files When one of your objects goes, its files should go with it, without you keeping a list of ids to loop over. ```bash Request curl -X DELETE "$BASE/v1/files?entityType=product&entityId=prod_abc123" \ -H "authorization: Bearer $KEY" ``` ```json Response { "deleted": 4 } ``` At least one of `entityType`, `entityId` or `role` is required, and they combine with AND. The operation is scoped to your project, so an entity id that also exists in someone else's cannot widen what it touches. Matching nothing is not an error — it returns `{ "deleted": 0 }`. ## A bare delete is refused, not interpreted ```json 400 { "error": "At least one filter required: entityType, entityId, or role" } ``` `DELETE /v1/files` is never read as "everything". ## Be careful with role alone ``` DELETE /v1/files?role=gallery ``` Valid, and it deletes every gallery image in the project across every product. If you mean one product's gallery, name the product. This is the call the 30-day window exists for. If it takes out more than you meant, everything it touched is listed at [`GET /v1/files/deleted`](/docs/files-api/restoring). ## After deleting Already-cached copies at the CDN edge can still be served briefly, the same as any other change to what a URL points at. Deleting stops a file being *distributed* rather than making it instantly unreachable everywhere. ## Errors ```json 404 { "error": "File not found" } ``` --- > Deletes are reversible for 30 days — how to see what is recoverable and bring it back. # Restoring a deleted file Deleting stops a file serving immediately. It does not destroy it. For **30 days** the stored copy is kept and the record survives alongside it, so the file can be brought back exactly as it was — same id, same URL, same entity attachment. After that the copy expires and the record is removed. This is not a feature you should need. It exists because the alternative was a system where one mistyped call was permanent, and `DELETE /v1/files?role=gallery` is a legal request that removes every gallery image in a project. ## GET /v1/files/deleted What is recoverable, most recently deleted first. ```json Response { "files": [ { "id": "E7iVnWA1LiEpu3u8dRTVK", "originalFilename": "eso1242a (2).tif", "contentType": "image/tiff", "size": 35233254, "deletedAt": "2026-08-17T09:12:44.001Z", "restorableUntil": "2026-09-16T09:12:44.001Z" } ] } ``` Deliberately **not** part of `GET /v1/files`, and there is no flag to include them. A deleted file is not a file: it does not serve, it has no working URL, and a caller who forgot a filter should never find one in a gallery. For the same reason no `url` or `variants` come back here. The object is behind a delete marker and any link would 404 — offering one would be offering something that cannot work. ## POST /v1/files/{id}/restore ```bash Request curl -X POST "$BASE/v1/files/E7iVnWA1LiEpu3u8dRTVK/restore" \ -H "authorization: Bearer $KEY" ``` ```json Response { "file": { "…": "the file, readable again, with fresh URLs" } } ``` Needs `files:write` — it changes what the project serves. Everything comes back with it: the id, the filename, the entity it was attached to, who uploaded it. **Smaller versions do not**, and do not need to: they are derived, and generation is on demand, so each rebuilds the first time it is requested. Storing them through a deletion would be paying to keep what a request can recreate. ## Errors ```json 400 — it was not deleted { "error": "That file was not deleted" } ``` ```json 410 — the window closed { "error": "The stored copy is gone. Deleted files are recoverable for 30 days." } ``` A `410` is final. The record may still be listed for a moment before the daily sweep removes it, but the bytes are gone and nothing will bring them back. ## What deleting actually does Worth knowing, because it explains the edges: 1. The stored object is deleted, which is what stops it serving. 2. Storage keeps the previous copy for 30 days rather than destroying it. 3. The record stays, marked deleted — because the bytes alone are not enough. What a file was attached to, and who uploaded it, exist only in the record. 4. Smaller versions are removed outright and rebuilt on demand if it comes back. **Already-cached copies can still be served briefly.** A public file's URL is permanent and cached at the edge, so deleting it stops it being *distributed* rather than making it instantly unreachable everywhere. If something must be unreachable now, that is a different problem from deletion. --- > Private files expire after five minutes. How to get a fresh set, and when not to. # Signed URLs A public file's URL is permanent. A private file's is signed and valid for five minutes, which is long enough to render a page and short enough that a leaked link is worthless by the time it is shared. ## POST /v1/files/{id}/url ```bash Request curl -X POST "$BASE/v1/files/abc123def456/url" \ -H "authorization: Bearer $KEY" ``` ```json Response { "url": "https://cdn…?Policy=…&Signature=…&Key-Pair-Id=…", "variants": [{ "width": 100, "url": "…" }], "srcset": "…100w, …300w, …", "expiresIn": 300 } ``` `expiresIn` appears only for private files. On a public one the field would be a lie, so it is absent. ## This has exactly one job Re-signing a private file after its five minutes are up. Public URLs never expire and every list response already carries them, so calling this in a loop over a listing is an N+1 round trip that buys nothing the listing did not already give you. ## Never persist a signed URL By the time a cached page renders, it has expired. Read them fresh per request, and cache the *file id* rather than the link. ## Public URLs are the opposite Permanent, derived from ids, and safe to store on your own record if you want to render without calling us at all — a static page, or a feed built ahead of time. That is an optimisation rather than a requirement. The same call returns the same strings every time, so putting it behind whatever caching your framework already has is usually enough, and it costs you nothing to invalidate when a file is replaced. The one thing that breaks a cached public URL is [changing the file's visibility](/docs/files-api/update-files), which moves the object. ## Errors ```json 404 { "error": "File not found" } ``` --- > Every field a file response carries, and which ones can be absent. # The file object The same shape everywhere a file is returned. ```json A file { "id": "E7iVnWA1LiEpu3u8dRTVK", "projectId": "WXm71CLq69pZAZCWR2l_m", "ownerUserId": null, "filename": "sneaker-front-dEPY6g.jpg", "originalFilename": "sneaker front.jpg", "key": "files/originals/public/…/sneaker-front-dEPY6g.jpg", "contentType": "image/jpeg", "size": 204800, "metadata": null, "visibility": "public", "uploadStatus": "confirmed", "createdAt": "2026-08-14T13:38:29.830Z", "updatedAt": "2026-08-14T13:38:33.412Z", "transformations": { "image": { "enabled": true, "visibility": "public" } }, "entity": { "type": "product", "id": "prod_abc123", "role": "gallery", "position": 0 }, "url": "https://cdn…/files/originals/public/…", "variants": [{ "width": 100, "url": "…" }], "srcset": "…100w, …300w, …" } ``` | Field | Notes | |---|---| | `id` | Ours. Stable for the file's life | | `projectId` | Always your own project | | `ownerUserId` | Whoever you said uploaded it, or `null` | | `filename` | Sanitised, with a uniqueness suffix | | `originalFilename` | What the user called it — display this | | `key` | The storage key. Returned so you can recognise your own objects; you never need to build one | | `contentType` | As declared at upload | | `size` | Bytes | | `visibility` | `public` or `private` | | `uploadStatus` | Always `confirmed` in list responses | | `transformations` | What was *asked for*, not what exists | | `entity` | The attachment, or `null` | | `url` | Permanent when public, signed for five minutes when private | | `variants` | Absent when the file has no sizes | | `srcset` | Absent for the same reason | ## What can be absent `variants` and `srcset` appear only for a transform-enabled image in a format we can resize. Their absence is information — check for it rather than assuming: ```tsx Rendering any file ``` `srcSet={undefined}` is valid and the browser falls back to `src`, so the same markup works for a PDF, an SVG and a photograph. Pick sizes with `sizes`, not by choosing a width yourself. Hard-coding one means shipping a 1200px image to a phone, or a 100px one to a retina display — the browser knows both facts and you do not. If you are not rendering to a browser — a PDF, an email, a native canvas — use `variants[]`, which carries the same widths as the `srcset`: ```ts Picking a width by hand const width = file.variants?.find((v) => v.width >= target); const url = width?.url ?? file.url; ``` ## transformations means requested, not built `transformations.image.enabled` records what you asked for at upload. It does not promise the sizes exist — each is generated the first time that width is requested. `variants` is the field that tells you which ones are on offer. --- > Sizes, expiries, widths, and every accepted content type. # Limits and formats | | | |---|---| | Images we can resize | 50 MB | | Everything else | 500 MB | | Upload URL expiry | 15 minutes (6 hours for a multipart part) | | Split into parts above | 100 MB, in 10 MB parts | | Private URL expiry | 5 minutes | | Page size | 20 | | Key required | secret (`sk_…`) | | Widths | 100, 300, 400, 600, 800, 1000, 1200 | The image limit **is** the resizing limit, and that is the point of it. Two different numbers would allow a file that uploads fine and whose smaller versions never arrive — a state that is hard to explain and easy to hit. One number deletes it: anything accepted as a resizable image can be resized. Both are checked before a byte moves, and the error names the one that applied: ```json 400 { "error": "That file is 95.4 MB. The limit is 50 MB for images, so every image can have its smaller versions built." } ``` ## Quotas Separate from the per-request limits above: these are ceilings on what a project *holds*, checked when an upload is started. | | Default | |---|---| | Stored bytes | 100 GB | | Files | 1,000,000 | Past a ceiling, an upload intent is refused before any bytes move: ```json 413 { "error": "This project holds 104.2 GB and its limit is 100.0 GB. Delete something, or ask us to raise it." } ``` Checked at intent rather than at confirm, because the presigned URL is the commitment — once a client holds one the bytes are going to storage whatever we decide afterwards. Both are raisable per project. Ask. ## Resizable formats These get `variants` and a `srcset`, and take the 50 MB limit. ``` image/jpeg image/png image/gif image/webp image/avif image/tiff ``` Variants are always WebP whatever the source was, so a TIFF that no browser renders still produces sizes that every browser renders. ## Stored and served, not resized 500 MB limit, no `variants`. ``` image/svg+xml image/bmp image/ico image/vnd.adobe.photoshop image/x-adobe-dng application/x-photoshop application/pdf application/msword application/vnd.ms-excel application/vnd.ms-powerpoint and the OpenXML equivalents text/plain text/csv text/html text/css text/javascript application/json application/xml application/zip application/gzip application/x-tar audio/mpeg audio/wav audio/ogg audio/webm video/mp4 video/webm video/ogg font/woff font/woff2 font/ttf font/otf application/postscript application/illustrator application/x-indesign application/vnd.adobe.indesign-idml-package application/vnd.adobe.aftereffects.project application/vnd.adobe.premiere application/vnd.adobe.xd model/gltf-binary model/gltf+json ``` Two are worth calling out. **SVG** is vector — it has no widths to render and is served as the original at any size. **Photoshop documents and raws** are `image/*` by MIME type but cannot be decoded by the resizer, which is why they take the larger limit rather than the image one. Anything on neither list is refused: ```json 400 { "error": "Content type not allowed" } ``` ## Displayable is not the same as resizable Worth knowing when you render an original directly. Browsers draw JPEG, PNG, GIF, WebP, AVIF and SVG. They do not draw TIFF, BMP, ICO, PSD or DNG — put one in an `` and you get a download or a broken icon, whatever its size. Use `srcset` and the browser picks a WebP variant instead. --- > A control plane that provisions independent services into a project, and the reasoning behind that shape. # What xplatform is xplatform provisions independent services — files, auth, commerce, marketing — into a customer's project, and gives them one account, one bill and one console across all of them. The services do not talk to each other. They do not share a database. What they share is the control plane. ## Why this shape Two of these services were built separately, and both independently arrived at the same primitives: a project as the unit of isolation, an API key per project, hashed and shown once, a dashboard, a docs site, metered usage. When two systems converge on the same shape without coordination, the abstraction is real rather than speculative. Building that control plane a third and fourth time is the cost this project exists to remove. ## What a service is Not a library the platform calls into. A service is anything that satisfies the contract: - it can be **provisioned** for a project, idempotently and resumably - it **reports** what it used - it can be **suspended** and later resumed - it can be **deprovisioned**, completely Everything else about it — how files are stored, how sessions are signed, what an order costs — is that service's own business and no concern of the platform's. ## Separation, and what it buys Each service owns its database, its storage and its credentials. The control plane has its own and never queries a service's. The link between a project and a service's resources is an **address, not a join**. Nothing in the control-plane database references a service's rows by foreign key. The consequence is worth stating plainly, because it is bought deliberately and it has a price: - no cross-service query is possible - no cross-service transaction exists - an outage in one service is not an outage in another Anything that spans two services has to be composed at the edge rather than joined in a database. That is the trade, and it is the right one for services that are meant to be independently deployable. ## Where enforcement lives The platform owns entitlement — balance, plan, limits. The **tool** enforces it, synchronously, in its own request path, from a cached copy. The platform is never a hop inside a service's request. If it were, one component's latency would be everyone's latency and one component's outage would be everyone's outage. --- > How services report what they used, why gauges and counters need different plumbing, and how limits are enforced. # Usage and billing Services report. The platform prices. Neither knows the other's job — a service has no idea what a gigabyte costs, and the platform has no idea what a gigabyte is — and keeping it that way is what lets a rate change without a deploy and a service be added without touching the billing path. ## The one interface Every service implements `meter`, and it takes a window: ```ts meter(projectId: string, window: { since: Date; until: Date }): Promise ``` `until` is exclusive, so two consecutive windows cannot both claim the boundary. The nightly job walks the project list, calls `meter` on every running service, and prices what comes back **by metric name**: ```ts const RATES = { "files.storage": storageCostCents, "files.bandwidth": bandwidthCostCents, "files.requests": requestCostCents, "files.transforms": transformCostCents, "auth.users": userCostCents, }; ``` That string is the whole join between the two halves, which is a real risk: a rename on either side prices nothing, charges nobody, and looks exactly like a quiet month. Two things guard it. A metric reported with no rate is logged as an error and counted in the job's result rather than silently treated as free, and a unit test asserts the two lists still match in both directions. **This replaced a billing job that imported the files service directly** and ran the rate functions inline. It worked because there was one service, and the second would have been added the same way. ## Two kinds of usage Conflating these is the single most expensive mistake available here. ### Gauges A **current value**: storage bytes, seats, active users. A missed reading costs nothing, because the next one is absolute and self-correcting. There is no accumulation to get wrong, so a gauge carries no idempotency key — there is nothing a repeat could double. ```ts { metric: "files.storage", kind: "gauge", value: 8_402_113, unit: "bytes" } ``` Storage is reported **averaged across the window**, not as today's total. A customer who held a terabyte for one hour did not hold a terabyte for the day. ### Counters A **total accumulated inside the window**: bandwidth served, requests handled, renditions built. Every counter carries an idempotency key. A report counted twice is money taken twice, and retries are normal rather than exceptional. ```ts { metric: "files.bandwidth", kind: "counter", value: 1_048_576, unit: "bytes", idempotencyKey: "files.bandwidth:proj_WXm71C:2026-08-16", } ``` The files service used to report bandwidth as a *gauge*, which is exactly the conflation this section warns about. ## What files reports | Metric | Kind | Measured from | |---|---|---| | `files.storage` | gauge | The daily snapshot's **billable** bytes — a walk of the bucket, so renditions count, floored per file | | `files.bandwidth` | counter | CloudFront's own access logs | | `files.requests` | counter | The same logs | | `files.transforms` | counter | The day-over-day *rise* in variant objects | Two of those need explaining. **Storage is billed on the billable figure, not the occupied one.** The per-file minimum is what makes a million tiny files pay for the per-object overhead they actually cost; charging the raw sum gives it away. **Renditions are counted by their rise, because the generator tells nobody.** Variants are built on demand and written straight to the bucket, so the only record that work happened is that there are more objects than yesterday. Only rises count — a day that deleted variants produces a fall, and billing a negative transform makes no sense. That makes a project's *first counted reading* a trap: it is a standing total, not a day's work. It becomes the baseline and is charged nothing, which under-bills exactly one period, once. Getting the condition wrong here billed a dev project 139 renditions built over a fortnight as a single day. ## What auth reports | Metric | Kind | Measured from | |---|---|---| | `auth.users` | gauge | `count(*)` of the project's own `user` table, taken daily and cached | One metric, and a gauge: accounts stored. A counter would charge for the same person again every month, which is not what storing them costs. **Stored users, not monthly active ones**, and the reason is that this should be boring. Activity billing needs a definition of "active", a window, and a guarantee that whatever rows the definition reads survive long enough to be counted — three moving parts, each able to change a customer's bill without anybody touching the pricing. A count of rows has none of them: it is the same number today and tomorrow, and a customer can reproduce it with one query against their own data. Worth reconsidering if a customer or a competitor gives a reason. Not before. The trade it makes is that a stored-user bill has no natural decay — dormant accounts accrue forever unless somebody removes them. That is why deleting a user is part of the API rather than a later nicety: a customer can remove accounts from their own server, and the count falls at the next nightly count. Without that, this pricing model would only ever ratchet up. The count is cached rather than taken live, because the users live in a database per project and those scale to zero: counting live would start a compute per project every time anybody asked, including a console page load. The `auth-users` job wakes each one once a night; `meter` reads the figure. A count more than 36 hours old is reported in the job's result, because a silently failing counter *under*-bills and nobody complains about that direction. ### The $1 project minimum A project with auth provisioned is charged `max($1, users × $0.02)` a month. A database per project is what makes one customer's users structurally unreachable from another's. It is also a Postgres compute that costs us something at zero users, so usage-only pricing loses money on every quiet project — and quiet projects are the majority of any signup list. A **floor**, not a base fee: a project with $4 of users pays $4, not $5. At these rates the two cross at 50 users. It is framed as money rather than as a quantity on purpose. This was briefly implemented as "every project counts as at least 50 users", which produces the same figure and asks a customer to believe in fifty people who do not exist. A minimum charge is the thing that is actually true. **A project without auth provisioned pays nothing**, and the mechanism is worth knowing because getting it wrong would charge the minimum to every project on the platform. The minimum keys on a service reporting *any* reading at all, so auth returns an empty array — not a zero — for a project it has no database for. "This project has no users" and "this project has no auth" are different answers, and only one owes us a dollar. Suspended projects report nothing for the same reason. **The $1 is the least certain number in the pricing file.** It rests on what a mostly-idle Neon project really costs on our plan, which is still an open question. Measure it before this bills a real account. ## Monthly rates against a daily charge A rate is quoted either **per month** or **per event**, and the difference decides whether it is prorated: | Basis | Metrics | Prorated | |---|---|---| | monthly | `files.storage`, `auth.users`, the project minimum | Yes — by the window's share of the calendar month | | event | `files.bandwidth`, `files.requests`, `files.transforms` | No — a counter's value is already the window's total | **This was missing, and it was a 30× overcharge.** Storage is published at $0.15 per GB-*month*; the charging job runs *daily* and charged the full monthly figure every night. A project holding one gigabyte paid about $4.50 a month against a rate that says fifteen cents. Nothing caught it because the gauge/counter split does not describe it. A gauge answers "how much was held" — a property of the reading. This answers "over what period is the price quoted" — a property of the rate. They looked like the same distinction and are not. Proration is against the **actual length of the calendar month**, not a flat 30, so a full month of daily charges sums to exactly the monthly rate rather than 31/30 of it in January and 28/30 in February. A test asserts that sum for four different month lengths, including a leap February. ## Charging## Charging Daily rather than monthly. A month of unbilled usage on a prepaid balance is a month of work nobody has paid for, which defeats the point of prepaid. The idempotency key is the organization and the day, so a retried run collides instead of billing twice — which is what happens the first time the job times out halfway through. Costs are summed **per organization**, not per project: a customer with staging and production should see one line. Deleted projects are excluded. They are charged up to the day they were deleted by the run that covered it, and billing one afterwards is how a cancelled customer gets one more invoice. ## Enforcement The platform decides the numbers, the service compares against them. Defaults are 100 GB and a million files per project, overridable per project in the `limits` column, and `on_exceeded` chooses what happens at the ceiling: - `block` — refuse further work. Correct for self-serve, where the alternative is an unbounded bill nobody agreed to. - `allow_and_flag` — serve it, record it, and tell a human. Correct for a negotiated customer, where cutting service mid-launch costs more than the overage. Checked on writes only. A read cannot make a project more expensive, so making every list call wait on a quota lookup pays for a check that can never fire. The entitlement is cached five minutes per process, so it is a ceiling rather than an emergency stop; the [rate limit](/docs/platform/operating) is the thing that acts immediately. Running out of credit blocks writes and leaves reads alone — a customer should still be able to fetch what they already paid to store. ## What the console shows The billing page reads the same `usageFor` the nightly job does, so the number a customer sees and the number they are charged are computed once. It used to run the rate functions itself, which meant two paths that would drift the first time either changed. ## Reconciliation The credit balance is a cached sum of an append-only ledger, and anything derived drifts — a crash between the insert and the update, a hand-edited row. Rebuilding the sum is cheap and the answer is either "agrees" or a number somebody needs to look at. ## Logs are not usage Usage is a handful of numbers per project per day. Logs are orders of magnitude larger, carry retention and privacy obligations, and scale with a customer's traffic rather than with revenue. Services own their logs and expose a query interface. The platform provides a unified **viewer**, not unified **storage** — otherwise it quietly becomes a log aggregation product that nobody chose to build. --- > Scheduled jobs, how a failure surfaces, and the one endpoint a monitor should watch. # Operating it Most of this platform runs on request. What does not is a handful of scheduled jobs, and those are the part that can stop working without anybody noticing — which is what this page is about. ## The scheduled jobs | Job | Runs | Does | |---|---|---| | `services-resume` | hourly, at :35 | Finishes service provisioning that failed or never came back | | `files-bandwidth` | hourly, at :20 | Reads CloudFront's access logs into per-project bandwidth | | `files-cleanup` | 03:10 daily | Sweeps uploads that were intended and never confirmed | | `files-storage` | 03:40 daily | Samples what each project occupies in storage | | `auth-cleanup` | 04:00 daily | Sweeps the console's own sign-in throttle | | `projects-purge` | 04:20 daily | Destroys projects past their restore window | | `auth-users` | 04:40 daily | Counts each project's end users, one database at a time | | `billing-charge` | 05:00 daily | Prices yesterday's usage and charges it against credit | The ordering is the load-bearing part of that table, and every gap in it is a decision. **Bandwidth runs at twenty past, not on the hour.** CloudFront delivers access logs with a lag, and starting on the hour races the delivery. **Cleanup runs before the storage sample, not after.** Swept objects have to be gone *before* the thing that measures them, or a customer is billed for uploads that were abandoned and deleted the same night. **`auth-users` runs after the purge and before the charge.** After, so a project destroyed last night is not counted and billed on its way out. Before, so the number being charged was taken today rather than yesterday. **`auth-cleanup` is not the auth service.** It sweeps the *console's* sign-in throttle and always has. The name predates there being an auth service and now reads as though it belonged to one; renaming it would change its cron path and orphan its run history, so the label carries the correction instead. ## When a job fails Every job runs through a wrapper that records the attempt, its duration and its outcome, then mails `PLATFORM_OWNER_EMAILS`. ```json A failed run's response { "ok": false, "job": "files-storage", "error": "The job failed. The detail was recorded." } ``` The response carries no detail on purpose — a job's error can contain a connection string, and this route is one misconfiguration away from being public. The detail is in the record and in the mail. ## When a job doesn't run at all This is the failure that alerting cannot catch. A job that never fires raises no error, sends no mail, and looks exactly like a quiet night — which is also the state after deploying a schedule that does not work. So every job declares how often it should run, and health is the *age* of the last attempt rather than its outcome. ## `GET /api/health` Unauthenticated, deliberately: a check that needs a credential is a check somebody skips configuring. ```json Healthy { "ok": true, "checkedAt": "2026-08-17T07:44:12.001Z", "jobs": [ { "name": "files-bandwidth", "label": "Bandwidth from CloudFront logs", "everyMinutes": 60, "lastRunAt": "2026-08-17T07:20:03.114Z", "lastOk": true, "ageMinutes": 24, "stale": false } ] } ``` It answers **503** when any job is late or last failed, so a monitor that reads only status codes still notices. It says nothing about what the jobs found — no project names, no counts, no error text. A job is **stale** at twice its declared interval. That tolerates one missed firing without crying wolf: a scheduler that skips a run is normal, one that skips two is not. A job that has never run is stale from the start. ## Rate limits Counted per key in Postgres rather than in memory, because this deploys serverless and memory is per-instance — an in-memory limit of 600 is 600 times however many instances happen to be warm, which is not a limit anyone could put a number on. `PLATFORM_RATE_LIMIT_PER_MINUTE` changes the ceiling. The customer-facing behaviour is in the [Files API](/docs/files-api). **The auth service has a second limiter underneath this one**, and it is not redundant. Ours counts per API key, which bounds what one customer can cost us. Better Auth's counts per end-user IP per endpoint, which is what stops somebody guessing passwords at one of a customer's accounts — the platform limit would happily allow 600 attempts a minute against a single account, because they all present the same valid key. Its counters go in **the project's own database**. That is not the default: Better Auth stores them in memory unless told otherwise, and on serverless an in-memory limit of three is three times however many lambdas are warm. The library ships a strict `/sign-in/email` rule of three attempts per ten seconds, and until the storage was set that rule did nothing at all. Verified by brute force: eight guesses from one address gave three 401s and then 429s, while a different address signed in normally throughout. It **fails open**: if the counter is unreachable, requests are allowed. Refusing would turn a database blip into a total outage for every customer, while allowing costs money for as long as the blip lasts — the second is recoverable and the first is not. ## Deleting a project Deleting is reversible for 30 days, the same window a deleted file gets and for the same reason — it is how long storage keeps the bytes. The moment it is deleted the project stops resolving keys and its services are told to suspend, so it serves nothing. Nothing is destroyed yet. When the window closes, `projects-purge` hands the project to each service's `deprovision`, which is what actually removes the objects, and then the project row and its keys go. A service that fails to deprovision leaves the project row in place rather than being skipped. A row is a reminder that something is still out there; deleting it would turn a retryable failure into orphaned storage nobody knows about — the job reports it as `failed` and tries again tomorrow. **This is the only caller of `deprovision`.** Until it existed the method was unreachable — nothing registered the service adapters, so the registry was empty and a deleted project kept its objects forever. Since the auth service shipped, that call destroys a Postgres database as well as a bucket prefix, which raises the stakes on the failure path. A service that cannot deprovision leaves the project row in place and is retried tomorrow; the Neon project is deleted *before* the auth service's own row, so a failure leaves a record of what still needs removing rather than a database nothing knows about. ## Standing a service up A project gets its `automatic` services when it is created, and its `on-request` ones when somebody asks. That split is a billing decision rather than a technical one: files namespaces a prefix inside a bucket that already exists and costs nothing to stand up, while auth creates a Postgres database and carries a monthly minimum. Handing auth to every project at signup would put a bill on people who never asked for it. **A provisioning failure never fails the project.** A customer who has just named a project should not be handed an error about a downstream service; the row records what went wrong and `services-resume` retries it hourly. That job does two things in order, and the order matters. It **reconciles** first — asking each service what state it believes it is in and correcting the platform's copy — because the common case for a `failed` row is that the work completed and the process died before it could say so. Re-provisioning that is harmless and pointless; correcting it is neither. Only what is still broken afterwards is retried, through `resume` rather than `provision`. ### `provision` was unreachable Until this shipped, nothing anywhere called it. The platform called `suspend`, `resumeService`, `deprovision` and `meter` — so a newly created project got no service resources at all, and every request to its auth endpoints answered 503 permanently. The one project that worked had been provisioned by hand from a script. That was the third contract method to be implemented and left unwired, after `deprovision` and `meter`, and the shape is always the same: an uncalled method fails no test, because there is nothing to fail. The feature is simply absent. A test now asserts every method on `Service` is reachable from outside the services layer. It found two more the moment it was written — `resume` and `status`, both of which now have real callers in the sweep above. It is a grep, and a grep only proves a call site exists rather than that it runs at the right moment, which is still the difference between five bugs that shipped and five that could not have. ## Provisioning that does real work The files service's `provision` records an address and returns — its tenancy is a prefix inside one bucket, so there is nothing to build. Auth gives every project its own Postgres database, so its `provision` spends money, takes about six seconds, and can fail halfway. It is idempotent and resumable, and the mechanism is worth knowing: each step's *output* is its own completion marker, so there is no state column that can disagree with reality. No Neon project id means create one; no sealed connection string means fetch one. Re-running picks up wherever the last attempt stopped. The step that matters most is the first. Before creating a Neon project it looks for one with the same deterministic name, because a run that died after creating it and before recording the id would otherwise leave a database billing forever with nothing pointing at it. The lookup is filtered on an exact name match rather than trusting Neon's `search`, which is a substring — adopting the wrong project would point one customer's auth at another customer's users. ## Migrating a database per project A schema change applies N times. There is one source-of-truth Drizzle schema, generated from Better Auth's own table definitions, and one runner that reads the project list and loops: ```bash npm run db:auth:tenant-migrate # what would happen npm run db:auth:tenant-migrate -- --yes # do it ``` It selects only projects behind the target tag, records each one's version on success, and carries on past a failure rather than aborting — with N databases, some run will die halfway, and the ones already migrated should stay migrated. **Migrations here must be backward-compatible.** Not a style rule: a partial rollout always exists mid-flight, and the HTTP driver has no multi-statement transactions, so a migration that fails partway leaves one database between versions. That is recoverable only if the old code still works against the new schema. Neon's own guide generates a Drizzle config and a CI workflow per project. That is fine for four demo tenants and unworkable at a thousand, where every new customer would be a commit. ## Quotas The platform decides the numbers, the service compares against them. Defaults are 100 GB and a million files per project; either can be overridden per project in the `limits` column, and `on_exceeded` chooses between refusing and serving it anyway with a flag in the log. Checked at upload intent, and only on writes — a read cannot make a project more expensive, so making every list call wait on a quota lookup would be paying for a check that can never fire. **The entitlement is cached for five minutes per process**, so lowering a limit takes up to that long to bite and there is no way to clear every serverless instance at once. It is a ceiling, not an emergency stop; the rate limit is the thing that acts immediately. ## Orphaned objects The daily cleanup counts objects that no record points at — the opposite of an abandoned upload, which is a record with no object. It **reports** rather than deletes: an object with no record looks identical to an object whose record the code cannot see because of a bug, and deleting customer data on the strength of a query returning nothing is not a trade worth making. The count lands in the job result. ## What is not here yet Said plainly because a gap you know about is cheaper than one you discover: - **Retries.** A failed job waits for its next scheduled run. - **Anything watching the request path.** Health covers scheduled work only.