Skip to content
esc

Search pages and sections. Try a tool name, an error code, a route.

↑↓ navigate↵ openesc close

API reference · Management plane

Sites

Deploy the static front end of an app, serve it at a readable public address, claim that address, take it down, and read what happened to it. The complete flow with GitHub is in Deploy from GitHub.

Host: https://api.manybases.com · Identity: an ak_, or a GitHub Actions token on the two write routes (see "Two accepted identities").

The model#

A site is three things: a script that holds only assets (it can carry no code, no key, no binding — it cannot read a database or a file store); a derived address on a second domain, https://proj-<id>.manybases.online, computed from the project id, so it exists for every site and nobody chose it; and, since 2026-09-06, a readable subdomain — https://habit-tracker.manybases.online — at most one per project, assigned once and never changed. There is one site per project. The site domain is deliberately different from manybases.com: a page served there shares no cookies and no origin with the dashboard or the API. Every call a hosted site makes to its project's API is a cross-origin call, subject to the same CORS rules as a site hosted anywhere else.

The four routes#

PUT/v1/projects/:pid/site
Tooldeploy_site (small sites only)
EffectDeposit or replace the site — the whole site, in one call. multipart/form-data, one part per file: the part's filename is the path in the site (absolute, /index.html), the part's Content-Type is the type that will be served. 200 with the number of files, the total bytes, the public address, and app_url
DELETE/v1/projects/:pid/site
Tool—
EffectTake the site down. Idempotent: a project that never had a site answers the same success. Returns app_url_cleared
PUT/v1/projects/:pid/site/subdomain {subdomain}
Toolclaim_site_subdomain
EffectClaim a PRECISE readable subdomain for this project, before or after its first deployment. 200 with {subdomain, url, claimed_via} — and the same 200 when you replay the same name. Permanent: a project has at most one, and it never changes. Refused by naming the cause: 400 (outside the grammar, or reserved), 409 taken (another project holds it), 409 immutable (this project holds a different one, and the response carries it), 503 on a race between two claims of the same name family — nothing was assigned, replay once for a final verdict. Account key only: a GitHub Actions token cannot claim
GET/v1/projects/:pid/site/deployments
Toollist_site_deployments
EffectThe history of the site — deployments, removals, refused attempts and subdomain claims — newest first. Never its current state

Deploying#

  • Bounds: 500 files per site · 5 MiB per file · 10 MiB for the whole site · 12 MiB for the body on the wire (the multipart envelope included), judged on the declared length before the first byte. Beyond: 413 payload_too_large, naming the file when the bound is per file. A path that does not start with /, contains .. or a backslash, or appears twice → 400 validation_error saying which and why. A part that is not a file is refused by name.
  • A declared body length is required; without it, 400. The type served is the one you declared on each part — nothing is guessed.
  • Atomic: either every file is accepted and the site switches in one block, or nothing switches and the previous site stays intact. A refused deposit has opened no session, uploaded no byte and created no script.
  • Public at once: there is no password, no allow-list, no staging URL. Do not deploy anything you would not publish.
  • No idempotency key, by construction: the script name comes from the project id and the content from the parts, so replaying an identical deposit produces exactly the same site.
  • No copy is kept: there is no server-side replay, no content audit, no "redeploy the previous version". To roll back, send the files again — your repository is the only place the site exists.

The app URL#

Deploying fills in the project's app_url when it is empty, with the site's address (https://proj-<id>.manybases.online/). It never replaces a value that is already set: changing an app URL redirects every user of the app, so a chosen value only enters through a signed-in human (PATCH /v1/projects/:pid). The response's app_url is what the project carries after the deposit: the value it already declared, the one this deposit just set, or null if it could not be recorded.

Taking the site down returns app_url_cleared with three values: true (the app URL was exactly this site's address, it is cleared), false (it points elsewhere — the nominal case of an app hosted somewhere else; nothing is touched), null (the control plane did not answer, so we do not know). The site is removed in all three cases.

History#

GET /v1/projects/:pid/site/deployments returns entries of the form { id, at, action, via, repository_full_name, detail }:

  • action is site.deploy, site.destroy, site.deploy.refused or site.subdomain.claimed. It is the only filter, passed as a bare value (?action=site.deploy.refused); any other parameter is refused by naming the three accepted (action, limit, cursor). There is no order.
  • via says where the entry came from, whatever its action — a removal and a refused attempt carry it too: github (and repository_full_name names the repository the call ran in), key (an account key made the call directly), or unknown (the entry predates provenance being recorded). On a refused entry it names who attempted, and on a subdomain claim who claimed the name; nothing was deployed in either case.
  • detail depends on the action: files, bytes, app_url_set, subdomain and subdomain_via for a deployment; already_gone and app_url_cleared for a removal; status, error_code and request_id for a refusal; subdomain and subdomain_via for a subdomain claim. A field the entry does not carry is null. Only an EXPLICIT claim writes a line of its own — a subdomain assigned by a first deployment travels in that deployment's detail.
  • limit defaults to 50, at most 200; cursor pages. at is rendered to the microsecond.

A refusal's request_id is the one the refused call returned in its X-Request-Id header, which the GitHub workflow prints in its log — the way to match "the push that was refused" with "the line I read". The refusal's human-readable message is not kept: naming the offending file or type is what this trace deliberately does not do.

This history is not guaranteed complete, and it cannot tell you when it is incomplete. Refusal entries are written under a rate limit counted per IP (10 per 10 seconds), so during a burst a legitimate refusal may never have been recorded. An absent refusal is not proof that a push was accepted.

Two accepted identities#

The two write routes accept an account key — a curl from anywhere — or a short-lived OIDC token minted by GitHub Actions for a run in a repository linked to the project. The two forms cannot be confused: a product key carries no dot, a JWT carries two. The token is checked on three points: its signature against GitHub's public keys (RS256 only), its audience (https://api.manybases.com, compared as an exact string), and the link between the repository it names and the project in the URL. No secret is stored on either side. See GitHub.