HTTP API
Standard REST mapping rules, CRUD operations, and request/response formats for ObjectStack
HTTP API
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.
Core Principles
- 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
API Discovery
Discovery Endpoint
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 redirects to the canonical discovery route:
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"
},
"features": {
"graphql": true,
"websockets": false,
"search": true,
"files": true,
"analytics": true,
"ai": false,
"workflow": true,
"notifications": true,
"i18n": true
},
"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 based on server capabilities
- Automatic configuration: SDKs auto-configure from discovery response
Standard Data API
All data operations use the base path from routes.data (default: /api/v1/data).
URL Structure
{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)
Authentication
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...Query Operations (List/Search)
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:
List responses are wrapped in the standard { success, data } envelope. For
queries, data is a FindDataResponse containing object, records, and the
optional total / hasMore pagination hints.
{
"success": true,
"data": {
"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
}
}Filtering
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 } }
]
}Sorting
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.
Pagination
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:
{
"success": true,
"data": {
"object": "account",
"records": [],
"total": 247,
"hasMore": true
}
}Note: The transport names
top/skipare normalized to the canonicallimit/offsetQueryAST fields before reaching the data layer.
Field Selection
Request only the fields you need to reduce payload size:
Request:
GET /api/data/account?select=id,name,industry,revenueResponse:
{
"success": true,
"data": {
"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.
Including Related Objects
Embed related objects to avoid N+1 queries using the expand parameter:
Request:
GET /api/data/task?expand=assignee,projectResponse:
{
"success": true,
"data": {
"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)
Retrieve Single Record
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):
{
"success": true,
"data": {
"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 Record
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):
{
"success": true,
"data": {
"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 Record
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):
{
"success": true,
"data": {
"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:
Attempting to update read-only fields (e.g., id, created_at) returns HTTP 400:
{
"error": "Cannot update read-only fields",
"code": "VALIDATION_FAILED",
"fields": [
{
"field": "created_at",
"message": "Field is read-only"
}
]
}Delete Record
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):
{
"success": true,
"data": {
"id": "acc_123",
"deleted": true
}
}Soft Delete (if enabled):
If object has enable.trash enabled, record is marked deleted but not removed:
{
"success": true,
"data": {
"id": "acc_123",
"deleted": true,
"deleted_at": "2024-01-16T12:00:00Z",
"deleted_by": "user_456"
}
}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.
Batch Operations
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/batchEach 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) - The entire batch runs inside one engine transaction — if any operation fails, all are rolled back (commit-all-or-nothing)
- Returns HTTP 501 if the underlying runtime does not support transactions
Metadata API
Retrieve object schemas and configuration.
Base path: From routes.metadata (default: /api/v1/meta)
List All Objects
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:
{
"success": true,
"data": [
{
"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
}
]
}Get Object Schema
Request:
GET /api/v1/meta/object/account
Authorization: Bearer <token>Response:
{
"success": true,
"data": {
"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
}
}
}Request Headers
Standard Headers
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 preferenceCORS Headers
ObjectStack sends CORS headers automatically:
Access-Control-Allow-Origin: https://app.acme.com
Access-Control-Allow-Methods: GET, POST, PATCH, DELETE, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 86400Preflight 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: 86400Caching
ObjectStack 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)
Rate Limiting
ObjectStack ships a token-bucket RateLimiter primitive
(@objectstack/runtime), but emission of the X-RateLimit-* response headers
and the 429 envelope below is deployment-specific and not wired into the
default REST response path. Treat the headers and response shape here as the
intended contract.
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.
Best Practices
Use Field Selection
❌ 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,statusUse Expand for Relations
❌ Bad: N+1 queries
const tasks = await fetch('/api/data/task');
for (const task of tasks.data) {
task.assignee = await fetch(`/api/data/user/${task.assignee_id}`);
}✅ Good: Single query with expand
const tasks = await fetch('/api/data/task?expand=assignee');Respect Rate Limits
✅ 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);
}Handle Errors Gracefully
✅ 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);
});
}
}