| title | HTTP API |
|---|---|
| description | Standard REST mapping rules, CRUD operations, and request/response formats for ObjectStack |
import { Radio, Code, Database, Lock, Zap, CheckCircle, AlertCircle } from 'lucide-react';
The HTTP API defines how ObjectStack maps data operations to RESTful HTTP endpoints. Every object you define automatically gets a complete set of CRUD (Create, Read, Update, Delete) operations with consistent request/response formats.
- Convention over Configuration: REST endpoints follow predictable patterns
- Consistency: Every object uses the same URL structure and response format
- Discoverability: API schema available via discovery endpoint
- Security First: Authentication and permissions enforced on every request
- Performance: Built-in caching, pagination, and field selection
Before making any API calls, clients should request the discovery endpoint to learn about available services:
Request:
GET /.well-known/objectstack HTTP/1.1
Host: api.acme.com/.well-known/objectstack and the versioned /api/v1/discovery route both return the
discovery document directly — there is no HTTP redirect between them:
GET /api/v1/discovery HTTP/1.1Response:
{
"name": "Acme CRM Production",
"version": "2.1.0",
"environment": "production",
"routes": {
"data": "/api/v1/data",
"metadata": "/api/v1/meta",
"packages": "/api/v1/packages",
"auth": "/api/v1/auth",
"ui": "/api/v1/ui",
"storage": "/api/v1/storage",
"graphql": "/api/v1/graphql"
},
"services": {
"data": { "enabled": true, "status": "available", "route": "/api/v1/data", "provider": "objectql" },
"metadata": { "enabled": true, "status": "available", "route": "/api/v1/meta", "provider": "objectql" },
"auth": { "enabled": true, "status": "available", "route": "/api/v1/auth", "provider": "@objectstack/plugin-auth" },
"workflow": { "enabled": false, "status": "unavailable", "message": "No implementation ships for the 'workflow' slot — register a service under it to enable" },
"ai": { "enabled": false, "status": "unavailable", "message": "No implementation ships for the 'ai' slot — register a service under it to enable" }
},
"locale": {
"default": "en-US",
"supported": ["en-US", "zh-CN", "es-ES", "fr-FR"],
"timezone": "America/Los_Angeles"
}
}Why discovery matters:
- Environment agnostic: Works across dev, staging, production without hardcoding URLs
- Version tolerance: API routes can change without breaking clients
- Feature detection: Clients enable/disable features by inspecting each entry's
enabled/statusin theservicesmap - Automatic configuration: SDKs auto-configure from discovery response
All data operations use the base path from routes.data (default: /api/v1/data).
{base_path}/{object_name}/{record_id?}
Examples:
/api/v1/data/account- Account collection/api/v1/data/account/acc_123- Specific account/api/v1/data/project_task- Project task collection (snake_case)
All requests require authentication via one of these methods:
1. Bearer Token (JWT):
GET /api/v1/data/task
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...2. API Key:
GET /api/v1/data/task
X-API-Key: sk_live_abc123...3. Session Cookie:
GET /api/v1/data/task
Cookie: session_id=xyz789...Retrieve multiple records from an object.
Endpoint:
GET /{base_path}/{object_name}Query Parameters:
| Parameter | Type | Description | Canonical Equivalent | Example |
|---|---|---|---|---|
select |
string | Comma-separated field list | fields |
id,name,status |
filter |
JSON | Filter criteria (see Filtering section) | where |
{"status":"active"} |
sort |
string | Sort fields (prefix - for desc) |
orderBy |
-created_at,name |
top |
number | Max records to return | limit |
25 |
skip |
number | Records to skip (offset) | offset |
50 |
expand |
string | Related objects to embed | expand |
assignee,comments |
search |
string | Full-text search query | search |
acme |
count |
boolean | Include total count in response | count |
true |
Transport → Protocol normalization: The HTTP dispatcher normalizes transport-level parameter names to Spec canonical (QueryAST) field names before forwarding to the broker layer:
filter→where,select→fields,sort→orderBy,top→limit,skip→offset. The deprecatedfilters(plural) parameter is also accepted and normalized towhere.
Example Request:
GET /api/v1/data/task?select=id,title,status&filter={"assignee_id":"user_123"}&sort=-created_at&top=25&count=true
Authorization: Bearer <token>Success Response:
A list query returns a FindDataResponse directly — there is no outer envelope.
The response carries the object name, the records array, and the optional
total / hasMore pagination hints.
{
"object": "task",
"records": [
{
"id": "task_456",
"title": "Implement login page",
"status": "in_progress",
"created_at": "2024-01-15T10:30:00Z"
},
{
"id": "task_789",
"title": "Fix navigation bug",
"status": "todo",
"created_at": "2024-01-14T16:20:00Z"
}
],
"total": 47,
"hasMore": true
}Filters are passed as JSON in the filter query parameter.
Basic equality:
{ "status": "active" }GET /api/data/account?filter={"status":"active"}Multiple conditions (AND):
{
"status": "active",
"industry": "Technology"
}Operators:
{
"revenue": { "$gte": 100000 },
"employees": { "$lte": 500 },
"name": { "$contains": "Tech" },
"created_at": { "$between": ["2024-01-01", "2024-12-31"] }
}Supported operators:
$eq- Equals (default)$ne- Not equals$gt- Greater than$gte- Greater than or equal$lt- Less than$lte- Less than or equal$in- In array$nin- Not in array$contains- String contains$notContains- String does not contain$startsWith- String starts with$endsWith- String ends with$between- Between two values (tuple)$null- Null check ({ "$null": true }for IS NULL,{ "$null": false }for IS NOT NULL)$exists- Field existence check
OR conditions:
{
"$or": [
{ "status": "urgent" },
{ "priority": "high" }
]
}Complex nested filters:
{
"$and": [
{ "status": "active" },
{
"$or": [
{ "industry": "Technology" },
{ "industry": "SaaS" }
]
},
{ "revenue": { "$gte": 1000000 } }
]
}Sort by one or more fields using the sort parameter:
Single field ascending:
GET /api/data/account?sort=nameSingle field descending (prefix with -):
GET /api/data/account?sort=-created_atMultiple fields:
GET /api/data/account?sort=-priority,created_atFirst sort by priority descending, then by created_at ascending.
ObjectStack uses offset-based pagination via the top (limit) and skip
(offset) parameters:
Request 50 items, skipping the first 50 (i.e. the "second page"):
GET /api/data/account?top=50&skip=50Response includes optional pagination hints:
When count=true is requested, the FindDataResponse carries a total
record count and a hasMore flag:
{
"object": "account",
"records": [],
"total": 247,
"hasMore": true
}Note: The transport names
top/skipare normalized to the canonicallimit/offsetQueryAST fields before reaching the data layer.
Request only the fields you need to reduce payload size:
Request:
GET /api/data/account?select=id,name,industry,revenueResponse:
{
"object": "account",
"records": [
{
"id": "acc_123",
"name": "Acme Corp",
"industry": "Technology",
"revenue": 5000000
}
]
}Benefits:
- Reduced bandwidth (especially for mobile)
- Faster response times
- Lower server CPU usage
Note: System fields (id, created_at, updated_at) are always included even if not in select.
Embed related objects to avoid N+1 queries using the expand parameter:
Request:
GET /api/data/task?expand=assignee,projectResponse:
{
"object": "task",
"records": [
{
"id": "task_123",
"title": "Implement API",
"assignee_id": "user_456",
"project_id": "proj_789",
"assignee": {
"id": "user_456",
"name": "John Doe",
"email": "john@acme.com"
},
"project": {
"id": "proj_789",
"name": "CRM Rebuild",
"status": "active"
}
}
]
}Multiple levels:
GET /api/data/task?expand=assignee.department,project.ownerLimits:
- Maximum expand depth: 3 levels by default (configurable via the query adapter's
maxDepth)
Get a specific record by ID.
Endpoint:
GET /{base_path}/{object_name}/{record_id}Example Request:
GET /api/v1/data/account/acc_123
Authorization: Bearer <token>Success Response (HTTP 200):
A single-record read returns a GetDataResponse: the object name, the record
id, and the full record under record.
{
"object": "account",
"id": "acc_123",
"record": {
"id": "acc_123",
"name": "Acme Corporation",
"industry": "Technology",
"revenue": 5000000,
"status": "active",
"owner_id": "user_456",
"created_at": "2024-01-10T14:30:00Z",
"updated_at": "2024-01-15T09:20:00Z"
}
}Not Found (HTTP 404):
REST error responses use a flat envelope: a top-level error message string and
a string code, plus optional context fields (object, per-field fields):
{
"error": "Record acc_999 not found in account",
"code": "RECORD_NOT_FOUND",
"object": "account"
}Create a new record.
Endpoint:
POST /{base_path}/{object_name}Request:
POST /api/v1/data/account
Authorization: Bearer <token>
Content-Type: application/json
{
"name": "TechStart Inc",
"industry": "SaaS",
"revenue": 250000,
"owner_id": "user_789"
}Success Response (HTTP 201):
Create returns a CreateDataResponse: the object name, the new record id, and
the created record (including server-generated fields) under record.
{
"object": "account",
"id": "acc_124",
"record": {
"id": "acc_124",
"name": "TechStart Inc",
"industry": "SaaS",
"revenue": 250000,
"status": "active",
"owner_id": "user_789",
"created_at": "2024-01-16T10:15:00Z",
"updated_at": "2024-01-16T10:15:00Z"
}
}Validation Error (HTTP 400):
{
"error": "Validation failed",
"code": "VALIDATION_FAILED",
"object": "account",
"fields": [
{
"field": "name",
"code": "required",
"message": "Name is required"
},
{
"field": "industry",
"code": "enum",
"message": "Must be one of: Technology, SaaS, Healthcare, Finance"
}
]
}Update an existing record (partial update).
Endpoint:
PATCH /{base_path}/{object_name}/{record_id}Request:
PATCH /api/v1/data/account/acc_123
Authorization: Bearer <token>
Content-Type: application/json
{
"revenue": 6000000,
"status": "vip"
}Success Response (HTTP 200):
Update returns an UpdateDataResponse: the object name, the record id, and the
updated record under record.
{
"object": "account",
"id": "acc_123",
"record": {
"id": "acc_123",
"name": "Acme Corporation",
"industry": "Technology",
"revenue": 6000000,
"status": "vip",
"owner_id": "user_456",
"created_at": "2024-01-10T14:30:00Z",
"updated_at": "2024-01-16T11:45:00Z"
}
}Note: Only fields included in the request body are updated. Other fields remain unchanged.
Read-only fields:
Caller-supplied writes to statically read-only fields (e.g., id, created_at) are
silently stripped from a non-system update rather than rejected (#2948): the
request succeeds with HTTP 200 and every other field is applied, but the read-only
field is left unchanged.
Note: This differs from field-level security. A write to a field the caller lacks edit permission on is rejected with
403 PermissionDeniedError, not stripped.
Delete a record by ID.
Endpoint:
DELETE /{base_path}/{object_name}/{record_id}Request:
DELETE /api/v1/data/account/acc_123
Authorization: Bearer <token>Success Response (HTTP 200):
Delete returns a DeleteDataResponse: the object name, the record id, and a
success flag.
{
"object": "account",
"id": "acc_123",
"success": true
}Constraint Violations: Database constraint failures are surfaced as structured errors. For example, a unique-constraint violation returns HTTP 409:
{
"error": "A record with this value already exists",
"code": "UNIQUE_VIOLATION",
"object": "account"
}Cascade behavior on delete (cascade / restrict / set-null) is governed by each relationship field's configuration in the object schema, enforced by the ObjectQL engine.
Perform multiple create/update/delete operations across objects in a single atomic transaction.
Endpoint: the batch endpoint is mounted at the top of the API surface
(not under /data):
POST /api/v1/batchThe typed SDK surface for this route is
client.data.batchTransaction(operations).
Each operation specifies an action (create, update, or delete), the
target object, and the relevant data / id. A field value of
{ "$ref": <earlier op index> } resolves to the id created by an earlier
operation in the same batch — useful for inserting a parent and its children
together (master-detail).
Request:
POST /api/v1/batch
Authorization: Bearer <token>
Content-Type: application/json
{
"operations": [
{
"action": "create",
"object": "account",
"data": { "name": "Company A", "industry": "Tech" }
},
{
"action": "update",
"object": "account",
"id": "acc_123",
"data": { "status": "active" }
},
{
"action": "delete",
"object": "account",
"id": "acc_456"
}
]
}Response: an ordered results array mirroring the input operations:
{
"results": [
{ "id": "acc_789", "name": "Company A" },
{ "id": "acc_123", "status": "active" },
{ "id": "acc_456", "deleted": true }
]
}Behavior:
- Maximum batch size: 200 operations by default (configurable via
maxBatchSize). Over the cap is400 BATCH_TOO_LARGE, carrying thecountsent and themaxallowed. The same cap and the same code apply to every bulk write route (createMany/updateMany/deleteMany/ per-objectbatch) - The entire batch runs inside one engine transaction — if any operation
fails, all are rolled back (commit-all-or-nothing). The batch is always
atomic; an explicit
"atomic": falseis rejected with400 BATCH_NOT_ATOMIC(usePOST /data/{object}/batchfor a non-atomic per-object batch) - Every operation is subject to the same per-object API-exposure gate as the
single-record routes, enforced before the transaction opens: an object with
enable.apiEnabled: falsereturns404 OBJECT_API_DISABLED, and an action outside an object'senable.apiMethodswhitelist returns405 OBJECT_API_METHOD_NOT_ALLOWED - The request shape is validated: a malformed operation, an unknown action, or a
missing
objectreturns400;update/deleterequire anid - A
{ "$ref": <index> }that does not resolve to an earlier create's id returns400 BATCH_UNRESOLVED_REF(never a silently-written null value) - Returns HTTP 501 if the underlying runtime does not support transactions
Retrieve object schemas and configuration.
Base path: From routes.metadata (default: /api/v1/meta)
Request:
GET /api/v1/meta/object
Authorization: Bearer <token>The metadata API is keyed by metadata type — GET /api/v1/meta/{type}
lists items of that type. Types are singular (object, view, app, …), so
objects are listed at /api/v1/meta/object.
Response:
A type listing returns { type, items } — the requested metadata type plus the
items array of matching entries.
{
"type": "object",
"items": [
{
"name": "account",
"label": "Account",
"plural_label": "Accounts",
"description": "Business accounts and customers",
"api_enabled": true,
"searchable": true
},
{
"name": "contact",
"label": "Contact",
"plural_label": "Contacts",
"api_enabled": true,
"searchable": true
}
]
}Request:
GET /api/v1/meta/object/account
Authorization: Bearer <token>Response:
A single-item read returns { type, name, item } — the metadata type, the item
name, and the full schema under item.
{
"type": "object",
"name": "account",
"item": {
"name": "account",
"label": "Account",
"plural_label": "Accounts",
"fields": {
"id": {
"name": "id",
"label": "ID",
"type": "text",
"readonly": true,
"required": true
},
"name": {
"name": "name",
"label": "Account Name",
"type": "text",
"required": true,
"maxLength": 255
},
"industry": {
"name": "industry",
"label": "Industry",
"type": "select",
"options": ["Technology", "SaaS", "Healthcare", "Finance"]
},
"revenue": {
"name": "revenue",
"label": "Annual Revenue",
"type": "number",
"format": "currency"
}
},
"enable": {
"trackHistory": true,
"apiEnabled": true,
"trash": true
}
}
}Required:
Authorization: Bearer <token>
Content-Type: application/json # For POST/PATCHOptional:
Accept-Language: en-US # Preferred language
X-Request-ID: uuid # Request tracking
X-API-Version: 2 # API version preferenceObjectStack sends CORS headers automatically:
Access-Control-Allow-Origin: https://app.acme.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With, X-Tenant-ID, X-Environment-Id, If-Match
Access-Control-Expose-Headers: set-auth-token, x-objectstack-dropped-fields
Access-Control-Max-Age: 86400Three of the allowed request headers are easy to overlook, and each one disables a feature if an intermediate proxy strips it:
| Header | Why it is allowed |
|---|---|
X-Tenant-ID / X-Environment-Id |
Route the request to its environment on a multi-tenant host. |
If-Match |
Carries the OCC token on record PATCHes. Without it, a cross-origin save fails in the browser with "Failed to fetch". |
The two exposed response headers matter to browser clients specifically:
set-auth-token delivers a rotated session token (without it a cross-origin
session silently breaks even though every request succeeds), and
x-objectstack-dropped-fields warns that a write dropped undeclared keys — the
response body's droppedFields stays the primary channel for that.
These are the defaults exported as DEFAULT_CORS_ALLOW_HEADERS and
DEFAULT_CORS_EXPOSE_HEADERS from @objectstack/plugin-hono-server. Supplying
allowHeaders replaces the default; supplying exposeHeaders merges
with it.
Preflight request:
OPTIONS /api/v1/data/account
Origin: https://app.acme.com
Access-Control-Request-Method: POSTPreflight response:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.acme.com
Access-Control-Allow-Methods: POST
Access-Control-Max-Age: 86400ObjectStack supports HTTP caching for GET requests:
Response with cache headers:
HTTP/1.1 200 OK
Cache-Control: private, max-age=60
ETag: "abc123def456"
Last-Modified: Wed, 15 Jan 2024 10:30:00 GMTConditional request:
GET /api/v1/data/account/acc_123
If-None-Match: "abc123def456"Not modified response:
HTTP/1.1 304 Not Modified
ETag: "abc123def456"Cache behavior:
- GET requests: Cacheable with ETags
- POST/PATCH/DELETE: Not cacheable
- Cache duration: Configurable per object (default 60 seconds)
When enabled, responses include rate limit headers:
HTTP/1.1 200 OK
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 847
X-RateLimit-Reset: 1705324800When limit exceeded:
HTTP/1.1 429 Too Many Requests
Retry-After: 45
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1705324800
{
"error": "Rate limit exceeded",
"code": "THROTTLED",
"retry_after": 45
}See Error Handling for more details.
❌ Bad: Fetch all fields when you only need a few
GET /api/data/account✅ Good: Request only needed fields
GET /api/data/account?select=id,name,status❌ Bad: N+1 queries
const res = await fetch('/api/data/task');
const { records } = await res.json();
for (const task of records) {
task.assignee = await fetch(`/api/data/user/${task.assignee_id}`);
}✅ Good: Single query with expand
const tasks = await fetch('/api/data/task?expand=assignee');✅ Good: Check headers and implement backoff
const response = await fetch('/api/data/task');
const remaining = response.headers.get('X-RateLimit-Remaining');
if (remaining < 10) {
console.warn('Approaching rate limit');
await sleep(1000);
}✅ Good: Parse error structure
const response = await fetch('/api/data/task', { method: 'POST', body: data });
const result = await response.json();
if (!response.ok) {
if (result.code === 'VALIDATION_FAILED') {
result.fields.forEach(field => {
showFieldError(field.field, field.message);
});
}
}