Ingestion API reference
The complete server-to-server API for getting documents into a search index and keeping them there.
This is the reference. If you are loading data for the first time, start with Loading data into an index — it covers the upload screen, file formats and field mapping, and links back here for the details.
Everything below is server-to-server. These endpoints send no CORS headers, so they cannot be called from a browser. That is deliberate: an ingestion key can write and delete, and a credential with that power has no business in client-side code.
Authentication
Two credentials work, and they are not interchangeable:
| Credential | Header | Used for |
|---|---|---|
| Ingestion key | Authorization: Bearer ik_… | Everything on this page except key management |
| Session cookie | — | The admin UI, and the only way to manage keys |
curl -X POST "$BASE/api/search-indexes/$INDEX_ID/documents" \
-H "Authorization: Bearer $INGESTION_KEY" \
-H "Content-Type: application/json" \
-d '{"documents": [ ... ]}'
A search experience access token will not work here and returns 401. Access tokens are public
by design — they ship in the embed snippet — so they can only ever read. See
Ingestion keys for the distinction.
Sending an Authorization header disables session fallback. If the header is present but the
key is invalid, expired or revoked, the request fails with 401; it does not quietly fall back to
your logged-in session. This is what stops a broken deploy from appearing to work while you happen
to be signed in.
Reads (GET) need only a key scoped to the index. Writes need the write operation, deletes need
delete.
Response envelope
Every response is wrapped:
{
"success": true,
"data": { /* the payload documented for each endpoint below */ }
}
Errors replace data with a message:
{
"success": false,
"error": "Document \"SKU-404\" not found",
"code": "NOT_FOUND"
}
Note that a write can return "success": true at the envelope level with data.success: false
inside it — the request was handled, but some operations in it failed. See
partial success below.
Rate limits
Limits are per ingestion key (falling back to client IP when there is no key), over a rolling 60-second window.
| Endpoint | Limit |
|---|---|
POST /documents | 30 / min |
PUT PATCH DELETE /documents/:documentId | 300 / min |
POST /documents/bulk | 60 / min |
POST /documents/delete-by-filter | 20 / min |
All GET routes, and the batches routes | not limited |
Responses from rate-limited endpoints carry the current state. Unlimited routes send no such headers, so do not rely on their presence:
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 58
Exceeding a limit returns 429 with Retry-After in seconds:
HTTP/1.1 429
Retry-After: 42
X-RateLimit-Limit: 30
X-RateLimit-Remaining: 0
Plan for this in backfills. The single most common mistake is looping over a large catalogue
with POST /documents and no pacing: at 100 documents per request, a 5,000-item catalogue is 50
requests against a 30/min limit, so it stalls a third of the way in. Either pace the loop, or
honour Retry-After:
async function post(body) {
for (;;) {
const res = await fetch(url, { method: "POST", headers, body: JSON.stringify(body) });
if (res.status !== 429) return res;
const wait = Number(res.headers.get("Retry-After") ?? 60);
await new Promise(r => setTimeout(r, wait * 1000));
}
}
If you are pushing a whole catalogue, prefer fewer, larger requests over many small ones — the cap is on requests, not documents.
Documents
All paths below are relative to /api/search-indexes/{indexId}.
POST /documents — bulk upload
Loads a set of documents and tracks the work as a batch. This is the endpoint the upload screen uses, and the right one for a backfill.
Requires: write · Rate limit: 30/min
{
"documents": [
{ "id": "PROD-001", "name": "Pacific runner sneaker", "price": 89.99 }
],
"sourceFileName": "catalogue-2026-08.jsonl" // optional, shown in Recent Uploads
}
Limits: 10,000 documents and 10 MB per request. Both are configurable per deployment
(ES_MAX_DOCUMENTS_PER_UPLOAD, ES_MAX_FILE_SIZE_BYTES).
{
"success": true,
"data": {
"success": true,
"batchId": "9f1c…", // use with the batches endpoints below
"message": "Indexed 412 of 412 documents",
"summary": { "total": 412, "indexed": 412, "failed": 0 },
"embeddingStats": { // semantic/hybrid indexes only
"enabled": true, "generated": 412, "failed": 0, "skipped": 0
},
"errors": [], // up to 100
"warnings": [], // up to 50
"durationMs": 8412
}
}
Unlike the incremental endpoints, this path creates the index if it does not exist yet, and may recreate it if the embedding dimensions no longer match.
GET /documents — browse
Walks the index a page at a time. Useful for checking that a mapping produced what you expected.
Requires: index scope only · Not rate limited
curl "$BASE/api/search-indexes/$INDEX_ID/documents?page=1&pageSize=25" \
-H "Authorization: Bearer $INGESTION_KEY"
pageSize defaults to 25 and caps at 100. Paging is capped at 10,000 documents — past that you
get 400, because Elasticsearch refuses deep pagination and Azure has its own ceiling. To extract
an entire large index, export it rather than walking pages.
{
"success": true,
"data": {
"documents": [
{ "id": "PROD-001", "fields": { "name": "Pacific runner sneaker", "price": 89.99 } }
],
"columns": [
{ "field": "uniqueId", "label": "ID", "type": "id" },
{ "field": "name", "label": "Name", "type": "text" },
{ "field": "price", "label": "Price", "type": "number" }
],
"pagination": { "page": 1, "pageSize": 25, "totalPages": 17, "totalItems": 412 }
}
}
Only the columns fields come back per document, not the whole thing — enough to fill a table.
Fetch a single document for everything. Embedding vectors are never returned by any read.
columns is derived from your field configuration: the document key first, then a title if one can
be identified, then attributes ranked by how well their type fits a table cell, then updatedAt.
Each carries its type so a client can render it appropriately.
GET /documents/:documentId — read one
Requires: index scope only · Not rate limited
The :documentId is the value of the index's mapped uniqueId field — commonly a source-system id
such as a SKU — not a UUID.
{
"success": true,
"data": {
"documentId": "PROD-001",
"document": { "name": "Pacific runner sneaker", "price": 89.99 },
"embeddingPreview": { // semantic/hybrid indexes only
"text": "Product Name: Pacific runner sneaker\nCategory: Footwear",
"totalChars": 62,
"parts": [
{ "fieldName": "name", "label": "Product Name", "text": "Product Name: …", "included": true },
{ "fieldName": "tags", "label": "Tags", "text": "", "included": false, "excludedBecause": "empty" }
]
}
}
}
embeddingPreview is the exact text that was sent to the embedding model, with a per-field
breakdown of what contributed and what did not — the only way to see why a document does or does not
match semantically, since the stored vector is never returned.
PUT /documents/:documentId — replace
Sends a complete document. Anything you omit is gone afterwards. Creates the document if absent.
Requires: write · Rate limit: 300/min
curl -X PUT "$BASE/api/search-indexes/$INDEX_ID/documents/PROD-001" \
-H "Authorization: Bearer $INGESTION_KEY" \
-H "Content-Type: application/json" \
-d '{"document": {"id": "PROD-001", "name": "Pacific runner sneaker", "price": 79.99}}'
PATCH /documents/:documentId — partial update
Sends a fragment. Anything you omit keeps its current value. Returns 404 if the document does not
exist.
Requires: write · Rate limit: 300/min
curl -X PATCH "$BASE/api/search-indexes/$INDEX_ID/documents/PROD-001" \
-H "Authorization: Bearer $INGESTION_KEY" \
-H "Content-Type: application/json" \
-d '{"document": {"price": 79.99}}'
Both return the write response shape.