host an app
measuring…
Docs ↗︎ openapi.json

API Reference

Interactive reference live from /api/v1/openapi.json, the same spec your agents use for self-discovery. No account, no key.

OpenAPI 3.1 v1.0.0 Download JSON … endpoints

Try it

Pick an endpoint below, copy the curl, or fetch /api/v1/openapi.json from your agent. CORS * is enabled, so it works from any origin.

Servers

Limits & rate limits

All writable surfaces are rate-limited per client IP at the edge. Static content is never rate-limited. Exceeding a limit returns 429 Too Many Requests with a Retry-After: 60 header. Back off and retry rather than hammering.

SurfaceLimit
POST /api/v1/deploy30 requests/min per IP, burst 15
POST /mcp30 requests/min per IP, burst 15
POST /{slug}/api/op120 requests/min per IP (burst 30), plus a per-deployment bucket of 300 requests/min (burst 60), so one busy app can't crowd out the rest; public — invokes an author-defined action
POST /{slug}/api/query · GET /{slug}/api/schemaShares the same buckets; owner-only (Bearer namespace credential)
POST /api/v1/auth/{slug}10 attempts/min per IP, burst 10
POST /api/v1/reports10 reports/min per IP, burst 5
Static contentNo rate limit

Other hard limits

Request bodies are capped at 1 MB at the edge (64 KB for query bodies); larger payloads get 413. Deployments expire after 7 days. Runtime databases (project backends) are capped at 5 MB per file, enforced by SQLite itself: a write that would exceed the cap fails atomically with 413. When the platform is at capacity, deploys are rejected with 503 Service Unavailable and a descriptive reason, distinct from 429 throttling.

Projects

A project is a durable envelope for an app: one identity, one expiry, and everything it owns inside it. Where a quick share (deploy_asset) is a single page that deletes itself after seven days, a project is for apps that must outlive that, or remember things.

The mental model, one line: namespace = account · project = envelope · backend = what the app talks to · frontend = what visitors see.

  • namespace — your account. One credential manages everything in it.
  • project — the envelope. One expiry clock, chosen at create (default: permanent). Expired projects are torn down by the same cascade as an explicit delete, and members added later simply inherit the clock — there is no per-member TTL, ever.
  • backend — the project's server-side tier: one SQLite database, optional and at most one.
  • frontend — a published page (html, markdown, mermaid, json, or csv), rendered by the same pipeline as quick shares.

Build an app in five steps

  1. manage_project create — hold the returned project id; it targets every later call.
  2. manage_backend create — provision the tier from DDL. Optional: static-only projects are first-class and can add a backend later.
  3. manage_frontend create — publish the pages. With a backend present, pages invoke it tokenlessly through the injected hostan.db runtime.
  4. manage_backend update — evolve the schema and define the actions pages may call. Pages pick up changes on their next call, no republish needed.
  5. Inspect and iterate: get/list everywhere, update to rename or republish, delete to tear down — all idempotent, retry freely.

One credential, one seam

Namespaces own projects. The namespace credential (hstn_…, issued by the operator) is the only platform credential: no per-project tokens to mint, store, or rotate, and no anonymous project creation. It always travels as the HTTP bearer credential — Authorization: Bearer hstn_… — never as a tool argument, URL parameter, or page content. Configure it once on the hostan entry of your MCP client, and every manage_* call authenticates from that connection:

"hostan": {
  "url": "https://api.hostan.app/mcp",
  "headers": { "Authorization": "Bearer hstn_…" }
}

An invalid credential is rejected at the transport with 401; a missing one leaves the connection anonymous — quick shares only. deploy_asset stays the free funnel: content fields only (content/type/slug/title/one_time/password), bound to the seeded free project.

The tools, at a glance

tool what it does
manage_project create, inspect, rename, list, delete the envelope
manage_backend attach and evolve the SQLite tier; define actions
manage_frontend publish, update, and remove the project's pages

The same surface exists over REST with full parity, under /api/v1/projects/{id}… (project lifecycle, …/frontends, …/backend, …/backend/actions), with the namespace credential as the bearer credential. The reference is published at /api-docs (OpenAPI).

