API Reference

All endpoints live under /api/v1. Responses are JSON. Authentication uses the Authorization: Bearer <token> header.

This reference covers the data/schema/deploy API — what you'd use to build or integrate with a project yourself. SupaBein's own in-dashboard AI chat builder is a separate, internal system (its endpoints aren't part of this public surface and aren't documented here) — if you're building externally, use the endpoints below rather than trying to reverse-engineer the dashboard's own network calls.

Quick Start

Three steps to your first API call:

  1. Sign up → get a JWT token (this creates a SupaBein operator account — the developer building the app, not one of your app's own end-users. There's no equivalent "signup"/"login" pair for your app's end-users — see the callout below.)
  2. Create a project → note the service_key
  3. Create a table → call the data API (no auth needed for public/anon access)
Building end-user auth for your own app (not this operator account)? There's no /auth/signup-style pair for that — it's table-based: create a table with exactly one PASSWORD-type column, "sign up" is a normal Data API insert into it (SupaBein hashes the password server-side), and "sign in" is POST .../login against that same table.
# 1. Sign up
curl -X POST "https://your-domain/api/v1/auth/signup" \
  -H "Content-Type: application/json" \
  -d '{"email": "you@example.com", "password": "yourpassword"}'
# → {"token": "eyJ..."}

# 2. Use the token to create a project
curl -X POST "https://your-domain/api/v1/projects" \
  -H "Authorization: Bearer eyJ..." \
  -H "Content-Type: application/json" \
  -d '{"name": "My App"}'
# → {"id": 1, "name": "My App", "service_key": "eyJ..."}

# 3. Query your table (no auth header = anon role; subject to table policies)
curl "https://your-domain/api/v1/data/1/users"

API Keys

Each project has a service_key, visible in the dashboard's API tab. Unauthenticated (anon) requests are made without any auth header at all — they are subject to the table's row-level security policies.

Key / tokenUse whenPolicies
service_keyTrusted server-side code onlyBypassed entirely
(no header)Public/anonymous frontend accessEnforced (anon role)
user JWT / PATAuthenticated platform operatorsEnforced (authenticated role)
⚠ Never expose the service_key in client-side or publicly accessible code. Rotate it via POST /api/v1/projects/:id/rotate-service-key if compromised.

A Personal Access Token (PAT) acts as your login credential for the control plane (managing projects, tables, etc.) — useful in CI/CD. Create them in Account. A PAT can optionally be scoped to a single project instead of your whole account — see Create a PAT.

Limitations

SupaBein is a constrained, whitelist-based layer over MySQL, not a general SQL front-end. This is deliberate — it's what makes it safe to generate an API and access-control model automatically. Design within these limits rather than working around them; there is no endpoint that accepts arbitrary SQL.

Notes for Automated Builders

If an AI or automated tool is building or editing a project against this API, these operating rules avoid the most common mistakes:

Platform Authentication

These endpoints are for SupaBein operators (developers who manage projects, tables, and deployments). To authenticate your app's own end-users within a project table, use the data API login endpoint.

POST /api/v1/auth/signup Create an account
Body fieldTypeDescription
emailstringValid email address
passwordstringMinimum 8 characters
curl -X POST "https://your-domain/api/v1/auth/signup" \
  -H "Content-Type: application/json" \
  -d '{"email": "you@example.com", "password": "yourpassword"}'

Returns {"token": "eyJ..."} — a JWT valid for the duration configured in your secrets.php (JWT_TTL).

POST /api/v1/auth/login Sign in, get a JWT
curl -X POST "https://your-domain/api/v1/auth/login" \
  -H "Content-Type: application/json" \
  -d '{"email": "you@example.com", "password": "yourpassword"}'
GET /api/v1/auth/me Get current user profile

Requires a valid user JWT or PAT. Returns id, email, role, created_at, project_id, and project_name.

curl "https://your-domain/api/v1/auth/me" \
  -H "Authorization: Bearer YOUR_TOKEN"
For an account-wide token, project_id is null. For a project-scoped PAT, this is the one endpoint that works without already knowing the project id — project_id/project_name tell you which project the token belongs to, so a scoped token never has to brute-force project ids to find out where it's supposed to work.
PATCH /api/v1/auth/password Change your password (requires auth)
Body fieldTypeDescription
current_passwordstringYour current password
new_passwordstringNew password (min 8 characters)
curl -X PATCH "https://your-domain/api/v1/auth/password" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"current_password": "old", "new_password": "new-password"}'
POST /api/v1/auth/forgot Generate a password-reset token (no auth required)

Returns a raw token in the response. Deliver it to the user via your own email flow. Token expires in 1 hour. Always returns the same shape to prevent email enumeration.

curl -X POST "https://your-domain/api/v1/auth/forgot" \
  -H "Content-Type: application/json" \
  -d '{"email": "you@example.com"}'

Response: {"message": "...", "token": "abc123...", "expires_in": 3600}

POST /api/v1/auth/reset Reset password with a token
Body fieldTypeDescription
tokenstringThe raw token from /auth/forgot
passwordstringNew password (min 8 characters)
curl -X POST "https://your-domain/api/v1/auth/reset" \
  -H "Content-Type: application/json" \
  -d '{"token": "abc123...", "password": "new-password"}'

On success returns a fresh JWT: {"message": "Password updated successfully.", "token": "eyJ..."}

Requires the user_reset_tokens table — see migration SQL below or in catalog_schema.sql.

Projects

GET /api/v1/projects List your projects
curl "https://your-domain/api/v1/projects" \
  -H "Authorization: Bearer YOUR_TOKEN"
POST /api/v1/projects Create a project
curl -X POST "https://your-domain/api/v1/projects" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "My App"}'

Response includes the new project's id, name, and service_key.

DELETE /api/v1/projects/:id Delete a project and all its data

Drops all physical MySQL tables, removes all site directories and storage files from disk, then deletes the project row (cascades to all catalog rows).

curl -X DELETE "https://your-domain/api/v1/projects/1" \
  -H "Authorization: Bearer YOUR_TOKEN"
POST /api/v1/projects/:id/rotate-service-key Issue a new service key (invalidates old one immediately)

Generates a new service_key JWT and stores it in the database. The previous service_key stops working as soon as this is called — update any consumers before rotating.

curl -X POST "https://your-domain/api/v1/projects/1/rotate-service-key" \
  -H "Authorization: Bearer YOUR_TOKEN"

Returns {"service_key": "eyJ..."}.

GET /api/v1/overview · /api/v1/projects/:id/overview Summary stats — across all projects, or one

GET /api/v1/overview summarizes every project you own (table counts, live sites). GET /api/v1/projects/:id/overview gives one project's tables, sites, and a recent-activity feed (deploys, project creation). Useful for a quick "what's already here" check before editing an existing project.

curl "https://your-domain/api/v1/projects/1/overview" \
  -H "Authorization: Bearer YOUR_TOKEN"
POST /api/v1/projects/:id/seed/clear Delete all AI-generated seed/demo rows

Removes only the rows that were inserted as seed/demo data (tracked separately from rows created afterward through normal use) — for clearing placeholder content before real users start using the app, without touching anything they've since added.

curl -X POST "https://your-domain/api/v1/projects/1/seed/clear" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
POST /api/v1/projects/:id/cleanup Reconcile the table catalog against actual MySQL state

Maintenance operation — drops orphaned physical tables no longer in the catalog, removes catalog rows for tables that no longer exist in MySQL, and clears stale deploy/directory records. Not something you'd normally need in regular use; useful if something got out of sync (e.g. a failed operation left partial state).

curl -X POST "https://your-domain/api/v1/projects/1/cleanup" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'

Tables

GET /api/v1/projects/:id/tables List tables in a project
curl "https://your-domain/api/v1/projects/1/tables" \
  -H "Authorization: Bearer YOUR_TOKEN"
POST /api/v1/projects/:id/tables Create a table
curl -X POST "https://your-domain/api/v1/projects/1/tables" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "users"}'

Table names must match ^[a-zA-Z_][a-zA-Z0-9_]{0,63}$. The physical MySQL table is named p{project_id}_{name}.

Columns

Supported data types: INT BIGINT VARCHAR(255) TEXT BOOLEAN DECIMAL(10,2) DATETIME DATE TIMESTAMP JSON FLOAT PASSWORD

PASSWORD columns are stored as bcrypt hashes. On read the value is always returned as null. Write a plaintext value and it is hashed automatically before saving. Use the data login endpoint to verify credentials.

POST /api/v1/projects/:id/tables/:name/columns Add a column
FieldTypeDescription
namestringColumn name
typestringOne of the supported data types
nullableboolAllow NULL values (default: true)
defaultstring | boolOptional default value. For BOOLEAN columns, true/false are accepted and coerced to 1/0 automatically.
referencesstringOptional: a logical table name in this project to foreign-key this column to (see below). If omitted, a real foreign key is still added automatically whenever the column name matches a table via the *_id convention (e.g. user_id → a user/users table).
uniqueboolAdds a real MySQL UNIQUE index (default: false). A conflicting insert/update on this column is rejected with 409 Conflict instead of silently succeeding — enforced at the database level, not just a client-side hint.
curl -X POST "https://your-domain/api/v1/projects/1/tables/users/columns" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "email", "type": "VARCHAR(255)", "nullable": false, "unique": true}'
Foreign keys: a column is automatically given a real MySQL FOREIGN KEY ... ON DELETE CASCADE constraint whenever its name matches the *_id convention against an existing table in the same project — curl -d '{"name":"user_id","type":"INT"}' against a table that has a users table in the same project is enough, no extra field needed. Use references to be explicit, or when the naming convention doesn't apply (e.g. author_id pointing at a users table): -d '{"name":"author_id","type":"INT","references":"users"}'. Either way the column is stored as INT UNSIGNED (required to match every table's id primary key) and deleting a referenced row cascades — deleting a row also deletes everything that points at it, so a project's data stays consistent instead of accumulating orphaned rows.
Strongly recommended for any login-identifier column (e.g. the column a /login endpoint matches against): without unique: true, nothing stops two rows from sharing the same email — and /login always matches the first one, so a duplicate signup (a retried request, a double-submit, a network blip between insert and login) silently creates a row nobody can ever log into again. Adding unique: true to an existing column with duplicate values already in it fails with 409 until the duplicates are cleaned up first.

Policies (Row-Level Security)

Policies control which API roles can perform which operations on a table.

PUT /api/v1/projects/:id/tables/:name/policies Create or update a policy (single or batch)

Three formats accepted: a shorthand object (fewest keystrokes), a JSON array of explicit objects, or a single explicit object.

## Shorthand (recommended) — list allowed ops, the rest are auto-denied
curl -X PUT "https://your-domain/api/v1/projects/1/tables/posts/policies" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '[
    { "api_role": "anon",          "allow": ["SELECT"] },
    { "api_role": "authenticated", "allow": ["SELECT", "INSERT", "UPDATE", "DELETE"] }
  ]'

## Explicit array — full control over each operation
curl -X PUT "https://your-domain/api/v1/projects/1/tables/posts/policies" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '[
    { "api_role": "anon",          "operation": "SELECT", "allowed": true  },
    { "api_role": "anon",          "operation": "INSERT", "allowed": false },
    { "api_role": "authenticated", "operation": "SELECT", "allowed": true  },
    { "api_role": "authenticated", "operation": "INSERT", "allowed": true  }
  ]'

