ObjectStackObjectStack

API wire format — request and response JSON

Complete HTTP request/response examples for ObjectStack API operations — CRUD, queries, metadata, errors, and batch operations

This page provides complete HTTP request and response examples for every common ObjectStack API operation. All examples use a task object with realistic data.

Base URL: All endpoints are relative to your ObjectStack instance, e.g. https://api.example.com.
Content-Type: All requests and responses use application/json.
Authentication: Include Authorization: Bearer <token> header on all requests.


1. Create Record

POST /api/v1/data/task

Creates a new record on the task object.

Request

The request body is the record — field values are sent at the top level, not wrapped in a data envelope.

{
  "title": "Implement login page",
  "description": "Build the authentication UI with email/password and OAuth support",
  "status": "open",
  "priority": "high",
  "assigned_to": "usr_01HQ3V5K8N2M4P6R7T9W",
  "due_date": "2025-03-15",
  "estimated_hours": 8,
  "tags": ["frontend", "auth"],
  "project": "prj_01HQ3V5K8N2M4P6R7T9X"
}

Response — 201 Created

The response is the CreateDataResponse envelope: { object, id, record }.

{
  "object": "task",
  "id": "tsk_01HQ4A7B9D3F5G8J2K4L",
  "record": {
    "id": "tsk_01HQ4A7B9D3F5G8J2K4L",
    "title": "Implement login page",
    "description": "Build the authentication UI with email/password and OAuth support",
    "status": "open",
    "priority": "high",
    "assigned_to": "usr_01HQ3V5K8N2M4P6R7T9W",
    "due_date": "2025-03-15",
    "estimated_hours": 8,
    "tags": ["frontend", "auth"],
    "project": "prj_01HQ3V5K8N2M4P6R7T9X",
    "created_at": "2025-01-20T10:30:00.000Z",
    "updated_at": "2025-01-20T10:30:00.000Z",
    "created_by": "usr_01HQ3V5K8N2M4P6R7T9W",
    "owner_id": "usr_01HQ3V5K8N2M4P6R7T9W"
  }
}

2. Read Record

GET /api/v1/data/task/tsk_01HQ4A7B9D3F5G8J2K4L

Retrieves a single record by ID.

Request

No request body. Optional query parameters:

ParameterTypeDescription
selectstringComma-separated list of fields to return
expandstringComma-separated lookup fields to expand
GET /api/v1/data/task/tsk_01HQ4A7B9D3F5G8J2K4L?select=title,status,assigned_to&expand=assigned_to

Response — 200 OK

The response is the GetDataResponse envelope: { object, id, record }.

{
  "object": "task",
  "id": "tsk_01HQ4A7B9D3F5G8J2K4L",
  "record": {
    "id": "tsk_01HQ4A7B9D3F5G8J2K4L",
    "title": "Implement login page",
    "status": "open",
    "assigned_to": {
      "id": "usr_01HQ3V5K8N2M4P6R7T9W",
      "name": "Jane Smith",
      "email": "jane@example.com"
    }
  }
}

3. List Records with Query

POST /api/v1/data/task/query

Executes a structured query (a QueryAST) against the task object.

Request

Filtering uses the MongoDB-style where clause: { field: value } for equality and { field: { $op: value } } for operators. Combine clauses with $and / $or / $not. expand is a map of relationship-field name → nested query.

{
  "where": {
    "$and": [
      { "status": { "$in": ["open", "in_progress"] } },
      { "priority": "high" },
      { "due_date": { "$lte": "2025-03-31" } }
    ]
  },
  "fields": ["id", "title", "status", "priority", "assigned_to", "due_date"],
  "expand": {
    "assigned_to": { "object": "sys_user", "fields": ["id", "name"] }
  },
  "orderBy": [
    { "field": "due_date", "order": "asc" }
  ],
  "limit": 20,
  "offset": 0
}

Response — 200 OK

The response is the FindDataResponse envelope: { object, records, total?, hasMore? }.

{
  "object": "task",
  "records": [
    {
      "id": "tsk_01HQ4A7B9D3F5G8J2K4L",
      "title": "Implement login page",
      "status": "open",
      "priority": "high",
      "assigned_to": {
        "id": "usr_01HQ3V5K8N2M4P6R7T9W",
        "name": "Jane Smith"
      },
      "due_date": "2025-03-15"
    },
    {
      "id": "tsk_01HQ4B8C0E4G6H9K3L5M",
      "title": "Fix payment processing bug",
      "status": "in_progress",
      "priority": "high",
      "assigned_to": {
        "id": "usr_01HQ3W6L9O3N5Q7S8U0X",
        "name": "Bob Johnson"
      },
      "due_date": "2025-03-20"
    }
  ],
  "total": 47,
  "hasMore": true
}

