| 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.comAlternative endpoint:
GET /api/v1 HTTP/1.1Response:
{
"name": "Acme CRM Production",
"version": "2.1.0",
"environment": "production",
"routes": {
"data": "/api/v1/data",
"metadata": "/api/v1/meta",
"auth": "/api/v1/auth",
"actions": "/api/v1/actions",
"storage": "/api/v1/storage",
"graphql": "/api/v1/graphql",
"realtime": "wss://api.acme.com/ws"
},
"features": {
"graphql": true,
"websocket": true,
"search": true,
"files": true,
"batch": true,
"webhooks": true
},
"limits": {
"max_page_size": 100,
"default_page_size": 25,
"max_batch_size": 50,
"rate_limit": 1000,
"rate_window": "1m"
},
"locale": {
"default": "en-US",
"supported": ["en-US", "zh-CN", "es-ES", "fr-FR"],
"timezone": "America/Los_Angeles"
},
"documentation": "https://docs.acme.com/api"
}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
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 |
page |
number | Page number (1-indexed) | — | 2 |
per_page |
number | Items per page (max from limits) | — | 50 |
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&page=1&per_page=25
Authorization: Bearer <token>Success Response:
{
"success": true,
"data": [
{
"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"
}
],
"pagination": {
"page": 1,
"per_page": 25,
"total": 47,
"total_pages": 2,
"has_next": true,
"has_prev": false
}
}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$startsWith- String starts with$endsWith- String ends with$between- Between two values$null- Is null$notNull- Is not null
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:
Request page 2 with 50 items:
GET /api/data/account?page=2&per_page=50Response includes pagination metadata:
{
"success": true,
"data": [...],
"pagination": {
"page": 2,
"per_page": 50,
"total": 247,
"total_pages": 5,
"has_next": true,
"has_prev": true
}
}Pagination limits:
- Default page size: 25 (from discovery
limits.default_page_size) - Maximum page size: 100 (from discovery
limits.max_page_size) - Requesting
per_page > max_page_sizereturns HTTP 400
Request only the fields you need to reduce payload size:
Request:
GET /api/data/account?select=id,name,industry,revenueResponse:
{
"success": true,
"data": [
{
"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:
Request:
GET /api/data/task?include=assignee,projectResponse:
{
"success": true,
"data": [
{
"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?include=assignee.department,project.ownerLimits:
- Maximum include depth: 3 levels
- Maximum included relations: 5 per request
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):
{
"success": false,
"error": {
"code": "NOT_FOUND",
"message": "Account with id 'acc_999' not found",
"resource": "account",
"resource_id": "acc_999"
}
}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):
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Validation failed",
"fields": [
{
"field": "name",
"message": "Name is required",
"constraint": "required"
},
{
"field": "industry",
"message": "Must be one of: Technology, SaaS, Healthcare, Finance",
"constraint": "enum",
"value": "InvalidIndustry"
}
]
}
}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:
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Cannot update read-only fields",
"fields": [
{
"field": "created_at",
"message": "Field is read-only"
}
]
}
}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.softDelete, 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"
}
}Cascade Considerations: If object has related records and cascade delete is not enabled:
{
"success": false,
"error": {
"code": "CONSTRAINT_VIOLATION",
"message": "Cannot delete account with active opportunities",
"constraint": "foreign_key",
"related_object": "opportunity",
"related_count": 5
}
}Perform multiple operations in a single request.
Endpoint:
POST /{base_path}/_batchRequest:
POST /api/v1/data/_batch
Authorization: Bearer <token>
Content-Type: application/json
{
"operations": [
{
"method": "POST",
"path": "/account",
"body": { "name": "Company A", "industry": "Tech" }
},
{
"method": "PATCH",
"path": "/account/acc_123",
"body": { "status": "active" }
},
{
"method": "DELETE",
"path": "/account/acc_456"
}
]
}Response:
{
"success": true,
"results": [
{
"success": true,
"status": 201,
"data": { "id": "acc_789", "name": "Company A" }
},
{
"success": true,
"status": 200,
"data": { "id": "acc_123", "status": "active" }
},
{
"success": true,
"status": 200,
"data": { "id": "acc_456", "deleted": true }
}
]
}Limits:
- Maximum batch size: 50 operations (from
limits.max_batch_size) - Operations are executed sequentially
- Failure of one operation doesn't stop others (non-transactional by default)
Transactional batch:
{
"operations": [...],
"atomic": true
}If atomic: true, all operations execute in a transaction. If any fails, all are rolled back.
Retrieve object schemas and configuration.
Base path: From routes.metadata (default: /api/v1/meta)
Request:
GET /api/v1/meta/objects
Authorization: Bearer <token>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
}
]
}Request:
GET /api/v1/meta/objects/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,
"softDelete": 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, 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: 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)
Requests 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
{
"success": false,
"error": {
"code": "RATE_LIMITED",
"message": "Rate limit exceeded",
"retry_after": 45,
"limit": 1000,
"window": "1m"
}
}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 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 includes
const tasks = await fetch('/api/data/task?include=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 (!result.success) {
if (result.error.code === 'VALIDATION_ERROR') {
result.error.fields.forEach(field => {
showFieldError(field.field, field.message);
});
}
}