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

Before making any API calls, clients should request a discovery endpoint to learn about available services. Two endpoints answer that question, and in a stack that mounts @objectstack/rest they are built by different packages — so they carry different values (and different envelopes), even though both now satisfy the same DiscoverySchema (#4828). Read the one that matches your composition.

GET /api/v1 (and GET /api/v1/discovery)

Returns the full discovery manifest. @objectstack/rest registers one handler at both paths — the API base path and <basePath>/discovery — so the two are the same document, not a redirect and not two shapes. In a REST-less composition the runtime dispatcher registers <basePath>/discovery as the fallback owner instead, and then serves its own /.well-known/objectstack payload there (see below); when @objectstack/rest is mounted the dispatcher cedes the route to it, so a single owner answers it (ADR-0076 D11).

Request:

GET /api/v1/discovery HTTP/1.1
Host: api.acme.com

Response:

{
  "version": "v1",
  "name": "ObjectStack API",
  "apiName": "ObjectStack API",
  "environment": "development",
  "routes": {
    "data": "/api/v1/data",
    "metadata": "/api/v1/meta"
  },
  "locale": {
    "default": "en",
    "supported": ["en"],
    "timezone": "UTC"
  },
  "services": {
    "metadata": { "enabled": true, "status": "available", "handlerReady": true, "route": "/api/v1/meta", "provider": "objectql" },
    "data": { "enabled": true, "status": "available", "handlerReady": true, "route": "/api/v1/data", "provider": "objectql" },
    "search": { "enabled": false, "status": "unavailable", "message": "No implementation ships for the 'search' slot — register a service under it to enable" },
    "ai": { "enabled": false, "status": "unavailable", "message": "Provided by @objectstack/service-ai in ObjectStack Cloud/Enterprise — no implementation ships in the open framework" }
  },
  "capabilities": {
    "cron": { "enabled": false },
    "automation": { "enabled": false },
    "search": { "enabled": false },
    "transactionalBatch": { "enabled": true, "description": "Atomic cross-object batch endpoint (POST {basePath}/batch)…" }
  },
  "scoping": {
    "enabled": false,
    "resolution": "auto",
    "scoped": false
  }
}

Three things about this body are worth stating explicitly:

  • version is the configured API version, not a product version. The handler overwrites the protocol's value with api.version — the same string that forms the path segment ("v1"). It is never a semantic version like 2.1.0.
  • name is canonical; apiName is a deprecated alias with the same value. Both are emitted today so clients pinned to the old spelling keep working; apiName is removed in protocol 18 (#4828). Read name.
  • scoping is added by the REST server, so clients can detect dual-mode routing; environmentId is present only on the environment-scoped mount (/api/v1/environments/:environmentId/...).

Both discovery documents now satisfy one schema (#4828). They used to diverge on the required identity fields — this response omitted name / environment / locale entirely, and the dispatcher document below spelled the capability map features while this one spelled it capabilities. Both producers are now checked against DiscoverySchema in CI, so a client can read the same keys from either. environment is always one of production / sandbox / development — never a raw NODE_ENV.

Disabled/uninstalled route keys are omitted from routes entirely rather than set to null; check services to tell "not installed" apart from "installed but not yet mounted here." capabilities maps each platform capability (comments, automation, cron, search, export, chunkedUpload, transactionalBatch) to a { "enabled": … } descriptor, each derived from what is actually registered — never hardcoded. See API → Discovery for the field-by-field reference.

GET /.well-known/objectstack

Served by the runtime dispatcher (@objectstack/runtime), not @objectstack/rest — its body is wrapped as { "data": { ... } } and includes fields (name, environment, features, locale) that the @objectstack/rest-served /api/v1 response above does not. This path is unconditionally dispatcher-owned: no other plugin registers it, so it answers with this shape whether or not REST is mounted. The client SDK's connect() tries /api/v1/discovery first and falls back to this endpoint, unwrapping either body.data or the bare body.

Request:

GET /.well-known/objectstack HTTP/1.1
Host: api.acme.com

Response:

{
  "data": {
    "name": "ObjectOS",
    "version": "1.0.0",
    "environment": "production",
    "routes": {
      "data": "/api/v1/data",
      "metadata": "/api/v1/meta",
      "packages": "/api/v1/packages",
      "auth": "/api/v1/auth",
      "ui": "/api/v1/ui",
      "i18n": "/api/v1/i18n"
    },
    "capabilities": {
      "search": { "enabled": false },
      "websockets": { "enabled": false },
      "files": { "enabled": false },
      "analytics": { "enabled": false },
      "ai": { "enabled": false },
      "notifications": { "enabled": false },
      "i18n": { "enabled": true }
    },
    "services": {
      "metadata": { "enabled": true, "status": "available", "handlerReady": true, "route": "/api/v1/meta", "provider": "kernel" },
      "data": { "enabled": true, "status": "available", "handlerReady": true, "route": "/api/v1/data", "provider": "kernel" },
      "auth": { "enabled": true, "status": "available", "handlerReady": true, "route": "/api/v1/auth" },
      "search": { "enabled": false, "status": "unavailable", "handlerReady": false, "message": "No implementation ships for the 'search' slot — register a service under it to enable" }
    },
    "locale": {
      "default": "en-US",
      "supported": ["en-US", "zh-CN"],
      "timezone": "UTC"
    }
  }
}

name and version are the dispatcher's own build identity, not your app's name — they are fixed strings, so do not display them as the deployment's title. locale is derived from the registered i18n service (getDefaultLocale() / getLocales()); with no i18n service it degrades to { "default": "en", "supported": ["en"], "timezone": "UTC" }.

environment is derived from NODE_ENV, not the raw value — the field is an enum (production / sandbox / development), so out-of-enum spellings are mapped rather than advertised verbatim (#4828):

NODE_ENVadvertised environment
production, prodproduction
sandboxsandbox
stagingsandbox — pre-production and production-like
development, devdevelopment
testdevelopment — an ephemeral developer-class run
unset (absent, or NODE_ENV=)production — the conservative reading of "the host did not say" (#5673)
anything elsedevelopment — never claims production on a guess

The last two rows answer two different questions and were one row until #5673. Unset is not a spelling, it is the absence of one, and the rest of the platform already read that absence as production: os start forces NODE_ENV=production when it is unset, and both os serve and os doctor resolve the .env* cascade for NODE_ENV || production. This field is machine-readable — a client uses it to decide whether it is talking to production — so a real production deployment whose operator forgot the variable must not be told development. An unrecognised spelling (qa, preview, uat) is a different case: it is a guess, and this field never claims production on a guess.

This table is the whole answer for every producer of /discovery (#5936). The mapping and the unset default both live in one shared function, so the dispatcher and the @objectstack/metadata-protocol builder served by @objectstack/rest cannot disagree, and a future producer inherits the same answers without copying anything. Until #5936 the unset default lived at the dispatcher's own call site, so a deployment with no NODE_ENV was advertised as production there and development through @objectstack/rest. If you read environment from a REST-served /discovery and relied on the old answer, set NODE_ENV=development explicitly.

Local development is unaffected: os dev runs serve --dev, which sets NODE_ENV=development in-process before the runtime loads. Anything that boots the runtime without os dev — a bare os serve, an embedded host, a hand-written container entry point — must now set NODE_ENV=development explicitly to keep being advertised as such.

The @objectstack/rest-served /api/v1/discovery document still reads an unset NODE_ENV as development. #5673 landed on the dispatcher producer only; the second producer is tracked in #5936. Until it lands, the two documents agree on every set value and differ only when nothing is set.

Retired in protocol 17 (#4828): this document used to carry a top-level features map and an endpoints key that duplicated routes verbatim. Neither was ever declared in DiscoverySchema. features is now the canonical capabilities (same flags, in the declared { "enabled": … } shape, so it matches the REST-served response); endpoints was removed outright after a consumer census across objectstack, objectui and cloud found no reader — use routes.

"Both paths return the same document" holds only in a REST-less composition. There, the dispatcher owns /api/v1/discovery as the fallback registrant, so that path and /.well-known/objectstack both answer with the dispatcher payload above (the bare /api/v1 is registered by @objectstack/rest alone and is not served at all). As soon as @objectstack/rest is mounted it takes /api/v1/discovery under the single-owner rule (ADR-0076 D11) and the two paths answer different documents — same schema, different producers, so the envelope ({ "data": … } here, bare there) and the values differ even though the key set no longer does.

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 / status in the services map
  • 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:

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
}

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:

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

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

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

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 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):

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 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):

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 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):

Delete returns a DeleteDataResponse: the object name, the record id, and a success flag.

{
  "object": "account",
  "id": "acc_123",
  "success": true
}

There is no soft-delete / recycle-bin runtime: DELETE performs a hard delete and returns the { object, id, success } shape above. Records are removed permanently — there are no deleted_at / deleted_by fields and no restore semantics. The enable.trash flag that once promised this was removed in v17 (#2377, ADR-0049 enforce-or-remove): authoring it is now a parse error rather than a silent no-op.

Constraint Violations: Database constraint failures are surfaced as structured errors. For example, a unique-constraint violation returns HTTP 409, naming the conflicting field when the database determinably reports one:

{
  "error": "A record with this email already exists",
  "code": "UNIQUE_VIOLATION",
  "field": "email",
  "object": "account"
}

field is best-effort and optional. It is present only when the driver's error determinably names a column; when it names an index instead (MySQL's for key 'idx_email_unique' always does), when the constraint is a composite key, or when the message cannot be parsed, the response omits field entirely and falls back to the unnamed sentence:

{
  "error": "A record with this value already exists",
  "code": "UNIQUE_VIOLATION",
  "object": "account"
}

That degradation is deliberate — a wrong field name would send the user to correct an input that was never the problem. Key on code, not on the message, and treat field as an enhancement: it is present when the platform can prove it, absent when it cannot, and never guessed. The response body never echoes the driver's own text, the offending value, or the index name.

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

The 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 is 400 BATCH_TOO_LARGE, carrying the count sent and the max allowed. The same cap and the same code apply to every bulk write route (createMany / updateMany / deleteMany / per-object batch)
  • 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": false is rejected with 400 BATCH_NOT_ATOMIC (use POST /data/{object}/batch for 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: false returns 404 OBJECT_API_DISABLED, and an action outside an object's enable.apiMethods whitelist returns 405 OBJECT_API_METHOD_NOT_ALLOWED
  • The request shape is validated: a malformed operation, an unknown action, or a missing object returns 400; update / delete require an id
  • A { "$ref": <index> } that does not resolve to an earlier create's id returns 400 BATCH_UNRESOLVED_REF (never a silently-written null value)
  • 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:

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
    }
  ]
}

