ZBSearch

API reference

Complete REST API for ZBSearch Edge - indexes, documents, search, rebuild, and status endpoints. Cloudflare deploy is in beta.

Beta

This API ships with the beta Cloudflare deploy. Endpoints and response fields may change before a stable release.

All endpoints are served from your Worker URL unless noted. JSON request and response bodies use Content-Type: application/json.

Authentication

Two secrets control access:

SecretAccess
WRITE_API_KEYFull access: every route below, reads and writes
READ_API_KEYRead-only: searches, index lists, status, manifest. 401 on write routes

Protected routes require:

Authorization: Bearer YOUR_API_KEY

The write key also satisfies read routes. If only READ_API_KEY is set, write routes are closed to everyone (403). The legacy single API_KEY secret still works as a full-access key.

These routes are always public (no auth):

MethodPathDescription
GET/healthLiveness probe
GET/v1/infoService name and version
OPTIONS*CORS preflight

All /v1/* routes below require auth when a key is set.

Error format

{
  "error": {
    "code": "NOT_FOUND",
    "message": "Index products not found"
  }
}
HTTP statusCodeTypical cause
400BAD_REQUESTInvalid input, duplicate index
401UNAUTHORIZEDMissing or invalid API key
403FORBIDDENWrite attempted with only a read key configured
404NOT_FOUNDIndex or route not found, no snapshot yet
409CONFLICTReserved for future use

Service

GET /health

Returns { "ok": true }. Use for uptime checks.

GET /v1/info

Returns service metadata:

{
  "name": "zbsearch-edge",
  "version": "0.1.0"
}

Indexes

GET /v1/indexes

List all indexes. Physical shard indexes (<id>--shard-<i>, see shards below) are hidden - only logical indexes appear.

Response 200:

{
  "indexes": [
    {
      "id": "products",
      "name": "products",
      "schema": { "title": "string", "price": "number" },
      "settings": {
        "rebuildIntervalSec": 300,
        "rebuildThresholdOps": 500,
        "mode": "edge"
      },
      "liveVersion": "2026-07-10T12-00-00-000Z-9f3ab2c1",
      "status": "ready",
      "documents": 42,
      "pendingOps": 0
    }
  ]
}

POST /v1/indexes

Create a new index.

Body:

FieldTypeRequiredDescription
namestringYesDisplay name; slugified to id (e.g. "My Products" -> my-products)
schemaobjectYesSame schema types as standalone ZBSearch
settingsobjectNoSee index settings
shardsnumberNoCreate a sharded index with N physical shards (2–64). Fixed after creation - re-sharding means re-importing into a new index.

Example:

curl -X POST "$WORKER_URL/v1/indexes" \
  -H 'content-type: application/json' \
  -d '{
    "name": "articles",
    "schema": {
      "title": "string",
      "body": "string",
      "tags": "string[]"
    },
    "settings": {
      "language": "english"
    }
  }'

Response 201: Full index metadata object. For a sharded index, the group meta includes "shards": { "count": N }.

Sharded indexes

Passing "shards": N (2–64) creates a logical index backed by N physical shard indexes named <id>--shard-<i> (i in 0..N-1). Shards are ordinary indexes in every respect - own WAL, snapshots, coordinator Durable Object, and rebuild thresholds - but they are hidden from GET /v1/indexes; you always address the logical index by its plain id.

Routing is deterministic: the document id is hashed (FNV-1a) modulo N, so a document always lands on the same shard. Document ids must be stable - changing a doc id moves the document to a different shard.

The shard count is fixed at creation. To change it, re-import into a new index:

zbsearch-edge-builder import products-v2 ./catalog.json \
  --create --schema-file ./schema.json --shards 8

Bulk import of a sharded index also goes through the CLI with --shards N; importing without --shards against an existing shard group fails with 400. Unsharded indexes behave exactly as before.

GET /v1/indexes/:indexId

Get metadata for one index. Response 200: index object.

PATCH /v1/indexes/:indexId

Update index settings (partial merge).

Body:

{
  "settings": {
    "rebuildThresholdOps": 100,
    "language": "english"
  }
}

Response 200: Updated index metadata.

DELETE /v1/indexes/:indexId

Remove index metadata from the registry. Response 202:

{
  "status": "deleting",
  "indexId": "products"
}

v1 does not automatically purge R2 objects (snapshots, WAL entries) on delete. Plan a cleanup job if you delete indexes frequently.


Documents

API writes append operations to the WAL in R2. They return 202 immediately. Documents are searchable after the next rebuild, or sooner if the Worker overlays pending WAL ops at query time (includesBuffer: true in search responses).

For large catalogs, prefer the bulk import CLI - it uploads a snapshot directly and skips per-document API writes.

PUT /v1/indexes/:indexId/documents/:docId

Upsert a document by ID. The request body is the document fields (matching your schema).

curl -X PUT "$WORKER_URL/v1/indexes/products/documents/sku-42" \
  -H 'content-type: application/json' \
  -d '{"title":"Mechanical Keyboard","price":149}'

Response 202:

{
  "status": "buffered",
  "changeId": "chg_a1b2c3d4e5f67890",
  "bufferedAt": "2026-07-10T12:00:00.000Z",
  "indexStatus": "empty"
}

POST /v1/indexes/:indexId/documents

Upsert with ID in the body.

Body:

{
  "id": "sku-42",
  "document": {
    "title": "Mechanical Keyboard",
    "price": 149
  }
}

Response 202: Same as PUT.

DELETE /v1/indexes/:indexId/documents/:docId

Buffer a delete operation. Response 202: buffered response object.

POST /v1/indexes/:indexId/documents/batch

Apply multiple operations in one request. All operations are written to a single WAL segment (one R2 PUT) when the IndexCoordinator Durable Object is bound, or a single WAL entry otherwise.

Body:

{
  "operations": [
    { "op": "upsert", "id": "1", "doc": { "title": "First" } },
    { "op": "upsert", "id": "2", "doc": { "title": "Second" } },
    { "op": "delete", "id": "3" }
  ]
}

Response 202: Buffered write response for the batch (changeId, bufferedAt, updated indexStatus).

On a sharded index, each operation is routed to its owning shard (per document id). The response keeps the top-level shape - changeId/bufferedAt are a group-level batch id/timestamp - and adds a shards[] array with the per-shard results:

{
  "status": "buffered",
  "changeId": "chg_a1b2c3d4e5f67890",
  "bufferedAt": "2026-07-10T12:00:00.000Z",
  "indexStatus": "ready",
  "shards": [
    {
      "indexId": "products--shard-0",
      "ops": 2,
      "changeId": "chg_0f1e2d3c4b5a6978",
      "bufferedAt": "2026-07-10T12:00:00.000Z",
      "indexStatus": "ready"
    },
    {
      "indexId": "products--shard-1",
      "ops": 1,
      "changeId": "chg_98a7b6c5d4e3f210",
      "bufferedAt": "2026-07-10T12:00:00.000Z",
      "indexStatus": "ready"
    }
  ]
}

Single-document writes on a sharded index return the owning shard's response unchanged (no shards[] array).


POST /v1/indexes/:indexId/search

Query the live snapshot, overlaying pending WAL operations when present.

Body:

FieldTypeDefaultDescription
termstring""Full-text query
mode"fulltext" | "vector" | "hybrid""fulltext"Search mode
whereobject-Filters (same as ZBSearch filters)
limitnumber20Max results
offsetnumber0Pagination offset
propertiesstring | string[]-Properties to search
boostobject-Field boosting
similaritynumber-Vector similarity threshold
vector{ value: number[], property: string }-Vector search query
hybridWeights{ text: number, vector: number }-Hybrid search weights

Example - full-text:

curl -X POST "$WORKER_URL/v1/indexes/products/search" \
  -H 'content-type: application/json' \
  -d '{
    "term": "wireless headphones",
    "limit": 10,
    "where": { "price": { "lte": 200 } }
  }'

Example - vector:

curl -X POST "$WORKER_URL/v1/indexes/products/search" \
  -H 'content-type: application/json' \
  -d '{
    "mode": "vector",
    "vector": {
      "property": "embedding",
      "value": [0.1, 0.2, 0.3]
    },
    "similarity": 0.8
  }'

Response 200: Standard ZBSearch search result plus edge metadata:

{
  "count": 3,
  "hits": [...],
  "indexVersion": "2026-07-10T12-00-00-000Z-9f3ab2c1",
  "includesBuffer": false
}

includesBuffer: true means results include documents from pending WAL operations not yet merged into the snapshot.

Sharded indexes (scatter-gather): the search fans out to all shards in parallel, merges hits by score descending (ties broken by document id), then applies offset/limit to the merged list; count is the sum across shards. The response adds a shards[] summary with each shard's contribution, indexVersion is null (versions are per shard), and includesBuffer is true if any shard has pending ops. Two v1 caveats:

  • Scores are approximate across shards. BM25 IDF is computed per shard, so scores are only approximately comparable - ranking of near-tied hits may differ slightly from a monolithic index.
  • groupBy results are dropped on sharded indexes (the groups field is not merged). Facets are merged - counts add up exactly because document sets are disjoint.

Response 404: No searchable documents yet - create the index, import data, or trigger a rebuild.


Rebuild & status

POST /v1/indexes/:indexId/rebuild

Merge pending WAL operations into a new immutable snapshot and promote it to liveVersion. On a sharded index, all shards are rebuilt in parallel (each shard has its own WAL and rebuild lock).

Response 202:

{
  "status": "rebuilt",
  "indexId": "products",
  "liveVersion": "2026-07-10T12-05-00-000Z-71d0e4f8"
}

Rebuild runs synchronously in the Worker request. For large indexes, use an external builder.

Version IDs are timestamp-based with a random 8-character hex suffix (e.g. 2026-07-10T12-05-00-000Z-71d0e4f8). Uniqueness is load-bearing: the Worker's snapshot caches key on the version, so two rebuilds never share an ID.

GET /v1/indexes/:indexId/status

Response 200:

{
  "indexId": "products",
  "liveVersion": "2026-07-10T12-00-00-000Z-9f3ab2c1",
  "status": "ready",
  "documents": 42,
  "indexSizeBytes": 1048576,
  "pendingOps": 5,
  "lastRebuildAt": "2026-07-10T12:00:00.000Z",
  "lastAppliedOffset": null
}
statusMeaning
emptyIndex exists but no live snapshot
buildingRebuild in progress
readySearchable snapshot available

On a sharded index, status and manifest aggregate across shards: documents, indexSizeBytes, and pendingOps are sums, status is the worst of all shards (building > ready > empty), liveVersion is null, and a shards[] array carries the per-shard breakdown:

{
  "indexId": "products",
  "liveVersion": null,
  "status": "ready",
  "documents": 100000,
  "indexSizeBytes": 134217728,
  "pendingOps": 12,
  "shards": [
    { "indexId": "products--shard-0", "status": "ready", "documents": 49876, "pendingOps": 12, "...": "..." },
    { "indexId": "products--shard-1", "status": "ready", "documents": 50124, "pendingOps": 0, "...": "..." }
  ]
}

GET /v1/indexes/:indexId/manifest

Returns index configuration and stats for clients that need schema or version info:

{
  "indexId": "products",
  "name": "products",
  "liveVersion": "2026-07-10T12-00-00-000Z-9f3ab2c1",
  "status": "ready",
  "schema": { "title": "string" },
  "settings": { "mode": "edge" },
  "stats": {
    "documents": 42,
    "totalBytes": 1048576
  }
}

Index settings

FieldTypeDefaultDescription
languagestring-Tokenizer language (e.g. "english")
rebuildIntervalSecnumber300Hint for external schedulers
rebuildThresholdOpsnumber500Auto-rebuild threshold (Worker cron)
mode"edge" | "hybrid" | "client""edge"Index mode marker

CORS

The Worker sets Access-Control-Allow-Origin: * on all responses and handles OPTIONS preflight for browser clients. Tighten this in a fork if you need origin restrictions.

Rate limits

Cloudflare Workers and R2 have platform-level limits. The default wrangler.toml and setup-CLI template set [limits] cpu_ms = 300000 (5 minutes) to accommodate in-Worker rebuilds on small indexes - on the Workers Paid plan this can also be tuned via limits.cpuMs in zbsearch.edge.config.json. Custom CPU limits require Workers Paid, and the Free plan's 10ms CPU cap makes search workloads fail outright (error 1102). Large rebuilds should run outside the Worker via @zbsearch/edge-index-builder.

On this page