index4.ai API Reference
Reference for the index4.ai REST API (v1): ingestion, retrieval and document endpoints.
- Base URL:
https://api.index4.ai/v1 - Auth:
Authorization: Bearer <token>for every endpoint. The token may be a login token, an account API key, or a project API key (prefixpoma_proj_gr_). When you authenticate with account-level credentials, add theX-Project-IDheader to select the target project (protected projects reject account credentials — use a project API key). - Interactive reference: api.index4.ai/docs
For higher-level walkthroughs, see Ingestion, Retrieval, RetrievalContext format, and Document management.
Endpoint summary
| Method | Path | Purpose |
|---|---|---|
POST | /ingest | Submit a file for ingestion into the project's namespace. |
POST | /ingestBucket | Bulk-ingest a whole S3/GCS bucket (or prefix) as one task. |
POST | /search | Hybrid search across the namespace; returns prompt-ready context. |
POST | /searchInDoc | Same as /search but doc_filter is required. |
GET | /attributes | List the project's typed attributes (name + type) and the name budget. Call before declaring a new attribute. |
GET | /docs | List documents in the namespace (paged). |
GET | /docs/{docId} | Get metadata for one document. |
PATCH | /docs/{docId} | Set, update or remove a document's attributes. |
DELETE | /docs/{docId} | Remove a document's vectors and storage. |
Ingest jobs are tracked with GET /jobs/{job_id}/status (polling) or the streaming endpoint GET https://api.index4.ai/status/v1/jobs/{job_id}.
POST /ingest
Create an ingestion job. Either send the file as raw bytes (application/octet-stream, filename carried in Content-Disposition) or point at a public URL with X-Remote-URL and let the server fetch it. Multipart is not accepted.
Request-size limit: the HTTP body is capped at 50 MB — larger uploads are rejected with
413before processing starts. For files above that, host them and passX-Remote-URLso the server fetches the bytes instead of receiving them in the request body.
Request headers
| Header | Required | Notes |
|---|---|---|
Authorization | yes | Bearer <token> — login token, account API key, or project API key. |
X-Project-ID | conditional | Required only when authenticating with account-level credentials, to select the target project. Omit when using a project API key. |
Content-Type | conditional | application/octet-stream for a raw-bytes upload. Not needed when using X-Remote-URL. |
Content-Disposition | conditional | attachment; filename="<name>.<ext>". The extension drives parser selection. Not required when using X-Remote-URL. |
X-Remote-URL | no | A publicly accessible URL to fetch the file from instead of uploading bytes. When set, Content-Disposition and the request body are optional. |
X-Unencrypted-Strings | no | JSON object of plaintext key→value metadata for wildcard/glob filtering. Stored unencrypted (vendor-visible — do not put secrets/PII here; use an encrypted_text attribute in X-Attributes for sensitive values). Max 32 keys; keys [A-Za-z0-9_] (case-insensitive) ≤ 64 chars, values ≤ 1024 chars, ≤ 16384 chars total. e.g. {"project": "acme-merger", "dept": "legal"}. Filter at search time with unencrypted_strings_match. |
X-Base-URL | no | Base URL used to resolve relative image links in the input file. |
X-Completion | no | URL (and optional headers) to send a completion webhook to when the job finishes. |
X-Attributes | no | Typed attributes — one JSON object holding any type. Before declaring a name, call GET /attributes and reuse an existing name and type where one fits. string/int/float/bool and their arrays are inferred; datetime and encrypted_text must be declared via X-Attribute-Schema (the reserved labels is typed []encrypted_text automatically). e.g. {"region":"emea","revision":12}. A name's type and the name itself are permanent — the first document that uses a name fixes both, and each project has a budget of 64 names. Names must match ^[a-z0-9_]{1,64}$. On ingest this header is the only carrier — ingest is octet-stream, with no multipart form path — so attributes are capped at 2048 chars per request. Declare datetime / encrypted_text with the companion X-Attribute-Schema header (same cap). Filter at search time with attribute_filters. |
X-Attribute-Schema | no | Type declarations for entries in X-Attributes, shaped {"name": {"type": "…"}}. Required for datetime and encrypted_text, which cannot be inferred from a value. A declaration that conflicts with the type already in force for that name returns 400 attribute_type_conflict. |
Opaque vs. plaintext metadata. An
encrypted_textattribute inX-Attributesis HMAC'd — the vector DB never sees the values — so it is safe for sensitive values, at the cost of exact-token matching.X-Unencrypted-Stringsis stored in plaintext (vendor-visible) precisely so its values can be wildcard-matched (filter viaunencrypted_strings_match) — never put secrets or PII there.Legacy fields.
X-Labels,X-Meta-Int-1andX-Meta-Int-2on ingest, andmeta_tags_any,meta_tags_all,meta_int_1_gte/_lteandmeta_int_2_gte/_lteon search, are still accepted and mapped onto typed attributes. Their removal will be announced in advance; see Migrating from labels / meta_int for the replacements. PrimeCut is unaffected by this retirement and continues to acceptX-Labels.
Response (201 Created) — PublicJob
{
"job_id": "550e8400-e29b-41d4-a716-446655440000",
"created_at": "2026-04-30T10:00:00Z",
"properties": {
"file": { "filename": "manual.pdf", "size": 1048576 }
}
}Status codes
| Status | Meaning |
|---|---|
201 | Job created. Poll status with GET /jobs/{job_id}/status or stream via GET /status/v1/jobs/{job_id}. |
400 | Bad request (no X-Remote-URL and missing Content-Disposition, unsupported MIME, or empty body). |
401 | Missing/invalid Bearer token. |
403 | Multipart submitted. |
500 | Server error. |
See Ingestion for the full lifecycle.
POST /ingestBucket
Preview
Bucket ingest is served by a separately enabled service and is not yet part of the published index4.ai OpenAPI spec. Request shape and limits below may change; check availability for your project before relying on it.
Bulk-ingest an entire S3 or GCS bucket (or a prefix inside one) as a single task — the server lists the objects and streams each file through the normal ingestion pipeline, so nothing passes through your HTTP request body (the 50 MB request limit does not apply here).
Same auth as the rest of the API: project API key, or account credentials + X-Project-ID.
Request body
{
"source": {
"type": "s3", // "s3" or "gcs"
"bucket": "my-corpus",
"prefix": "contracts/", // optional
"region": "eu-central-1", // optional
"endpoint": "…", // optional (S3-compatible stores)
"credentials": { "access_key_id": "…", "secret_access_key": "…" }
},
"filter_glob": "**/*.pdf", // optional
"dry_run": false, // true = validate + sample listing, no task created
"acknowledge_egress_cost": true
}Credentials per source type: s3 needs access_key_id + secret_access_key; gcs needs service_account_json. An optional X-Completion header (pipe-separated webhook/email targets, max 1024 bytes) fires once when the task reaches a terminal state.
Response — 202 Accepted:
{
"bucket_ingest_task_id": "…",
"state": "created",
"state_description": "…",
"poll_url": "/index4ai/v1/ingestBucket/{id}",
"polling_guidance": { "min_interval_seconds": …, "max_interval_seconds": … }
}With dry_run: true you instead get a validation report: a sample of discovered objects and, for cross-region setups, a rough egress-cost estimate.
Task lifecycle & sub-operations (all under /ingestBucket/{id}):
| Operation | Effect |
|---|---|
GET /{id} | Task status. States: created → queued_org → listing → materialising → done / done_with_errors / failed; may pause as paused_credit_exhausted, paused_credential_error, paused_destination_credential_error, paused_egress_quota_exhausted, paused_user. |
POST /{id}/pause · /{id}/resume | Pause / resume the task. |
DELETE /{id} | Cancel the task. |
GET /{id}/failures | List per-file failures. |
POST /{id}/retryFailed | Re-queue the failed files. |
PATCH /{id}/credentials | Rotate source credentials (e.g. after paused_credential_error). |
Limits
| Limit | Value |
|---|---|
| Active tasks per account | 1 — a second submit returns 409 while one is running |
| Max size per file | 500 MB |
| Max total size per task | 500 GB |
| Files processed concurrently | 100 per account |
| Files per task | Capped during the phased rollout (target ceiling: 100,000). Listing beyond the cap ends the task as done_with_errors — everything discovered up to the cap is ingested. |
Documents land in the project namespace and are searchable exactly like single-file ingests.
POST /search
Hybrid retrieval. Returns a prompt-ready RetrievalContext.
Request body — SearchRequest
| Field | Type | Required | Description |
|---|---|---|---|
query | string | yes | Natural-language query. |
doc_filter | string | no | Optional single-document id filter. Use /searchInDoc when this is required. |
doc_ids | array of string | no | Restrict search to this set of document ids. |
exclude_doc_ids | array of string | no | Doc ids to exclude from results (max 100). Useful in agent loops to avoid re-citing docs already shown. |
attribute_filters | array of {attr, op, value} | no | Filter on typed attributes. Operators depend on the attribute's type: eq ne in nin glob iglob (string); eq ne in nin lt lte gt gte (int); eq ne lt lte gt gte (float, datetime); eq ne (bool); contains contains_any eq in ([]string); contains contains_any any_lt any_gt ([]int, []float); contains any_lt any_gt ([]datetime); contains contains_any ([]bool); has_all_tokens has_any_tokens (encrypted_text, []encrypted_text). An operator the type does not offer returns 400 naming the ones it does. Clauses AND together. A filter naming an attribute the project has never used matches nothing — it is not ignored; list the names that exist with GET /attributes. encrypted_text matching is exact: same tokenization on both sides, no stemming, no partial words. Max 32 clauses. |
unencrypted_strings_match | object (string→string) | no | Match on plaintext X-Unencrypted-Strings: a key→glob-pattern map. Case-insensitive Unix glob (* = any run, ? = one char; a pattern with no wildcard is an exact match). Multiple keys AND together. |
return_assets | boolean | no | Return cited docs' figures (and tables, where available) in an assets field keyed by doc_id; images are base64 data URIs, referenced as [IMAGE: name] in the context. |
format | string | no | prompt_ready (default) renders the XML+Markdown context block; json returns structured ranked hits instead. |
min_relevance | float 0..1 | no | Relevance floor (default 0.3). Hits below it are dropped. Higher = stricter, lower = more permissive. |
min_top_relevance | float 0..1 | no | Floor for the top hit. If the best hit scores below this, the entire result set comes back empty. |
target_tokens | integer 100..500000 | no | Soft token budget — the typical answer size (default 5000). Hits are admitted best-first up to this. The knob to size a response. Can never exceed max_tokens (clamped down if it would). |
max_tokens | integer 100..500000 | no | Hard ceiling (default 15000). index4.ai expands past target_tokens toward it only for tight multi-document clusters. Set it below target_tokens and the soft target is clamped down to match — the hard ceiling always wins; it never inflates a response on its own. |
expand_tightness | float 0..1 | no | How aggressively the engine expands context around each hit. |
retrieval_tier | string | no | Per-query retrieval tier — standard (fusion only) or advanced (adds a reranker). See Retrieval tiers. |
premium | boolean | no | Legacy flag — true is equivalent to retrieval_tier: "advanced". Prefer retrieval_tier. |
Result count is bounded server-side by relevance and the token budget — there is no
top_kparameter.
Response (200 OK) — RetrievalContext (default, format: "prompt_ready")
{
"context": "<context><doc id=\"…\">…</doc></context>",
"query": "How did operating margin change year over year?",
"namespace": "project_docs_rag_4f2",
"result_count": 4,
"tokens_estimated": 5820,
"results_dropped": 2,
"detected_lang": "english",
"mode": "advanced",
"search_units": 1
}context is the prompt-ready block (see RetrievalContext format for the grammar) and the field you'll use 99% of the time; the siblings are metadata — result_count / results_dropped (how many hits made it / were dropped for budget), tokens_estimated (rendered size), detected_lang, mode (retrieval tier used), and search_units (billing). When return_assets: true and a cited doc has figures/tables, an assets object (keyed by doc_id) is also present; images are base64 data URIs referenced as [IMAGE: name] inside context.
With format: "json" the response is a SearchResponse instead: a results array of per-doc hits (doc_id, content, title, canonical_url, pages, score, scores.{ann,bm25,rrf,reranker}, …) plus the same query / namespace / result_count / detected_lang / results_dropped metadata — no rendered context.
Status codes
| Status | Meaning |
|---|---|
200 | OK. |
400 | Validation error or upstream 400. |
401 | Missing/invalid token, or upstream 401. |
403 | Upstream 403. |
404 | doc_filter references an unknown doc. |
502 | Other upstream / proxy error. |
503 | Cannot reach the retrieval backend. |
POST /searchInDoc
Same shape as /search, but doc_filter is required and must be non-empty. Use this when you want server-side enforcement that retrieval stays inside one document.
Request body — SearchInDocRequest
| Field | Type | Required | Description |
|---|---|---|---|
query | string | yes | Natural-language query. |
doc_filter | string | yes | Document id to restrict search to. |
return_page_images | boolean | no | Deprecated / not available — no-op today (page screenshots will be served via a dedicated endpoint, not inline). |
It also accepts every optional field from /search — doc_ids, exclude_doc_ids, attribute_filters, unencrypted_strings_match, return_assets, format, min_relevance, min_top_relevance, target_tokens, max_tokens, expand_tightness, retrieval_tier, and premium — with the same semantics.
Same as
/search: result count is bounded by relevance and the token budget; there is notop_k.
Response (200 OK) — RetrievalContext (identical to /search).
Status codes
| Status | Meaning |
|---|---|
200 | OK. |
400 | Missing query, missing/empty doc_filter, or upstream 400. |
401 | Missing/invalid token. |
403 | Upstream 403. |
404 | Doc not found in this project's namespace. |
502 | Upstream / proxy error. |
503 | Retrieval backend unreachable. |
GET /attributes
List the typed attributes already in use in the project: each name with its type, plus the project's name budget. No request body, no query parameters. Same auth as every other route (add X-Project-ID with account-level credentials).
Call this before declaring a new attribute. A name and its type are permanent once the first document uses them, and every name counts against max_names. Agents and integrations should read this list first, reuse an existing name and type where one fits (don't invent doc_year when year already exists), and only introduce a new name when nothing fits. It is also the way to learn which names — and so which attribute_filters operators — exist before searching.
curl -sS "https://api.index4.ai/v1/attributes" -H "authorization: Bearer $INDEX4AI_API_KEY"Response (200 OK)
{
"attributes": [
{ "name": "region", "type": "string" },
{ "name": "revision", "type": "int" },
{ "name": "published_at", "type": "datetime" },
{ "name": "case_notes", "type": "encrypted_text" }
],
"max_names": 64,
"note": "…"
}typeis one ofstring,int,float,bool,datetime,encrypted_text, or an array form ([]string,[]int,[]float,[]bool,[]datetime,[]encrypted_text).max_namesis the per-project cap on distinct names (64). Names already listed are spent; clearing a value does not free its name.noteis human-readable guidance; don't parse it.- An empty
attributesarray means the project has no typed attributes yet.
Status codes
| Status | Meaning |
|---|---|
200 | OK. |
401 | Missing/invalid token. |
403 | The key has no access to this project |
410 | The project was purged (namespace_purged). |
503 | The attribute schema is temporarily unreadable. Retry with backoff — never treat a 503 as "no attributes exist", or you will declare duplicates of names that are already there. |
GET /docs
List documents in the project's namespace. Results are paged.
Query parameters
| Name | Type | Description |
|---|---|---|
limit | integer | Max documents to return (default 100, max 500; larger values are clamped). Omit both limit and cursor to get all documents of a namespace with up to 100 documents; larger namespaces return the first 100 plus has_more / next_cursor. |
cursor | string | Opaque cursor from a previous response's next_cursor. Do not construct it manually; a malformed cursor returns 400. |
Response (200 OK) — ListDocsResponse
{
"namespace": "project_docs_rag_4f2",
"total_documents": 3,
"documents": [ DocInfo, … ],
"has_more": false,
"next_cursor": null,
"degraded": false
}| Field | Type | Description |
|---|---|---|
namespace | string | Project namespace identifier. |
total_documents | integer | Full count of documents in the namespace (never the page size). |
documents | array | DocInfo entries (see below). |
has_more | boolean | true when more documents exist beyond this page; pass next_cursor to continue. |
next_cursor | string or null | Opaque cursor for the next page; absent or null when has_more is false. |
degraded | boolean | true when at least one document in this page failed a transient fetch and was skipped. |
notice | string or null | Set only on truncated responses where neither limit nor cursor was supplied; says how to page through the rest. |
Status codes
| Status | Meaning |
|---|---|
200 | OK. |
401 | Missing/invalid token. |
403 | Upstream 403. |
404 | Upstream 404. |
502 / 503 | Upstream issues. |
GET /docs/{docId}
Fetch metadata for one document.
Path parameters
| Name | Type | Description |
|---|---|---|
docId | string | The exact doc_id returned by /docs. |
Response (200 OK) — DocInfo
| Field | Type | Description |
|---|---|---|
doc_id | string | Stable identifier within the namespace. |
title | string | Detected title (filename fallback). |
language | string | BCP-47 language code. |
filename | string | Original ingest filename. |
canonical_url | string | Citation URL stamped at ingest by Core (e.g. the resolved target of a short link / redirect when ingesting from a URL). Empty when not applicable. |
pages | integer | Source page count. |
chunkset_count | integer | Chunksets produced. |
chunk_count | integer | Chunks produced. |
image_count | integer | Detected figures/images. |
table_count | integer | Detected tables. |
ingested_at | string (RFC 3339) | Job completion time. |
source_job_id | string | job_id of the originating /ingest. |
bm25_state | string | State of the document's BM25 (lexical) index. |
attributes | object | Typed attributes exactly as supplied at ingest. Present only when the document has any. |
attribute_schema | object | Type of each attribute of this document as {name: {"type": t}}. Empty means the types are unknown, not that nothing is encrypted. |
Status codes
| Status | Meaning |
|---|---|
200 | OK. |
400 | Empty docId. |
401 | Missing/invalid token. |
403 | Upstream 403. |
404 | Doc not found in this namespace. |
502 / 503 | Upstream issues. |
PATCH /docs/{docId}
Set, update or remove typed attributes on an existing document without re-ingesting it.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
attributes | object | yes | Names to set or update. A name with a value is written; a name whose value is null is removed; names not present are untouched. Removing a value does not free the name — names stay permanent for the project and keep counting against its budget. |
attribute_schema | object | no | Types, as {name: {"type": t}}, for names this document is the first in the project to use. Required for datetime and encrypted_text. Every name declared here must also appear in attributes. Declaring a name that already has a type returns 400 attribute_type_conflict. |
{
"attributes": { "region": "apac", "reviewed_at": "2026-09-21T07:30:00Z", "obsolete_key": null },
"attribute_schema": { "reviewed_at": { "type": "datetime" } }
}Response (200 OK) — the document id with its resulting attributes and attribute_schema.
DELETE /docs/{docId}
Remove a document's vectors and storage from the namespace. Project, key, and other documents are unaffected.
Path parameters
| Name | Type | Description |
|---|---|---|
docId | string | Document to delete. |
Response (200 OK) — DeleteDocResponse
| Field | Type | Description |
|---|---|---|
doc_id | string | Echoes the deleted docId. |
vectors_deleted | integer | Vector count removed from the namespace. |
storage_deleted | boolean | Whether the document's stored content and assets were removed. (Page images are never stored regardless of storage_deleted; nothing there was ever written.) |
Status codes
| Status | Meaning |
|---|---|
200 | OK. |
400 | Empty docId. |
401 | Missing/invalid token. |
403 | Upstream 403. |
404 | Doc not found in this namespace. |
502 / 503 | Upstream issues. |
Schemas (canonical shapes)
These are the same definitions used by the OpenAPI v3 spec. All are JSON.
SearchRequest
{
"query": "string (required)",
"doc_filter": "string", // restrict to one doc
"doc_ids": ["string"], // restrict to a set of docs
"exclude_doc_ids": ["string"], // max 100
"attribute_filters": [{ "attr": "region", "op": "eq", "value": "emea" }], // typed attributes
"unencrypted_strings_match": { "path": "legal/contracts/*" }, // glob on X-Unencrypted-Strings
"return_assets": false,
"return_page_images": false, // deprecated, no-op
"format": "prompt_ready", // or "json" for structured hits
"min_relevance": 0.3, // relevance floor, 0..1 (default 0.3)
"min_top_relevance": 0.0, // floor for the top hit; below → empty set
"target_tokens": 5000, // soft budget / answer size (default 5000, 100..500000; ≤ max_tokens)
"max_tokens": 15000, // hard ceiling (default 15000, 100..500000)
"expand_tightness": 0.0, // context-expansion aggressiveness, 0..1
"retrieval_tier": "standard", // or "advanced" (reranker)
"premium": false // legacy: true == retrieval_tier "advanced"
}Every field except
queryis optional.min_relevancetunes precision (higher = stricter);target_tokenssizes the answer;max_tokensis a ceiling that only matters for tight multi-document clusters (set it belowtarget_tokensand the soft target is clamped down to match — the hard ceiling always wins).attribute_filtersandunencrypted_strings_matchfilter against metadata set at ingest (all combined via AND).retrieval_tierpicks the reranking tier — see Retrieval tiers.
SearchInDocRequest
Same fields as SearchRequest, except both query and doc_filter are required and doc_filter must be non-empty.
RetrievalContext
{
"context": "string (XML+Markdown — see RetrievalContext format page)",
"query": "string",
"namespace": "string",
"result_count": 0, // hits included in context
"tokens_estimated": 0, // rendered size of context
"results_dropped": 0, // hits dropped for the token budget
"detected_lang": "string",// BM25 query language; null on low confidence
"mode": "string", // retrieval tier actually used
"search_units": 0, // billing units
"assets": { // present only when return_assets=true and assets exist
"<doc_id>": { "images": [], "tables": [] }
}
}
format: "json"instead returns aSearchResponse:{ results: [{ doc_id, content, title, canonical_url, pages, chunk_indices, score, scores: { ann, bm25, rrf, reranker } }], query, namespace, result_count, detected_lang, results_dropped }.
DocInfo
{
"doc_id": "string (required)",
"title": "string",
"language": "string",
"filename": "string",
"canonical_url": "string", // citation URL, set by Core at ingest
"pages": 0,
"chunkset_count": 0,
"chunk_count": 0,
"image_count": 0,
"table_count": 0,
"ingested_at": "RFC 3339 timestamp",
"source_job_id": "uuid",
"bm25_state": "string",
"attributes": { "region": "emea" }, // present only when set
"attribute_schema": { "region": { "type": "string" } }
}ListDocsResponse
{
"namespace": "string",
"total_documents": 0,
"documents": [ /* DocInfo */ ],
"has_more": false,
"next_cursor": "string | null",
"degraded": false
}DeleteDocResponse
{
"doc_id": "string",
"vectors_deleted": 0,
"storage_deleted": true
}PublicJob (for /ingest)
{
"job_id": "uuid",
"created_at": "RFC 3339 timestamp",
"status": { "job_id": "…", "status": "pending|processing|indexing|done|failed|blocked", "code": 200 },
"properties": { "file": { "filename": "string", "size": 0 }, "base_url": "string" }
}