---
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';

# 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:**
```http
GET /.well-known/objectstack HTTP/1.1
Host: api.acme.com
```

**Alternative endpoint:**
```http
GET /api/v1 HTTP/1.1
```

**Response:**
```json
{
  "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

## 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):**
```http
GET /api/v1/data/task
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
```

**2. API Key:**
```http
GET /api/v1/data/task
X-API-Key: sk_live_abc123...
```

**3. Session Cookie:**
```http
GET /api/v1/data/task
Cookie: session_id=xyz789...
```

### Query Operations (List/Search)

Retrieve multiple records from an object.

**Endpoint:**
```http
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 deprecated `filters` (plural) parameter is also accepted and
> normalized to `where`.

**Example Request:**
```http
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:**
```json
{
  "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
  }
}
```

### Filtering

Filters are passed as JSON in the `filter` query parameter.

**Basic equality:**
```json
{ "status": "active" }
```
```http
GET /api/data/account?filter={"status":"active"}
```

**Multiple conditions (AND):**
```json
{
  "status": "active",
  "industry": "Technology"
}
```

**Operators:**
```json
{
  "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:**
```json
{
  "$or": [
    { "status": "urgent" },
    { "priority": "high" }
  ]
}
```

**Complex nested filters:**
```json
{
  "$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:**
```http
GET /api/data/account?sort=name
```

**Single field descending (prefix with `-`):**
```http
GET /api/data/account?sort=-created_at
```

**Multiple fields:**
```http
GET /api/data/account?sort=-priority,created_at
```
First sort by priority descending, then by created_at ascending.

### Pagination

ObjectStack uses offset-based pagination:

**Request page 2 with 50 items:**
```http
GET /api/data/account?page=2&per_page=50
```

**Response includes pagination metadata:**
```json
{
  "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_size` returns HTTP 400

### Field Selection

Request only the fields you need to reduce payload size:

**Request:**
```http
GET /api/data/account?select=id,name,industry,revenue
```

**Response:**
```json
{
  "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`.

### Including Related Objects

Embed related objects to avoid N+1 queries:

**Request:**
```http
GET /api/data/task?include=assignee,project
```

**Response:**
```json
{
  "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:**
```http
GET /api/data/task?include=assignee.department,project.owner
```

**Limits:**
- Maximum include depth: 3 levels
- Maximum included relations: 5 per request

### Retrieve Single Record

Get a specific record by ID.

**Endpoint:**
```http
GET /{base_path}/{object_name}/{record_id}
```

**Example Request:**
```http
GET /api/v1/data/account/acc_123
Authorization: Bearer <token>
```

**Success Response (HTTP 200):**
```json
{
  "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):**
```json
{
  "success": false,
  "error": {
    "code": "NOT_FOUND",
    "message": "Account with id 'acc_999' not found",
    "resource": "account",
    "resource_id": "acc_999"
  }
}
```

### Create Record

Create a new record.

**Endpoint:**
```http
POST /{base_path}/{object_name}
```

**Request:**
```http
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):**
```json
{
  "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):**
```json
{
  "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 Record

Update an existing record (partial update).

**Endpoint:**
```http
PATCH /{base_path}/{object_name}/{record_id}
```

**Request:**
```http
PATCH /api/v1/data/account/acc_123
Authorization: Bearer <token>
Content-Type: application/json

{
  "revenue": 6000000,
  "status": "vip"
}
```

**Success Response (HTTP 200):**
```json
{
  "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:

```json
{
  "success": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Cannot update read-only fields",
    "fields": [
      {
        "field": "created_at",
        "message": "Field is read-only"
      }
    ]
  }
}
```

### Delete Record

Delete a record by ID.

**Endpoint:**
```http
DELETE /{base_path}/{object_name}/{record_id}
```

**Request:**
```http
DELETE /api/v1/data/account/acc_123
Authorization: Bearer <token>
```

**Success Response (HTTP 200):**
```json
{
  "success": true,
  "data": {
    "id": "acc_123",
    "deleted": true
  }
}
```

**Soft Delete (if enabled):**
If object has `enable.softDelete`, record is marked deleted but not removed:

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

```json
{
  "success": false,
  "error": {
    "code": "CONSTRAINT_VIOLATION",
    "message": "Cannot delete account with active opportunities",
    "constraint": "foreign_key",
    "related_object": "opportunity",
    "related_count": 5
  }
}
```

## Batch Operations

Perform multiple operations in a single request.

**Endpoint:**
```http
POST /{base_path}/_batch
```

**Request:**
```http
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:**
```json
{
  "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:**
```json
{
  "operations": [...],
  "atomic": true
}
```
If `atomic: true`, all operations execute in a transaction. If any fails, all are rolled back.

## Metadata API

Retrieve object schemas and configuration.

**Base path:** From `routes.metadata` (default: `/api/v1/meta`)

### List All Objects

**Request:**
```http
GET /api/v1/meta/objects
Authorization: Bearer <token>
```

**Response:**
```json
{
  "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:**
```http
GET /api/v1/meta/objects/account
Authorization: Bearer <token>
```

**Response:**
```json
{
  "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
    }
  }
}
```

## Request Headers

### Standard Headers

**Required:**
```http
Authorization: Bearer <token>
Content-Type: application/json  # For POST/PATCH
```

**Optional:**
```http
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:

```http
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:**
```http
OPTIONS /api/v1/data/account
Origin: https://app.acme.com
Access-Control-Request-Method: POST
```

**Preflight response:**
```http
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
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:**
```http
GET /api/v1/data/account/acc_123
If-None-Match: "abc123def456"
```

**Not modified response:**
```http
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

Requests include rate limit headers:

```http
HTTP/1.1 200 OK
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 847
X-RateLimit-Reset: 1705324800
```

**When limit exceeded:**
```http
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](/docs/transport/error-handling) for more details.

## Best Practices

### Use Field Selection
❌ **Bad:** Fetch all fields when you only need a few
```http
GET /api/data/account
```

✅ **Good:** Request only needed fields
```http
GET /api/data/account?select=id,name,status
```

### Use Includes for Relations
❌ **Bad:** N+1 queries
```javascript
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
```javascript
const tasks = await fetch('/api/data/task?include=assignee');
```

### Respect Rate Limits
✅ **Good:** Check headers and implement backoff
```javascript
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
```javascript
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);
    });
  }
}
```

## Next Steps

<Cards>
  <Card
    icon={<Zap />}
    title="Real-Time Protocols"
    description="Learn WebSocket subscriptions and event streaming"
    href="/docs/transport/realtime"
  />
  <Card
    icon={<AlertCircle />}
    title="Error Handling"
    description="Master error codes and debugging strategies"
    href="/docs/transport/error-handling"
  />
</Cards>
