Skip to content
esc

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

↑↓ navigate↵ openesc close

API reference · Basics

API conventions

Everything that is true of every request and every response: hosts, keys, headers, the response envelope, filters and pagination, platform columns, idempotency, CORS.

Two planes, two hosts#

Manybases has two API surfaces, and the key you hold decides which one you talk to.

PlaneHostKeyWhat lives there
Managementhttps://api.manybases.com/v1/…ak_ (account key)Projects, usage, project users, keys, SQL and logs of a project, edge functions, sites, GitHub links, secrets
Datahttps://proj-<id>.manybases.com/v1/…pk_ (publishable) or sk_ (secret)Rows, table schemas, spaces, end-user auth, files

The data host of a project is published as urls.api on the project card (GET /v1/projects/:pid). Its label is derived from the project id and never chosen: proj_<ULID> becomes proj-<ulid in lowercase> (31 characters). Two more hosts are derived the same way — an edge function answers at https://proj-<id>-fn-<slug>.manybases.com, and a deployed site at https://proj-<id>.manybases.online. A site also answers at a readable subdomain — https://habit-tracker.manybases.online — which is the one exception to "derived, never chosen": it is claimed or derived from the project name once, lives in a registry, and never changes.

The URL addresses, the key authorizes. Owning a URL gives access to nothing. Calling the host of project B with a key of project A is refused, and the refusal is the same neutral 404 you would get for a project that does not exist. api, dash, mcp and the other service hosts are never projects.

Every data route is also mounted on the management host under /v1/projects/:pid/… for the account key — see Mirrors.

Versioning#

/v1 only ever receives additions. A breaking change ships as /v2, and /v1 keeps running. A path under an unknown version (/v2/… today) is refused with 404 unknown_version. Breaking changes already taken are listed in the changelog.

Authentication headers#

Every request carries a key. Two optional headers add an identity on top of it.

Authorization: Bearer <key>        required on every request — pk_, sk_ or ak_
X-Session: <token>                 optional — the session of an end user of the app (data plane)
X-Account-Session: <token>         optional — proves a signed-in human of the account; only an ak_ reads it

The API derives one identity from them, and the access presets decide with that identity:

IdentityHowWhat it is
anonpk_ without X-SessionA visitor with no account (a public page, a form)
userpk_ + a valid X-SessionA signed-in end user of the app
skan sk_ keyThe app's own server code — every right on its project, the access matrix does not apply
akan ak_ keyAn agent (local MCP server or remote connector) or the dashboard — every project the key's scope allows

"Anonymous" never means "without a key": a request without a valid key is refused with 401 before anything else. The two session headers are never interchangeable — X-Session names an end user of an app, X-Account-Session names the owner of the account.

An account key has a scope, chosen by the human who issued it: every project, or a list of projects. Outside its scope every project route answers the neutral 404. Creating a project (POST /v1/projects) requires the "every project" scope; a restricted key receives 403 forbidden. The scope is declared at issuance and cannot be changed — a key whose scope must change is revoked and reissued. See Keys.

The response envelope#

Every response has the same shape, with two exceptions: GET /v1/files/:id/content returns the file bytes with their Content-Type, and a CORS preflight returns 204 with no body.

json
{ "data": [ ... ], "cursor": "opaque-string-or-null" }   // lists
{ "data": { ... } }                                       // a single object

A refusal always carries three fields. The hint is written so that an agent can correct itself on the next call: it names the exact problem, lists the valid values, and points at the tool that helps.

json
{ "error": {
    "code": "unknown_column",
    "message": "Column 'done' does not exist on table 'habits'.",
    "hint": "Available columns: id, name, checked_at, created_at. Call list_tables for the full schema."
} }

Two additions, and only two: every 5xx also carries request_id, and a 428 confirmation_required also carries confirmation_token and will_destroy. The full catalogue is in Errors.

Filters, sort, pagination#

Lists accept filters as query parameters, column=operator.value. Nine operators:

