Skip to content
flypod
API & library

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

MethodPathAuthBody
GET/healthzNone(none)
POST/sitesOptional account sessionzip
POST/sites/:id/deploysRequiredzip
POST/sites/:id/rollbackRequiredJSON
POST/sites/:id/timelineRequiredJSON
GET/sites/:idRequired(none)
GET/sites/:id/diffRequired(none)
POST/sites/:id/forksNone(none)
POST/sites/:id/claimIdentity + claim_tokenJSON
POST/account/bulk-attachIdentityJSON
GET/account/sitesIdentity(none)
PATCH/account/sites/:idAccount 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.zip

Send 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.zip

Returns 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>/forks

Returns 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

EndpointDescription
GET /account/sitesList your sites: { sites: [{ site_id, slug, label, url }, …] }.
PATCH /account/sites/:idRename a site: {"label":"Client pitch"} → { site_id, label }. An empty label clears it.
POST /account/sites/:id/rollbackThe dashboard's rollback: same body and answer as POST /sites/:id/rollback.
POST /account/sites/:id/timelineThe dashboard's timeline switch: same body and answer as POST /sites/:id/timeline.
DELETE /account/sites/:idDelete one site you own (blobs + metadata + versions) → { deleted: true, site_id }.
POST /account/sites/bulk-deleteDelete several sites you own: {"site_ids":[…]}, 1 to 200 ids → { deleted, skipped }. Ids you don't own are skipped.
DELETE /accountDelete 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" }
codeStatusMeaning
bad_request400A malformed JSON body (e.g. enabled not a boolean).
invalid_upload400The upload couldn't be read: empty, an invalid path, or a broken archive.
unauthorized401No valid manage_token or owning session for this site.
token_rejected401A bearer token was sent to POST /sites but not accepted.
login_required401The route needs an account session.
invalid_claim401Unknown site or wrong claim_token.
invalid_origin403A cookie-authenticated request from another origin.
forbidden403Not allowed, e.g. forking an owned site.
blocked403The content screen refused the upload.
challenge_required403The deploy needs a browser challenge this client can't solve.
not_found404No such route, or no such site you can see.
unknown_version404No such version on this site.
already_claimed409The site belongs to a different account.
expired410The site has expired.
too_large413Too many files or bytes.
rate_limited429Retry later.
taken_down451Disabled by the operator.
server_error500Retry; 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-revalidate

except 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).

StatusMeaning
404Unknown host, or no such file.
410Site expired.
451Operator-disabled.

On this page