ObjectStackObjectStack

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

  1. Convention over Configuration: REST endpoints follow predictable patterns
  2. Consistency: Every object uses the same URL structure and response format
  3. Discoverability: API schema available via discovery endpoint
  4. Security First: Authentication and permissions enforced on every request
  5. 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.1

Response:

{
  "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:

ParameterTypeDescriptionCanonical EquivalentExample
selectstringComma-separated field listfieldsid,name,status
filterJSONFilter criteria (see Filtering section)where{"status":"active"}
sortstringSort fields (prefix - for desc)orderBy-created_at,name
topnumberMax records to returnlimit25
skipnumberRecords to skip (offset)offset50
expandstringRelated objects to embedexpandassignee,comments
searchstringFull-text search querysearchacme
countbooleanInclude total count in responsecounttrue

Transport → Protocol normalization: The HTTP dispatcher normalizes transport-level parameter names to Spec canonical (QueryAST) field names before forwarding to the broker layer: filterwhere, selectfields, sortorderBy, toplimit, skipoffset. The deprecated filters (plural) parameter is also accepted and normalized to where.

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=name

Single field descending (prefix with -):

GET /api/data/account?sort=-created_at

Multiple fields:

GET /api/data/account?sort=-priority,created_at

First 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=50

Response 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/skip are normalized to the canonical limit/offset QueryAST 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,revenue

Response:

{
  "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.

Embed related objects to avoid N+1 queries using the expand parameter:

Request:

GET /api/data/task?expand=assignee,project

Response:

{
  "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.owner

Limits:

  • 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/batch

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)
  • 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 typeGET /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/PATCH

Optional:

Accept-Language: en-US  # Preferred language
X-Request-ID: uuid  # Request tracking
X-API-Version: 2  # API version preference

CORS 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: 86400

Preflight request:

OPTIONS /api/v1/data/account
Origin: https://app.acme.com
Access-Control-Request-Method: POST

Preflight response:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.acme.com
Access-Control-Allow-Methods: POST
Access-Control-Max-Age: 86400

Caching

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 GMT

Conditional 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: 1705324800

When 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,status

Use 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);
    });
  }
}

Next Steps

On this page