## Single object — one policy per call
curl -X PUT "https://your-domain/api/v1/projects/1/tables/posts/policies" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "api_role": "anon", "operation": "SELECT", "allowed": true }'

constraint_sql is an optional WHERE clause appended to queries. Use :current_user_id as a placeholder for the authenticated user's ID (e.g. "user_id = :current_user_id"). Batch/shorthand response: {"updated": N, "policies": [...]}.

Data API

The data API lets you read and write rows in your tables. Requests with no Authorization header are treated as anon role. Operations are subject to the table's row-level security policies.

The project owner's JWT and the service_key both bypass all policies. Use the service_key only in trusted server-side code.
GET /api/v1/data/:project_id/:table List rows
Query paramDefaultDescription
limit20Max rows to return (cap: 1000)
offset0Skip N rows (pagination)
col=value—Exact-match filter (shorthand for eq), e.g. ?status=active
col=op.value—Filter with operator: eq neq gt gte lt lte like — e.g. ?age=gte.18 or ?name=like.Alice%25
order=col.dirid DESCSort by column: ?order=name.asc or multiple: ?order=age.desc,name.asc

IDs in responses are numbers (integers), not strings.

The list response is a paginated envelope — always unwrap .data in client code:

# Anonymous (no auth header — subject to anon policy)
curl "https://your-domain/api/v1/data/1/users?limit=20&offset=0&status=active"

# Authenticated (service_key bypasses all policies)
curl "https://your-domain/api/v1/data/1/users?limit=20&offset=0&status=active" \
  -H "Authorization: Bearer <service_key>"

Returns: {"data": [{...}, ...], "count": N, "limit": N, "offset": N} — read rows from .data.

POST /api/v1/data/:project_id/:table Insert a row
curl -X POST "https://your-domain/api/v1/data/1/users" \
  -H "Content-Type: application/json" \
  -d '{"name": "Alice", "email": "alice@example.com"}'

Only fields that match defined columns are accepted. Extra fields are silently ignored.

If the table has a unique: true column and this insert's value for it already exists on another row, the request fails with 409 Conflict instead of creating a duplicate — check for that status rather than assuming any non-2xx means the same thing a validation error would.

POST /api/v1/data/:project_id/:table/batch Bulk insert rows (up to 500)

Send a JSON array of objects. Each row is inserted individually and subject to the same INSERT policy as a single insert. Returns all inserted rows.

curl -X POST "https://your-domain/api/v1/data/1/skills/batch" \
  -H "Authorization: Bearer <service_key>" \
  -H "Content-Type: application/json" \
  -d '[
    {"name": "PHP",        "category": "Backend",  "level": 5},
    {"name": "JavaScript", "category": "Frontend", "level": 4},
    {"name": "MySQL",      "category": "Database", "level": 5}
  ]'

Returns: {"inserted": 3, "rows": [{...}, {...}, {...}]}. Max 500 rows per request.

GET /api/v1/data/:project_id/:table/:id Get a single row by primary key
curl "https://your-domain/api/v1/data/1/users/42"
PATCH /api/v1/data/:project_id/:table/:id Update a row (partial update)
curl -X PATCH "https://your-domain/api/v1/data/1/users/42" \
  -H "Authorization: Bearer <service_key>" \
  -H "Content-Type: application/json" \
  -d '{"name": "Alice Smith"}'
DELETE /api/v1/data/:project_id/:table/:id Delete a row
curl -X DELETE "https://your-domain/api/v1/data/1/users/42" \
  -H "Authorization: Bearer <service_key>"
POST /api/v1/data/:project_id/:table/login Authenticate a row via a PASSWORD column

The table must have exactly one PASSWORD-type column. Provide a column that identifies the user (e.g. email) and the plaintext password. If the credentials match, a project_user JWT (role: authenticated) is returned and can be passed as Authorization: Bearer in subsequent data API calls.

Body fieldDescription
<identifier_col>Any non-PASSWORD column to look up the row (e.g. email)
passwordPlaintext password to verify against the stored hash
curl -X POST "https://your-domain/api/v1/data/1/users/login" \
  -H "Content-Type: application/json" \
  -d '{"email": "alice@example.com", "password": "secret123"}'

Returns {"token": "eyJ...", "user": {...}} — user is the matched row (password field masked). The JWT carries sub (row id), pid (project id), type: "project_user" and auth: "password". Use it as Bearer auth in subsequent data API calls to get the authenticated role.

POST /api/v1/data/:project_id/:table/google Sign in with Google (an ID token from Google Identity Services)

"Continue with Google" for your app's end users. Your page gets an ID token from Google Identity Services (the credential in its callback) and posts it here. SupaBein verifies it itself (RS256 signature against Google's published keys, issuer, audience = one of your client_ids, not expired with 60 s clock-skew allowance, email_verified), then signs the person in to the matching row, or creates one, and returns the same token as /login. Policies, Functions (user, sb.auth.getUser()) and end-user storage all work unchanged. Turn it on first with PUT /projects/:id/auth/google.

curl -X POST "https://your-domain/api/v1/data/1/users/google" \
  -H "Content-Type: application/json" \
  -d '{"id_token": "eyJhbGciOiJSUzI1NiIs..."}'

Returns 200 {"token": "eyJ...", "user": {...}, "created": false}: user is the row (password masked) and created is true when this sign-in made a new account. The token's auth claim is "google"; a Function can read it with sb.auth.method().

What happens
MatchingBy the Google account (sub_column, if set), then by email ignoring case. A matched row is signed in; its password is never changed, so email + password login keeps working. If sub_column is set it is filled in on first Google sign-in, so the account still matches after the person changes their Google email. A row that has email_verified is marked verified (Google has just proven the address).
New accountWith auto_create: a row with the lowercased email, the mapped Google claims, your defaults, email_verified = true, the Google sub and provider_column = "google" if configured. The PASSWORD column gets a value no password can match, so there's no password login until they use /forgot + /reset. The table's insert triggers fire as usual.
401{"error": "Google sign-in could not be verified"}: bad or missing signature, wrong audience or issuer, expired, unverified email.
404{"error": "No account for this email"} when auto_create is off; Google sign-in is not enabled for this table when it isn't configured for this table.
409The email belongs to a row already linked to a different Google account (only with sub_column).
429Rate limits: the project's usual Data API limit, plus 30 attempts per minute per IP and 10 per minute per email.

The ID token is never stored or logged.

PUT /api/v1/projects/:id/auth/google Configure Sign in with Google (owner only)
curl -X PUT "https://your-domain/api/v1/projects/1/auth/google" \
  -H "Authorization: Bearer YOUR_TOKEN" -H "Content-Type: application/json" \
  -d '{
    "enabled": true,
    "client_ids": ["1234-abc.apps.googleusercontent.com"],
    "table": "users",
    "email_column": "email",
    "auto_create": true,
    "map": { "name": "name", "picture": "avatar_url" },
    "defaults": { "role": "learner", "onboarded": false },
    "sub_column": "google_sub",
    "provider_column": "auth_provider"
  }'
FieldDescription
enabledTurns the /google endpoint on or off
client_idsYour Google OAuth Web client ID(s): the accepted aud values. Required when enabled.
tableThe end-user table (it must have a PASSWORD column)
email_columnColumn holding the email (default email)
auto_createCreate a row when no account matches (default false)
mapGoogle claim → column, used only when creating. Claims: name given_name family_name picture locale email hd
defaultsExtra column values set when creating (strings, numbers, booleans, null)
sub_columnOptional (recommended) column storing the Google account id, so the account keeps matching if its email changes
provider_columnOptional column set to "google" on rows created by Google sign-in

Every column is checked against the table when you save (422 with the reason otherwise). GET on the same path returns the settings plus the endpoint to call. A project-scoped PAT can use both.

In the browser, the button from Google Identity Services gives you the token:

<script src="https://accounts.google.com/gsi/client" async></script>
<div id="g_id_onload" data-client_id="1234-abc.apps.googleusercontent.com" data-callback="onGoogle"></div>
<div class="g_id_signin" data-type="standard"></div>
<script>
  async function onGoogle({ credential }) {
    const res = await fetch('/api/v1/data/1/users/google', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ id_token: credential }),
    })
    const { token, user, created } = await res.json()   // store token like a /login token
  }
</script>
POST /api/v1/data/:project_id/:table/forgot Generate a password-reset token for a row in this table
curl -X POST "https://your-domain/api/v1/data/1/users/forgot" \
  -H "Content-Type: application/json" \
  -d '{"email": "alice@example.com"}'

Same identifier auto-detection as /login — whichever non-PASSWORD column you send becomes the lookup key. Always returns the same generic {"message": "..."} regardless of whether it matched a row, whether an auth email provider is registered, or whether sending succeeded — this is deliberate, it's what stops the endpoint being used to check which addresses have accounts.

The token isn't emailed unless you register an auth email provider for this project. Without one, this endpoint still generates and stores the token (so your own project can email it another way if you build that), but nothing is sent automatically.

POST /api/v1/data/:project_id/:table/reset Exchange a reset token for a new password
curl -X POST "https://your-domain/api/v1/data/1/users/reset" \
  -H "Content-Type: application/json" \
  -d '{"token": "<from the emailed link>", "password": "newSecret123"}'

Token expires 1 hour after /forgot generated it, and is single-use. On success, returns {"message": "...", "token": "eyJ..."} — the same project_user JWT shape as /login, so you can log the user straight in.

File Storage

Store files (images, documents, assets) per project inside named buckets. Files are served publicly via a read-only URL — no auth needed to fetch them. Uploading/listing/deleting requires either operator auth (JWT/PAT/service_key, full bucket access) or an end-user's project_user JWT (from a table's login endpoint) — see below.

Bucket names: 1–63 characters, lowercase letters/numbers/hyphens/underscores. Max file size: 100 MB by default, adjustable per bucket up to 2 GB (see size limits); files over a few MB — especially video — should use a resumable upload. Blocked extensions: .php .py .sh .exe .cgi .rb .pl .bat .cmd .htaccess
POST /api/v1/projects/:project_id/storage/:bucket Upload a file to a bucket

Send as multipart/form-data with field name file. The bucket is created automatically if it doesn't exist.

curl -X POST "https://your-domain/api/v1/projects/1/storage/avatars" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -F "file=@photo.jpg"

Returns {"name": "photo.jpg", "bucket": "avatars", "size": 24580, "url": "/api/v1/storage/1/avatars/photo.jpg"}

GET /api/v1/projects/:project_id/storage/:bucket List files in a bucket
curl "https://your-domain/api/v1/projects/1/storage/avatars" \
  -H "Authorization: Bearer YOUR_TOKEN"

Returns {"files": [{name, size, last_modified, url}, ...], "count": N}

DELETE /api/v1/projects/:project_id/storage/:bucket/:filename Delete a file
curl -X DELETE "https://your-domain/api/v1/projects/1/storage/avatars/photo.jpg" \
  -H "Authorization: Bearer YOUR_TOKEN"
GET /api/v1/storage/:project_id/:bucket/:filename Serve a file publicly (no auth)

