REST API
Every table you create gets a full REST API automatically. No code generation, no config — just create a table and start querying.
Endpoints are auto-generated for all tables except users and sessions, which are managed by the auth system.
Access policies
Each table has one access policy. Based picks a default when the table is created: owner-scoped if the table has a user_id (or userId) column, otherwise private. Change it any time:
based table access notes owner-scoped
based table access posts public-read
based table access audit_log private| Policy | Read | Write |
|---|---|---|
| owner-scoped | Any signed-in user, limited to rows they own. Anon is 403 | Any signed-in user, on their own rows. user_id is forced to the caller |
| public-read | Everyone, including anon keys — the whole table | Owners only |
| private | Owners only | Owners only |
owner-scoped needs an owner column to mean anything, so a table without one falls back to private rather than becoming readable by every member. Roles come from signup order: the first account on a project is the owner, everyone after is a member.
| Operation | Behavior under owner-scoped |
|---|---|
| GET list (auth) | Only returns the caller's own rows |
| GET list (anon) | Rejected (403 FORBIDDEN) |
| GET :id | 404 if the row belongs to another user |
| POST | user_id is forced to the caller, ignoring any client value |
| PUT :id | Scoped to the caller; user_id is immutable |
| DELETE :id | 404 for rows belonging to another user |
Set a table to public-read for content everyone should see but only you should change — a blog, a changelog, a price list.
GET /api/:table
List records with pagination, filtering, and ordering.
# Paginated list
curl "https://my-app.based.yourdomain.com/api/posts?limit=10&offset=0" \
-H "apikey: <anonKey>"
# Filter, then sort newest first
curl "https://my-app.based.yourdomain.com/api/posts?status=draft&order=createdAt.desc" \
-H "apikey: <anonKey>"{
"data": [
{ "id": "V1StGXR8_Z5jdHi6B", "title": "Hello", "status": "draft" },
{ "id": "q8Wn3aF2kLpZ0xRtY", "title": "World", "status": "draft" }
],
"total": 2
}Query parameters
| Parameter | Default | Notes |
|---|---|---|
| limit | 20 | Capped at 100 — a larger value is clamped, not rejected |
| offset | 0 | Rows to skip |
| order | id.asc | `<column>.asc` or `<column>.desc`. An unknown column is a 400 |
| <column>=<value> | — | Equality filter. An unknown column is a 400 |
Filter values are coerced to the column's type: integers and reals are parsed as numbers, booleans accept true/false, and timestamp columns accept either an ISO date or epoch milliseconds. A value that cannot be coerced is a 400 VALIDATION_ERROR naming the parameter.
GET /api/:table/:id
Fetch a single record by id. Ids are strings — the server generates a nanoid when you omit one on create.
curl "https://my-app.based.yourdomain.com/api/posts/V1StGXR8_Z5jdHi6B" \
-H "apikey: <anonKey>"{
"data": { "id": "V1StGXR8_Z5jdHi6B", "title": "Hello", "status": "draft" }
}POST /api/:table
Create a new record. Validation is strict: a key that is not a column is rejected rather than ignored, so a typo fails loudly instead of silently dropping data.
curl -X POST "https://my-app.based.yourdomain.com/api/posts" \
-H "Authorization: Bearer <accessToken>" \
-H "Content-Type: application/json" \
-d '{ "title": "New Post", "status": "draft" }'{
"data": {
"id": "q8Wn3aF2kLpZ0xRtY",
"title": "New Post",
"status": "draft",
"createdAt": "2026-09-09T10:00:00.000Z",
"updatedAt": "2026-09-09T10:00:00.000Z"
}
}PUT /api/:table/:id
Upsert: creates the record if it does not exist, updates it if it does. Returns 201 on create and 200 on update, and validates as strictly as POST.
# Update existing
curl -X PUT "https://my-app.based.yourdomain.com/api/posts/q8Wn3aF2kLpZ0xRtY" \
-H "Authorization: Bearer <accessToken>" \
-H "Content-Type: application/json" \
-d '{ "status": "published" }'
# Upsert at an id you choose (creates if missing)
curl -X PUT "https://my-app.based.yourdomain.com/api/preferences/alice--theme" \
-H "Authorization: Bearer <accessToken>" \
-H "Content-Type: application/json" \
-d '{ "value": "dark" }'{
"data": { "id": "q8Wn3aF2kLpZ0xRtY", "title": "New Post", "status": "published" }
}DELETE /api/:table/:id
Delete a record.
curl -X DELETE "https://my-app.based.yourdomain.com/api/posts/q8Wn3aF2kLpZ0xRtY" \
-H "Authorization: Bearer <accessToken>"Authentication
| Method | Header | Acts as |
|---|---|---|
| Bearer token | Authorization: Bearer <accessToken> | The signed-in user, with their role |
| Anon key | apikey: <anonKey> | Anonymous — reads public-read tables only |
Error responses
Errors share one envelope, with an optional details object. Driver messages are never echoed back — a SQLite error is translated, not forwarded.
{
"error": {
"code": "NOT_FOUND",
"message": "Record not found"
}
}{
"error": {
"code": "CONFLICT",
"message": "A record with the same unique value already exists",
"details": { "constraint": "unique", "column": "posts.slug" }
}
}| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Unknown column, uncoercible value, bad order, or a body Zod rejects |
| 400 | INVALID_JSON | The request body is not valid JSON |
| 401 | UNAUTHORIZED | Missing, malformed, or expired credentials |
| 403 | FORBIDDEN | The table's access policy does not allow this caller |
| 404 | NOT_FOUND | No such row, or it belongs to another user |
| 409 | CONFLICT | A unique constraint rejected the write |