Documents
Ingest, update, delete and export documents — POST /documents, bulk update, TTL, idempotent retries and NDJSON export.
POST /api/v1/indexes/{slug}/documents
POST /api/v1/indexes/{slug}/documents/bulk
GET /api/v1/indexes/{slug}/documents/{externalId}
DELETE /api/v1/indexes/{slug}/documents/{externalId}
DELETE /api/v1/indexes/{slug}/documents?filter_by=…
GET /api/v1/indexes/{slug}/documents/exportWrites need documents:write, single-document reads need search:read, export needs documents:read.
Document shape
| Field | Required | Notes |
|---|---|---|
external_id | yes | 1–256 characters; your primary key inside the index |
content | yes | Indexed text; up to SEARCH_MAX_CONTENT_LENGTH (200,000 characters by default) |
title | no | ≤ 1000 characters; part of the default query_by |
url | no | ≤ 2048 characters, stored but not indexed |
source_type | no | ≤ 64 characters; facetable, useful to separate api, webhook, connector |
tags | no | Up to 50 strings of ≤ 64 characters |
metadata | no | Arbitrary JSON, stored as a string, not searchable |
expires_at | no | ISO 8601 datetime; the document is removed from the index after it |
| your fields | no | Any field declared on the index schema is passed through |
Upsert a batch
curl -sS -X POST "$AAC_API/indexes/documentation/documents" \
-H "Authorization: Bearer $AAC_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: import-2026-10-23-01" \
-d '{
"documents": [
{ "external_id": "pricing", "title": "Pricing", "content": "Pro is 29 USD per month.", "url": "https://example.com/pricing", "tags": ["billing"] },
{ "external_id": "limits", "title": "Rate limits", "content": "600 requests per minute per key.", "tags": ["api"], "expires_at": "2026-12-31T23:59:59Z" }
],
"action": "upsert"
}'{
"index": "documentation",
"indexed": 2,
"failed": [],
"usage": {
"plan": "pro",
"period": "2026-10",
"resetsAt": "2026-11-01T00:00:00.000Z",
"searches": { "used": 4120, "limit": 500000 },
"documents": { "used": 139, "limit": 50000 }
},
"request_id": "req_2a6c8e0b4d1f3a5c7e9b1d3f"
}action controls how existing documents are treated:
| Action | Behaviour |
|---|---|
upsert (default) | Replace an existing document, insert a new one |
create | Insert only; an existing external_id fails for that document |
update | Partial update — only the fields you send change |
emplace | Insert or merge, without clearing omitted fields |
A batch is not all-or-nothing. Rejected documents come back in failed and the rest are indexed:
{
"index": "documentation",
"indexed": 1,
"failed": [{ "external_id": "draft-1", "error": "Document content is required" }],
"usage": { "documents": { "used": 140, "limit": 50000 } },
"request_id": "req_7d1e3b5a9c0f2a4d6b8e0c2a"
}Limits: at most 500 documents per request and 200,000 characters per content. Exceeding either returns 422 INVALID_DOCUMENT with details:
{
"error": {
"code": "INVALID_DOCUMENT",
"message": "Too many documents in a single request",
"request_id": "req_4c8a0e2b6d1f3a5c7b9d1e3f",
"details": { "max": 500, "received": 812 }
}
}Idempotent retries
Send Idempotency-Key on POST /documents and a retry of the same write returns the original response instead of double-counting documents:
curl -sS -i -X POST "$AAC_API/indexes/documentation/documents" \
-H "Authorization: Bearer $AAC_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: import-2026-10-23-01" \
-d '{"documents":[{"external_id":"pricing","content":"Pro is 29 USD per month."}]}'HTTP/1.1 200 OK
idempotent-replayed: trueReplay records live for 10 minutes and are shared across instances when Redis is configured, otherwise kept in-process.
How a key is matched:
- Keys are scoped to the index: the same key on two indexes is two separate writes.
- The record stores a fingerprint of method, path and JSON body. Reusing a key for a different body is refused with
422 IDEMPOTENCY_KEY_REUSEDinstead of replaying the old answer and silently dropping the new write. - While the first request is still running, a concurrent duplicate gets
409 IDEMPOTENCY_IN_FLIGHT; retry after a short pause. The in-flight marker expires after 120 seconds. - A write that fails is not recorded, so the same key can be retried right away.
- Only a successful (
200) response is replayed, byte for byte, including its originalrequest_id.
Read, update, delete
curl -sS "$AAC_API/indexes/documentation/documents/pricing" \
-H "Authorization: Bearer $AAC_KEY"{
"document": {
"id": "pricing",
"external_id": "pricing",
"title": "Pricing",
"content": "Pro is 29 USD per month.",
"url": "https://example.com/pricing",
"source_type": "api",
"organization_id": "org_01h9",
"tags": ["billing"],
"indexed_at": 1761214364
},
"request_id": "req_9e2b4d6a8c0f1e3b5d7a9c1e"
}curl -sS -X DELETE "$AAC_API/indexes/documentation/documents/pricing" \
-H "Authorization: Bearer $AAC_KEY"{
"deleted": true,
"external_id": "pricing",
"request_id": "req_1a3c5e7b9d0f2a4c6e8b0d1f"
}A missing document is 404 DOCUMENT_NOT_FOUND:
{
"error": {
"code": "DOCUMENT_NOT_FOUND",
"message": "Document \"pricing\" was not found",
"request_id": "req_6b0d2f4a8c1e3b5d7f9a1c3e"
}
}Bulk update and delete by filter
curl -sS -X POST "$AAC_API/indexes/documentation/documents/bulk" \
-H "Authorization: Bearer $AAC_KEY" \
-H "Content-Type: application/json" \
-d '{ "filter_by": "tags:=[billing]", "document": { "category": "pricing" } }'{
"index": "documentation",
"updated": 12,
"filter_by": "tags:=[billing]",
"request_id": "req_3d5f7b9a1c2e4a6b8d0f2a4c"
}curl -sS -X DELETE "$AAC_API/indexes/documentation/documents" \
-H "Authorization: Bearer $AAC_KEY" \
--data-urlencode "filter_by=source_type:=connector && indexed_at:<1761214364" \
-G{
"index": "documentation",
"deleted": 348,
"filter_by": "source_type:=connector && indexed_at:<1761214364",
"request_id": "req_8f0b2d4a6c8e1b3d5f7a9c1e"
}filter_by is mandatory for the filter-based delete; without it you get 400 INVALID_QUERY with the message filter_by query parameter is required. The same tenant filter injection applies as in search.
Export
curl -sS "$AAC_API/indexes/documentation/documents/export" \
-H "Authorization: Bearer $AAC_KEY" \
-o documentation.ndjsonThe response is application/x-ndjson with content-disposition: attachment; filename="documentation-export.ndjson", one JSON document per line — the same format the importer accepts, so an export can be replayed into another index.
Expiring documents
expires_at is stored with the document and enforced by the retention job, which removes expired documents from the collection. It is the cheapest way to keep time-boxed content (events, releases, promotions) out of results without a delete call. The retention sweep runs on the scheduler; an operator can trigger it with POST /admin/jobs/run?job=retention (platform admin token required).
Writing from a webhook instead
If documents arrive from another system, POST /indexes/{slug}/webhook accepts the same document array with an HMAC signature and allows up to 1000 documents per call. See Webhooks.
Preview a feed before importing
POST /indexes/{indexSlug}/feed-preview (scope documents:write) reads an XML, YAML, CSV or JSON feed from a public URL and returns the detected fields, a suggested mapping, a mapped sample and a proposed schema. Nothing is written to the index. Pass mapping ({ "rules": [{ "source", "target", "transforms" }] }) to preview your own rules. Transforms: strip_html, to_number, to_bool, split, join, default, lowercase, date_to_unix, first_non_empty. An unreadable feed answers 422 FEED_UNREADABLE.