Use the url value returned by upload or list. This URL is public — link to it directly from your frontend.

curl "https://your-domain/api/v1/storage/1/avatars/photo.jpg"

Returns the file with correct Content-Type (video, audio, images and PDFs are typed by extension so they play/display inline) and cache headers (Cache-Control: public, max-age=86400, ETag, Last-Modified — repeat visits get a cheap 304).

Video and audio stream properly: byte-range requests are supported (Accept-Ranges: bytes, 206 Partial Content, HEAD), so a plain <video src="…/lesson.mp4" controls preload="none"> can seek, iPhone Safari plays it, and a viewer who stops watching stops downloading. Media is sent in slices of at most 8 MB per request, which players fetch as they need them. This works the same on a site's own domain (/api/v1/storage/… is proxied through to SupaBein without buffering).

Files are served at the quality you uploaded — there's no automatic transcoding into lower-quality versions. For audiences on mobile data, upload a sensibly compressed file (e.g. 720p H.264 MP4 around 1–1.5 Mbps), keep preload="none" so nothing downloads until the viewer presses play, or use a dedicated video host with adaptive streaming.

Resumable uploads (large files, video)

A single-request upload of a big file over a mobile connection often fails halfway and has to start again. A resumable upload sends the file in chunks of up to 8 MB; if the connection drops, the client asks how much arrived and continues from there. The file only appears in the bucket once the last byte is in. Same auth as a normal upload (operator token, or an end-user token for a bucket with end-user uploads enabled — their file lands in their own scoped space as usual). An unfinished upload expires after 24 hours.

From a browser, use the helper — it does chunking, a progress bar, retries with back-off, and resume after a page reload:
<script src="https://your-domain/sdk/supabein-upload.js"></script>
<script>
  const file = await SupaBeinUpload.upload(input.files[0], {
    apiUrl: 'https://your-domain/api',      // omit on a site's own domain ('/api')
    projectId: 1, bucket: 'lessons', token: userToken,
    onProgress: (fraction) => bar.value = fraction,
  });
  video.src = file.url;                     // { name, bucket, size, url }
</script>
POST /api/v1/projects/:project_id/storage/:bucket/uploads Start a resumable upload
curl -X POST "https://your-domain/api/v1/projects/1/storage/lessons/uploads" \
  -H "Authorization: Bearer YOUR_TOKEN" -H "Content-Type: application/json" \
  -d '{"filename": "lesson-1.mp4", "size": 73400320}'

Returns 201 {"upload_id", "bucket", "name", "size", "received": 0, "chunk_size": 8388608, "complete": false, "expires_at"}. 413 if size is over the bucket's limit.

PUT /api/v1/projects/:project_id/storage/:bucket/uploads/:upload_id Send the next chunk

Raw bytes as the body (up to chunk_size), and the chunk's starting position in an Upload-Offset header — which must equal received. Returns the updated status; the chunk that completes the file returns 201 {"complete": true, "name", "bucket", "size", "url"}, like a normal upload.

curl -X PUT "https://your-domain/api/v1/projects/1/storage/lessons/uploads/UPLOAD_ID" \
  -H "Authorization: Bearer YOUR_TOKEN" -H "Content-Type: application/octet-stream" \
  -H "Upload-Offset: 0" --data-binary @chunk-0.bin

409 {"received": N} if the offset is wrong (continue from N); 413 if the chunk is too big.

GET /api/v1/projects/:project_id/storage/:bucket/uploads/:upload_id How much has arrived (to resume)

Returns the same status object; continue sending from received. DELETE on the same path abandons the upload. 404 once it has completed or expired.

Size limits per bucket

Every bucket allows files up to 100 MB unless you change it. Raise it for a video bucket (up to 2 GB, the server's ceiling), or lower it for, say, avatars — it applies to normal and resumable uploads alike:

curl -X PUT "https://your-domain/api/v1/projects/1/storage/lessons/policy" \
  -H "Authorization: Bearer YOUR_TOKEN" -H "Content-Type: application/json" \
  -d '{"max_file_size": 524288000}'     # 500 MB; null = back to the default

End-user uploads (profile pictures, product images, etc.)

A logged-in end-user — someone authenticated via a table's login endpoint, not the project owner — can upload/list/delete files too, once the project owner explicitly enables it per bucket. Every upload/list/delete an end-user makes is transparently confined to their own files only, computed server-side from their JWT (never from anything the client sends) — user A can never see, overwrite, or delete user B's files, even if they guess B's filenames. Public read (the serve endpoint above) is unaffected — anyone's uploaded avatar or product image is still publicly viewable once uploaded, exactly like operator-uploaded files.

Off by default. A bucket accepts only operator uploads until you explicitly opt it in — the same "unpolicied = locked" default as table policies. Enabling this for one bucket (e.g. avatars) has no effect on any other bucket.
PUT /api/v1/projects/:project_id/storage/:bucket/policy Enable (or disable) end-user uploads for one bucket — owner only
curl -X PUT "https://your-domain/api/v1/projects/1/storage/avatars/policy" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"authenticated_upload": true}'

Returns {"project_id", "bucket", "allow_authenticated_upload", "max_file_size", "effective_max_file_size"}. Settings you leave out keep their current value (authenticated_upload and max_file_size can be sent together or separately). There's a matching GET on the same path to check the current settings.

Once enabled, an end-user calls the exact same upload/list/delete endpoints above — just with their own project_user JWT instead of an operator token:

## The logged-in end-user's own upload -- lands in their own scoped space,
## automatically. No path or user id to pass -- it comes from their token.
curl -X POST "https://your-domain/api/v1/projects/1/storage/avatars" \
  -H "Authorization: Bearer END_USER_PROJECT_USER_JWT" \
  -F "file=@my-photo.jpg"
Rate limiting: The data API enforces a limit of 600 requests per minute per project (429 + Retry-After: 60 on excess). End-user (project_user) storage uploads/deletes have their own, tighter limit (60/minute per project; for resumable uploads only starting and cancelling count, not each chunk) — reachable by any signed-up visitor, not just the operator, so it needs a lower ceiling than real operator traffic. Operator storage calls, and auth endpoints, are not rate-limited.

Sites & Deploys

Staging-first rule: Every deploy (zip upload, file-by-file finalize) lands in staging — never directly live. Only call Publish to live when explicitly instructed to do so. Staging is safe to overwrite at any time; current/ is what end-users see.
GET /api/v1/projects/:id/sites List sites for a project
curl "https://your-domain/api/v1/projects/1/sites" \
  -H "Authorization: Bearer YOUR_TOKEN"

Returns an array of site objects including id, subdomain, spa_mode, current_deploy_id, and created_at. Each project supports one site.

GET /api/v1/projects/:id/sites/:site_id Get a single site
curl "https://your-domain/api/v1/projects/1/sites/1" \
  -H "Authorization: Bearer YOUR_TOKEN"
DELETE /api/v1/projects/:id/sites/:site_id Delete a site and all its deploys
curl -X DELETE "https://your-domain/api/v1/projects/1/sites/1" \
  -H "Authorization: Bearer YOUR_TOKEN"

Permanently deletes the site record and all associated deploy records. The deployed files on disk are also removed.

POST /api/v1/projects/:id/sites Create a site (one per project)
curl -X POST "https://your-domain/api/v1/projects/1/sites" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"subdomain": "myapp", "spa_mode": false}'

subdomain must be 2–63 lowercase alphanumeric characters or hyphens (e.g. "my-app"). name is accepted as an alias for subdomain. Set spa_mode: true for single-page apps — unknown paths will serve index.html instead of 404. Each project supports one site.

PATCH /api/v1/projects/:id/sites/:site_id Change a site's subdomain or set a custom domain
curl -X PATCH "https://your-domain/api/v1/projects/1/sites/1" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"subdomain": "new-name", "custom_domain": "www.example.com"}'

Both fields are optional — omit one to leave it unchanged. Send custom_domain: "" to remove a custom domain. Changing the subdomain does not free it up for anyone else to reuse until the change is saved. Also settable from the dashboard's project page, under the Domain tab.

This is separate from registering a hostname below — a site's own subdomain/custom_domain is the one canonical domain shown in the dashboard, whereas hostname registration lets a project claim any number of additional hostnames (e.g. one per end-user storefront in a multi-tenant app). Setting either field here automatically keeps the registration in sync — nothing else needs to be called for a site's own subdomain to start resolving.

Reserved names: around 120 common words (www, api, admin, shop, status, etc.) can't be claimed as a subdomain or hostname label, by a project's own subdomain or via the hostname API below — they're kept free for platform/first-party use. This only applies to labels under the platform's own domain; an external custom domain you actually own is never checked against this list.
POST /api/v1/projects/:id/hostnames Register an additional hostname for this project's site
curl -X POST "https://your-domain/api/v1/projects/1/hostnames" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"hostname": "joes-store.your-domain"}'