manage_project

  • create {name, description?, ttl_seconds?|permanent?} — returns the project (target its id on every later call) and these docs. Omitting both ttl_seconds and permanent means permanent. The response carries host_label and url: the project's frontends are served at https://<host_label>.hostan.app/<slug> — a project-scoped origin shared by all its frontends (#113). The label is derived from the project name and your namespace and is unique platform-wide; URL paths are identical to the shared-sites form.
  • get {project_id} — identity, visit rollup, and the member inventory.
  • update {project_id, name?, description?, allowed_providers?} — identity, plus the per-project sign-in restriction (#275). allowed_providers is a list of provider registry names (e.g. ["google", "github"]) the project's frontends may offer at sign-in; a present-but-empty list clears the restriction (every registered provider is allowed). Names must be registered providers — unknown ones are a rejected update. Passing null explicitly also clears the restriction (MCP detects key presence, so JSON null and an empty list are equivalent here; REST treats a present array as the update and null as leave-unchanged). Expiry, namespace, and structure never change here.
  • list — your namespace's projects.
  • delete {project_id} — cascades to every member (frontends, then the backend) and frees their slugs. Retrying after success is a clean no-op.
manage_project { "action": "create", "name": "Tasks",
                 "description": "Personal task tracker",
                 "ttl_seconds": 604800 }

manage_frontend

Frontends are plain static deployments owned by the project and sharing its clock. There is no per-frontend TTL, password, or one_time self-destruction — those remain deploy_asset capabilities. Slugs stay global and flat: the project owns the page, it never prefixes the URL.

  • create {project_id, type, content, requested_slug?, title?}url, api_base, and the deployment id. On a slug collision a suffixed variant is chosen automatically. url/api_base advertise the project's subdomain form (https://<host_label>.<sites domain>/<slug>, #283) when the sites domain is configured; sites.hostan.app/<slug> stays valid and remains the storage/edge identity. The page's URL is purged from the edge cache, so a slug redeployed after a delete never serves the previous site's cached bytes.
  • get {project_id, slug|id} — state, revision, the project's expiry, the member's visit total, and — once the frontend is in the revision workflow — active_revision_id (revision_count).
  • content {project_id, slug|id, revision_id?, known_content_sha256?} — the exact stored source: what to edit and feed back through create_revision (the rendered page carries platform injections; the content read never returns those). With revision_id, the source of one specific revision. An html frontend published before sources were kept falls back to its live index.html. known_content_sha256 is a conditional read: when it still matches, the answer is {unchanged: true} with no body — the cheapest "nothing changed".
  • list {project_id} — the project's frontends.
  • update {project_id, slug|id, content, title?} — the LEGACY immediate publish: creates a revision and promotes it unconditionally (last-write-wins) via the same atomic swap. The URL's edge-cache entry is purged, so visitors see the change immediately. Prefer the revision loop below for agent-managed appsupdate puts content on production with this call.
  • create_revision {project_id, slug|id, base_revision_id, content, title?, message?} — render + validate + publish an IMMUTABLE draft and return {revision, preview_url}; the canonical page is untouched. base_revision_id must equal the current active revision (a stale one is a structured conflict). Same-content writes deduplicate to the original revision (idempotent retries). A preview is static-only: it renders layout/styling/copy with no hostan.db/auth/payments runtime, so inspecting a draft can never mutate production data — promoting re-injects the runtime per the project's current capabilities.
  • list_revisions {project_id, slug|id, limit?} — revision metadata only (never bodies), newest first, with the current active_revision_id.
  • promote {project_id, slug|id, revision_id, expected_active_revision_id, message?} — the ONLY way the revision workflow changes production: re-renders the stored source through the production pipeline and publishes it. expected_active_revision_id is the optimistic-concurrency check — a stale one is refused (409-style conflict) so concurrent editors can never silently overwrite each other. See the preview first.
  • rollback {project_id, slug|id, revision_id (a formerly promoted one), expected_active_revision_id, message?} — restore a historic revision to the canonical slug; the release event records the rollback kind and the displaced revision becomes rolled_back.
  • delete {project_id, slug|id} — idempotent removal; the slug is freed, and a later redeploy of it under another project starts from zero. The URL's edge-cache entry is purged, so the page stops being served at the edge immediately instead of until the cache TTL.

A frontend enters the revision workflow lazily: the first revision-surface call records a baseline revision mirroring its already-live source and claims the release pointer, so get/content report revision_id and content_sha256 from then on. Revision preview URLs look like https://sites.hostan.app/_preview/<deployment-id>/<revision-id>/ and are immutable (aggressively cacheable) but unlisted.

manage_frontend { "action": "create", "project_id": "0193…",
                  "type": "html", "content": "<h1>Hi</h1>",
                  "requested_slug": "tasks-ui" }

manage_backend

The backend is the project's single server-side tier: one SQLite database, name default, provisioned into a private runtime directory keyed by an immutable id (never web-served) and hard-capped in size — a write past the cap fails atomically with 413. It inherits the project's clock: when the project expires or is deleted, the tier goes with it and frontends keep serving while their action calls answer 404, until a new backend exists and pages are republished.

  • create {project_id, ddl, realtime?} — provisions the database from a DDL script: CREATE TABLE / CREATE INDEX / CREATE VIEW plus optional seed INSERTs (additive only; at least one table). Returns the backend id and the database resource id. Frontends published from then on embed the hostan.db runtime; pages published earlier need one manage_frontend update to receive it. realtime: true additionally opts the tier into the project-scoped WebSocket room relay (see "Real-time rooms").
  • get {project_id} — the spec, the schema derived live from the file (never from stored state), the migration ledger with every entry's SQL (the schema's only stored truth, verbatim — read it to compose the next additive migration), the actions with their SQL, the realtime capability state, the inventory, and drift warnings (see under the hood).
  • update {project_id, ddl|actions|realtime, note?} — one additive migration (CREATE TABLE/INDEX/VIEW, ALTER TABLE … ADD COLUMN, seed INSERTs), one batch of actions, OR a realtime capability flip (realtime: true|false, alone) per call. Send exactly one of the three. Everything destructive — DROP, DELETE, UPDATE, other ALTER forms, PRAGMA, ATTACH, VACUUM, EXPLAIN, explicit transactions — is rejected.
  • delete {project_id} — idempotent tier teardown; frontends keep serving, as above.