Field Operators: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $between, $contains, $notContains, $startsWith, $endsWith, $null, $exists. Combine clauses with $and / $or / $not for complex queries.


4. Update Record

PATCH /api/v1/data/task/tsk_01HQ4A7B9D3F5G8J2K4L

Updates specific fields on an existing record. Only include fields you want to change. As with create, the changed fields are sent at the top level — not wrapped in a data envelope.

Request

{
  "status": "in_progress",
  "estimated_hours": 12,
  "tags": ["frontend", "auth", "urgent"]
}

Optimistic concurrency: Pass the updated_at value you last read as an If-Match request header (or an expectedVersion field in the body) and the server returns 409 CONCURRENT_UPDATE if the record changed in the meantime. Omitting If-Match/expectedVersion entirely performs an unguarded write. Sending the empty entity-tag If-Match: "" (or expectedVersion: '""') is refused 400 VALIDATION_FAILED — an empty token can never match any stored version, so it is treated as a client defect rather than either "no guard requested" or a real conflict.

Response — 200 OK

The response is the UpdateDataResponse envelope: { object, id, record }.

{
  "object": "task",
  "id": "tsk_01HQ4A7B9D3F5G8J2K4L",
  "record": {
    "id": "tsk_01HQ4A7B9D3F5G8J2K4L",
    "title": "Implement login page",
    "description": "Build the authentication UI with email/password and OAuth support",
    "status": "in_progress",
    "priority": "high",
    "assigned_to": "usr_01HQ3V5K8N2M4P6R7T9W",
    "due_date": "2025-03-15",
    "estimated_hours": 12,
    "tags": ["frontend", "auth", "urgent"],
    "project": "prj_01HQ3V5K8N2M4P6R7T9X",
    "created_at": "2025-01-20T10:30:00.000Z",
    "updated_at": "2025-01-20T14:15:00.000Z",
    "created_by": "usr_01HQ3V5K8N2M4P6R7T9W",
    "owner_id": "usr_01HQ3V5K8N2M4P6R7T9W"
  }
}

5. Delete Record

DELETE /api/v1/data/task/tsk_01HQ4A7B9D3F5G8J2K4L

Permanently deletes a record.

Request

No request body.

Response — 200 OK

The response is the DeleteDataResponse envelope: { object, id, success }.

{
  "object": "task",
  "id": "tsk_01HQ4A7B9D3F5G8J2K4L",
  "success": true
}

6. Get Metadata

GET /api/v1/meta/object

Metadata is addressed by type name. object is the metadata type, so this route lists every registered object the current user can access. The response is an { type, items } envelope.

Response — 200 OK

{
  "type": "object",
  "items": [
    {
      "name": "task",
      "label": "Task",
      "pluralLabel": "Tasks",
      "description": "Project tasks and work items",
      "fields": [
        {
          "name": "title",
          "label": "Title",
          "type": "text",
          "required": true,
          "maxLength": 200
        },
        {
          "name": "status",
          "label": "Status",
          "type": "select",
          "required": true,
          "options": [
            { "label": "Open", "value": "open" },
            { "label": "In Progress", "value": "in_progress" },
            { "label": "Done", "value": "done" },
            { "label": "Cancelled", "value": "cancelled" }
          ],
          "defaultValue": "open"
        },
        {
          "name": "assigned_to",
          "label": "Assigned To",
          "type": "lookup",
          "reference": "sys_user"
        },
        {
          "name": "due_date",
          "label": "Due Date",
          "type": "date"
        }
      ],
      "enable": {
        "apiEnabled": true,
        "trackHistory": true,
        "searchable": true
      }
    }
  ]
}

Single Object Metadata

GET /api/v1/meta/object/task

Returns metadata for a single object including all fields, relationships, and configuration.


7. Error Response Format

Data route errors from the kernel REST server (@objectstack/rest) are returned as a flat envelope: a top-level error message string, a machine-readable code, and operation-specific extras (fields, object, currentVersion, …). The HTTP status is carried by the response, not duplicated in the body. A host built with createHonoApp answers the same refusals nested instead when its catch-all serves /data — see Hono hosts nest the extras. One that registers the kernel REST server's routes on the same Hono app before it mounts createHonoApp, the hosted composition, answers them flat, as below: the routes registered first win the match.

Validation Error — 400 Bad Request

Field-level failures carry a fields array.

{
  "error": "title is required",
  "code": "VALIDATION_FAILED",
  "fields": [
    {
      "field": "title",
      "code": "required",
      "message": "title is required"
    }
  ],
  "object": "task"
}

Not Found — 404 Not Found

{
  "error": "Record tsk_01HQ4A7B9D3F5G8J2K4L not found in task",
  "code": "RECORD_NOT_FOUND",
  "object": "task"
}

Permission Denied — 403 Forbidden

