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.
| Plane | Host | Key | What lives there |
|---|---|---|---|
| Management | https://api.manybases.com/v1/… | ak_ (account key) | Projects, usage, project users, keys, SQL and logs of a project, edge functions, sites, GitHub links, secrets |
| Data | https://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 itThe API derives one identity from them, and the access presets decide with that identity:
| Identity | How | What it is |
|---|---|---|
anon | pk_ without X-Session | A visitor with no account (a public page, a form) |
user | pk_ + a valid X-Session | A signed-in end user of the app |
sk | an sk_ key | The app's own server code — every right on its project, the access matrix does not apply |
ak | an ak_ key | An 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.
{ "data": [ ... ], "cursor": "opaque-string-or-null" } // lists
{ "data": { ... } } // a single objectA 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.
{ "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:
| Operator | Meaning | Example |
|---|---|---|
eq / neq | equal / not equal | ?status=eq.active |
gt / gte | greater than / or equal | ?score=gte.100 |
lt / lte | less than / or equal | ?date=lt.2026-08-01 |
like | pattern, * is the wildcard, case-insensitive | ?name=like.*jo* |
in | in the list | ?status=in.(todo,doing) |
is | null / 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.ascororder=column.desc. Defaultcreated_at.desc; one sort column. The request log sorts byts.descby default because its entries have nocreated_at. - Pagination is cursor-based and mandatory:
limitdefaults to 50 and is capped at 200 — a larger value is refused with400 validation_error, never silently truncated. The response carriescursor; pass it back unchanged as?cursor=….cursor: nullmeans the last page. There is no offset. order,limitandcursorare 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.
| Column | Type | Rule |
|---|---|---|
id | TEXT (ULID) | Generated by the API, sortable by creation time. Never supplied by the client. |
owner_id | TEXT | Set 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_id | TEXT | Supplied by the client at creation only, on a spaces preset — the API checks membership. Immutable afterwards. |
created_at / updated_at | TEXT, ISO 8601 UTC | Automatic. |
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,408and401are re-evaluated on every call. A428is 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 receives409, never the first response. The same de-duplicate-only regime applies to responses that carry a secret, such as the201of 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:
| Header | Value |
|---|---|
Access-Control-Allow-Origin | * |
Access-Control-Allow-Methods | GET, POST, PATCH, DELETE, OPTIONS |
Access-Control-Allow-Headers | authorization, x-session, content-type, idempotency-key |
Access-Control-Expose-Headers | x-request-id, retry-after, x-idempotent-replay, content-length |
Access-Control-Max-Age | 86400 |
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.
