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
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.comResponse:
{
"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:
versionis the configured API version, not a product version. The handler overwrites the protocol's value withapi.version— the same string that forms the path segment ("v1"). It is never a semantic version like2.1.0.nameis canonical;apiNameis a deprecated alias with the same value. Both are emitted today so clients pinned to the old spelling keep working;apiNameis removed in protocol 18 (#4828). Readname.scopingis added by the REST server, so clients can detect dual-mode routing;environmentIdis 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.comResponse:
{
"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_ENV | advertised environment |
|---|---|
production, prod | production |
sandbox | sandbox |
staging | sandbox — pre-production and production-like |
development, dev | development |
test | development — an ephemeral developer-class run |
unset (absent, or NODE_ENV=) | production — the conservative reading of "the host did not say" (#5673) |
| anything else | development — 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/statusin theservicesmap - 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:
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=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:
{
"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:
{
"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:
{
"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):
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/batchThe 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 is400 BATCH_TOO_LARGE, carrying thecountsent and themaxallowed. The same cap and the same code apply to every bulk write route (createMany/updateMany/deleteMany/ per-objectbatch) - 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": falseis rejected with400 BATCH_NOT_ATOMIC(usePOST /data/{object}/batchfor 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: falsereturns404 OBJECT_API_DISABLED, and an action outside an object'senable.apiMethodswhitelist returns405 OBJECT_API_METHOD_NOT_ALLOWED - The request shape is validated: a malformed operation, an unknown action, or a
missing
objectreturns400;update/deleterequire anid - A
{ "$ref": <index> }that does not resolve to an earlier create's id returns400 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 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:
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/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, 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: 86400Three of the allowed request headers are easy to overlook, and each one disables a feature if an intermediate proxy strips it:
| Header | Why it is allowed |
|---|---|
X-Tenant-ID / X-Environment-Id | Route the request to its environment on a multi-tenant host. |
If-Match | Carries 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: 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
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
- 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.
- 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:
- Match — the request path must be under
<prefix>/apps/, andMETHOD+ path (one trailing slash trimmed) must hit exactly one declaration. - Policy chain —
rateLimit→authRequired→cacheTtl, 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. - 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 declares | Answer |
|---|---|
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 caller | 401 UNAUTHENTICATED, the same envelope every seam answers |
rateLimit armed and exhausted | 429 + Retry-After, never with a cache directive |
cacheTtl: 30 on a successful GET | Cache-Control: private, max-age=30 — private is a security rule, not tuning: any response can be RLS-trimmed |
cacheTtl: 0 | Cache-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:
| Gate | Rejects |
|---|---|
| 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 target | type: 'script' / 'proxy' (neither executes in 17.x), an object_operation missing objectParams.object or .operation, a flow naming no target |
| Mapping | a 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 |
| Policy | authRequired: false without rateLimit.enabled: true (ADR-0121 D6), an unusable armed budget, a negative cacheTtl, or cacheTtl on a non-GET method |
| Uniqueness | two 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 more
— inputMapping 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,statusUse 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);
});
}
}