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=valueExact-match filter (shorthand for eq), e.g. ?status=active
col=op.valueFilter 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), and type: "project_user". Use it as Bearer auth in subsequent data API calls to get the authenticated role.

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": "", "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: 50 MB. 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 and cache headers (Cache-Control: public, max-age=86400).

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"}. There's a matching GET on the same path to check the current setting.

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) — 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.

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.

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.

models is optional — omit it entirely to use the platform's own default model selection (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.

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".

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"