manage_backend { "action": "create", "project_id": "0193…",
                 "ddl": "CREATE TABLE todos (
                   id INTEGER PRIMARY KEY,
                   title TEXT NOT NULL
                 );" }

Actions — the public surface

Visitors never send SQL. Everything a page can do to the database goes through named actions the author defines, one manage_backend update per batch:

manage_backend { "action": "update",
  "project_id": "…",
  "actions": [
    { "name": "book_slot",
      "sql": "INSERT INTO bookings(slot_id, name, code)
              SELECT :slot_id, :name, substr(hex(randomblob(6)),1,8)
              WHERE NOT EXISTS (SELECT 1 FROM bookings WHERE slot_id = :slot_id)
              RETURNING id, code",
      "note": "one booking per slot; returns the booking id + cancel code" },
    { "name": "cancel_booking",
      "sql": "DELETE FROM bookings WHERE id = :id AND code = :code" }
] }

The contract, in short:

  • One SELECT/INSERT/UPDATE/DELETE statement per action (WITH allowed). Parameters are :name binds only, and a call must pass exactly the declared caller-owned names.
  • Caller parameters and platform identity are separate namespaces. An action may reference two reserved binds the caller can never supply: :hostan_user_id (the signed-in visitor's stable usr_… id) and :hostan_user_email (their verified email, SQL NULL when the provider asserted none). The platform injects both after resolving the visitor's session; a call naming any hostan_* parameter is a 400, and an anonymous call to an action that uses them is a 401. Sessions come from the frontend's /{slug}/auth/login flow and are project-scoped: a token minted for one project never authenticates another's actions. Sign-in is per frontend — each frontend's login mints its own session under its own cookie name, and a frontend honors only its own — so signing in on one frontend does not sign the visitor in on a sibling frontend, even within the same project. The page reads the visitor's identity through the injected hostan.auth runtime (below).
  • An action is a ledger row like a migration: checksummed, versioned by name. Re-sending an unchanged body is a no-op; a changed body appends the next version. Notes are immutable per version — redefinition, never delete, is how an action changes.
  • Business rules live in the statement and the schema constraints: a page (or a curl script) cannot bypass UNIQUE any more than it can bypass the action list.

Pages invoke actions tokenlessly through the injected runtime:

const res = await hostan.db.op("book_slot", { slot_id: 3, name });
// res.rows[0].code — SELECT/RETURNING results come back as rows

The same injected runtime exposes the signed-in visitor as hostan.auth (managed identity, above):

const user = hostan.auth.user();        // null, or { id, email?, name?, picture?, provider?, locale?, expiresAt? }
hostan.auth.login();                    // redirect into this frontend's sign-in flow
hostan.auth.login('github');            // or force a specific sign-in method (#275)
const providers = hostan.auth.providers(); // the project's allowed sign-in methods
await hostan.auth.logout();             // revoke the session, then reload
hostan.auth.onChange(u => render(u));   // subscribe to the resolved identity

user() is a synchronous read that starts null: the session cookie is HttpOnly, so the runtime resolves it once per page load from GET /{slug}/auth/status and fires onChange listeners with the result — registering before or after that resolution both work. id is the same stable usr_… value actions receive as :hostan_user_id; the profile fields (email, name, picture, locale) are provider-asserted metadata and may be absent — email is kept only when the provider verified it, picture is the provider's avatar URL and locale the visitor's language tag when it supplies them (render initials when picture is missing). provider names who authenticated the session (e.g. "google"), and expiresAt (RFC3339 UTC) is the session's hard end — pages can warn before it passes; it is the hard absolute bound, so it does not slide even as the session refreshes on each call. Sign-in uses Google or GitHub (operator-configured) and is operator-enabled; where identity is not configured, login() answers 503 and every visitor stays anonymous, so pages should code for both states. A failed resolution (auth disabled, rate limit, offline) settles as anonymous — the runtime never errors on a page that does not use it. Provider tokens and session identifiers never reach the page; login() asks the flow to land back on the current page, and logout() revokes server-side before reloading.

Raw SQL and schema introspection over HTTP (POST /{slug}/api/query, GET /{slug}/api/schema) are owner-only: send the owning namespace credential as Authorization: Bearer hstn_…. The credential must never appear in a page — anything a page carries is public.

Real-time rooms

A backend created with realtime: true (or flipped on later with manage_backend update {"realtime": true}, alone) exposes one project-scoped WebSocket relay and an injected hostan.ws runtime. Frontends published after the capability was enabled carry the runtime; older pages need one republish. The endpoint rides every frontend of the project, resolved server-side from the slug:

const room = hostan.ws.connect('game-abc');

room.on('open', () => console.log('connected'));
room.on('message', (env) => {   // env = {v, type, data, sender, room, ts}
  if (env.type === 'move') renderMove(env.data);
});
room.on('presence', (p) => updateCount(p.members)); // {members: N}
room.on('join',    (e) => markJoined(e.client_id));
room.on('leave',   (e) => markLeft(e.client_id));
room.on('close',   () => showReconnecting());
room.on('error',   (e) => console.error(e));

room.send('move', { row: 1, column: 2 });
room.close();

The model is an ephemeral, single-process relay: live peers synchronize; nothing is stored. No history, no replay, no delivery receipts, no offline inbox, no cross-backend rooms. A server restart ends all sockets — the runtime reconnects with capped exponential backoff and jitter, but anything broadcast while a peer was away is gone, so an app reloads authoritative state from hostan.db on open.

  • Client frames are JSON text {v: 1, type, data, client_message_id?}; type is 1–64 characters of [A-Za-z0-9._~-]; binary frames are refused; the frame cap is 16 KiB; sustained ~10 messages/second per socket is enforced (violations close with 1008/1013).
  • The server stamps sender (opaque connection id), room, and ts before fanout; clients cannot forge them, nor the hostan:* platform events. The sender does not receive its own message back.
  • Same backend + same room fan out together; rooms and backends are strictly isolated. Room ids are 1–64 characters of [A-Za-z0-9._~-]. Limits per backend: 100 concurrent sockets, 100 active rooms, 20 sockets per client IP, a 32-message outbound queue per client (a slow receiver is dropped with close code 1013 rather than stalling the room), ping every 25 s with a 60 s pong deadline.
  • Realtime is public to the same degree as the backend URL — it is not authentication. Use actions with :hostan_user_id for identity and the database as the source of truth: write via hostan.db.op, broadcast the small event via hostan.ws, reload on open.

Managed payments

A project with a backend can collect one-time payments through the platform's managed Stripe integration. The platform hosts the checkout, verifies the webhook, and owns the verdict; the application decides what a paid visitor may do. No Stripe SDK, no keys in pages, no webhook code — and amounts are never client-supplied: the visitor picks a price id from your catalog, nothing else.

Define prices through the owner surface (namespace credential):

  • manage_payments add_price {product_name, description?, label, amount, currency, recurring?} (MCP) or POST /api/v1/projects/{id}/payments/catalog (REST). amount is an integer in minor units (cents for usd); currency is ISO 4217. The response carries the price_… id pages use.
  • manage_payments catalog / GET …/payments/catalog lists products and prices; deactivate_price / DELETE …/payments/catalog/{price_id} stops a price being purchasable (history keeps its snapshots).
  • manage_payments sessions / GET …/payments/sessions lists recent checkouts with their verdicts — open, paid, canceled, failed, refunded — for support.

In a page (the injected hostan.payments runtime, present in frontends published after the project got its backend — one republish adds it to older pages). The runtime addresses the visitor endpoints under the slug ROOT, like hostan.auth: checkout() POSTs /<slug>/payments/checkout, status() GETS /<slug>/payments/status — never under /<slug>/api/* (only hostan.db.op() lives there):

hostan.payments.checkout('price_…'); // redirect into hosted checkout
const pays = await hostan.payments.status();
// [{price, status: 'paid', amount, currency, user_id?, email?, created_at}]
  • status() is authoritative. Matching: a signed-in visitor matches their platform user id, plus any paid session whose Stripe-collected email equals their account email; an anonymous visitor is recognized only through this browser's checkout cookie (30 days) — a payment made without signing in is invisible from another browser or device, so gate valuable access behind hostan.auth.login() before checkout. The success redirect is UX, never proof.
  • A price flagged recurring bills as a monthly subscription; the entitlement is granted on the first verified success webhook either way. The platform enforces the provider lifecycle both ways: a FULL refund revokes the entitlement and a terminated subscription (visitor cancellation, or dunning ending in canceled/unpaid) revokes it; status() returns only currently-entitled entries, so a revoked payment disappears from it. A failed renewal is dunning, not revocation — access continues while Stripe retries, and only Stripe ending the subscription revokes. Partial refunds are recorded in the ledger but do not revoke.
  • The platform also materializes a reserved hostan_payments table into the project's database (unique session_id, price_id, price_label, amount, currency, user_id, email, status, created_at) — a derived view for JOINs and composability in your own actions. Treat status() as the hardened gate: the table is re-derivable from the platform's event ledger, which is the source of truth.
  • The hostan_ table prefix is reserved platform-wide; author DDL (provisioning, migrations, actions' target schema) may never create hostan_* tables.
  • Where the platform has payments unconfigured, checkout answers 503 (feature off, fail closed); the catalog surface still works so prices can be defined ahead of the credential.

Under the hood

Optional reading — none of this is needed to build an app.

  • The ledger is the schema's only stored truth. Schemas are introspected live from the database file; there is no second stored schema to drift.
  • Drift self-heals. The DDL and its ledger row cannot commit atomically together, so the only possible lag is schema ahead of ledger (a crash between the two commits). get detects it, reports a schema_ahead_of_ledger warning, and re-sending the same migration heals it idempotently.
  • Migrations serialize. Concurrent migrations to one project are applied one at a time; a loser answers "applied but not recorded" and heals when re-sent.
  • Visit rollups tolerate a small window. Totals sum durable per-deployment counters; the last few seconds may be missing (visits buffer for up to one flush window) — bounded loss, never a double count.
  • Publishes purge the edge cache. A project frontend create, update, or delete purges its URL from the Cloudflare edge cache (purge by prefix), best-effort and asynchronous: a failed purge never fails the operation, it only leaves the previous state cached until its TTL. Prefix matching is literal, so sibling slugs sharing the prefix are over-evicted and simply re-fetch once. Unconfigured (no purge token/endpoint), purging is disabled. Quick shares never purge — their slugs are always freshly minted.

Endpoints

Click a row to expand request/response schemas. All examples live from the spec.

Components