Get Object Schema

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
    }
  }
}

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

Three of the allowed request headers are easy to overlook, and each one disables a feature if an intermediate proxy strips it:

HeaderWhy it is allowed
X-Tenant-ID / X-Environment-IdRoute the request to its environment on a multi-tenant host.
If-MatchCarries 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: 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

Inbound rate limiting is off unless a stack declares it, and it is declared in one place — the stack's server block:

import { defineStack } from '@objectstack/spec';

export default defineStack({
  manifest: { /* … */ },
  server: {
    security: {
      rateLimit: {
        enabled: true,
        windowMs: 60_000,   // budget window, in MILLISECONDS
        maxRequests: 600,   // requests permitted per window, per caller
      },
    },
    // Believe `X-Forwarded-For` / `X-Real-IP`? Only behind a proxy you control.
    trustProxy: false,
  },
});

objectstack serve / dev forward that block to the dispatcher plugin, which arms a token bucket in front of every route the server mounts — not just the dispatcher's own. capacity is maxRequests (so a full bucket absorbs one window's worth of traffic as a burst) and it refills at maxRequests / (windowMs / 1000) tokens per second (so the sustained rate is exactly the declared one).

What the bucket is keyed on

  1. The resolved principal, when the request carries a valid session. One user cannot spend another's budget, and users behind a shared NAT do not throttle each other.
  2. The caller's IP, for anonymous traffic — the case that most needs a limit (credential stuffing, scraping) and has no identity yet.

