Browse documentation

Motherbase exposes core platform endpoints and dynamic REST routes for exposed database tables.

Swagger covers the core API surface. Dynamic /rest/v1/:model routes depend on the tables discovered in each project.

Authentication

Auth is powered by Better Auth. Most endpoints require the session token in the Authorization header:

Authorization: Bearer <session-token>

The server verifies the session, then reloads the full user record by id — role and status are always live, never trusted from the token. The SDK attaches the token and revalidates the session on 401. See Auth for the full method and plugin surface.

Running as another user

x-motherbase-impersonate: <user-id> | anon

Runs the request as that user, or as a signed-out visitor. It exists so a row level security policy can actually be asserted: proving a policy denies what it claims to deny means issuing the same query as several callers, and without this that means creating real users and signing in as each of them inside a test.

Gated on the secret key, which already bypasses RLS, so the header can only ever lower a caller's privilege. Presented with anything else it is refused with 403 rather than ignored — a test that silently ran as service_role instead of the user it named would pass while proving nothing. Nothing derived from a user's own token or from a request body can set it.

The SDK wrapper is createTestClient.


Auth endpoints

All auth routes are served by Better Auth under the /api/auth base path. Core routes:

GET  /api/auth/get-session           # current session + user
POST /api/auth/sign-up/email         # email + password account
POST /api/auth/sign-in/email         # sign in with email + password
POST /api/auth/sign-out              # end the session
 
GET  /api/auth/token                 # mint a signed JWT for the session
GET  /api/auth/jwks                  # JWKS to verify issued JWTs

