Skip to content

Latest commit

 

History

History
1065 lines (892 loc) · 28.5 KB

File metadata and controls

1065 lines (892 loc) · 28.5 KB
title HTTP API
description Standard REST mapping rules, CRUD operations, and request/response formats for ObjectStack

import { Radio, Code, Database, Lock, Zap, CheckCircle, AlertCircle } from 'lucide-react';

HTTP API

The HTTP API defines how ObjectStack maps data operations to RESTful HTTP endpoints. Every object you define automatically gets a complete set of CRUD (Create, Read, Update, Delete) operations with consistent request/response formats.

Core Principles

  1. Convention over Configuration: REST endpoints follow predictable patterns
  2. Consistency: Every object uses the same URL structure and response format
  3. Discoverability: API schema available via discovery endpoint
  4. Security First: Authentication and permissions enforced on every request
  5. Performance: Built-in caching, pagination, and field selection

API Discovery

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 do not return the same shape — they are built by different packages. Read the one that matches your composition; do not mix their fields.

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",
  "apiName": "ObjectStack API",
  "routes": {
    "data": "/api/v1/data",
    "metadata": "/api/v1/meta"
  },
  "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, because they are what the /.well-known/objectstack document below does not share:

  • 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.
  • There is no name, environment or locale here. Those are dispatcher fields (see below). A client that initialises i18n from locale must read /.well-known/objectstack, not this response.
  • 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/...).

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 is a flat map of platform feature flags (comments, automation, cron, search, export, chunkedUpload, transactionalBatch), 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"
    },
    "features": {
      "search": false,
      "websockets": false,
      "files": false,
      "analytics": false,
      "ai": false,
      "notifications": false,
      "i18n": 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. environment is the process NODE_ENV. locale is derived from the registered i18n service (getDefaultLocale() / getLocales()); with no i18n service it degrades to { "default": "en", "supported": ["en"], "timezone": "UTC" }. The body also repeats routes under an endpoints key as a backward-compatibility alias, and carries no capabilities map — that one exists only on the REST-served response above.

**"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 shapes. Never write a client that reads `locale` or `environment` off `/api/v1/discovery`.

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:

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

Including Related Objects

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:

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

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:

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

ObjectStack ships a token-bucket `RateLimiter` primitive (`@objectstack/runtime`), but emission of the `X-RateLimit-*` response headers and the `429` envelope below is deployment-specific and not wired into the default REST response path. Treat the headers and response shape here as the intended contract.

When enabled, responses include rate limit headers:

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

When limit exceeded:

HTTP/1.1 429 Too Many Requests
Retry-After: 45
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1705324800

{
  "error": "Rate limit exceeded",
  "code": "THROTTLED",
  "retry_after": 45
}

See Error Handling for more details.

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

Good: Check headers and implement backoff

const response = await fetch('/api/data/task');
const remaining = response.headers.get('X-RateLimit-Remaining');

if (remaining < 10) {
  console.warn('Approaching rate limit');
  await sleep(1000);
}

Handle Errors Gracefully

Good: Parse error structure

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

} title="Real-Time Protocols" description="Learn WebSocket subscriptions and event streaming" href="/docs/protocol/kernel/realtime-protocol" /> } title="Error Handling" description="Master error codes and debugging strategies" href="/docs/protocol/kernel/error-handling" />