That IP comes from X-Forwarded-For / X-Real-IP only when server.trustProxy is declared true. Left at its default, the address is the transport's own peer address, which a client cannot forge. This is deliberate: an attacker who can choose their own X-Forwarded-For otherwise gets an unlimited supply of fresh buckets and can drain a chosen victim's. Declare trustProxy only when a reverse proxy you control overwrites those headers on every inbound request.

CORS preflights (OPTIONS) are never metered.

When the limit is exceeded

HTTP/1.1 429 Too Many Requests
Retry-After: 45
Content-Type: application/json

{
  "success": false,
  "error": {
    "code": "RATE_LIMIT_EXCEEDED",
    "message": "Rate limit exceeded. Retry after the interval in the Retry-After header.",
    "httpStatus": 429,
    "details": { "retryAfterSeconds": 45, "resetAt": "2026-08-03T12:00:45.000Z" }
  }
}

Retry-After is computed from the bucket itself, so the wait it advertises is the wait the bucket will actually take to refill. The body is the standard error envelope — see Error Handling.

Counting across nodes

Counters live in the kernel cache service when one is registered, so a multi-node deployment enforces one budget rather than one per node (ADR-0069 D2). With no cache service the limiter falls back to a per-process store and says so once, at warn, naming the consequence: until a shared cache is registered the effective limit is the declared budget multiplied by the number of nodes.

