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:
- 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.)
- Create a project → note the
service_key - Create a table → call the data API (no auth needed for public/anon access)
/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 / token | Use when | Policies |
|---|---|---|
| service_key | Trusted server-side code only | Bypassed entirely |
| (no header) | Public/anonymous frontend access | Enforced (anon role) |
| user JWT / PAT | Authenticated platform operators | Enforced (authenticated role) |
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.
- Column types are limited to
INT BIGINT SMALLINT TINYINT VARCHAR(255/128/64/36/32) TEXT/MEDIUMTEXT/LONGTEXT BOOLEAN DECIMAL(10,2)/DECIMAL(15,4) FLOAT DOUBLE DATETIME DATE TIMESTAMP JSON PASSWORD. NoENUM,SET, spatial types, or native UUID. - Foreign keys are supported and applied automatically — a
*_id-named column gets a realFOREIGN KEY ... ON DELETE CASCADEconstraint against a matching same-project table (or explicitly viareferences, see Columns). No composite primary/unique keys, generated columns, CHECK constraints, views, or triggers — every table only ever gets an autoidprimary key pluscreated_at, and a column can only reference another table'sid, not an arbitrary column. - Data API filters are AND-only. There are no
ORgroups and noin/is nulloperators — onlyeq neq gt gte lt lte like, all combined with AND. Make separate requests or restructure the model if you need an OR condition. - No joins, embedding, or aggregates. A list request can't pull in a related table's rows in the same call, and there's no
COUNT/SUM/GROUP BY. Aggregate client-side or across multiple requests. - 1000-row hard cap on any list request, regardless of the
limitvalue requested. - Policies are table + role + operation scoped, not column scoped — you can't grant
SELECTon some columns of a table and deny others for the same role. constraint_sqlis a single opaque WHERE-fragment string, not a structured expression. Only:current_user_idis substituted — there are no other placeholders. A broken constraint fails at request time (403/500), not at save time, so test it.- One site per project — but that one site can resolve at any number of hostnames via hostname registration, not just its own
subdomain/custom_domain. - No server-executable file types in file storage or static-site deploys (
.php .py .sh .exe .cgi .rb .pl .bat .cmd .htaccessand related extensions are blocked, and deployed sites have PHP execution force-disabled at the directory level). This isn't configurable — dynamic server-side logic belongs in the Data API, not in a deployed site.
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:
- Discover which project a scoped token belongs to via
GET /api/v1/auth/me. A project-scoped PAT can call this even though it can't callGET /api/v1/projects— the response includesproject_idandproject_name. Call this first; don't brute-force project ids. - To read an existing app's code, don't guess from file lists — the tables/columns/policies endpoints tell you the data model, and for the deployed frontend use Browse deployed files (reads one file's actual content) or Download a deploy as zip (the whole file tree at once). A deploy's file list alone (names + sizes) is not enough to edit code safely — always read the real content first.
- Check existing state before creating. When editing an existing project, call
GET /api/v1/projects/:id/tables(and list policies) first, and build additively — don't assume a clean slate. - An unpolicied table is fully locked. A table with no policy rows set denies every operation to
anonandauthenticated— only the owner token/PAT orservice_keycan touch it. Set policies explicitly for every table you create. - Never expose
service_keyin client-side code. It bypasses every policy. Use it only from trusted server-side code, or not at all if the app is pure frontend + anon/authenticated policy access. - Deploys are staging-first by design. A zip/file upload always lands in
staging/; call Publish to live only when explicitly instructed to make it the live version. - Set a real
User-Agentheader on every request (e.g.User-Agent: MyBuilder/1.0), especially file uploads. The hosting-level WAF in front of this API blocks generic/absent User-Agents on some routes and returns a plain HTML 403 page instead of a JSON error — indistinguishable from an auth failure unless you know to check for it. - The root
.htaccessof a deployed site is managed by SupaBein and cannot be uploaded, changed, or removed — every deploy/finalize call overwrites it. If you need client-side routing (a SPA with pushState routes), setspa_mode: truewhen creating the site instead of trying to write rewrite rules yourself; hash-based routing (#/path) works with no server config at all. - To verify a deploy, prefer a plain request over driving a headless browser at the live URL. A live subdomain may not resolve from a sandboxed/CI network, and the same WAF that blocks bad User-Agents can reset headless-browser traffic too. A direct
curl/fetchof the returnedlive_url/staging_url, or reading files back via Browse deployed files, is more reliable from an automated environment. After a deploy, also check Error Logs — every deployed app auto-reports its own client-side JS errors with zero setup, which is real evidence the app works for actual visitors, not just that the files are present. - Design within the Limitations above rather than discovering them by trial and error — most "I need X" cases (auth, simple CRUD, storage) are already covered by this API; the limits are a stable design constraint, not a bug to route around.
- Building a multi-tenant app (one deploy, many end-user-facing sites)? Register a hostname per tenant via POST /hostnames instead of trying to deploy separately per tenant. The app itself is still responsible for reading
window.location.hostnameat runtime and rendering the right tenant — registering a hostname only makes it resolve, it doesn't change what gets rendered. Do this from the app's own mounted code (e.g. a redirect inside your router once it's running), not from a script that rewrites the URL before your JS bundle's own assets have loaded — if those assets are referenced with relative paths, rewriting the path first breaks their resolution.
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.
| Body field | Type | Description |
|---|---|---|
| string | Valid email address | |
| password | string | Minimum 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).
curl -X POST "https://your-domain/api/v1/auth/login" \
-H "Content-Type: application/json" \
-d '{"email": "you@example.com", "password": "yourpassword"}'
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"
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.| Body field | Type | Description |
|---|---|---|
| current_password | string | Your current password |
| new_password | string | New 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"}'
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}
| Body field | Type | Description |
|---|---|---|
| token | string | The raw token from /auth/forgot |
| password | string | New 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..."}
user_reset_tokens table — see migration SQL below or in catalog_schema.sql.Projects
curl "https://your-domain/api/v1/projects" \ -H "Authorization: Bearer YOUR_TOKEN"
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.
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"
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 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"
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 '{}'
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
curl "https://your-domain/api/v1/projects/1/tables" \ -H "Authorization: Bearer YOUR_TOKEN"
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.
| Field | Type | Description |
|---|---|---|
| name | string | Column name |
| type | string | One of the supported data types |
| nullable | bool | Allow NULL values (default: true) |
| default | string | bool | Optional default value. For BOOLEAN columns, true/false are accepted and coerced to 1/0 automatically. |
| references | string | Optional: 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). |
| unique | bool | Adds 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 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.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.
- anon — unauthenticated requests (no Authorization header)
- authenticated — requests with a valid user JWT, PAT, or project_user JWT
- service_role — service_key bypasses all policies entirely
- Project owner JWT also bypasses policies regardless of role
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.
| Query param | Default | Description |
|---|---|---|
| limit | 20 | Max rows to return (cap: 1000) |
| offset | 0 | Skip 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.dir | id DESC | Sort 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.
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.
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.
curl "https://your-domain/api/v1/data/1/users/42"
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"}'
curl -X DELETE "https://your-domain/api/v1/data/1/users/42" \ -H "Authorization: Bearer <service_key>"
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 field | Description |
|---|---|
| <identifier_col> | Any non-PASSWORD column to look up the row (e.g. email) |
| password | Plaintext 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.
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.
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.
.php .py .sh .exe .cgi .rb .pl .bat .cmd .htaccessSend 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"}
curl "https://your-domain/api/v1/projects/1/storage/avatars" \ -H "Authorization: Bearer YOUR_TOKEN"
Returns {"files": [{name, size, last_modified, url}, ...], "count": N}
curl -X DELETE "https://your-domain/api/v1/projects/1/storage/avatars/photo.jpg" \ -H "Authorization: Bearer YOUR_TOKEN"
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.
avatars) has no effect on any other bucket.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"
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
current/ is what end-users see.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.
curl "https://your-domain/api/v1/projects/1/sites/1" \ -H "Authorization: Bearer YOUR_TOKEN"
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.
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.
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.
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.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.
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.
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.
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.
✓ 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.
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.
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.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 '{}'
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.
"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 }
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 }
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 }, ...] }
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 }
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.
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
}
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.
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.
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).
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.
curl "https://your-domain/api/v1/projects/1/errors/download" \ -H "Authorization: Bearer YOUR_TOKEN" \ -o errors.json
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.
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.
curl "https://your-domain/api/v1/projects/1/integrations" \ -H "Authorization: Bearer YOUR_TOKEN"
curl -X DELETE "https://your-domain/api/v1/projects/1/integrations/paystack" \ -H "Authorization: Bearer YOUR_TOKEN"
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.
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.
curl "https://your-domain/api/v1/projects/1/webhooks" \ -H "Authorization: Bearer YOUR_TOKEN"
curl -X DELETE "https://your-domain/api/v1/projects/1/webhooks/paystack-charge-success" \ -H "Authorization: Bearer YOUR_TOKEN"
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.
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.
curl "https://your-domain/api/v1/projects/1/auth-email-provider" \ -H "Authorization: Bearer YOUR_TOKEN"
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).
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.
curl "https://your-domain/api/v1/projects/1/triggers" \ -H "Authorization: Bearer YOUR_TOKEN"
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.
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.
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:
| Value | Resolves 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.
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.curl "https://your-domain/api/v1/projects/1/meta-resolvers" \ -H "Authorization: Bearer YOUR_TOKEN"
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.
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.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.
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.
curl "https://your-domain/api/v1/projects/1/ai-assistants" \ -H "Authorization: Bearer YOUR_TOKEN"
curl -X DELETE "https://your-domain/api/v1/projects/1/ai-assistants/support-bot" \ -H "Authorization: Bearer YOUR_TOKEN"
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".
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.
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.
| Body field | Type | Description |
|---|---|---|
| name | string | Required. Label for the token. |
| project_id | int | Optional. 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.
curl -X DELETE "https://your-domain/api/v1/auth/tokens/1" \ -H "Authorization: Bearer YOUR_TOKEN"