HTTP API
The flypod REST API at https://flypod.dev deploys sites from a zip, redeploys new versions, rolls back, and manages accounts with bearer-token auth.
Base URL: https://flypod.dev. Authenticated requests use Authorization: Bearer <token>, where the token is a per-site manage_token or a Better Auth account session token (the credential flypod login saves; there are no separate API keys). A site's manage_token manages it only while it's anonymous: once an account owns the site, only that account's session does. See Tokens & ownership.
Deploy requests may send x-flypod-source to say which surface they came from (cli, library, ci, …). It's an attribution label, not a credential; unrecognised values are recorded as unknown.
Endpoints
| Method | Path | Auth | Body |
|---|---|---|---|
GET | /healthz | None | (none) |
POST | /sites | Optional account session | zip |
POST | /sites/:id/deploys | Required | zip |
POST | /sites/:id/rollback | Required | JSON |
POST | /sites/:id/timeline | Required | JSON |
GET | /sites/:id | Required | (none) |
GET | /sites/:id/diff | Required | (none) |
POST | /sites/:id/forks | None | (none) |
POST | /sites/:id/claim | Identity + claim_token | JSON |
POST | /account/bulk-attach | Identity | JSON |
GET | /account/sites | Identity | (none) |
PATCH | /account/sites/:id | Account session (dashboard) | JSON |
GET /healthz
Liveness check.
curl https://flypod.dev/healthz{ "ok": true }POST /sites
Deploy a new site. Send a zip of the site as the raw request body with content-type: application/zip (or application/octet-stream). For small text-only sites you can instead send content-type: application/json with {"files": {"index.html": "<h1>hi</h1>", …}} (path → UTF-8 text, at most 5 MB of JSON). Anonymous unless you send an account session.
curl -X POST https://flypod.dev/sites \
-H "content-type: application/zip" \
--data-binary @site.zipSend Authorization: Bearer <session_token> to make the deploy owned and permanent. A bearer token that isn't accepted fails with 401 token_rejected; it never falls back to an anonymous deploy.
On flypod.dev an upload may hold at most 200 files and 10 MB of content (12 MB as a zip); over that is 413 too_large. An upload with no files is 400 invalid_upload. Deploys are rate-limited per IP (429 rate_limited), and the content screen can refuse one (403 blocked).
Returns the deploy JSON: url, site_id, slug, version_id, expires_at, manage_token, optional claim_token, optional owner_account_id, next_actions, healed, warnings, and render.
expires_at is epoch milliseconds on an anonymous deploy and null when the site is owned. The optional keys are omitted when they don't apply, not returned as null: claim_token appears only on anonymous deploys, owner_account_id only on owned ones.
healed lists what flypod fixed on your behalf (e.g. spa-fallback). warnings lists what it could not (e.g. missing-index), each with a code and a fix_recipe naming the change to make — check warnings.length before treating the URL as good, because a deploy can return 200 and still serve a broken site.
render is a headless-browser receipt — screenshot, console output, failed requests, DOM summary. It requires a render engine the server operator opts into, and flypod.dev does not run one: on this host render.status is always "pending", the arrays are always empty, and no screenshot_url key is returned. Treat it as reserved. It populates only on a self-hosted deployment started with RENDER=playwright.
Save the manage_token from the response. Calling the API directly, you are the only thing holding it — the server keeps just a hash, and there's no registry doing it for you the way the CLI does. It's the bearer token for redeploys, rollback, and reads on this site. It does not authorize deletion; that's account-only.
POST /sites/:id/deploys
Redeploy. Uploads a new version that becomes live. Requires a bearer token: the manage_token while the site is anonymous, the owning account's session once it's claimed. Body is a zip (or the JSON file map), with the same limits as POST /sites.
curl -X POST https://flypod.dev/sites/<id>/deploys \
-H "authorization: Bearer <manage_token>" \
-H "content-type: application/zip" \
--data-binary @site.zipReturns site_id, version_id, url, expires_at (the site's current expiry, or null once owned), healed, warnings, render, and next_actions. A version id is derived from the content, so redeploying files the site already has a version for reuses that version (and makes it live) instead of adding one. A bad or missing token is 401 unauthorized.
POST /sites/:id/rollback
Repoint the live site to a prior version. Requires a bearer token. JSON body with the target version_id.
curl -X POST https://flypod.dev/sites/<id>/rollback \
-H "authorization: Bearer <manage_token>" -H "content-type: application/json" \
-d '{"version_id":"<version_id>"}'Returns { site_id, live_version_id, url, next_actions }. An unknown or missing version_id returns 404 unknown_version. The dashboard uses the session-authenticated twin, POST /account/sites/:id/rollback, with the same body and answer.
POST /sites/:id/timeline
Turn the version timeline on or off. Requires a bearer token: the site's manage_token, or the owning account's session.
curl -X POST https://flypod.dev/sites/<id>/timeline \
-H "authorization: Bearer <manage_token>" -H "content-type: application/json" \
-d '{"enabled":true}'{
"site_id": "<id>",
"timeline": true,
"url": "https://<id>.flypod.page",
"history_url": "https://<id>.flypod.page/_flypod/history"
}history_url is the compare page, present when enabled is true. The dashboard uses the session-authenticated twin, POST /account/sites/:id/timeline, with the same body and answer.
enabled must be a boolean; anything else returns 400 bad_request.
GET /sites/:id
Returns the site record and its version list. Requires a bearer token.
curl https://flypod.dev/sites/<id> -H "authorization: Bearer <manage_token>"The record has site_id, slug, label, url, created_at, expires_at, live_version_id, timeline, and versions (each with version_id, created_at, content_hash, is_spa, and title). timeline is whether the version timeline is on; while it is, the response also has history_url and each version has the url it is served at. label is the display name set from the dashboard (null when unset — clients fall back to slug). Each version carries the title of that deploy's index.html, or null when it has none.
GET /sites/:id/diff
Which files changed between two versions of the site. Requires a bearer token. Pass both version ids as query parameters.
curl "https://flypod.dev/sites/<id>/diff?from=<version_id>&to=<version_id>" \
-H "authorization: Bearer <manage_token>"Returns from, to, changed_assets ({ added, removed, modified }, each a sorted list of paths), and screenshot_a / screenshot_b (paths of each version's render screenshot, which exist only where a render engine runs). An unknown from or to returns 404 unknown_version.
POST /sites/:id/forks
Copy an anonymous site's live version into a new anonymous site. No auth: anyone with the site id can fork it, as anyone with the URL can view it.
curl -X POST https://flypod.dev/sites/<id>/forksReturns a new site_id, slug, url, version_id, expires_at, claim_token, manage_token, and next_actions. The fork never outlives its source. A site owned by an account can't be forked (403 forbidden); an expired one returns 410 expired and a disabled one 451 taken_down. Draws on the same per-IP rate limit as POST /sites.
POST /sites/:id/claim
Attach a single anonymous deploy to an account using its claim_token. Requires an authenticated identity and a valid claim_token (from the original anonymous deploy response).
curl -X POST https://flypod.dev/sites/<id>/claim \
-H "authorization: Bearer <session_token>" -H "content-type: application/json" \
-d '{"claim_token":"<claim_token>"}'Returns { site_id, owner_account_id, expires_at: null, url, next_actions }.
Returns 409 already_claimed if the site already belongs to a different account: a claim_token proves you deployed the site, not that you still hold it, so it cannot re-point a site away from its current owner. 401 invalid_claim covers an unknown site or a bad token, and 401 login_required a missing or invalid session. Rate-limited per IP.
Most clients use POST /account/bulk-attach instead — see below. claim_token is the older, weaker path and is not the recommended one.
POST /account/bulk-attach
Attach multiple anonymous sites to the caller's account in one request, using each site's manage_token as proof of ownership. This is the endpoint flypod login calls to auto-claim every anonymous site the local CLI remembers.
curl -X POST https://flypod.dev/account/bulk-attach \
-H "authorization: Bearer <session_token>" -H "content-type: application/json" \
-d '{"sites":[{"site_id":"abc","manage_token":"fk_..."},{"site_id":"def","manage_token":"fk_..."}]}'Body: { sites: [{ site_id, manage_token }, …] }, 1 to 100 items (otherwise 400 bad_request). Returns:
{
"attached": ["abc"],
"skipped": [
{ "site_id": "def", "reason": "invalid_token" }
]
}reason is one of not_found, already_owned, already_yours, invalid_token, or invalid_input. Rate-limited per IP.
Account
| Endpoint | Description |
|---|---|
GET /account/sites | List your sites: { sites: [{ site_id, slug, label, url }, …] }. |
PATCH /account/sites/:id | Rename a site: {"label":"Client pitch"} → { site_id, label }. An empty label clears it. |
POST /account/sites/:id/rollback | The dashboard's rollback: same body and answer as POST /sites/:id/rollback. |
POST /account/sites/:id/timeline | The dashboard's timeline switch: same body and answer as POST /sites/:id/timeline. |
DELETE /account/sites/:id | Delete one site you own (blobs + metadata + versions) → { deleted: true, site_id }. |
POST /account/sites/bulk-delete | Delete several sites you own: {"site_ids":[…]}, 1 to 200 ids → { deleted, skipped }. Ids you don't own are skipped. |
DELETE /account | Delete the account and every site it owns → { deleted: true }. |
All of these require an account session (the browser cookie or a bearer session token); the /account/sites/:id routes answer 404 not_found for a site you don't own. Deletion is account-only — a manage_token does not authorize it, and there is no route that accepts one for delete. A request a browser page on another origin sends is refused with 403 invalid_origin.
Comment moderation has its own endpoints under /api/sites/:id/comments; see Comments.
Errors
Every control-plane failure is JSON with a stable code to branch on and a prose error (usually repeated as message) that may change:
{ "error": "Too many requests", "code": "rate_limited", "message": "Too many requests" }code | Status | Meaning |
|---|---|---|
bad_request | 400 | A malformed JSON body (e.g. enabled not a boolean). |
invalid_upload | 400 | The upload couldn't be read: empty, an invalid path, or a broken archive. |
unauthorized | 401 | No valid manage_token or owning session for this site. |
token_rejected | 401 | A bearer token was sent to POST /sites but not accepted. |
login_required | 401 | The route needs an account session. |
invalid_claim | 401 | Unknown site or wrong claim_token. |
invalid_origin | 403 | A cookie-authenticated request from another origin. |
forbidden | 403 | Not allowed, e.g. forking an owned site. |
blocked | 403 | The content screen refused the upload. |
challenge_required | 403 | The deploy needs a browser challenge this client can't solve. |
not_found | 404 | No such route, or no such site you can see. |
unknown_version | 404 | No such version on this site. |
already_claimed | 409 | The site belongs to a different account. |
expired | 410 | The site has expired. |
too_large | 413 | Too many files or bytes. |
rate_limited | 429 | Retry later. |
taken_down | 451 | Disabled by the operator. |
server_error | 500 | Retry; the body carries a request_id to quote if it persists. |
Every control-plane response carries an x-request-id header (yours, if you sent one).
Serving behavior
Served files carry:
cache-control: public, max-age=60, must-revalidateexcept files whose names are fingerprinted by a build tool (e.g. /_astro/…, app.3f9a2c1b.js), which get public, max-age=31536000, immutable.
The ETag is "<live_version_id>:<path>", sent weak (W/"…") for compressible types such as HTML, CSS, and JS.
On a site with the version timeline on, each version is also served at https://<site_id>--<version_id>.flypod.page, pages carry a :t suffix on the ETag, and the site answers two more paths: GET /_flypod/timeline.json (its versions with their addresses, which is live, and which this host is serving) and GET /_flypod/history (the compare page).
| Status | Meaning |
|---|---|
404 | Unknown host, or no such file. |
410 | Site expired. |
451 | Operator-disabled. |
TypeScript demo
A runnable TypeScript example that deploys a folder to a live URL with the flypod library. Download it and run, or check it inline. Zero auth.
How it works
flypod has two planes. A deploy pipeline ingests a zip into an immutable version, and a serve plane routes a subdomain to that version's files.