{
  "error": "[Security] Access denied: operation 'update' on object 'task' is not permitted for positions [standard_user]",
  "code": "PERMISSION_DENIED",
  "object": "task"
}

Concurrent Update — 409 Conflict

Returned when an If-Match / expectedVersion token no longer matches the stored record.

{
  "error": "Record was modified by another user",
  "code": "CONCURRENT_UPDATE",
  "currentVersion": "2025-01-20T14:15:00.000Z",
  "object": "task"
}

Malformed Concurrency Token — 400 Bad Request

Returned when If-Match / expectedVersion is the empty entity-tag "" — a syntactically legal RFC 7232 token, but one that can never match a stored version. Distinct from both the 409 above (a real token that lost a race) and an omitted If-Match (a deliberate unguarded write): sending "" is treated as a client defect, since no stored version can ever equal "nothing".

{
  "error": "expectedVersion (If-Match) is the empty entity-tag \"\". An empty version token can never match any stored version, so this is almost certainly a client defect rather than a real concurrency check — send the real version token you read (e.g. the record's `updated_at`), or omit If-Match / expectedVersion entirely to perform an unguarded write.",
  "code": "VALIDATION_FAILED",
  "fields": [],
  "object": "task"
}

Datasource Unavailable — 503 Service Unavailable

Returned when the object's declared datasource has no live driver: the host's connect policy refused it, or it failed to connect at startup and the server was started with OS_ALLOW_DRIVER_CONNECT_FAILURE. Nothing about the request is wrong, and the state may clear — so this is a 503, not a 400 or a 500.

reason is the class (blocked | failed). The underlying cause is deliberately not included — it routinely contains hosts, ports and DSNs; it stays in the server logs and Setup → Datasources. See External Datasources.

{
  "error": "[ObjectQL] Datasource 'analytics' configured for object 'visit' is declared but not connected: the host's datasource connect policy refused it. See the startup logs or Setup → Datasources for the cause.",
  "code": "ERR_DATASOURCE_UNAVAILABLE",
  "datasource": "analytics",
  "reason": "blocked",
  "object": "visit"
}

Error Codes: See the Error Catalog for the complete list of error codes and their meanings.

Service-mounted routes use the declared envelope

