AACSearch

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/export

Writes need documents:write, single-document reads need search:read, export needs documents:read.

Document shape

FieldRequiredNotes
external_idyes1–256 characters; your primary key inside the index
contentyesIndexed text; up to SEARCH_MAX_CONTENT_LENGTH (200,000 characters by default)
titleno≤ 1000 characters; part of the default query_by
urlno≤ 2048 characters, stored but not indexed
source_typeno≤ 64 characters; facetable, useful to separate api, webhook, connector
tagsnoUp to 50 strings of ≤ 64 characters
metadatanoArbitrary JSON, stored as a string, not searchable
expires_atnoISO 8601 datetime; the document is removed from the index after it
your fieldsnoAny 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:

ActionBehaviour
upsert (default)Replace an existing document, insert a new one
createInsert only; an existing external_id fails for that document
updatePartial update — only the fields you send change
emplaceInsert 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: true

Replay 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_REUSED instead 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 original request_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.ndjson

The 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.

On this page