For apps that serve many end-user-facing sites from a single deploy — e.g. a store-builder project where every merchant gets their own storefront address. Register a hostname (a subdomain of the platform's own domain, or a fully external domain you control) and it immediately starts serving this project's current deploy, no redeploy needed.

The target is always this project's own current deploy — there is no way to point a hostname at another project's files, and no docroot/path parameter is accepted. One project can register any number of hostnames this way; there's no fixed limit.

Accepts a project_user token, not just an owner JWT or PAT — this is what makes fully automatic registration practical: call this endpoint using the end-user's own session token, right at signup/onboarding time (e.g. the moment a merchant finishes creating their store), and their subdomain goes live with no separate owner-level credential involved. A project-user token is only ever trusted for the one project it was issued from — it can't be used to register a hostname for, or otherwise touch, any other project.

Each hostname remembers which specific end-user registered it, not just which project — a second merchant's project_user token in the same app can't reassign or delete a hostname registered by a different merchant's token, even though both belong to the same project. A hostname registered by the project's own owner JWT or a PAT (e.g. the project's own default subdomain) can never be touched by any project_user at all, only by the owner. This exists specifically to stop one end-user from griefing another's storefront address — it's an availability protection, not a data one; your own row-level policies are still what protect the underlying business data.

Your app is responsible for reading window.location.hostname client-side and rendering the right content for it (e.g. looking up a merchant by slug) — registering the hostname only makes it resolve to your deploy, it doesn't change what your app renders.

DELETE /api/v1/projects/:id/hostnames/:hostname Unregister a hostname
curl -X DELETE "https://your-domain/api/v1/projects/1/hostnames/joes-store.your-domain" \
  -H "Authorization: Bearer YOUR_TOKEN"

An owner JWT or PAT can remove any hostname belonging to its project. A project_user token can only remove a hostname it registered itself — see the per-registrant note above. Do this whenever whatever the hostname pointed at (a merchant, a business, a tenant) is deleted or renamed — an unregistered hostname is freed up for anyone to claim.

GET /api/v1/projects/:id/sites/:site_id/deploys List deploy history
curl "https://your-domain/api/v1/projects/1/sites/1/deploys" \
  -H "Authorization: Bearer YOUR_TOKEN"

Returns deploys newest-first with id, version_label, status (pending / processing / ready / failed), size_bytes, and uploaded_at.

POST /api/v1/projects/:id/sites/:site_id/deploys Deploy a zip file

Upload a zip archive (multipart/form-data, field name zipfile, max 50 MB). The zip must not contain .php, .sh, or other server-executable files. If the zip has a single wrapper folder, it's automatically unwrapped.

Zip structure: files must be at the root of the zip, not inside a subfolder.
✓ correct: cd dist && zip -r ../deploy.zip .
✗ wrong: zip -r deploy.zip dist/ — creates a dist/ subfolder inside the zip and the site will 404.
cd dist && zip -r ../deploy.zip . && cd ..
curl -X POST "https://your-domain/api/v1/projects/1/sites/1/deploys" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -F "zipfile=@./deploy.zip" \
  -F "label=v1.0.0"

After a successful upload, the deploy lands in staging (not live yet).
Preview at: https://your-domain/sites/s{site_id}/staging/
Then call Publish to live to make it the active version.

POST /api/v1/projects/:id/sites/:site_id/deploys/:deploy_id/publish Promote staged deploy to live

Copies staging/ to current/, sets current_deploy_id, and clears staging_deploy_id on the site. The deploy must be the current staging deploy (its ID must match site.staging_deploy_id). Returns the updated site object including live_url and staging_url.

curl -X POST "https://your-domain/api/v1/projects/1/sites/1/deploys/5/publish" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'

Response includes live_url (the public URL) and staging_url for immediate use — no need to construct URLs manually.

⚠ Always send Content-Type: application/json and a body, even an empty {}, on POSTs with no real payload. Some hosting-level WAFs (this one included) flag a truly bodyless/content-type-less POST as suspicious and return a generic HTML 403 instead of a JSON error — indistinguishable from an auth failure unless you know to look for it.
POST /api/v1/projects/:id/sites/:site_id/deploys/:deploy_id/rollback Roll back to a previous deploy
curl -X POST "https://your-domain/api/v1/projects/1/sites/1/deploys/3/rollback" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'

File-by-File Deploy API

An alternative to zip uploads. Open a staging deploy, push individual files over HTTP, then finalize. Ideal for CI/CD pipelines and build tools.

# 1. Open a deploy (returns deploy_id)
DID=$(curl -sX POST "https://your-domain/api/v1/projects/1/sites/1/deploys/open" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"label":"v2.1.0"}' | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")

# 2. Upload changed files
for f in dist/**/*; do
  [[ -f "$f" ]] || continue
  REL="${f#dist/}"
  curl -sX POST "https://your-domain/api/v1/projects/1/sites/1/deploys/$DID/files?path=$REL" \
    -H "Authorization: Bearer YOUR_TOKEN" \
    --data-binary "@$f"
done

# 3. Finalize (go live)
curl -sX POST "https://your-domain/api/v1/projects/1/sites/1/deploys/$DID/finalize" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
POST /api/v1/projects/:id/sites/:site_id/deploys/open Open a new pending deploy

Creates an empty staging directory and returns a deploy record with status: "pending". Optionally accepts a label for version tracking. Use the returned id in subsequent file upload calls.

labelOptional version label (e.g. "v2.1.0"). Defaults to current timestamp.
curl -X POST "https://your-domain/api/v1/projects/1/sites/1/deploys/open" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"label":"v2.1.0"}'

Returns: { id, site_id, version_label, status: "pending", staged_dir, uploaded_at }

POST /api/v1/projects/:id/sites/:site_id/deploys/:deploy_id/files?path= Upload a single file into the staging deploy

Send the raw file bytes as the request body. The ?path= query parameter specifies where the file goes relative to the site root. Subdirectories are created automatically. Blocked extensions (.php, .sh, etc.) return 403.

curl -X POST "https://your-domain/api/v1/projects/1/sites/1/deploys/7/files?path=assets/app.js" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  --data-binary "@./dist/assets/app.js"

Returns: { path, size }

GET /api/v1/projects/:id/sites/:site_id/deploys/:deploy_id/files List all staged files in a deploy
curl "https://your-domain/api/v1/projects/1/sites/1/deploys/7/files" \
  -H "Authorization: Bearer YOUR_TOKEN"

Returns: { deploy_id, status, files: [{ path, size }, ...] }

DELETE /api/v1/projects/:id/sites/:site_id/deploys/:deploy_id/files?path= Remove a staged file from a pending deploy

Only works while the deploy is still in pending status.

curl -X DELETE "https://your-domain/api/v1/projects/1/sites/1/deploys/7/files?path=old-page.html" \
  -H "Authorization: Bearer YOUR_TOKEN"

Returns: { deleted: true, path }

POST /api/v1/projects/:id/sites/:site_id/deploys/:deploy_id/finalize Finalize a pending deploy (moves to staging)

Writes the hardening .htaccess, moves files to the staging directory, and marks the deploy ready. Returns 422 if no files were uploaded (empty deploy). The site is not live yet — call Publish to live to promote it.

curl -X POST "https://your-domain/api/v1/projects/1/sites/1/deploys/7/finalize" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'

Returns the updated deploy record with status: "ready" and staging_url.

GET /api/v1/projects/:id/sites/:site_id/deploys/:deploy_id/diff?vs=:other_id Compare two deploy snapshots

Compares the file tree of :deploy_id against ?vs=:other_id using SHA-256 hashes. Both deploys must be in ready status. Files in added are present in :deploy_id but not in vs; removed is the inverse.

curl "https://your-domain/api/v1/projects/1/sites/1/deploys/7/diff?vs=5" \
  -H "Authorization: Bearer YOUR_TOKEN"
{
  "deploy_id": 7,
  "vs": 5,
  "added":    ["assets/new-logo.svg"],
  "removed":  ["old-page.html"],
  "modified": ["index.html", "assets/app.js"],
  "unchanged": 12
}
GET /api/v1/projects/:project_id/sites/:site_id/browse?path= Read one file's content, or list a directory, from the live deploy

This is how you read the actual source of an already-deployed app — the deploy-list and diff endpoints only ever give you file names and sizes, never content. Omit path (or use /) to list the site root; point path at a file to read it.

## List a directory
curl "https://your-domain/api/v1/projects/1/sites/1/browse?path=core" \
  -H "Authorization: Bearer YOUR_TOKEN"

## Read a file's content
curl "https://your-domain/api/v1/projects/1/sites/1/browse?path=core/router.js" \
  -H "Authorization: Bearer YOUR_TOKEN"

Directory response: {"path", "type": "dir", "items": [{"name", "type", "size"}, ...]}. File response: {"path", "type": "file", "size", "content", "truncated"} — content is the raw file text, null with truncated: true for files over 512 KB.

GET /api/v1/projects/:project_id/sites/:site_id/deploys/:deploy_id/download Download an entire deploy (current, staging, or historical) as a zip

Faster than browsing file-by-file when you need the whole app at once — e.g. before editing an existing project. Works on any past deploy, not just the live one.

curl "https://your-domain/api/v1/projects/1/sites/1/deploys/7/download" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -o deploy.zip

Returns the zip as a binary response (Content-Type: application/zip), not JSON.

GET /api/v1/projects/:project_id/sites/:site_id/debug Filesystem diagnostics — did the deploy actually land where it should?

Returns the resolved current/ directory path, whether it exists, and its top-level file listing. Useful when a deploy reports success but the live site doesn't reflect it — diagnoses that class of problem directly instead of guessing from the outside.

curl "https://your-domain/api/v1/projects/1/sites/1/debug" \
  -H "Authorization: Bearer YOUR_TOKEN"

Error Logs

Every deployed app automatically loads a platform-injected script (core/errors.js) that reports the visiting end-user's own client-side JS errors, unhandled promise rejections, and failed API calls back to SupaBein — with no setup required and no auth possible for the report itself (it's a logged-out visitor's browser reporting on itself). This is the most reliable way to check whether a deployed app is actually working for real users, especially when you can't drive a browser against the live URL yourself (see Notes for Automated Builders).

GET /api/v1/projects/:id/errors List logged client-side errors, most recent first

Errors are deduplicated server-side by a fingerprint of type+message+stack — repeated occurrences of the same error increment occurrences and last_seen_at rather than creating duplicate rows.

curl "https://your-domain/api/v1/projects/1/errors" \
  -H "Authorization: Bearer YOUR_TOKEN"

Returns {"errors": [{id, type, message, stack, url, user_agent, meta, occurrences, first_seen_at, last_seen_at}, ...]}. type is one of js_error, promise_rejection, api_error, console_error.

GET /api/v1/projects/:id/errors/download Same data as a downloadable JSON file
curl "https://your-domain/api/v1/projects/1/errors/download" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -o errors.json
DELETE /api/v1/projects/:id/errors Clear all logged errors for a project

Housekeeping once triaged — there's no per-error delete, only clear-all.

curl -X DELETE "https://your-domain/api/v1/projects/1/errors" \
  -H "Authorization: Bearer YOUR_TOKEN"

Integrations

A registered secret + a hard-locked base_url that SupaBein calls on your project's behalf, for the one thing a static, backend-less app can never do safely on its own: talk to a third-party API that requires a secret key (a payments provider, an SMS/WhatsApp provider, anything like that). The secret is write-only, the same trust model as the PASSWORD column type — no GET on this resource ever returns it, and it never touches the client bundle or a git repo.

POST /api/v1/projects/:id/integrations Register (or replace) an integration
curl -X POST "https://your-domain/api/v1/projects/1/integrations" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "paystack",
    "base_url": "https://api.paystack.co/",
    "secret": "sk_live_...",
    "auth_style": "bearer",
    "allowed_project_user_paths": ["bank/resolve"]
  }'

Owner JWT or PAT only — never a project_user token. base_url must be https:// and is a hard prefix: the proxy only ever forwards requests whose resolved URL stays under it, and both registration and every call re-check that the host doesn't resolve to a private/internal address (blocks pointing an integration at your own infrastructure).

auth_style is one of bearer (Authorization: Bearer <secret>), header:X-Api-Key (a literal header name of your choice), or query:api_key (a literal query param name) — enough to cover Paystack, most SMS/WhatsApp providers, and Meta's Graph API without needing arbitrary request-shaping logic.

allowed_project_user_paths is optional and defaults to null, meaning no project_user may ever call this integration's proxy — only the project's own owner JWT/PAT can. List specific request paths (exact match, or ending in / to match everything under that prefix) to let any authenticated end-user trigger just those calls — e.g. letting a business owner resolve their own bank account before you save their payout details, without giving them the secret itself.

GET /api/v1/projects/:id/integrations List integrations (never includes the secret)
curl "https://your-domain/api/v1/projects/1/integrations" \
  -H "Authorization: Bearer YOUR_TOKEN"
DELETE /api/v1/projects/:id/integrations/:name Delete an integration
curl -X DELETE "https://your-domain/api/v1/projects/1/integrations/paystack" \
  -H "Authorization: Bearer YOUR_TOKEN"
POST /api/v1/projects/:id/integrations/:name/proxy Call the integration
curl -X POST "https://your-domain/api/v1/projects/1/integrations/paystack/proxy" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "method": "POST",
    "path": "subaccount",
    "body": {"business_name": "Lois Stores", "settlement_bank": "058", "account_number": "0123456789", "percentage_charge": 2}
  }'

