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:
| Secret | Access |
|---|---|
WRITE_API_KEY | Full access: every route below, reads and writes |
READ_API_KEY | Read-only: searches, index lists, status, manifest. 401 on write routes |
Protected routes require:
Authorization: Bearer YOUR_API_KEYThe 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):
| Method | Path | Description |
|---|---|---|
GET | /health | Liveness probe |
GET | /v1/info | Service 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 status | Code | Typical cause |
|---|---|---|
400 | BAD_REQUEST | Invalid input, duplicate index |
401 | UNAUTHORIZED | Missing or invalid API key |
403 | FORBIDDEN | Write attempted with only a read key configured |
404 | NOT_FOUND | Index or route not found, no snapshot yet |
409 | CONFLICT | Reserved 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:
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Display name; slugified to id (e.g. "My Products" -> my-products) |
schema | object | Yes | Same schema types as standalone ZBSearch |
settings | object | No | See index settings |
shards | number | No | Create 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 8Bulk 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).
Search
POST /v1/indexes/:indexId/search
Query the live snapshot, overlaying pending WAL operations when present.
Body:
| Field | Type | Default | Description |
|---|---|---|---|
term | string | "" | Full-text query |
mode | "fulltext" | "vector" | "hybrid" | "fulltext" | Search mode |
where | object | - | Filters (same as ZBSearch filters) |
limit | number | 20 | Max results |
offset | number | 0 | Pagination offset |
properties | string | string[] | - | Properties to search |
boost | object | - | Field boosting |
similarity | number | - | 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.
groupByresults are dropped on sharded indexes (thegroupsfield 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
}status | Meaning |
|---|---|
empty | Index exists but no live snapshot |
building | Rebuild in progress |
ready | Searchable 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
| Field | Type | Default | Description |
|---|---|---|---|
language | string | - | Tokenizer language (e.g. "english") |
rebuildIntervalSec | number | 300 | Hint for external schedulers |
rebuildThresholdOps | number | 500 | Auto-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.