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
/v1/documents?index=productsList 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
{
"results": [
{
"id": 1,
"title": "MacBook Pro",
"price": 1999
},
{
"id": 2,
"title": "iPhone 16",
"price": 999
}
],
"total": 19547,
"limit": 20,
"offset": 0
}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.
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);{
"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.
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
/v1/documents/:docId?index=productsGet a single document by its primary key.
Use server-side only; search keys cannot browse raw source documents.
- Auth
- admin key only
- Returns
- 200
{
"id": 1,
"title": "MacBook Pro",
"price": 1999,
"category": "laptops"
}Add documents
/v1/documents?index=products&primaryKey=idAdd 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
[
{
"id": 1,
"title": "MacBook Pro",
"price": 1999,
"category": "laptops"
},
{
"id": 2,
"title": "iPhone 16",
"price": 999,
"category": "phones"
}
]{
"taskUid": 2,
"indexUid": "products",
"status": "enqueued",
"enqueuedAt": "2025-01-15T09:00:00Z"
}Update fields
/v1/documents?index=products&primaryKey=idChange 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
[
{ "id": 1, "price": 1899 },
{ "id": 2, "inStock": false }
]{
"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:
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
/v1/documents?index=productsDelete documents from an index by IDs or by filter.
- Auth
- admin key only
- Returns
- 202
// Delete by IDs:
{ "ids": [1, 2] }
// Or delete by filter:
{ "filter": "price > 1000" }{
"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.
/v1/bulkImport documents in optimized parallel batches.
- Auth
- admin key only
- Returns
- 202
{
"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
]
}{
"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
}Streaming progress
/v1/bulk/streamImport 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)
{
"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
]
}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.
// 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);