Documents

Indexes and documents

Documents

Documents are JSON objects stored in an index. Headband auto-detects schema from the document structure, with no upfront schema definition required.

You can send thousands of documents in a single request for bulk ingestion.

List documents

GET/v1/documents?index=products

List documents in an index, a page at a time by offset, or walk the whole index by cursor.

Use server-side only; search keys cannot browse raw source documents.

Auth
admin key only
Returns
200
Response
{
  "results": [
    {
      "id": 1,
      "title": "MacBook Pro",
      "price": 1999
    },
    {
      "id": 2,
      "title": "iPhone 16",
      "price": 999
    }
  ],
  "total": 19547,
  "limit": 20,
  "offset": 0
}
ParameterTypeDefaultDescription
index · stringstringRequiredThe index to list.
limit · numbernumber20Documents per page. A walk by cursor answers at most 1000 a page (250 on Typesense).
offset · numbernumber0Documents to skip. Offset paging stops at the first 1000 documents on Visor and the first 10,000 on Elasticsearch. Not with cursor.
fields · stringstring–Comma-separated fields to return, such as id,title.
cursor · stringstring–Walk the whole index: empty on the first request, then each page's nextCursor.

Walk a whole index

To read every document, on any engine, page by cursor: send cursor empty, then each answer's nextCursor unchanged. Stop when it is null, not on a short page.

JavaScript
const api = "https://api.headband.dev";
const base = api + "/v1/documents";
const key =
  process.env.HEADBAND_ADMIN_KEY;

let cursor = "";
do {
  const query =
    "?index=products&limit=1000" +
    "&cursor=" +
    encodeURIComponent(cursor);
  const res = await fetch(base + query, {
    headers: {
      Authorization: `Bearer ${key}`,
    },
  });
  const page = await res.json();
  for (const doc of page.results) {
    handle(doc);
  }
  cursor = page.nextCursor;
} while (cursor);
Response
{
  "results": [
    {
      "id": 1,
      "title": "MacBook Pro",
      "price": 1999
    },
    {
      "id": 2,
      "title": "iPhone 16",
      "price": 999
    }
  ],
  "limit": 1000,
  "total": 19547,
  "nextCursor": "eyJ2IjoxLCJrIjoiaWQifQ.Hc3m0qTn"
}

Writes during a walk

You can keep writing to an index while you walk it. A document that exists for the whole walk comes back exactly once: never skipped, never repeated, on every engine. A document added, deleted or deleted and added again during the walk may or may not come back, so a walk is not a snapshot of one moment (except on Elasticsearch, below). To catch documents written during an export, walk again or replay your own writes.

EngineHow the walk reads
Meilisearch, TypesenseIn the engine's own document order, which a write never reshuffles. The cursor remembers the last documents it returned, and the next page picks up right after them, wherever writes moved them. If they were all deleted between two pages, or thousands of writes landed around them, the next page answers 400 cursor_expired and the walk starts again. It never guesses.
ElasticsearchFrom one point-in-time snapshot, past the 10,000 offset paging reaches. The walk returns the index exactly as it was on the first page: nothing written later shows up. A walk may pause at most 5 minutes between pages; after that the next page answers 400 cursor_expired and the walk starts again.
VisorIn primary key order, so writes during the walk do not shift it. Visor keeps the primary key in filterableAttributes and sortableAttributes from the moment the key is known. An older index without them is read whole on every page while it holds at most 1000 documents. Past 1000 it answers 400 feature_not_supported on the first page, naming the settings to add. A walk never changes settings itself.

A cursor that was changed, or that another index issued, answers 400 invalid_cursor: start again with an empty one. A cursor from the older offset walk (Meilisearch, Typesense, or a small Visor index) answers 400 cursor_expired, because that walk could skip or repeat documents under writes: start again with an empty one. Send cursor or offset, not both. headband docs export and headband sync reconcile walk this way.

Get document

GET/v1/documents/:docId?index=products

Get a single document by its primary key.

Use server-side only; search keys cannot browse raw source documents.

Auth
admin key only
Returns
200
Response
{
  "id": 1,
  "title": "MacBook Pro",
  "price": 1999,
  "category": "laptops"
}

Add documents

POST/v1/documents?index=products&primaryKey=id

Add or replace documents in an index. If a document with the same primary key already exists, it will be replaced.