OperatorMeaningExample
eq / neqequal / not equal?status=eq.active
gt / gtegreater than / or equal?score=gte.100
lt / lteless than / or equal?date=lt.2026-08-01
likepattern, * is the wildcard, case-insensitive?name=like.*jo*
inin the list?status=in.(todo,doing)
isnull / not_null / true / false?deleted_at=is.null
  • Several filters combine with AND. Two filters on the same column are allowed (?score=gte.10&score=lte.20). There is no OR.
  • Sort: order=column.asc or order=column.desc. Default created_at.desc; one sort column. The request log sorts by ts.desc by default because its entries have no created_at.
  • Pagination is cursor-based and mandatory: limit defaults to 50 and is capped at 200 — a larger value is refused with 400 validation_error, never silently truncated. The response carries cursor; pass it back unchanged as ?cursor=…. cursor: null means the last page. There is no offset.
  • order, limit and cursor are reserved parameter names: a column with one of these names cannot be filtered on through the query string.

One list does not paginate: the members of a space.

Platform columns#

Every table carries five columns managed by the platform. They cannot be written by a client, dropped, renamed or retyped.

ColumnTypeRule
idTEXT (ULID)Generated by the API, sortable by creation time. Never supplied by the client.
owner_idTEXTSet to the end user of the session. Never supplied by the client. Rows written with an sk_ or ak_ key have owner_id: null.
space_idTEXTSupplied by the client at creation only, on a spaces preset — the API checks membership. Immutable afterwards.
created_at / updated_atTEXT, ISO 8601 UTCAutomatic.

A body that contains owner_id or id is refused with 400 validation_error.

Types#

The database is SQLite. Column types are TEXT, INTEGER, REAL, BOOLEAN (stored as 0/1, exposed as true/false in JSON) and JSON (stored as text). Dates are TEXT in ISO 8601 UTC. There is no SERIAL, jsonb, timestamptz or now().

Three table-name prefixes are reserved and cannot be created, read or altered through SQL: _mb_ (platform metadata), sqlite_ (the engine catalogue) and _cf_ (internal to the database engine).

Idempotency#

On a POST, send Idempotency-Key: <uuid>. The same key seen again within 24 hours returns the original response without redoing anything, with the header X-Idempotent-Replay: true. The MCP server sends one on every creation, which is why calling create_project twice does not create two projects.

Rules:

  • The key is bound to the caller (the key or session presented), the method, the path, and the project — two different callers sending the same key never see each other's response.
  • Same key, different body → 409 conflict. Bodies are compared in normalized form (object keys sorted, array order kept).
  • Transient refusals are never stored: 5xx, 429, 402, 408 and 401 are re-evaluated on every call. A 428 is not stored either — it produced nothing.
  • A replay re-evaluates every check: a caller who lost a right between the two calls receives the current refusal, never the stored response.
  • Without an individual caller — an anonymous visitor holding only a pk_ — the key only de-duplicates: the write happens once, and a replay receives 409, never the first response. The same de-duplicate-only regime applies to responses that carry a secret, such as the 201 of a key creation.
  • An operation still in flight under the same key answers 409 conflict.

Where it applies today: POST of rows, spaces and members, and POST /v1/projects. PATCH, DELETE, and the POST routes of keys and functions do not read the header yet. Three POST routes never will, by design: POST /v1/sql (re-running a statement is the wanted behaviour), POST /v1/files (the body is the file, and two uploads of the same file create two files), and POST /v1/account/github/installations (GitHub already burns its one-time code).

Request id#

Every response carries an X-Request-Id header, generated by the API and never taken from a client. The same id is written in the project's request log, which is how you find a failed call again — from the dashboard's Logs screen or with get_logs. The id is also in the body of every 5xx. A preflight response carries no request id.

CORS#

Both hosts serve the whole /v1 surface to browsers from any origin:

HeaderValue
Access-Control-Allow-Origin*
Access-Control-Allow-MethodsGET, POST, PATCH, DELETE, OPTIONS
Access-Control-Allow-Headersauthorization, x-session, content-type, idempotency-key
Access-Control-Expose-Headersx-request-id, retry-after, x-idempotent-replay, content-length
Access-Control-Max-Age86400

Access-Control-Allow-Credentials is never sent, and x-account-session is not an allowed header: the human session of the account never travels from a browser to the API. A preflight is answered with 204 before any key check. Because a pk_ is shared by every browser of an app, the 300 requests per minute of a key are a common bucket for all of them.

Sizes and rates#

The size, rate and quota limits that apply to every request are listed once, in Limits and quotas.