API Reference
Interactive reference live from /api/v1/openapi.json, the same spec your agents use for self-discovery. No account, no key.
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.
| Surface | Limit |
|---|---|
| POST /api/v1/deploy | 30 requests/min per IP, burst 15 |
| POST /mcp | 30 requests/min per IP, burst 15 |
| POST /{slug}/api/op | 120 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/schema | Shares the same buckets; owner-only (Bearer namespace credential) |
| POST /api/v1/auth/{slug} | 10 attempts/min per IP, burst 10 |
| POST /api/v1/reports | 10 reports/min per IP, burst 5 |
| Static content | No 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, orcsv), rendered by the same pipeline as quick shares.
Build an app in five steps
manage_project create— hold the returned project id; it targets every later call.manage_backend create— provision the tier from DDL. Optional: static-only projects are first-class and can add a backend later.manage_frontend create— publish the pages. With a backend present, pages invoke it tokenlessly through the injectedhostan.dbruntime.manage_backend update— evolve the schema and define the actions pages may call. Pages pick up changes on their next call, no republish needed.- Inspect and iterate:
get/listeverywhere,updateto rename or republish,deleteto 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 bothttl_secondsandpermanentmeans permanent. The response carrieshost_labelandurl: the project's frontends are served athttps://<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_providersis 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. Passingnullexplicitly 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 andnullas 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_baseadvertise 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 throughcreate_revision(the rendered page carries platform injections; the content read never returns those). Withrevision_id, the source of one specific revision. An html frontend published before sources were kept falls back to its liveindex.html.known_content_sha256is 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 apps —updateputs 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_idmust 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 nohostan.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 currentactive_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_idis 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 therollbackkind and the displaced revision becomesrolled_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 VIEWplus optional seedINSERTs (additive only; at least one table). Returns the backend id and the database resource id. Frontends published from then on embed thehostan.dbruntime; pages published earlier need onemanage_frontendupdate to receive it.realtime: trueadditionally 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, seedINSERTs), 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, otherALTERforms,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/DELETEstatement per action (WITHallowed). Parameters are:namebinds 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 stableusr_…id) and:hostan_user_email(their verified email, SQLNULLwhen the provider asserted none). The platform injects both after resolving the visitor's session; a call naming anyhostan_*parameter is a400, and an anonymous call to an action that uses them is a401. Sessions come from the frontend's/{slug}/auth/loginflow 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 injectedhostan.authruntime (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
UNIQUEany 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?};typeis 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, andtsbefore fanout; clients cannot forge them, nor thehostan:*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_idfor identity and the database as the source of truth: write viahostan.db.op, broadcast the small event viahostan.ws, reload onopen.
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) orPOST /api/v1/projects/{id}/payments/catalog(REST).amountis an integer in minor units (cents forusd);currencyis ISO 4217. The response carries theprice_…id pages use.manage_payments catalog/GET …/payments/cataloglists products and prices;deactivate_price/DELETE …/payments/catalog/{price_id}stops a price being purchasable (history keeps its snapshots).manage_payments sessions/GET …/payments/sessionslists 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 behindhostan.auth.login()before checkout. The success redirect is UX, never proof.- A price flagged
recurringbills 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 incanceled/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_paymentstable into the project's database (uniquesession_id,price_id,price_label,amount,currency,user_id,email,status,created_at) — a derived view for JOINs and composability in your own actions. Treatstatus()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 createhostan_*tables. - Where the platform has payments unconfigured,
checkoutanswers503(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).
getdetects it, reports aschema_ahead_of_ledgerwarning, 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.