Send many documents in one request, up to a 4 MiB body (413 payload_too_large above it).

Auth
admin key only
Returns
202
Request body
[
  {
    "id": 1,
    "title": "MacBook Pro",
    "price": 1999,
    "category": "laptops"
  },
  {
    "id": 2,
    "title": "iPhone 16",
    "price": 999,
    "category": "phones"
  }
]
Response
{
  "taskUid": 2,
  "indexUid": "products",
  "status": "enqueued",
  "enqueuedAt": "2025-01-15T09:00:00Z"
}

Update fields

PATCH/v1/documents?index=products&primaryKey=id

Change only the fields each document names. Every field you leave out keeps its stored value. This is Algolia's partialUpdateObject.

Same query, body, 4 MiB cap and limits as POST. Each document must carry its id.

Auth
admin key only
Returns
202
Request body
[
  { "id": 1, "price": 1899 },
  { "id": 2, "inStock": false }
]
Response
{
  "taskUid": 4,
  "indexUid": "products",
  "status": "enqueued",
  "enqueuedAt": "2025-01-15T09:00:00Z"
}

A document whose id the index does not hold yet is created with the fields you send. A field you send replaces the stored one whole: send all of an object or a list to change part of it. Each engine runs its own update, so a few details differ:

EngineHow the fields mergeA document without its id
MeilisearchTop-level fields replace the stored ones. A field sent as null is stored as null.The whole batch fails in its task, as on POST.
TypesenseTop-level fields replace the stored ones, but an object field merges key by key: keys you leave out keep their values. A field or key sent as null is removed.That document fails alone with missing_document_id in a 422. The rest are written.
ElasticsearchTop-level fields replace the stored ones. A field sent as null is stored as null.That document fails alone with missing_document_id in a 422. The rest are written.
VisorTop-level fields replace the stored ones. A field sent as null is stored as null.The whole request fails with 400, as on POST.

Pass primaryKey as you did when you added the documents. On Typesense and Elasticsearch it names the field that holds each id; without it the id is the id field. A partial update never stores a document without its id as a new one. PATCH /v1/bulk and PATCH /v1/bulk/stream take the same body as their POST and update the same way.

Delete documents

DELETE/v1/documents?index=products

Delete documents from an index by IDs or by filter.

Auth
admin key only
Returns
202
Request body
// Delete by IDs:
{ "ids": [1, 2] }

// Or delete by filter:
{ "filter": "price > 1000" }
Response
{
  "taskUid": 3,
  "indexUid": "products",
  "status": "enqueued"
}

Bulk import

Import large volumes of documents efficiently. The bulk endpoint splits your documents into batches and sends them in parallel. A request body can be at most 4 MiB, on this route and on POST /v1/documents: a larger one answers 413 payload_too_large and writes nothing. For more data, send several requests.

POST/v1/bulk

Import documents in optimized parallel batches.

Auth
admin key only
Returns
202
Request body
{
  "index": "products",
  "primaryKey": "id",
  "documents": [
    {
      "id": 1,
      "title": "Widget",
      "price": 9.99
    },
    {
      "id": 2,
      "title": "Gadget",
      "price": 24.99
    }
    // ... as many as fit in 4 MiB
  ]
}
Response
{
  "tasks": [
    {
      "taskUid": 10,
      "indexUid": "products",
      "status": "enqueued",
      "batchNumber": 1,
      "documentsInBatch": 10000
    },
    {
      "taskUid": 11,
      "indexUid": "products",
      "status": "enqueued",
      "batchNumber": 2,
      "documentsInBatch": 10000
    }
  ],
  "totalDocuments": 20000,
  "totalBatches": 2,
  "batchSize": 10000
}
ParameterTypeDefaultDescription
batchSize · numbernumber10000Documents per batch (min 100, max 50000).

Streaming progress

POST/v1/bulk/stream

Import documents with real-time Server-Sent Events progress. Same request body as /v1/bulk.

Use the streaming endpoint for large imports to get real-time progress feedback. Combine with the Tasks API to poll individual batch status after import.