Plugin routes are mounted under the same base path when their setting group is enabled — for example /api/auth/magic-link/*, /api/auth/email-otp/*, /api/auth/two-factor/*, /api/auth/passkey/*, /api/auth/organization/*, /api/auth/api-key/*, and /api/auth/sso/*.

Sign-in / sign-up response

{
  "token": "<session-token>",
  "user": {
    "id": "cuid",
    "email": "user@example.com",
    "name": "alice",
    "role": "USER",
    "status": "active"
  }
}

Send token back as Authorization: Bearer <token>. The user role is resolved server-side from the live record on every protected request.

OAuth callback URLs

Provider kindSetting groupCallback URL
Social (Google, Apple, …)auth.social/api/auth/callback/<provider>
Custom OIDC / OAuth2auth.oauth.custom/api/auth/oauth2/callback/<providerId>
Per-organization SSOauth.sso/api/auth/sso/callback/<providerId>

Providers are assembled only when enabled and configured. GET /api/admin/auth/status — admin-only — reports per-provider readiness (enabled, configured, missing, callbackUrl). See Auth for setup.


REST table endpoints

Dynamic tables and eligible internal models are exposed as REST table routes. Replace :model with a table/model name such as todo, post, or product.

GET    /rest/v1/:model           # list records
HEAD   /rest/v1/:model           # count only, no rows read
POST   /rest/v1/:model           # create record
PATCH  /rest/v1/:model           # update records matching filters
DELETE /rest/v1/:model           # delete records matching filters
POST   /rest/v1/batch            # several writes in one transaction, all or nothing

Only the public schema is reachable. Every Motherbase-managed table lives in auth, storage or platform, so system tables are unreachable by construction; a name-based blocklist (session, account, verification, jwks, app_settings, edge_function_secrets, schema_migrations, realtime_outbox) is the second gate in case one of those names is recreated in public. Views and materialized views are also excluded — they cannot carry row-level security. See REST engine.

RLS defaults

Mental model: RLS is the REST authorization layer.

Motherbase maps requests to PostgreSQL roles:

RoleMeaning
anonNo valid bearer token
authenticatedValid bearer token

Tables created from Project Studio SQL/Table editor have RLS enabled automatically. If RLS is enabled and there is no matching policy, anon and authenticated callers see no rows and writes fail.

If RLS is disabled, REST can access the whole table because the anon and authenticated data-plane roles have broad table grants. Keep RLS on for public app tables.

PATCH and DELETE require filters for non-admin callers. Unfiltered bulk mutations are rejected.

RLS policies required per method

# Needs a SELECT policy for anon or authenticated
GET /rest/v1/todo?done=eq.false&order=created_at.desc&limit=20
 
# Needs an INSERT policy with WITH CHECK
POST /rest/v1/todo
Content-Type: application/json
 
{ "title": "Buy milk", "done": false }
 
# Needs SELECT + UPDATE policies and a filter unless caller is admin
PATCH /rest/v1/todo?id=eq.2df3...
Content-Type: application/json
 
{ "done": true }
 
# Needs a DELETE policy and a filter unless caller is admin
DELETE /rest/v1/todo?id=eq.2df3...

Common owner policies:

CREATE POLICY "todo owner read"
ON todo FOR SELECT
TO authenticated
USING (auth.uid() IS NOT NULL AND auth.uid() = user_id);
 
CREATE POLICY "todo owner write"
ON todo FOR ALL
TO authenticated
USING (auth.uid() IS NOT NULL AND auth.uid() = user_id)
WITH CHECK (auth.uid() IS NOT NULL AND auth.uid() = user_id);

See RLS for the full SQL Editor workflow.

Query parameters

Reserved parameters (everything else is treated as a filter on a column):

ParameterDescription
selectColumns to return, with embedding, aliases (alias:column) and casts (qty::text)
orderfield.asc / field.desc, comma-separated; .nullsfirst / .nullslast supported
limitMax records. Omitted, the server returns up to the api.rest → maxRows setting (1000 by default), which is one number governing both the default and the ceiling. The parser caps that setting at 1000
offsetPagination offset
on_conflictUpsert conflict target
columnsRestrict the columns an insert reads from the body
<field>Filter: ?field=op.value

Filter operators

OperatorMeaning
eq, neqEqual / not equal
gt, gte, lt, lteComparisons
like, ilikeLIKE / ILIKE
match, imatchPOSIX regex ~ / ~*
in?id=in.(1,2,3); quoted values may contain commas: in.("a,b",c)
is?deleted_at=is.null — the only way to reach SQL NULL
isdistinctIS DISTINCT FROM
fts, plfts, phfts, wftsFull-text search (to_tsquery, plainto_, phraseto_, websearch_to_), optional config: ?body=fts(english).cat
cs, cd, ovContains / contained by / overlaps (arrays, ranges)
sl, sr, nxr, nxl, adjRange: strictly left/right, not extending right/left, adjacent

Prefix any operator with not. to negate it: ?status=not.eq.archived. Modifiers any and all are supported where PostgREST supports them.

Values are sent to Postgres untyped and coerced from the operand's type, exactly as in PostgREST: eq.42 matches a text column containing "42", and eq.null matches the literal string null.

JSON columns can be traversed in both filters and select: ?data->>name=eq.alice, ?data->0->>id=eq.7. ->> yields text, -> yields JSON.

Logic trees

GET /rest/v1/todos?or=(done.eq.false,and(priority.gte.3,owner_id.eq.me))
GET /rest/v1/todos?not.and=(done.eq.true,archived.eq.true)

and(...) and or(...) nest arbitrarily, and not. composes with both.

Embedding

GET /rest/v1/posts?select=id,title,author(id,name)
GET /rest/v1/posts?select=*,comments!inner(body)
GET /rest/v1/posts?select=*,author!left(name)
GET /rest/v1/posts?select=*,author!fk_posts_author(name)

!inner / !left choose the join type; any other !hint names the relationship to disambiguate multiple foreign keys between the same pair of tables. Foreign keys are known in both directions, so parent → child and child → parent both embed. An embedded relation passes the same exposure checks as a top-level one.

Prefer, Range and Content-Range

HeaderValues
Prefer: count=exact, planned, estimated — populates the total in Content-Range
Prefer: return=representation, minimal, headers-only. Default: minimal on a table write, representation on /rpc/ and /batch
Prefer: resolution=merge-duplicates, ignore-duplicates — turns an insert into an upsert
Prefer: params=single-objectPass one JSON object as a single /rpc/ argument
Prefer: max-affected=NRefuse a write that would touch more than N rows, and roll it back. What the SDK's .maxAffected(n) sends
Prefer: tx=rollbackRun the write, report what it would have done, roll the transaction back. A dry run; the SDK's .rollback()
RangeRows, not bytes, and inclusive: Range: 0-9 is ten rows. Applied only when limit/offset are absent
Content-RangeReturned on every collection response: 0-9/57, 0-9/* when no count was asked for
Range-UnitAlways items on a collection response

An unknown preference name is ignored (RFC 7240 makes preferences advisory); an unknown value for a preference the server implements is rejected with 400 PGRST122, so count=aproximate fails loudly instead of returning a plausible wrong answer.

A table write (POST/PATCH/DELETE /rest/v1/:model) defaults to return=minimal and answers with no rows, as PostgREST does — ask for them with Prefer: return=representation, which is what the SDK's .select() sends. /rpc/:fn and /batch default to representation instead, because there the result is the whole point of the call.

Response encodings

The Accept header picks the representation. Anything unrecognised falls back to JSON.

AcceptResult
(default)JSON array of rows
application/vnd.pgrst.object+jsonExactly one row as a bare object, or 406 PGRST116 for zero or many
text/csvRFC 4180 CSV; header row is the union of the keys in first-seen order, JSON columns are rendered as their JSON text
application/vnd.pgrst.planEXPLAIN output. Admin callers only — it leaks row estimates and index names for rows the caller cannot read. Anyone else gets 403 PGRST103

Status codes

MethodSuccess
GET200
HEAD200, no body — the row query is skipped entirely, only the count runs
POST201
PATCH, DELETE200

Batch writes

POST /rest/v1/batch

Several writes, one Postgres transaction, all or nothing. There is no PostgREST or supabase-js equivalent; the usual workaround is a sequence of awaited writes inside a try, which is not a rollback — whatever already committed stays committed.

{
  "operations": [
    { "method": "POST",   "model": "orders", "body": { "id": 7, "total": 40 } },
    { "method": "PATCH",  "model": "stock",  "query": { "sku": "eq.A1" }, "body": { "count": 4 } },
    { "method": "DELETE", "model": "cart",   "query": { "user_id": "eq.42" } }
  ]
}

query carries the same filters as the single-row routes, plus on_conflict for an upsert. The response is { "status": 1, "message": "OK", "data": { "results": [...] } } with one result array per operation, in the order they were declared.

Rules:

  • Writes only. POST, PATCH, DELETE. A read is refused with 400, because rows returned mid-batch would invite ordering guarantees nothing can make.
  • 100 operations maximum. A batch holds one transaction open for its whole run, which is how long every other writer waits on the rows it touches.
  • One caller for the whole batch. Keys and impersonation are resolved per request, so two operations can never run as different users.
  • A failure anywhere rolls back every statement and every realtime outbox row written alongside them, so nothing is announced that did not happen.

The Prefer header applies to the batch as a whole. In the SDK this is client.transaction().

Functions (RPC)

POST /rest/v1/rpc/my_function      # arguments in the JSON body
GET  /rest/v1/rpc/my_function?a=1  # read-only form, arguments as query params
HEAD /rest/v1/rpc/my_function      # count only

Overloads resolve by argument names, as in PostgREST: every supplied name must be an input argument, and every argument without a default must be supplied. VARIADIC-tail overloads are not reachable over REST. An unmatched call returns 404 PGRST202.

Error codes

CodeStatusMeaning
PGRST100400Malformed query string
PGRST103403 / 416EXPLAIN requested by a non-admin caller, or an inverted Range header
PGRST116406application/vnd.pgrst.object+json asked for exactly one row and got zero or many
PGRST122400Invalid Prefer value
PGRST202404No function matched the supplied argument names

Database failures are mapped separately, by SQLSTATE — an RLS denial surfaces as 403 with a fixed sentence. See the error reference.

Engine internals: REST engine.


Storage endpoints

Authenticated user file endpoints:

POST   /api/files/                     # upload file, multipart/form-data field "file"
POST   /api/files/presign              # get a presigned PUT URL, S3 driver only
POST   /api/files/:id/finalize         # confirm a presigned upload landed
GET    /api/files/:id/url              # get a download URL, ?expiresIn=<seconds>
GET    /api/files/:id/raw              # stream a file
DELETE /api/files/:id                  # delete a file

The same objects are also addressable by bucket and key, for callers that track storage paths rather than file ids:

GET    /api/files/list/:bucket         # list objects in a bucket
GET    /api/files/object/:bucket/*     # stream by key
GET    /api/files/object-url/:bucket/* # download URL by key, ?expiresIn=<seconds>
GET    /api/files/object-info/:bucket/* # object metadata, no body
DELETE /api/files/object/:bucket/*     # delete by key
 
POST   /api/files/copy                 # { bucket, from, to, upsert? }
POST   /api/files/move                 # { bucket, from, to, upsert? }

copy and move take both keys in the body rather than the URL: a destination key contains slashes too, and two wildcards in one route cannot be told apart.

Bucket public/token endpoints:

POST   /api/storage/:slug/upload     # bucket-token upload, requires write token
GET    /api/storage/:slug/files/:id  # public bucket read, or read token for private buckets
DELETE /api/storage/:slug/files/:id  # requires delete token

/api/files/* routes require a signed-in user. Public bucket reads go through /api/storage/:slug/files/:id.

Upload

POST /api/files/
Authorization: Bearer <token>
Content-Type: multipart/form-data
 
file=<binary>
visibility=public        # optional: public | private
bucket=avatars           # optional logical bucket slug

When bucket is provided, Motherbase stores the file under that logical bucket, applies bucket visibility and quota rules, and prefixes S3 keys with <bucket-slug>/. Legacy uploads without bucket keep the visibility-based fallback.

Admin buckets

GET    /api/admin/storage/buckets
POST   /api/admin/storage/buckets
GET    /api/admin/storage/buckets/:slug
PATCH  /api/admin/storage/buckets/:slug
DELETE /api/admin/storage/buckets/:slug?force=true
GET    /api/admin/storage/buckets/:slug/files

Bucket CORS, access tokens, and webhooks are managed under /api/admin/storage/buckets/:slug/*. See Storage for the full bucket workflow.

Presign

POST /api/files/presign
Authorization: Bearer <token>
Content-Type: application/json
 
{
  "filename": "photo.jpg",
  "size": 184320,
  "mime": "image/jpeg",
  "visibility": "private",
  "bucket": "avatars"
}

Returns { "file": { ... }, "uploadUrl": "https://s3.example.com/...?X-Amz-Signature=..." } inside the standard data envelope.

Presigning creates a reservation, not a finished file. The full flow is three steps:

  1. POST /api/files/presign → a reservation row plus a signed PUT URL.
  2. PUT the bytes straight to the returned uploadUrl. The server is not in this hop.
  3. POST /api/files/:id/finalize → the server HEADs the object to confirm it landed, flips the reservation to a real file, and fires the bucket's uploaded webhook.

A reservation that is never finalized is swept by the expired-reservation job, which deletes the orphaned object. Skip step 3 and the file never becomes readable.

Warning

Presigned uploads require STORAGE_DRIVER=s3. With the default local driver the endpoint returns 400 Presigned uploads are not supported with the local storage driver — upload through POST /api/files instead. See storage pipeline.


Edge functions

POST /api/edge/:route
Content-Type: application/json
 
{ ...payload }

:route is configured in Project Studio → Edge Functions. Anonymous invocation requires EDGE_PUBLIC_INVOKE_ENABLED=true. Otherwise send a bearer token or x-motherbase-edge-token.


User endpoints

Require authentication.

PUT    /api/users/username
PUT    /api/users/image
DELETE /api/users/

Device / push notification endpoints

POST   /api/devices/             # register a push token   — any signed-in user
DELETE /api/devices/             # unregister a push token — any signed-in user
 
POST   /api/notifications/send   # send to other users     — ADMIN role only
POST   /api/notifications/test   # send to yourself        — any signed-in user

/api/notifications/send is guarded by requireRole(ADMIN), not just by a session: fan-out to arbitrary users is an operator action. Use /test from an app account.


Supabase import

Admin-only endpoint used by the CLI migration command.

POST /api/admin/import/supabase
Content-Type: application/json

Six sections, three credentials. The connection string alone carries the public schema, table data, RLS policies, email/password users and the bucket catalog. A service_role key adds the storage objects' bytes. A Supabase Management API token (sbp_…) adds edge functions and project secrets.

Dry-run preview:

{
  "connectionString": "postgresql://...",
  "serviceRoleKey": "eyJ...",
  "managementToken": "sbp_...",
  "include": {
    "schema": true, "data": true, "auth": true,
    "storage": true, "edgeFunctions": true, "secrets": true
  },
  "preview": true
}

Import: drop preview and send "confirm": "IMPORT" instead.

Optional projectRef overrides the ref derived from the connection string, and projectUrl overrides https://<ref>.supabase.co for a self-hosted Supabase.

The preview answers with tables, policies, authUsers, buckets, edgeFunctions, secrets and notes. The real run answers with a per-section summary and a status of success, partial or failed.

Every section is independent: a missing credential downgrades that section to a note rather than failing the import, and a section that fails never takes another down. None of the three credentials is logged, written to disk, or echoed back in a summary, a note or an error.

See Migrate from Supabase.


Admin routes

Admin routes under /api/admin/* are for Platform Console and Project Studio. They are not client-app APIs and may be blocked publicly in production by Traefik or an IP allowlist.


Health

GET /api/health-check

Returns 200 with { "message": "Server is running!", "status": 200, "db": "connected" } when the API and database are both reachable, and 503 with { "message": "Database unavailable", "status": 503, "db": "disconnected" } when the SELECT 1 probe fails. It is unauthenticated, so it works as a load-balancer probe as-is.


OpenAPI

Swagger UI is available at /api/docs. It covers core endpoints; dynamic REST table routes depend on project tables and policies. See OpenAPI spec for what the document contains and how to regenerate it.

npm run swagger