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:
| Parameter | Type | Description |
|---|---|---|
select | string | Comma-separated list of fields to return |
expand | string | Comma-separated lookup fields to expand |
GET /api/v1/data/task/tsk_01HQ4A7B9D3F5G8J2K4L?select=title,status,assigned_to&expand=assigned_toResponse — 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…" }
}
}| Field | Meaning |
|---|---|
code | The semantic code — the field to branch on. Never a number. |
message | Human-readable text, safe to show. 5xx messages are sanitised. |
httpStatus | The response status, mirrored. Redundant with the response line by design. |
details | Structured 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
| Header | Required | Description |
|---|---|---|
Authorization | Yes | Bearer <access_token> |
Content-Type | Yes | application/json |
X-Request-Id | No | Client-generated request ID for tracing (honored by the observability dispatcher) |
X-Environment-Id | No | Targets a specific environment/project on unscoped routes |
If-Match | No | Optimistic-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-Language | No | Locale for translated labels (e.g., en-US) |
Response Headers
| Header | Description |
|---|---|
X-Request-Id | Server-assigned (or echoed) request ID |
ETag | Entity 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).