SupaBein resolves base_url + path, injects the stored secret per auth_style, makes the request server-side, and returns {"status": <upstream HTTP status>, "body": <upstream response, parsed if JSON>}. The caller never sees the secret. Redirects are never followed. Authorization accepts the project's own owner JWT/PAT, or a project_user token if this integration's allowed_project_user_paths opts the requested path in — otherwise a project_user call is rejected with 403.

Signed Webhooks

Lets an external service (a payments provider confirming a charge, for example) tell your project something happened, automatically — no human, no separate hosting platform. On each verified delivery, SupaBein applies a fixed, declarative column-update template to one row of one of your project's own tables. This is a bounded, not arbitrary, mapping — the same reasoning that makes a policy's constraint_sql safe as an opaque-but-bounded string applies here too; a webhook can never run arbitrary code.

POST /api/v1/projects/:id/webhooks Register (or replace) a webhook
curl -X POST "https://your-domain/api/v1/projects/1/webhooks" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "paystack-charge-success",
    "signature_header": "x-paystack-signature",
    "signature_algorithm": "hmac-sha512",
    "signature_secret": "sk_live_...",
    "match": { "event": "charge.success" },
    "write": {
      "table": "orders",
      "match_column": "payment_reference",
      "match_value_path": "data.reference",
      "set": {
        "payment_status": { "literal": "paid" },
        "amount_confirmed_kobo": { "path": "data.amount" }
      }
    }
  }'

Owner JWT or PAT only. signature_algorithm is hmac-sha256 or hmac-sha512. match is optional — if set, a delivery whose payload doesn't match every key (resolved as a dot-path, e.g. "event" or "data.event") is a no-op, not an error. write.table/match_column/every key in write.set are validated against your project's real columns at registration time, so a typo fails loudly here instead of silently on every real delivery.

Each write.set value is either {"literal": ...} (a fixed value) or {"path": "a.b"} (pulled from the payload via dot-path) — never an expression. The write is idempotent: if every target column already holds the value the write would set, it's skipped — safe against the at-least-once redelivery every real webhook sender (Paystack included) does.

GET /api/v1/projects/:id/webhooks List webhooks (never includes the signing secret)
curl "https://your-domain/api/v1/projects/1/webhooks" \
  -H "Authorization: Bearer YOUR_TOKEN"
DELETE /api/v1/projects/:id/webhooks/:name Delete a webhook
curl -X DELETE "https://your-domain/api/v1/projects/1/webhooks/paystack-charge-success" \
  -H "Authorization: Bearer YOUR_TOKEN"
POST /api/v1/projects/:id/webhooks/:name The public receiver — give this URL to the external service, no auth header needed

No Authorization header — the sender proves itself via the signature header you configured, verified over the exact raw request body. An invalid or missing signature gets 401 and no write is attempted. Anything else — including a payload match didn't match, or a row that couldn't be found — returns 200 with {"received": true, "applied": <bool>, ...}, by design: most senders retry aggressively on a non-2xx response, and a slow/erroring receiver causes duplicate-delivery storms, not safety. Check the response body (or your project's Error Logs) to see what actually happened, don't infer it from the status code.

GitHub Export

Attach one GitHub repository to a project and SupaBein exports the project's source and configuration into it as commits. This is one way: SupaBein writes to GitHub, and changes made on GitHub are not applied back. It gives you history, backup and code review of your project; the project's tables, rows and files are not touched. Owner JWT or account-wide PAT only (a project-scoped token cannot read or change the link).

Create a fine-grained token limited to that one repository with Contents: Read and write. SupaBein checks it against the repository before saving, stores it encrypted, and never returns it.

PUT /api/v1/projects/:id/repo Connect (or change) the repository
curl -X PUT "https://your-domain/api/v1/projects/1/repo" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "repo": "your-name/your-repo",
    "token": "github_pat_...",
    "branch": "main",
    "path_prefix": "supabein",
    "auto_push": false
  }'

token is required the first time and optional afterwards (leave it out to keep the stored one). branch must already exist; leave it out for the repository's default branch. path_prefix puts everything in a subfolder instead of the repository root. auto_push lets a scheduled job (scripts/repo_push.php, run from cron) push whenever the project has changed. GET on the same path returns the link and the last push result; DELETE disconnects it (the repository is left as it is).

GET /api/v1/projects/:id/repo The link and the last push result
curl "https://your-domain/api/v1/projects/1/repo" \
  -H "Authorization: Bearer YOUR_TOKEN"

Returns {"connected": false} when no repository is linked. Otherwise {"connected": true, "repo", "url", "branch" (null = the repository's default branch), "path_prefix", "auto_push", "last_status" ("pushed" | "unchanged" | "error"), "last_commit", "last_error", "last_pushed_at"}. The token is never returned.

POST /api/v1/projects/:id/repo/push Export now
curl -X POST "https://your-domain/api/v1/projects/1/repo/push" \
  -H "Authorization: Bearer YOUR_TOKEN"

Returns {"status": "pushed" | "unchanged", "commit": "...", "url": "...", "added": n, "changed": n, "deleted": n, "skipped": [...]}. Nothing is committed when nothing changed. The push never forces: if the branch moved on GitHub in the meantime it fails and can simply be run again. GET /api/v1/projects/:id/repo/preview lists the files a push would send.

GET /api/v1/projects/:id/repo/preview The files a push would send
curl "https://your-domain/api/v1/projects/1/repo/preview" \
  -H "Authorization: Bearer YOUR_TOKEN"

Returns {"files": [{"path", "bytes"}, ...], "skipped": [...]}: everything that would be exported, and the site files left out (over 5 MB, or past the 500-file / 40 MB limit). Nothing is sent to GitHub and no repository needs to be connected.

DELETE /api/v1/projects/:id/repo Disconnect the repository
curl -X DELETE "https://your-domain/api/v1/projects/1/repo" \
  -H "Authorization: Bearer YOUR_TOKEN"

Removes the link and the stored token. The repository and everything already pushed to it stay as they are. 404 if nothing was connected.

INFO What is exported Source and configuration only
supabein.json                manifest: project name, stack, sites
schema/tables.json           tables, columns, policies
functions/NAME.js            the function's draft code
functions/NAME.json          method, path, auth, enabled, live version
functions/NAME.live.js       only when the published code differs from the draft
config/assistants.json  webhooks.json  triggers.json  meta-resolvers.json
config/integrations.json     names and base URLs, never the secrets
config/function-env.json     names, and values of non-secret variables
sites/SUBDOMAIN/...          the files of each site's live version

Never exported: table rows, uploaded files, service keys, tokens, integration secrets, webhook signing secrets, secret function variables and end-user data. Only the folders above are managed: SupaBein adds, updates and removes files there, and leaves everything else in the repository (a README, other folders) alone. Site files over 5 MB, and anything beyond 500 files or 40 MB of site files, are skipped and listed in the push result.

Auth Email Provider

Lets /forgot actually deliver its reset email, by dispatching through an already-registered Integration — reuses that secret entirely, no new secret storage. One provider per project; it applies to whichever table's /forgot was called.

POST /api/v1/projects/:id/auth-email-provider Register (or replace) the provider
curl -X POST "https://your-domain/api/v1/projects/1/auth-email-provider" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "integration": "resend",
    "forgot_password": {
      "path": "emails",
      "from": "no-reply@your-domain",
      "subject": { "literal": "Reset your password" },
      "text": { "template": "Reset it here: https://your-app/#/reset-password?token={{token}}" }
    }
  }'

integration must already be registered (see Integrations) — this only stores routing + a template, not a secret. subject/text are declarative value specs — {"literal": ...} or {"template": "...{{token}}..."} — resolved against exactly two variables: email (whatever identifier /forgot was called with) and token (the raw reset token). The outbound request body is {"to": [email], "subject": ..., "text": ..., "from": ...} — matches Resend's API shape; any provider that accepts a similar JSON body works.

GET /api/v1/projects/:id/auth-email-provider Get the registered provider
curl "https://your-domain/api/v1/projects/1/auth-email-provider" \
  -H "Authorization: Bearer YOUR_TOKEN"
DELETE /api/v1/projects/:id/auth-email-provider Delete the provider — /forgot goes back to generating a token silently
curl -X DELETE "https://your-domain/api/v1/projects/1/auth-email-provider" \
  -H "Authorization: Bearer YOUR_TOKEN"

Triggers

The reverse of Signed Webhooks: "when a row is inserted into this table, call this Integration with this templated request" — for notifying a business owner the instant a lead/order comes in, without the client needing its own session token (it fires from the insert itself, server-side, regardless of who or what inserted the row).

Best-effort, synchronous delivery fired inline with the insert — a failure is logged server-side and never blocks or fails the insert, but there's no retry queue in this version. If you need guaranteed delivery, have your own backend poll instead.
POST /api/v1/projects/:id/triggers Register (or replace) a trigger
curl -X POST "https://your-domain/api/v1/projects/1/triggers" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "notify-new-lead",
    "table": "leads",
    "event": "insert",
    "integration": "resend",
    "request": {
      "method": "POST",
      "path": "emails",
      "body": {
        "to": { "literal": "owner@example.com" },
        "subject": { "template": "New lead: {{row.name}}" },
        "text": { "template": "{{row.name}} ({{row.email}}) says: {{row.message}}" }
      }
    }
  }'

event only supports "insert" today. integration must already be registered. Every value in request.body is a declarative spec — {"literal": ...}, {"path": "row.x"}, or {"template": "...{{row.x}}..."} — resolved against {"row": <the inserted row>} (password columns already masked). Multiple triggers can target the same table/event — all of them fire.

GET /api/v1/projects/:id/triggers List triggers
curl "https://your-domain/api/v1/projects/1/triggers" \
  -H "Authorization: Bearer YOUR_TOKEN"
DELETE /api/v1/projects/:id/triggers/:name Delete a trigger
curl -X DELETE "https://your-domain/api/v1/projects/1/triggers/notify-new-lead" \
  -H "Authorization: Bearer YOUR_TOKEN"

Bot-Visible Meta Tag Resolvers

A static SPA sets its page title/social-preview image client-side after mounting — invisible to WhatsApp/Facebook/Twitter/Slack/Discord/etc., which fetch a shared link with a plain HTTP client and never run your JS. Register a resolver and a matching, crawler-only request gets the real <title>/<meta property="og:..."> tags injected server-side into the HTML — every normal human visitor gets the completely unmodified file, same as always.

POST /api/v1/projects/:id/meta-resolvers Register (or replace) a resolver
curl -X POST "https://your-domain/api/v1/projects/1/meta-resolvers" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "storefront",
    "path_pattern": "/store/:slug/*",
    "lookup": { "table": "businesses", "match_column": "slug", "match_value_path": "params.slug" },
    "meta": {
      "title": { "path": "row.name" },
      "og:description": { "path": "row.home_tagline", "fallback": { "path": "row.about_text" } },
      "og:image": { "path": "row.logo_url", "fallback": { "path": "row.hero_image_url" } }
    }
  }'