Not implemented, deliberately named rather than implied. ObjectStack does not emit X-RateLimit-Limit / -Remaining / -Reset headers on successful responses — only Retry-After on a 429. That is still true of every budget on the platform, including the per-endpoint one below. (The second spelling this callout used to name, ApiEndpointRegistrationSchema, was retired outright in #4939.)

The per-endpoint rateLimit on ApiEndpointSchema is a second, independent budget. Between #4936 and protocol 17 it was unreachable — that surface had no executor and a non-empty apis: was rejected outright — but the executor shipped with #5040 and the key is now enforced. Endpoint buckets are keyed in their own namespace, so an endpoint budget and the server.security.rateLimit budget above meter separately rather than sharing a counter. See the next section.

Declarative Endpoints (apis:)

A stack can declare an HTTP endpoint as metadata instead of writing a handler:

Declared endpoints are LIVE from protocol 17. An apis: block written against an older major changes meaning without changing a byte — what used to be inert documentation becomes an execution entry point. Before upgrading, work through the declarative-apis-endpoints-live entry of the protocol upgrade guide; it is a security review, not a rename.

import { defineStack } from '@objectstack/spec';

export default defineStack({
  manifest: {
    id: 'acme-crm',
    name: 'Acme CRM',
    version: '1.0.0',
    type: 'app',
    // REQUIRED to declare `apis:` — the URL carve-out is derived from it, and
    // there is deliberately no fallback that derives it from `manifest.id`.
    namespace: 'acme',
  },
  apis: [
    {
      name: 'acme_lead_feed',
      // `/api/v1/apps/<manifest.namespace>/<subpath>` — only the subpath is yours.
      path: '/api/v1/apps/acme/leads',
      method: 'GET',
      summary: 'Lead feed',
      type: 'object_operation',
      target: 'acme_lead',
      objectParams: { object: 'acme_lead', operation: 'find' },
      // Defaults to `true`. Omitting it is safe; see the policy table below.
      authRequired: true,
      // Seconds. GET-only, and only ever on a successful answer.
      cacheTtl: 30,
    },
  ],
});

How a request is served

Declared endpoints are not registered routes. They run in the dispatcher's unmatched-request seam, which is what makes it structurally impossible for a declaration to shadow a built-in route:

  1. Match — the request path must be under <prefix>/apps/, and METHOD + path (one trailing slash trimmed) must hit exactly one declaration.
  2. Policy chainrateLimitauthRequiredcacheTtl, in that order. Metering runs before the auth gate on purpose: the traffic that most needs a budget (credential stuffing, scraping) is exactly the traffic that ends in a 401, so a denied request still spends a token.
  3. Delegation — a request that passed is executed by the same pipelines the built-in routes use, under the caller's own execution context, so RLS/FLS and the exposure gate apply identically. A declared endpoint is a stable URL plus a policy layer over an existing pipeline, never a second execution dialect.
Endpoint declaresAnswer
type: 'object_operation'delegated to the same callData binding that serves /api/v1/data/{object} — byte-identical data
type: 'flow'delegated to the same automation pipeline as POST /api/v1/automation/{name}/trigger
authRequired: true (or omitted) + anonymous caller401 UNAUTHENTICATED, the same envelope every seam answers
rateLimit armed and exhausted429 + Retry-After, never with a cache directive
cacheTtl: 30 on a successful GETCache-Control: private, max-age=30private is a security rule, not tuning: any response can be RLS-trimmed
cacheTtl: 0Cache-Control: no-store
an error answer (401/429/5xx)never carries Cache-Control, and outputMapping is never applied to it

What an unmatched request answers

The endpoint seam writes nothing when it does not match, so it changes no existing answer. Both of these are the transport's own bare 404, byte for byte:

GET /api/v1/apps/acme/no-such-endpoint   →  404  {"error":"Not found"}
GET /api/v1/no-such-route                →  404  {"error":"Not found"}

A method mismatch on a declared path is also a 404, not 405 + Allow:

POST /api/v1/apps/acme/leads   →  404  {"error":"Not found"}

That is a consequence of the seam, not an inconsistency — nothing registered a route for that path, so there is no method set to report. A registered route still answers 405 when it exists and the verb does not fit — that contract is unchanged.

The five publish gates

A declaration this runtime cannot serve is rejected at publish, naming the endpoint, the key and the fix — never parsed into silence. objectstack validate (or os build) runs the same gates your publish path does:

GateRejects
Namespace (ADR-0121 D1/D2)a path outside /api/v1/apps/<manifest.namespace>/<subpath>, or a stack declaring apis: with no explicit manifest.namespace
Supported targettype: 'script' / 'proxy' (neither executes in 17.x), an object_operation missing objectParams.object or .operation, a flow naming no target
Mappinga mapping transform (there is no transformation registry), an unusable dot path (empty segment, __proto__), two entries writing the same target path, or inputMapping on a find / get / delete operation that never reads a body
PolicyauthRequired: false without rateLimit.enabled: true (ADR-0121 D6), an unusable armed budget, a negative cacheTtl, or cacheTtl on a non-GET method
Uniquenesstwo endpoints in one stack claiming the same METHOD + path

authRequired: false is the one answer that cannot be taken back. It defaults to true, so omitting it is safe; an explicit false is the only thing that opens an unauthenticated execution entry point, and ADR-0121 D6 pairs it with an armed budget — rateLimit.enabled itself defaults to false, so writing only windowMs / maxRequests declares a budget that meters nothing. The gate checks enabled === true, not the key's presence.

inputMapping / outputMapping move and rename fields by dot path, and nothing moreinputMapping projects the request body before delegation (so it can never buy a caller past the policy chain), outputMapping projects a successful response body only.

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

Respect Rate Limits

Bad: Poll X-RateLimit-Remaining — that header is not emitted, so the check always reads null and the backoff never runs.

Good: Handle the 429 and honour Retry-After

const response = await fetch('/api/data/task');

if (response.status === 429) {
  const retryAfter = Number(response.headers.get('Retry-After') ?? 1);
  await sleep(retryAfter * 1000);
  // …then retry once; the budget refills continuously, so a single wait is enough.
}

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