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:

terminal
based table access notes owner-scoped
based table access posts public-read
based table access audit_log private
PolicyReadWrite
owner-scopedAny signed-in user, limited to rows they own. Anon is 403Any signed-in user, on their own rows. user_id is forced to the caller
public-readEveryone, including anon keys — the whole tableOwners only
privateOwners onlyOwners 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.

OperationBehavior under owner-scoped
GET list (auth)Only returns the caller's own rows
GET list (anon)Rejected (403 FORBIDDEN)
GET :id404 if the row belongs to another user
POSTuser_id is forced to the caller, ignoring any client value
PUT :idScoped to the caller; user_id is immutable
DELETE :id404 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.

request
# 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>"
response — 200
{
  "data": [
    { "id": "V1StGXR8_Z5jdHi6B", "title": "Hello", "status": "draft" },
    { "id": "q8Wn3aF2kLpZ0xRtY", "title": "World", "status": "draft" }
  ],
  "total": 2
}

Query parameters

ParameterDefaultNotes
limit20Capped at 100 — a larger value is clamped, not rejected
offset0Rows to skip
orderid.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.

request
curl "https://my-app.based.yourdomain.com/api/posts/V1StGXR8_Z5jdHi6B" \
  -H "apikey: <anonKey>"
response — 200
{
  "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.

request
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" }'
response — 201
{
  "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.

request
# 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" }'
response — 200 (update) or 201 (create)
{
  "data": { "id": "q8Wn3aF2kLpZ0xRtY", "title": "New Post", "status": "published" }
}

DELETE /api/:table/:id

Delete a record.

request
curl -X DELETE "https://my-app.based.yourdomain.com/api/posts/q8Wn3aF2kLpZ0xRtY" \
  -H "Authorization: Bearer <accessToken>"

Authentication

MethodHeaderActs as
Bearer tokenAuthorization: Bearer <accessToken>The signed-in user, with their role
Anon keyapikey: <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 response
{
  "error": {
    "code": "NOT_FOUND",
    "message": "Record not found"
  }
}
error response — 409
{
  "error": {
    "code": "CONFLICT",
    "message": "A record with the same unique value already exists",
    "details": { "constraint": "unique", "column": "posts.slug" }
  }
}
StatusCodeWhen
400VALIDATION_ERRORUnknown column, uncoercible value, bad order, or a body Zod rejects
400INVALID_JSONThe request body is not valid JSON
401UNAUTHORIZEDMissing, malformed, or expired credentials
403FORBIDDENThe table's access policy does not allow this caller
404NOT_FOUNDNo such row, or it belongs to another user
409CONFLICTA unique constraint rejected the write