The flat envelope above is what the kernel REST server emits for /api/v1/data/*. Routes mounted directly by a service plugin — /api/v1/storage/* and /api/v1/i18n/* — instead return the BaseResponse shape their contract declares, with the code inside the error object:

{
  "success": false,
  "error": {
    "code": "ATTACHMENT_DOWNLOAD_DENIED",
    "message": "You do not have access to a record this file is attached to"
  }
}

Read body.error.code, not body.code, on these routes. ObjectStackClient normalizes both — error.code and error.message are populated whichever envelope the server used — so SDK callers do not need to branch.

Dispatcher routes use it too

Everything the runtime dispatcher serves — /api/v1/meta/*, /actions/*, /packages/*, /automation/*, /analytics/*, /ready, … — answers in the same declared envelope, plus httpStatus:

{
  "success": false,
  "error": {
    "code": "PROJECT_MEMBERSHIP_REQUIRED",
    "message": "Forbidden: user usr_01H… is not a member of project env_prod",
    "httpStatus": 403,
    "details": { "environmentId": "env_prod", "userId": "usr_01H…" }
  }
}
FieldMeaning
codeThe semantic code — the field to branch on. Never a number.
messageHuman-readable text, safe to show. 5xx messages are sanitised.
httpStatusThe response status, mirrored. Redundant with the response line by design.
detailsStructured context only (fields[], issues[], ids). Never a parked code.

A route-resolution failure spells code from DispatcherErrorCode (ROUTE_NOT_FOUND, …) and adds route / hint / service as siblings. A branch with no code of its own gets a StandardErrorCode derived from the status.

Before #3842 this envelope put the HTTP status in error.code and the real code in error.details.code, error.details.type or error.type. ObjectStackClient no longer reads those legacy spots (#4007 — servers and SDKs ship as one release group); any code reading them directly should move to error.code.

Hono hosts nest the extras

A host built with @objectstack/hono's createHonoApp, and a route mounted on @objectstack/plugin-hono-server's HonoHttpServer, answer a declared refusal that escapes the handler in the nested envelope above, without httpStatus. The extras the REST door's classification adds (fields, currentVersion, currentRecord, dependentObject, …) ride in error.details; an object the flat body takes from the request's route is not sent. currentRecord is the row as the caller reads it, on either door: a field the caller's field-level rules hide is absent from it. The concurrent update above, on a createHonoApp host whose catch-all serves /data:

{
  "success": false,
  "error": {
    "code": "CONCURRENT_UPDATE",
    "message": "Record task/tsk_01HQ4A7B9D3F5G8J2K4L was modified by another user (current version 2025-01-20T14:15:00.000Z, expected 2025-01-20T14:10:00.000Z)",
    "details": {
      "currentVersion": "2025-01-20T14:15:00.000Z",
      "currentRecord": {
        "id": "tsk_01HQ4A7B9D3F5G8J2K4L",
        "title": "Implement login page",
        "updated_at": "2025-01-20T14:15:00.000Z"
      }
    }
  }
}

See Error handling → Hono doors for every code that carries error.details there.


8. Batch Operations

POST /api/v1/data/task/batch

Process many records of a single operation type in one request. The body carries one operation (create, update, upsert, or delete) plus a records array.

By default (options.atomic omitted or false) processing stops at the first failing record, and records written earlier in the same batch are not rolled back — there is no wrapping transaction. Add options.continueOnError: true to keep going instead and collect a full partial-success report.

Send options.atomic: true to run the whole batch inside one database transaction: the first failure rolls back every prior write, and the response reports zero successes — each row's errors[0].code says what happened: ROLLED_BACK (written, then undone), the causal row's own error code, or NOT_ATTEMPTED (never reached). A deployment whose driver cannot roll back rejects an atomic request with 501 NOT_IMPLEMENTED rather than quietly running it best-effort — probe capabilities.transactionalBatch on /discovery to know in advance. atomic takes precedence over continueOnError.

Request

{
  "operation": "update",
  "records": [
    { "id": "tsk_01HQ4A7B9D3F5G8J2K4L", "data": { "status": "done" } },
    { "id": "tsk_01HQ4B8C0E4G6H9K3L5M", "data": { "status": "done" } }
  ],
  "options": {
    "atomic": false,
    "continueOnError": false
  }
}

Response — 200 OK

The response is the BatchUpdateResponse envelope: a top-level success flag plus total / succeeded / failed counts and a per-record results array — each entry is a BatchOperationResult (id, success, index, optional errors, optional data). index is the row's position in the request records array. Each successful entry echoes the written record on data; pass options.returnRecords: false to get back just { id, success, index } per result.

{
  "success": true,
  "operation": "update",
  "total": 2,
  "succeeded": 2,
  "failed": 0,
  "results": [
    { "id": "tsk_01HQ4A7B9D3F5G8J2K4L", "success": true, "index": 0, "data": { "id": "tsk_01HQ4A7B9D3F5G8J2K4L", "status": "done" } },
    { "id": "tsk_01HQ4B8C0E4G6H9K3L5M", "success": true, "index": 1, "data": { "id": "tsk_01HQ4B8C0E4G6H9K3L5M", "status": "done" } }
  ]
}

Partial Failure Response

When the batch is not atomic and some records fail, each failing entry carries an errors array of ApiError objects — read the human-readable cause from errors[0].message and branch on the machine-readable errors[0].code. An atomic batch never returns this shape — it either commits everything or reports every row as failed (with errors[0].code ROLLED_BACK / the causal code / NOT_ATTEMPTED):

{
  "success": false,
  "operation": "update",
  "total": 2,
  "succeeded": 1,
  "failed": 1,
  "results": [
    { "id": "tsk_01HQ4A7B9D3F5G8J2K4L", "success": true, "index": 0, "data": { "id": "tsk_01HQ4A7B9D3F5G8J2K4L", "status": "done" } },
    { "id": "tsk_invalid_id", "success": false, "index": 1, "errors": [{ "code": "RECORD_NOT_FOUND", "message": "Record tsk_invalid_id not found in task", "httpStatus": 404 }] }
  ]
}

Batch Limits: Maximum 200 records per batch request. For larger imports, use the streaming import endpoint POST /api/v1/data/:object/import.


Common Headers

Request Headers

HeaderRequiredDescription
AuthorizationYesBearer <access_token>
Content-TypeYesapplication/json
X-Request-IdNoClient-generated request ID for tracing (honored by the observability dispatcher)
X-Environment-IdNoTargets a specific environment/project on unscoped routes
If-MatchNoOptimistic-concurrency token for PATCH / DELETE (the updated_at you last read). Omit for an unguarded write; the empty entity-tag "" is refused 400, not treated as omitted.
Accept-LanguageNoLocale for translated labels (e.g., en-US)

Response Headers

HeaderDescription
X-Request-IdServer-assigned (or echoed) request ID
ETagEntity tag for conditional requests

Rate limiting is opt-in: ObjectStack ships a token-bucket RateLimiter primitive (@objectstack/runtime), but it is not wired into the default REST response path — a deployment must add it at the adapter layer. Only when a deployment does so will responses carry X-RateLimit-Limit / X-RateLimit-Remaining / X-RateLimit-Reset (and a 429 with Retry-After once the limit is hit).

On this page