Auth
admin key only
Returns
200 (SSE stream)
Request body
{
  "index": "products",
  "primaryKey": "id",
  "documents": [
    {
      "id": 1,
      "title": "Widget",
      "price": 9.99
    },
    {
      "id": 2,
      "title": "Gadget",
      "price": 24.99
    }
    // ... as many as fit in 4 MiB
  ]
}
Response
data: {"event": "batch_complete", "batchNumber": 1, "taskUid": 10, "documentsInBatch": 10000}

data: {"event": "batch_complete", "batchNumber": 2, "taskUid": 11, "documentsInBatch": 10000}

data: {"event": "complete", "totalDocuments": 20000, "totalBatches": 2}

Split a large import

A file over 4 MiB has to go in several requests. This script reads a JSON array, sends it to /v1/bulk in pieces under the limit, waits out 429 answers for the time Retry-After names, and waits for each task. A write is accepted before the engine checks it, and one bad record, such as one without the primary key, fails its whole batch. So check each task: a 202 alone does not mean the documents are in.

/v1/bulk shares a limit of 100 requests a minute per key with the other writes. The script needs Node 18 or later and no packages. Run it with HEADBAND_ADMIN_KEY=hb_adm_... node import.mjs records.json.

import.mjs
// node import.mjs records.json
import { readFileSync } from "node:fs";

const HEADBAND =
  process.env.HEADBAND_URL ??
  "https://api.headband.dev";
// hb_adm_..., on a server only
const KEY =
  process.env.HEADBAND_ADMIN_KEY;
const INDEX =
  process.env.HEADBAND_INDEX ??
  "products";
// "objectID" for an Algolia export
const PRIMARY_KEY =
  process.env.HEADBAND_PRIMARY_KEY ??
  "id";
// under the 4 MiB body limit,
// with room to spare
const MAX_BYTES = 3.5 * 1024 * 1024;

const sleep = (ms) =>
  new Promise((resolve) =>
    setTimeout(resolve, ms)
  );

const headers = {
  Authorization: `Bearer ${KEY}`,
  "Content-Type": "application/json",
};

// Waits out 429s for the time
// Retry-After names, then answers
// status and body.
async function call(method, path, body) {
  for (;;) {
    const url = `${HEADBAND}${path}`;
    const res = await fetch(url, {
      method,
      headers,
      body:
        body === undefined
          ? undefined
          : JSON.stringify(body),
    });
    if (res.status === 429) {
      const after =
        res.headers.get("retry-after");
      const seconds = Number(after ?? 1);
      await sleep(seconds * 1000);
      continue;
    }
    const data = await res
      .json()
      .catch(() => null);
    return { status: res.status, data };
  }
}

// One bad record fails its whole
// batch later, in the task, so wait
// for each task.
async function waitFor({
  taskUid,
  status,
}) {
  let task = { status };
  const path =
    `/v1/tasks/${taskUid}` +
    `?index=${INDEX}`;
  while (
    task.status === "enqueued" ||
    task.status === "processing"
  ) {
    await sleep(500);
    task = (await call("GET", path)).data;
  }
  if (task.status !== "succeeded") {
    throw new Error(
      `task ${taskUid} ` +
        `${task.status}: ` +
        JSON.stringify(task.error)
    );
  }
}

async function send(documents) {
  const res = await call(
    "POST",
    "/v1/bulk",
    {
      index: INDEX,
      primaryKey: PRIMARY_KEY,
      documents,
    }
  );
  if (res.status >= 300) {
    throw new Error(
      `bulk ${res.status}: ` +
        JSON.stringify(res.data)
    );
  }
  for (const task of res.data.tasks) {
    await waitFor(task);
  }
  const n = documents.length;
  console.log(`indexed ${n}`);
}

const records = JSON.parse(
  readFileSync(process.argv[2], "utf8")
);

// 409 means the index exists already.
const created = await call(
  "POST",
  "/v1/indexes",
  { uid: INDEX, primaryKey: PRIMARY_KEY }
);
if (
  created.status >= 400 &&
  created.status !== 409
) {
  throw new Error(
    JSON.stringify(created.data)
  );
}

let batch = [];
let bytes = 0;
for (const record of records) {
  const json = JSON.stringify(record);
  const size =
    Buffer.byteLength(json) + 1;
  if (
    bytes + size > MAX_BYTES &&
    batch.length > 0
  ) {
    await send(batch);
    batch = [];
    bytes = 0;
  }
  batch.push(record);
  bytes += size;
}
if (batch.length > 0) await send(batch);