path_pattern uses the same :name capture syntax as everything else on the platform, plus an optional trailing /* to match (and ignore) anything after that point — e.g. /store/:slug/* matches /store/lois-stores/products/42 with params.slug = "lois-stores". Each meta value is a declarative spec resolved against {"row": <looked-up row>, "params": {...}}, with an optional same-shaped fallback tried if the primary resolves empty. The key "title" is special-cased into <title>; keys starting with og: or twitter: become <meta property="...">; anything else becomes <meta name="..."> (e.g. "description").

Only ever runs for a request from a known crawler UA (WhatsApp, facebookexternalhit, Twitterbot, Slackbot, Discordbot, LinkedInBot, and a handful of others) landing on an HTML document — a real visitor's browser, and every asset request, is completely unaffected.

POST /api/v1/projects/:id/meta-resolvers Hostname-based lookup — for a business/tenant on its own subdomain

lookup.match_value_path is normally "params.<name>" — a value captured from path_pattern. It can also be one of two reserved values that read the request's Host header instead, for the case where there's no path segment to capture from at all — e.g. a business's own <slug>.your-domain subdomain, whose bare root (GET /) is the natural URL to share:

ValueResolves to
"host"The full Host header — match against a domain-style column for a fully custom domain.
"host.label"Just the first label (the subdomain) — match against a subdomain-style column, e.g. lois-stores from lois-stores.your-domain.
curl -X POST "https://your-domain/api/v1/projects/1/meta-resolvers" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "storefront-subdomain",
    "path_pattern": "/*",
    "lookup": { "table": "businesses", "match_column": "subdomain", "match_value_path": "host.label" },
    "meta": {
      "title": { "path": "row.name" },
      "og:description": { "path": "row.home_tagline" }
    }
  }'

The two lookup sources compose — register both a path-based resolver (for a shared domain serving many tenants under one path prefix) and a hostname-based one (for per-tenant subdomains) on the same project.

Precedence when more than one resolver matches the same request (e.g. lois-stores.your-domain/store/lois-stores matches both a hostname-only /* resolver and a path resolver on /store/:slug/*): the resolver with the more specific path_pattern wins — a hostname-only catch-all (/* or /) is always the lowest-precedence fallback, ranked below anything with at least one literal path segment. This is deterministic and doesn't depend on registration order.
GET /api/v1/projects/:id/meta-resolvers List resolvers
curl "https://your-domain/api/v1/projects/1/meta-resolvers" \
  -H "Authorization: Bearer YOUR_TOKEN"
DELETE /api/v1/projects/:id/meta-resolvers/:name Delete a resolver
curl -X DELETE "https://your-domain/api/v1/projects/1/meta-resolvers/storefront" \
  -H "Authorization: Bearer YOUR_TOKEN"

AI Assistants

A hosted AI capability for your project — unlike Integrations, you don't bring your own provider API key. SupaBein calls its own AI provider on your behalf, the same infrastructure the AI app-builder itself runs on, and meters usage against your account's AI credit balance (shared across every project you own — not a per-project budget). An assistant is either a conversational "kind": "chat" assistant (the default — chat) or an image-generation "kind": "image" assistant (image); each kind is validated against, and called through, its own separate provider/model registry.

Every call is billed to the credit balance of the SupaBein account that owns the project, regardless of who or what actually calls it (your own token, a PAT, or an end-user's project_user token). A registered system_prompt is always enforced server-side — a caller can only ever supply conversation turns, never override it. Out of credit? Calls return 402 until your account is topped up.
GET /api/v1/ai/models List every provider/model pair currently available on this server
curl "https://your-domain/api/v1/ai/models" \
  -H "Authorization: Bearer YOUR_TOKEN"

Returns {"models": [{"label": "...", "provider": "...", "model": "...", "badge": "..."}, ...]} — the exact provider/model values are what you pass in a chat assistant's models field below. This list is text-chat providers/models only; there's no separate discovery endpoint for image providers/models yet — see Generate an image for the current image registry. Deliberately dynamic rather than a hardcoded list here in the docs — a provider with no configured API key on this server is already filtered out, so everything returned is guaranteed callable right now, not aspirational.

The default model. On a server whose models come from Bein, this list is Bein's own model list, in Bein's order (provider is always "bein", model is the name Bein gives it). When a call names no model, SupaBein tries them in that order and the first one that answers is used, so the first entry is the default and whoever runs Bein sets it by reordering Bein's configuration. A model you name explicitly is the only one tried: if it fails, the call fails with that model's own error rather than quietly switching. A model that is listed can still fail when its provider account is out of credit, so check a model with a short test call before relying on it.

POST /api/v1/projects/:id/ai-assistants Register (or replace) an assistant
curl -X POST "https://your-domain/api/v1/projects/1/ai-assistants" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "support-bot",
    "kind": "chat",
    "system_prompt": "You are Lois Stores'"'"' support assistant. Be concise and friendly.",
    "allow_project_user": true,
    "models": [
      {"provider": "zhipu", "model": "glm-4.5-flash"},
      {"provider": "groq", "model": "llama-3.3-70b-versatile"}
    ],
    "json_mode": false
  }'

kind is optional, defaulting to "chat" — a conversational assistant called via POST .../chat. Set it to "image" to register an image-generation assistant instead, called via POST .../image; its models (if given) is validated against the separate image provider/model registry rather than the text one, and system_prompt/json_mode are ignored.

system_prompt is optional (omit for a generic assistant with no special instructions) and locked — end users can never see or override it. allow_project_user defaults to false (owner/PAT-only); set it to let any authenticated end user of your project (a project_user token) call it directly — the natural setting for a customer-facing support/chat feature or an in-app image tool.

The provider and model names above (zhipu, groq, ...) apply to servers that use their own provider keys. On a server where the models come from Bein, the only provider is bein and model is a name from GET /api/v1/ai/models, for example {"provider": "bein", "model": "haiku-5.5"}. An assistant saved with an old provider or model falls back to the server's default models (Bein's list in Bein's order, see the default model) instead of failing.

models is optional — omit it entirely to use the platform's own default model selection (on a Bein server: the first model in Bein's list that answers; no code change needed if you never touch this). When set, it's an ordered list of {"provider", "model"} pairs (see Available models for valid chat values) this assistant tries in turn: on a hard provider failure (rate limit exhausted, no credit, invalid key) it automatically moves to the next candidate, so a call only ever fails outright if every candidate in your list is currently unavailable. Each pair is validated against the server's real, currently-configured models at registration time — an unrecognized provider or model returns 422 with the actual valid options for that provider, rather than silently storing something that would fail later.

json_mode (chat assistants only) defaults to false — the right setting for a normal conversational bot; the model replies in plain language, and forcing JSON syntax on that would either mangle a reply into a stray wrapped value or get the request outright rejected by some providers. Set it true only if this assistant's own system_prompt instructs the model to output structured JSON and your own code parses reply as JSON afterward — a one-shot content/config generator reusing this chat endpoint rather than a real back-and-forth conversation.

GET /api/v1/projects/:id/ai-assistants List assistants
curl "https://your-domain/api/v1/projects/1/ai-assistants" \
  -H "Authorization: Bearer YOUR_TOKEN"
DELETE /api/v1/projects/:id/ai-assistants/:name Delete an assistant
curl -X DELETE "https://your-domain/api/v1/projects/1/ai-assistants/support-bot" \
  -H "Authorization: Bearer YOUR_TOKEN"
POST /api/v1/projects/:id/ai-assistants/:name/chat Send a chat turn, get a reply
curl -X POST "https://your-domain/api/v1/projects/1/ai-assistants/support-bot/chat" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "messages": [
      {"role": "user", "content": "What are your store hours?"}
    ]
  }'

messages is the full conversation so far (send prior turns back each time — there's no server-side session) — each item is {"role": "user"|"assistant", "content": "..."}. The final item must be a user turn. Returns {"reply": "...", "provider": "...", "model": "...", "usage": {...}, "cost_micro_usd": N}. cost_micro_usd is what this one call was billed (1,000,000 = $1.00) — useful for your own usage display, though the source of truth is always the account's actual balance.

Attachments (optional). Add "attachments": [{"filename": "menu.pdf", "mime_type": "application/pdf", "data_base64": "..."}] to send documents or images with the last user message — PNG/JPEG/WEBP/GIF images, PDF, .docx, and text/Markdown/HTML/CSV/JSON. Up to 8 files, 15 MB each, 40 MB in total; a bad file is a 422, never silently dropped. From a Function use sb.ai.chat(name, message, { attachments }) (see Functions).

reply's format follows the assistant's own json_mode setting (see Register an assistant) — plain conversational text by default, regardless of which candidate in a models fallback chain actually answered, or strict JSON if the assistant was registered with json_mode: true. There's no per-call override here; the setting is fixed at registration time for the assistant as a whole.

Callable by the project's own owner/PAT, or by a project_user token if the assistant's allow_project_user is set. 402 if the owning account is out of AI credit and not on an unlimited grant. 400 if this assistant's kind isn't "chat".

POST /api/v1/projects/:id/ai-assistants/:name/image Generate an image from a prompt
curl -X POST "https://your-domain/api/v1/projects/1/ai-assistants/icon-generator/image" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "prompt": "3D clay render icon of a potted cactus, isolated, plain background"
  }'

Image-generation counterpart to the chat endpoint above, callable only on a "kind": "image" assistant — same allow_project_user auth posture. prompt is a single plain-text description; there's no conversation history or system prompt for image assistants. Returns {"image_base64": "...", "provider": "...", "model": "...", "cost_micro_usd": N} — image_base64 is a PNG, base64-encoded.

If this assistant's models was left unset at registration, every currently-configured image provider/model on the server is tried in turn on failure, same fallback posture as chat assistants. 402 if the owning account is out of AI credit and not on an unlimited grant. 400 if this assistant's kind isn't "image".

Functions (custom API endpoints)

Functions are your own API endpoints with custom business logic — checking stock and placing an order atomically, verifying a payment webhook, calculating prices, combining several tables into one response, calling an external API with a secret key. You write them in JavaScript (a sandboxed subset called SupaScript), and SupaBein runs them itself: there is no separate server, container or third-party runtime to set up.

A function can be the whole backend of an API-only project, or sit next to a deployed frontend in the same project and be called from it.

Functions replace BLogic (per-table PHP actions), which has been retired: its /tables/:name/blogic and /data/…/:id/actions/:action routes now return 410 Gone. Write a Function for that logic instead — for example POST /orders/:id/confirm. To keep an existing frontend working unchanged, publish the Function at POST /<table>/:id/actions/:action: the old URL POST /api/v1/data/:project_id/<table>/:id/actions/:action is then forwarded to it (same body, auth and limits as calling /api/v1/fn/…).

Why it's safe. SupaScript is executed by an interpreter built into SupaBein, not by PHP or Node. Code can only reach what SupaBein hands it: your project's tables (through the same policy engine as the Data API), your project's registered Integrations, AI Assistants, storage buckets and env vars. There is no file system, no raw network access, no way to see another project, and no way to reach the server itself. Every run has hard limits on work, time, memory, recursion and output, and a run that hits one is stopped — try/catch can't catch it.

Quick start

Create and publish a function (the dashboard's Functions tab does the same thing with an editor and a test runner):

curl -X POST "https://your-domain/api/v1/projects/1/functions" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "hello",
    "method": "GET",
    "path": "/hello/:name",
    "auth": "public",
    "code": "return { message: `Hello, ${params.name}!` }",
    "publish": true
  }'

Call it:

curl "https://your-domain/api/v1/fn/1/hello/Ada"
# {"message":"Hello, Ada!"}

From a frontend deployed on the same project, you can leave out the project id and call it on the site's own domain:

const res = await fetch('/api/fn/hello/Ada');
const data = await res.json();

How functions work

Who may call a function is set per function with auth:

authWho can call itTypical use
publicAnyone. user is filled in if a valid token is sent, otherwise null.Public reads, sign-up flows, webhooks from other services (verify their signature in code)
userCallers with an end-user token from /data/:project/:table/login (or the owner/service key). Others get 401.Anything "for the signed-in customer": place an order, update my profile
ownerOnly your own token, a PAT for this project, or the service key. Others get 401/403.Admin actions, internal jobs, reports

The request, and building a response

These globals are available in every function:

GlobalWhat it is
request{ method, path, params, query, headers, body, rawBody, ip } — the whole request. ip is the visitor's real address, also when the call comes through your site's own domain (the site proxy passes it on; a client can't fake it).
paramsURL params from the path, e.g. params.id for /orders/:id (always strings)
queryQuery string values, e.g. query.page (strings)
bodyThe parsed body: JSON (objects stay objects), form fields as an object, or the raw text for other content types. null when empty. Invalid JSON is rejected with 400 before your code runs.
headersRequest headers with lowercase names, e.g. headers["x-signature"]. authorization and cookie are removed.
usernull for anonymous callers; { id, table, role: "user" } for a signed-in end user (id is their row id in table); { id, role: "owner" } for you; { role: "service" } for the service key
envYour project's function env vars, e.g. env.WEBHOOK_SECRET
sbAccess to your project's data and services — see the sb object

What you return becomes the response:

In your codeResponse
return value200 with the value as JSON (objects, arrays, strings, numbers…)
return (nothing)204 No Content
return json(data, status?, headers?)JSON with your status (200–599) and extra headers
return text(string, status?, headers?)text/plain
return html(string, status?, headers?)text/html
return redirect(url, status?)A 301/302/303/307/308 redirect (302 by default)
fail(status, message, details?)Stops the function and answers {"error": message, "details": …} with a 4xx/5xx status. Use it for validation: if (!body.email) fail(422, "email is required")
uncaught error / limit hit500 {"error": "The function failed…", "request_id": N}. The real error message and line are not sent to the caller; they are in the function's logs under that request id.

Every response also carries an X-Function-Request-Id header that matches the invocation's log entry. Responses are Cache-Control: no-store unless you set Cache-Control yourself. You can set any header except framing headers (Content-Length, Transfer-Encoding, …) and CORS headers, which SupaBein manages.

The sb object

sb is how a function touches anything outside itself. Every call is scoped to the function's own project.

sb.db and sb.adminDb — your tables

Both have the same methods. The difference is whose permissions they use:

MethodReturns
list(table, filters?, options?)Array of rows. options: { limit (default 100, max 1000), offset, order: "created_at.desc" }
first(table, filters?, options?)The first matching row, or null
get(table, id)The row, or null if it doesn't exist or the caller can't see it
count(table, filters?)Number of matching rows
insert(table, row)The inserted row (with id and created_at). PASSWORD columns are hashed, and triggers fire.
insertMany(table, rows)Array of inserted rows (max 500)
update(table, id, changes, { where }?)The updated row, or null if not found / not permitted. With { where: { status: "PENDING" } } (same filter syntax as list) it is a compare-and-set: the row is only changed if it still matches, otherwise null — so two simultaneous calls can never both apply the same state change.
increment(table, id, column, by = 1)Atomically adds by (can be negative) to a numeric column, with no read-then-write race. Returns the updated row or null.
delete(table, id)true if a row was deleted, false if not found

Filters are objects. All conditions are AND-ed. Filtering on a column that doesn't exist is an error, not a silent "match everything":

sb.db.list("orders", { status: "paid" })                     // equals
sb.db.list("orders", { total: { gte: 100, lt: 500 } })       // range: eq neq gt gte lt lte
sb.db.list("orders", { status: { in: ["paid", "shipped"] } }) // one of
sb.db.list("orders", { shipped_at: null })                   // IS NULL
sb.db.list("orders", { shipped_at: { neq: null } })          // IS NOT NULL
sb.db.list("users",  { email: { like: "%@example.com" } })   // SQL LIKE
sb.db.list("orders", { created_at: { gte: new Date("2024-01-01") } })
sb.db.list("orders", { user_id: user.id }, { order: "created_at.desc", limit: 20 })

Rows come back with real types: numbers, booleans, and JSON columns as objects. PASSWORD columns always read as null. When writing, Date objects are stored as YYYY-MM-DD HH:MM:SS UTC and objects/arrays go into JSON columns. A failed call throws an error with name: "DatabaseError" and an HTTP-style status (403 policy denied, 404 table not found, 409 unique conflict, 422 bad column…), which you can catch.

sb.transaction(fn) — all or nothing

Runs fn in a database transaction. If fn throws (including via fail()) or the function hits a limit, every write inside it is undone. Returns whatever fn returns. Writes outside a transaction are kept even if the function fails later.

const order = sb.transaction(() => {
  const product = sb.adminDb.get("products", body.product_id)
  if (product.stock < body.qty) fail(409, "Not enough stock")
  sb.adminDb.increment("products", product.id, "stock", -body.qty)
  return sb.adminDb.insert("orders", { user_id: user.id, product_id: product.id, qty: body.qty })
})

sb.integrations.call(name, method, path, body?)

Calls an external API through one of your project's Integrations. The secret is added server-side, so your code never sees it. Only the integration's locked base URL can be reached, over HTTPS, with the same private-network (SSRF) protections as the proxy endpoint. Returns { status, ok, body }, where body is parsed JSON when possible. A network failure throws IntegrationError.

const res = sb.integrations.call("paystack", "GET", "transaction/verify/" + body.reference)
if (!res.ok || res.body.data.status !== "success") fail(402, "Payment not confirmed")

sb.ai.chat(assistant, message, { attachments }?)

Asks one of your project's AI Assistants. message is a string or an array of { role: "user"|"assistant", content }. Returns { reply, model, cost_micro_usd }. Billed to your account's AI credit; throws AIError when out of credit.

const { reply } = sb.ai.chat("support-bot", body.question)
return { answer: reply }

The optional third argument attaches documents and images to the last user message. Each attachment is either a file already in this project's storage, or the content itself:

const { reply } = sb.ai.chat("reader", "Summarise this contract and list the deadlines", {
  attachments: [
    { bucket: "docs", filename: "contract.pdf" },                                   // from file storage
    { filename: "scan.png", mime_type: "image/png", data_base64: body.image }       // or inline (max 1 MB per string)
  ]
})
return { summary: reply }

Supported: PNG, JPEG, WEBP and GIF images (sent to the model as attachments), PDFs (their text is read and added to the message), .docx (its text and embedded images are extracted), and text, Markdown, HTML, CSV and JSON files (their text is added to the message, first 30,000 characters of each). For a storage file the type comes from its extension unless you pass mime_type. Up to 8 files, 15 MB each and 40 MB in total; storage reads count towards the 50 storage calls. Anything unsupported or missing throws a script error rather than being skipped. Text, PDF and .docx content always reach the model as text. Scanned or image-only PDFs have no text to read (the model is told so); send those as images instead. Models without image support (some free ones) ignore images.

sb.storage — files

MethodDoes
list(bucket)Array of { name, size, last_modified, url }
read(bucket, filename, { encoding: "base64" }?)File contents as text (or base64), max 1 MB; null if missing
write(bucket, filename, content, { encoding: "base64" }?)Creates/overwrites a file (max 10 MB, executable extensions blocked). Returns { name, bucket, size, url }.
delete(bucket, filename)true if deleted
url(bucket, filename)The file's public URL

sb.auth and sb.project

sb.auth.getUser() returns the signed-in end user's own row from the table they logged in through (password masked), or null. sb.auth.method() says how they signed in: "password" or "google" (Sign in with Google); null when the caller isn't an end user. sb.project is { id, name }.

The SupaScript language

SupaScript is JavaScript with the parts you use for business logic, and without the parts that don't make sense in a sandbox. Semicolons are optional.

Supported
Variableslet, const, var (behaves like let), block scope, closures
Functionsdeclarations (hoisted), expressions, arrow functions, default and ...rest parameters, recursion, this in object methods, call/apply/bind
Control flowif/else, for, for...of, for...in, while, do...while, switch, break/continue, try/catch/finally, throw
Syntaxtemplate literals, destructuring (objects, arrays, parameters, assignment), spread (...), optional chaining ?., ??, ??= ||= &&=, ternary, all arithmetic/comparison/bitwise/logical operators, typeof, delete, in, instanceof
Built-insMath, JSON, Object (keys values entries assign fromEntries hasOwn), Array (isArray from of), Number, String, Boolean, Date, parseInt parseFloat isNaN isFinite, encodeURIComponent decodeURIComponent, btoa atob, structuredClone, Error TypeError RangeError, console.log/info/warn/error
Array methodspush pop shift unshift slice splice concat join reverse indexOf lastIndexOf includes find findIndex findLast findLastIndex filter map forEach reduce reduceRight some every sort toSorted toReversed flat flatMap fill at with keys values entries
String methodslength at charAt charCodeAt indexOf lastIndexOf includes startsWith endsWith slice substring substr toUpperCase toLowerCase trim trimStart trimEnd padStart padEnd repeat split replace replaceAll concat localeCompare
Numbers & datestoFixed toString(radix) toPrecision toLocaleString; new Date(), Date.now(), Date.parse, getFullYear/Month/Date/Day/Hours/…, the matching setters, toISOString
cryptocrypto.randomUUID(), randomInt(max) / randomInt(min, max), randomHex(bytes), sha256 sha512 sha1 md5, hmacSha256(key, msg), hmacSha512(key, msg), timingSafeEqual(a, b) (hashes are hex strings)

Not supported: class, async/await and Promises (there is nothing to wait for, because sb.* calls return their result directly), generators, import/require, regular expressions (use includes, startsWith, split and replaceAll), getters/setters, labels, Map/Set (use objects and arrays), setTimeout, fetch (use sb.integrations), eval. Using an unsupported keyword gives a clear syntax error.

Differences from browser JavaScript: dates are always UTC (there is no local timezone). String lengths and indexes count Unicode characters, so "😀".length is 1. String(new Date()) gives the ISO format. Top-level return ends the function.

Limits

Per invocation. Hitting any of them stops the function with a 500, and the log says which limit was hit.

LimitValue
Work2,000,000 operations (roughly one per expression evaluated — plenty for normal logic; an accidental infinite loop is stopped in well under a second)
Wall-clock time10 seconds, including time spent waiting on the database and integrations. Time spent waiting for an sb.ai.chat reply does not count: each AI call may take up to about a minute, and one invocation may spend at most 90 seconds waiting on models in total
Memory48 MB
Call depth100 nested function calls
Strings / arrays1 MB per string, 100,000 items per array or object
sb calls200 database calls, 10 integration calls, 3 AI calls, 50 storage calls
console output200 lines / 32 KB (extra lines are dropped, not an error)
Code size100 KB per function
Request body1 MB
Rate limit600 invocations per minute per project (a separate bucket from the Data API; database calls a function makes internally don't count against the Data API limit)

Security model

Examples

Cancel my order (POST /orders/:id/cancel, auth: "user"):

const order = sb.adminDb.get("orders", params.id)
if (!order || order.user_id !== user.id) fail(404, "Order not found")
if (order.status !== "pending") fail(409, `Can't cancel an order that is ${order.status}`)

const fee = order.paid_with === "card" ? 0.03 : 0
const refund = Math.round(order.total * (1 - fee) * 100) / 100

sb.transaction(() => {
  sb.adminDb.update("orders", order.id, { status: "cancelled", refund })
  sb.adminDb.increment("products", order.product_id, "stock", order.qty)
})

sb.integrations.call("resend", "POST", "emails", {
  from: "shop@example.com",
  to: [sb.auth.getUser().email],
  subject: "Your order was cancelled",
  text: `We've refunded $${refund.toFixed(2)}.`,
})

return { ok: true, refund }

Verify a signed webhook (POST /webhooks/paystack, auth: "public", with a secret env var PAYSTACK_SECRET):

const expected = crypto.hmacSha512(env.PAYSTACK_SECRET, request.rawBody)
if (!crypto.timingSafeEqual(expected, headers["x-paystack-signature"] ?? "")) {
  fail(401, "Invalid signature")
}

if (body.event === "charge.success") {
  const order = sb.adminDb.first("orders", { reference: body.data.reference })
  if (order && order.status !== "paid") {
    sb.adminDb.update("orders", order.id, { status: "paid", paid_at: new Date() })
  }
}
return { received: true }

A dashboard summary (GET /stats, auth: "owner"):

const since = new Date()
since.setDate(since.getDate() - 30)

const recent = sb.adminDb.list("orders", { created_at: { gte: since }, status: { neq: "cancelled" } }, { limit: 1000 })
const byDay = {}
for (const o of recent) {
  const day = o.created_at.slice(0, 10)
  byDay[day] = (byDay[day] ?? 0) + o.total
}
return {
  orders: recent.length,
  revenue: recent.reduce((s, o) => s + o.total, 0),
  // No Set in SupaScript -- an object's keys de-duplicate just as well
  uniqueCustomers: Object.keys(Object.fromEntries(recent.map(o => [o.user_id, true]))).length,
  byDay,
}

Management API

All management endpoints need your token or a PAT (a project-scoped PAT works for its own project). The dashboard's Functions tab uses these same endpoints.

GET /api/v1/projects/:id/functions List functions
curl "https://your-domain/api/v1/projects/1/functions" \
  -H "Authorization: Bearer YOUR_TOKEN"

Returns { base_url, functions: [...] }. Each function has name, description, method, path, auth, enabled, live_version, published_at, has_unpublished_changes, url (code bodies are left out of the list).

POST /api/v1/projects/:id/functions Create a function
curl -X POST "https://your-domain/api/v1/projects/1/functions" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "cancel-order",
    "method": "POST",
    "path": "/orders/:id/cancel",
    "auth": "user",
    "description": "Customer cancels their own pending order",
    "code": "...",
    "publish": false
  }'
FieldDescription
nameRequired. Lowercase letters, digits, -, _ (max 63). Used in management URLs.
methodGET POST PUT PATCH DELETE ANY (default POST)
pathRequired. Like /orders/:id/cancel. Two functions can't share a method and path shape (/a/:x and /a/:y clash).
authpublic (default), user or owner
codeThe SupaScript source, saved as the draft
enabledDefault true
publishAlso publish the draft immediately (fails with 422 on a syntax error)

Returns the function including draft_code, live_code and syntax_error (null or { message, line, column }). 409 if the name or route is taken.

GET /api/v1/projects/:id/functions/:name Get one function
curl "https://your-domain/api/v1/projects/1/functions/cancel-order" \
  -H "Authorization: Bearer YOUR_TOKEN"

Includes draft_code, live_code, syntax_error for the draft, and versions ([{ version, size, created_at }], newest first).

PATCH /api/v1/projects/:id/functions/:name Update code or settings
curl -X PATCH "https://your-domain/api/v1/projects/1/functions/cancel-order" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "code": "...", "publish": true }'

Any of code (updates the draft only), method, path, auth, description, enabled, publish. Routing fields apply immediately. A draft with a syntax error is still saved — check syntax_error in the response.

DELETE /api/v1/projects/:id/functions/:name Delete a function
curl -X DELETE "https://your-domain/api/v1/projects/1/functions/cancel-order" \
  -H "Authorization: Bearer YOUR_TOKEN"

Its URL stops working immediately; versions and logs are deleted with it.

POST /api/v1/projects/:id/functions/:name/publish Publish the draft
curl -X POST "https://your-domain/api/v1/projects/1/functions/cancel-order/publish" \
  -H "Authorization: Bearer YOUR_TOKEN"

Copies the draft to live as the next version number. 422 with syntax_error if the draft doesn't parse — live keeps serving the previous version.

POST /api/v1/projects/:id/functions/:name/rollback Serve an earlier version
curl -X POST "https://your-domain/api/v1/projects/1/functions/cancel-order/rollback" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "version": 3 }'

Points live at a previously published version. The draft is not changed. To bring an old version back into the editor, fetch its code (below) and PATCH it as the draft.

GET /api/v1/projects/:id/functions/:name/versions/:version Get a version's code
curl "https://your-domain/api/v1/projects/1/functions/cancel-order/versions/3" \
  -H "Authorization: Bearer YOUR_TOKEN"

Returns { version, code }.

POST /api/v1/projects/:id/functions/validate Check syntax without saving
curl -X POST "https://your-domain/api/v1/projects/1/functions/validate" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "code": "return {" }'

Always 200: { "ok": false, "error": { "message": "…", "line": 1, "column": 9 } } or { "ok": true, "error": null }.

POST /api/v1/projects/:id/functions/:name/test Test-run a function
curl -X POST "https://your-domain/api/v1/projects/1/functions/cancel-order/test" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "stage": "draft",
    "method": "POST",
    "path": "/orders/42/cancel",
    "query": {},
    "headers": {},
    "body": {},
    "as": { "user_id": 7, "table": "users" }
  }'
FieldDescription
stagedraft (default) or live
methodDefaults to the function's method (POST for ANY)
pathA concrete path matching the function's pattern, used to fill params. Or leave it out and send params: {...}.
query, headers, bodyThe request to simulate
as"owner" (default), "anon", or { "user_id", "table" } to run as one of your end users
Test runs are real: database writes, integration calls and AI calls all happen. The access check (auth) is skipped so you can test as anyone; user is set from as.

Always answers 200 with the function's own response inside: { status, headers, body, error, logs, ops, duration_ms, log_id }. Unlike live calls, error contains the full error message and line.

GET /api/v1/projects/:id/functions/:name/logs Invocation logs for one function
curl "https://your-domain/api/v1/projects/1/functions/cancel-order/logs?limit=50" \
  -H "Authorization: Bearer YOUR_TOKEN"

Newest first: { logs: [{ id, stage, version, method, path, status, duration_ms, ops, error, logs: [{ level, message, line }], created_at }] }. id is the request_id a failed call returned. Page with ?before=<id>. Request bodies and headers are never stored. The most recent 1,000 invocations per project are kept. GET /api/v1/projects/:id/function-logs gives the same list across all functions (with function_name), and DELETE …/functions/:name/logs clears one function's logs.

Env vars

Project-wide configuration available to every function as env.NAME. Plain values are shown in the dashboard. Secret values are encrypted at rest and never returned by the API; only running functions can read them. Use secrets for signing keys and tokens your code needs directly. For calling external APIs, prefer an Integration, which keeps the key out of your code entirely.

GET /api/v1/projects/:id/function-env List env vars
curl "https://your-domain/api/v1/projects/1/function-env" \
  -H "Authorization: Bearer YOUR_TOKEN"

{ env: [{ name, value, secret, updated_at }] }. value is null for secrets.

PUT /api/v1/projects/:id/function-env/:name Create or replace an env var
curl -X PUT "https://your-domain/api/v1/projects/1/function-env/PAYSTACK_SECRET" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "value": "sk_live_...", "secret": true }'

Names start with a letter or _ and contain letters, digits and _ (max 64). Values up to 10,000 bytes.

DELETE /api/v1/projects/:id/function-env/:name Delete an env var
curl -X DELETE "https://your-domain/api/v1/projects/1/function-env/PAYSTACK_SECRET" \
  -H "Authorization: Bearer YOUR_TOKEN"

Calling a function

ANY /api/v1/fn/:project_id/<path> Invoke a published function
# Public function
curl "https://your-domain/api/v1/fn/1/hello/Ada"

# auth: "user" -- send the end user's token from /data/:project/:table/login
curl -X POST "https://your-domain/api/v1/fn/1/orders/42/cancel" \
  -H "Authorization: Bearer END_USER_TOKEN"

# From a site deployed on the same project, on its own domain:
#   fetch('/api/fn/orders/42/cancel', { method: 'POST', headers: { Authorization: 'Bearer ' + token } })
StatusMeaning
404No published, enabled function matches the path (drafts are never served)
405The path exists but not for this method; allowed_methods lists which
401 / 403The function's auth setting rejected the caller
400 / 413Invalid JSON body / body over 1 MB
429The project is over its 600 invocations per minute
500The function threw or hit a limit. The response has a request_id; look it up in the logs.
anything elseWhatever the function returned

Personal Access Tokens

PATs authenticate as your user account. By default they're account-wide (all your projects); optionally scope one to a single project. Use them in scripts, CI/CD pipelines, or when handing an external tool/AI builder access to just one project, instead of storing your password.

GET /api/v1/auth/tokens List your PATs
curl "https://your-domain/api/v1/auth/tokens" \
  -H "Authorization: Bearer YOUR_TOKEN"

Token values are never returned after creation. Only id, name, project_id (null for account-wide), created_at, and last_used_at are shown.

POST /api/v1/auth/tokens Create a PAT
Body fieldTypeDescription
namestringRequired. Label for the token.
project_idintOptional. Scopes the token to one project you own. Omit for an account-wide token (original behavior).
## Account-wide (default)
curl -X POST "https://your-domain/api/v1/auth/tokens" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "CI deploy"}'

## Scoped to one project — e.g. handing this to an external AI builder
curl -X POST "https://your-domain/api/v1/auth/tokens" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "AI builder — project 12", "project_id": 12}'

Returns {"token": "sb_pat_...", "project_id": 12 | null}. The raw token value is shown only once — store it immediately.

A project-scoped token is confined to that project's tables, columns, policies, data, sites/deploys, storage, and error logs — it cannot list/create other projects, manage account settings or other tokens, or be used on a different project. It still has full owner-level power within its one project (bypasses that project's policies, same as an account-wide token would).
DELETE /api/v1/auth/tokens/:id Revoke a PAT
curl -X DELETE "https://your-domain/api/v1/auth/tokens/1" \
  -H "Authorization: Bearer YOUR_TOKEN"