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.

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 onlyReturns 200
Response
{
  "results": [
    { "id": 1, "title": "MacBook Pro", "price": 1999 },
    { "id": 2, "title": "iPhone 16", "price": 999 }
  ],
  "total": 19547,
  "limit": 20,
  "offset": 0
}
ParameterTypeDefaultDescription
indexstringRequiredThe index to list.
limitnumber20Documents per page. A walk by cursor answers at most 1000 a page (250 on Typesense).
offsetnumber0Documents to skip. Offset paging stops at the first 1000 documents on Visor and the first 10,000 on Elasticsearch. Not with cursor.
fieldsstring—Comma-separated fields to return, such as id,title.
cursorstring—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 and at any size, page by cursor. Send cursor empty on the first request, then each response's nextCursor, until it is null. The cursor is opaque: hand it back unchanged. A page can hold fewer documents than limit, so stop on a null cursor, never on a short page. The same loop works whichever engine holds the index.

JavaScript
let cursor = "";
do {
  const res = await fetch(
    `https://headband.dev/v1/documents?index=products&limit=1000&cursor=${encodeURIComponent(cursor)}`,
    { headers: { Authorization: `Bearer ${process.env.HEADBAND_ADMIN_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": "eyJ2IjoxLCJtIjoiayIsImsiOiJpZCJ9.Hc3m0qTn0aDl9QyPpj6p1w"
}

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/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 onlyReturns 200
Response
{
  "id": 1,
  "title": "MacBook Pro",
  "price": 1999,
  "category": "laptops"
}
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 onlyReturns 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"
}
DELETE/v1/documents?index=products

Delete documents from an index by IDs or by filter.

Auth admin key onlyReturns 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 onlyReturns 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
batchSizenumber10000Documents per batch (min 100, max 50000).

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://headband.dev";
const KEY = process.env.HEADBAND_ADMIN_KEY; // hb_adm_..., on a server only
const INDEX = process.env.HEADBAND_INDEX ?? "products";
const PRIMARY_KEY = process.env.HEADBAND_PRIMARY_KEY ?? "id"; // "objectID" for an Algolia export
const MAX_BYTES = 3.5 * 1024 * 1024; // under the 4 MiB body limit, with room to spare

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

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

// One bad record fails its whole batch later, in the task, so wait for each task.
async function waitFor({ taskUid, status }) {
  let task = { status };
  while (task.status === "enqueued" || task.status === "processing") {
    await sleep(500);
    task = (await call("GET", `/v1/tasks/${taskUid}?index=${INDEX}`)).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);
  console.log(`indexed ${documents.length}`);
}

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 size = Buffer.byteLength(JSON.stringify(record)) + 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);

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 onlyReturns 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}