Skip to main content

API overview

This page is the integration starting point for frontend and partner-backend teams. The HTTP API is mounted at the environment root and includes JSON routes plus SSE streaming. Kafka/DMS partner ingestion has a separate event contract. The consumer accepts doc.uploaded without aiDocumentId and generates that ID after persistence for stage-status events. Frontend teams do not need repository access to integrate; use the target environment's base URL, credentials, OpenAPI document, and the route pages linked below.

What to get from the platform team​

Before writing client code, ask the platform team for:

ItemWhy it matters
Base URLThe origin that hosts the API, for example https://rag.example.gov. Routes are mounted directly at the root.
API-key header name and valueData routes require the configured shared-key header. The default header name is X-API-Key, but treat the configured name as authoritative.
Identity-header injection planX-User-* headers are trusted and load-bearing only when verified authentication is configured and trusted infrastructure injects them. With verified auth off (require_auth=false), retrieval RBAC/ABAC filtering is allow-all. Browsers must not set these directly.
Deployment origin modelBrowser clients need same-origin hosting, a backend-for-frontend (BFF), or a gateway that injects credentials and CORS headers.
Collection identifiersOptional collection_ids values narrow /query and /search to named partner collections. Nonblank unknown IDs return no rows. Access-based hiding of forbidden collections applies only when verified filtering is enabled; under allow-all there is no forbidden-collection filtering.
AI-engine document UUID mappingOptional document_ids values narrow /query, /query/stream, and /search to selected AI-engine documents.id values. The partner maps its registry record to the post-persistence Kafka aiDocumentId; upload status exposes the same value as stored_document_id, while retrieval results and citations call it document_id. Well-formed unknown or forbidden IDs return no rows rather than 403.
Upload and URL-ingestion limitsFile size, batch size, URL-ingestion enablement, and URL allow-list are environment configuration.

Base URL and root paths​

Environment base URLs are supplied by the platform team. Production serves the client frontend at https://rag-aceh-neo.reviewstagepro.com (Studio at /studio) on apps-0002 (firstmate production audit 2026-10-07); the former review-staging endpoint below is torn down:

EnvironmentBase URLStatus
Review staginghttps://rag-aceh-ai-eng.reviewstagepro.comTorn down 2026-10-07
Productionhttps://rag-aceh-ai-eng-neo.reviewstagepro.comLive (deployed ref 2cc9a417)

Direct FastAPI routes use https://rag-aceh-ai-eng-neo.reviewstagepro.com and are mounted at that origin’s root. The frontend and Studio remain at https://rag-aceh-neo.reviewstagepro.com; the frontend BFF proxies RAG calls only under /api/rag/*, and unmatched GET paths fall back to the SPA HTML. Direct FastAPI routes currently have no /api, /api/v1, tenant, or version prefix.

BASE_URL=https://rag-aceh-ai-eng-neo.reviewstagepro.com
GET ${BASE_URL}/healthz
GET ${BASE_URL}/readyz
POST ${BASE_URL}/query
POST ${BASE_URL}/query/stream
POST ${BASE_URL}/query/v2
POST ${BASE_URL}/playground # verified administrators only
POST ${BASE_URL}/search

The production origin terminates TLS on port 443 and preserves SSE responses. Port 80 is closed; do not attempt to call the internal application or model ports directly.

For the documented frontend BFF, use its proxy path on the same origin:

await fetch("/api/rag/query", { method: "POST", headers, body });

Use the full base URL when a server-side adapter calls the API from another service.

Authentication and trusted identity​

Every data route requires the configured API-key header. /healthz and /readyz do not require the API key. When api_docs_enabled=true, /openapi.json, /docs, /docs/oauth2-redirect, and /redoc are also available without an API key; this setting is currently enabled in production. When disabled, the documentation routes are absent.

X-API-Key: <shared API key>

The API key authenticates the calling system. It is not the end-user identity. X-User-* headers become trusted and load-bearing only when verified authentication is configured and trusted infrastructure strips caller-supplied copies before injecting verified claims. With verified authentication off, retrieval RBAC/ABAC filtering is allow-all, so forgeable identity headers do not hide results by access policy. Routes that create, read, resume, or delete user-owned data also resolve a principal from identity headers:

HeaderRequired when identity is injected?Meaning
X-User-SubjectYesStable authenticated user subject. Conversation ownership, memory ownership, and audit records bind to this value.
X-User-RoleNoRole used by access policy. Defaults to user when absent.
X-User-UnitNoOrganization unit used by ABAC visibility rules.
X-User-LevelNoInteger position level used by ABAC visibility rules.

Some request bodies still include a user_id field or query parameter. Treat it as a development fallback only. When a verified trusted principal is resolved, X-User-Subject wins and the caller-supplied user_id does not grant access to another user's conversations or memories.

Browser safety rule​

Do not ship a shared API key to browser JavaScript. Do not let users set or override X-User-* headers from the browser. Use one of these deployment patterns:

  1. Same-origin BFF: the browser calls your application backend; the backend authenticates the browser session, adds X-API-Key plus trusted X-User-* headers, and forwards to the API.
  2. API gateway injection: the gateway validates the user's session or token, injects the shared key and identity headers, and strips any user-supplied copies of those headers.
  3. Server-side integration: scheduled jobs and partner backends call the API directly with the shared key, but still use a trusted identity source for principal-bearing actions.

Content types​

SurfaceRequest content typeResponse content typeNotes
JSON routesapplication/jsonapplication/jsonSend JSON for /query, /search, /documents/txt, conversations, memories, batch, and URL ingestion.
Multipart uploadmultipart/form-dataapplication/jsonPOST /documents and POST /ocr accept file uploads. Let fetch or the browser set the multipart boundary.
Query streamapplication/jsontext/event-streamPOST /query/stream returns Server-Sent Events. Use fetch() plus ReadableStream, not native EventSource, because the request needs headers and a POST body.

The server is permissive about missing request Content-Type, but clients should still send the correct type for portability and gateway compatibility.

CORS deployment requirement​

The application does not add app-level CORS headers. If a browser page is hosted on a different origin than the API, the browser's OPTIONS preflight will fail unless your gateway or BFF handles CORS.

Recommended browser deployments:

  • host the frontend and API behind the same origin; or
  • put a BFF/gateway on the frontend origin that forwards to the API; or
  • configure the gateway to answer CORS preflight and to inject credentials safely.

Do not work around CORS by exposing the shared API key in browser code.

OpenAPI and Swagger​

RouteAvailabilityUse
/openapi.jsonapi_docs_enabled=true (currently on in production)Machine-readable current schema. Generate clients from this per target environment when possible.
/docsapi_docs_enabled=true (currently on in production)Swagger UI for interactive exploration and quick request trials.
/redocapi_docs_enabled=true (currently on in production)ReDoc API reference.

The OpenAPI route documents schemas and route shapes, but the integration notes in this section remain important for gateway behavior, browser security, SSE lifecycle, CORS, ownership semantics, and current limitations.

Quick JSON fetch​

Use server-side code or a BFF for this pattern. Browser code must not contain the shared key.

curl -s -X POST "$BASE_URL/query" \
-H "Content-Type: application/json" \
-H "X-API-Key: $API_KEY" \
-H "X-User-Subject: 00000000-0000-0000-0000-000000000001" \
-H "X-User-Role: user" \
-d '{
"query": "Apa isi Qanun Aceh tentang Baitul Mal?",
"conversation_id": null,
"collection_ids": null
}'
type Citation = {
document_id: string;
document_title: string;
chunk_id: string;
chunk_ordinal: number;
page_number: number | null;
section_path: string[];
legal_reference: string | null;
char_start: number | null;
char_end: number | null;
confidential?: boolean;
owning_org_unit?: string;
min_position_level?: number;
};

type QueryResponse = {
answer: string;
conversation_id: string;
message_id: string;
citations: Citation[];
fallback_used: boolean;
fallback_reason: string | null;
dropped_claims_count: number;
};

export async function askQuestion(query: string): Promise<QueryResponse> {
const response = await fetch("/api/rag/query", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ query }),
});

if (!response.ok) {
const message = await readApiError(response);
throw new Error(message);
}

return (await response.json()) as QueryResponse;
}

For error parsing, see TypeScript error parsing.

SSE lifecycle summary​

POST /query/stream uses the same JSON request body as POST /query, but returns text/event-stream. See the authoritative Query stream contract for live progress, artifact redaction, guarded answer frames, and terminal errors.

Citation object​

Query answers, search results, conversation messages, and SSE citation events use the same citation object shape.

FieldTypeAlways present?
document_idstring (UUID)Yes
document_titlestringYes
chunk_idstring (UUID)Yes
chunk_ordinalnumberYes
page_numbernumber | nullYes
section_pathstring[]Yes
legal_referencestring | nullYes
char_startnumber | nullYes
char_endnumber | nullYes
confidentialbooleanOnly when the cited document is confidential
owning_org_unitstringOnly when the cited document is scoped to an org unit
min_position_levelnumberOnly when the cited document has a minimum position level

See shared citation object for route-specific examples.

Partner event integration​

The endpoint index below lists HTTP and SSE routes only. The intended Kafka/DMS partner ingestion contract is documented separately at Partner events. The consumer expects the configured doc.uploaded input topic and publishes to stage-specific status destinations (doc.extracted, doc.ocr.pending, doc.ocr.done, doc.chunked, doc.embedded, and doc.indexed). The consumer accepts input without aiDocumentId and generates that ID after persistence for stage-status events.

Endpoint index​

MethodPathPurposeDetailed contract
GET/healthzProcess liveness and environment metadata.This page: health and discovery.
GET/readyzReadiness of PostgreSQL, embedding, reranker, Langfuse, and Redis dependencies.This page: health and discovery.
GET/openapi.jsonCurrent OpenAPI schema when api_docs_enabled=true.This page: OpenAPI and Swagger.
GET/docsSwagger UI when api_docs_enabled=true.This page: OpenAPI and Swagger.
GET/redocReDoc reference when api_docs_enabled=true.This page: OpenAPI and Swagger.
POST/queryJSON answer with conversation and citations.Query and search.
POST/query/streamSSE answer stream with citations and deltas.Query and search.
POST/searchRaw retrieval results without synthesis.Query and search.
GET/analytics/query-trendsForward-only aggregate source, category, and keyword trends for internal operators.Query trends.
POST/generateDirect text generation. Not a question-answering surface: no retrieval, no citations.This page: direct generation and model listing.
GET/modelsModel ids this engine is configured to serve, per role.This page: direct generation and model listing.
POST/embeddingsVectors for submitted texts.This page: model primitives.
POST/rerankSubmitted documents scored against a query and ordered.This page: model primitives.
POST/conversationsCreate a conversation explicitly.Conversations and memory.
GET/conversationsList the caller's conversations.Conversations and memory.
GET/conversations/{conversation_id}Get one owned conversation and its messages.Conversations and memory.
DELETE/conversations/{conversation_id}Delete one owned conversation and all its messages.Conversations and memory.
GET/memoriesList the caller's long-term memories.Conversations and memory.
DELETE/memories/{memory_id}Delete one owned memory.Conversations and memory.
DELETE/memoriesForget-me deletion for all caller memories.Conversations and memory.
GET/quality/flaggedAdmin list of answers users disliked, filtered and keyset-paged.Answer quality review.
GET/quality/flagged/{message_id}Admin detail of one flagged answer with root causes and review history.Answer quality review.
POST/quality/flagged/{message_id}/reviewAdmin review step: set the status, add a note or issue link.Answer quality review.
POST/documents/txtIngest one inline text document synchronously.Ingestion and documents.
POST/documentsUpload one or more files as an async ingestion job.Ingestion and documents.
GET/documentsList uploaded/ingested documents, optionally filtered by status.Ingestion and documents.
GET/documents/{document_id}Read one ingestion document record.Ingestion and documents.
DELETE/documents/{document_id}Hard-delete an indexed document, its chunks, and its raw object when present.Ingestion and documents.
POST/ocrOCR-only extraction of one uploaded file: text plus confidence, no embedding or indexing.Ingestion and documents.
POST/ingestion/batchEnqueue ingestion from object-storage prefix or key manifest.Ingestion and documents.
POST/ingestion/urlEnqueue allow-listed URL ingestion when enabled.Ingestion and documents.
GET/ingestion/jobs/{job_id}Poll an ingestion job.Ingestion and documents.

Direct generation and model listing​

POST /generate runs the self-hosted language model over a prompt you supply. Use it for generation work that is not document question-answering: drafting boilerplate, reformatting or rewriting text you already have, translating, classifying a field.

/generate is not a question-answering surface

Nothing is retrieved, so the response carries no citations and is not faithfulness-checked — there is no retrieved context for it to be faithful to. Every response includes "grounded": false; branch on that field rather than on the absence of citations. Do not present /generate output to a citizen as if it were sourced from JDIH, PPID, or any other official corpus. For a grounded, cited answer over the document corpus, call POST /query.

Pasting retrieved chunks from POST /search into prompt and rendering the result as an answer is outside this contract. The engine cannot detect it; the responsibility sits with the consuming application.

POST /generate is an authenticated raw model call. The engine adds no system framing, output Policy Guard, rewrite, or audit write. A caller-supplied system_prompt is sent unchanged before the user prompt, and the model's completion is returned unchanged. The consuming application owns any moderation, audit, or publication controls it needs.

Only request-shape and model-runtime failures remain:

ConditionStatus
Serving runtime rejects the request as longer than its context window413
Malformed body, missing prompt, or wrong field type422
Model endpoint or selected model is unavailable502

A completion cut short by the token budget returns truncated: true — the text is incomplete, not merely short.

curl -s -X POST "$BASE_URL/generate" \
-H "Content-Type: application/json" \
-H "X-API-Key: $API_KEY" \
-d '{"prompt": "Buat draf surat tugas perjalanan dinas.", "max_tokens": 512}'

The model id is deployment-specific; this example uses the local A30 profile (production serves qwen3.8-27b).

{
"text": "Draf surat tugas ...",
"model": "qwen3-14b",
"finish_reason": "stop",
"truncated": false,
"grounded": false
}

GET /models lists the configured models per role. usable_for_generation marks configured chat-model suggestions; it is not an allow-list. POST /generate forwards a caller-supplied model id to the runtime, which may reject it. The listing reports configuration, not model liveness. Use /readyz, not /healthz, for dependency readiness.

Health and discovery​

GET /healthz returns 200 when the API process is running and reports active backend configuration. It is the liveness probe and deliberately does not contact dependencies.

{
"status": "ok",
"version": "<api version>",
"env": "local | test | staging | production",
"repository_backend": "memory | postgres",
"object_storage_backend": "memory | s3"
}

GET /readyz runs five independent checks concurrently, with a two-second timeout on each: PostgreSQL SELECT 1, the embedding and reranker /health routes, Langfuse /api/public/health when Langfuse is enabled, and authenticated Redis PING. It returns 200 only when every required check succeeds. When any check fails or times out it returns 503, keeps successful results visible, and lists every failed dependency without exposing credentials.

{
"status": "not_ready",
"dependencies": {
"postgres": { "healthy": true, "detail": "SELECT 1 succeeded" },
"embedding": { "healthy": false, "detail": "dependency check failed" },
"reranker": { "healthy": true, "detail": "health endpoint returned 200" },
"langfuse": { "healthy": true, "detail": "health endpoint returned 200" },
"redis": { "healthy": true, "detail": "PING succeeded" }
},
"failed_dependencies": ["embedding"]
}

Use /healthz for process liveness and /readyz for load-balancer, Compose, and deployment readiness. When api_docs_enabled=true, use /openapi.json to discover the schema for the target environment; production currently enables it.

Model primitives​

POST /embeddings and POST /rerank expose the engine's embedding and reranking models directly, for applications that need vectors or relevance scores rather than an answer. Both require the API-key header and both are primitives: they do not retrieve from the corpus, attach citations, run the faithfulness check, or apply the government policy guard. Use POST /query when an answer is needed, and POST /search to rank the ingested corpus.

POST /embeddings takes {"input": ["text", ...]} and returns vectors in request order, together with the model name and vector width:

{ "model": "bge-m3", "dimensions": 1024, "embeddings": [[0.01, -0.02, "..."]] }

A 200 always carries exactly one vector per input, in request order, at the deployment's fixed width. If the backend returns a different number of vectors than texts sent, a different width, or a non-finite element, the route fails with 502 naming what came back. It is never a shortened or re-aligned list, so embeddings[i] always corresponds to input[i].

POST /rerank takes {"query": "...", "documents": ["text", ...]} and returns the documents ordered by descending relevance, each with the index it had in the request:

{ "model": "bge-reranker-v2-m3", "results": [{ "index": 1, "relevance_score": 0.97, "text": "..." }] }

A 200 always scores every submitted document exactly once. Two counts are checked: the backend must return as many results as documents were sent, and those results must cover every submitted index. Rerankers commonly return only their own top-N; if the backend scores fewer documents than were sent, repeats an index, returns an index outside the request, or returns a non-finite score, the route fails with 502 naming both counts rather than returning a partial ranking that looks like a complete one — or a de-duplicated one, where the discarded score contradicts the score you were given.

Both routes reject an over-budget request rather than trimming it. The defaults are 64 items, 8000 characters per item (the rerank query counts as an item), 64,000 characters per request, and — because the reranker scores query + document as a single sequence — 8000 characters per query/document pair. A deployment can tighten these (ACEH_RAG_PRIMITIVES__*). Exceeding a deployed size budget is 413, with a string detail naming the bound that was exceeded and the size that exceeded it; breaking the published schema — including its absolute ceilings, which a deployment can tighten but never raise — is pydantic's 422, whose detail is a list of error objects. An unavailable model backend returns 502.

Note that the pair budget defaults to the same 8000 as the per-item cap, so a maximum-length document is only rerankable with an empty query, which the schema forbids. Size documents against the pair budget, not the per-item cap.

These caps bound what the API accepts, in characters. They are not a promise about the model's tokenizer: the served embedding backend (TEI) runs with --auto-truncate and the reranker (Infinity) truncates to the model's maximum sequence length internally, so text that fits the character cap but exceeds the model's token window is truncated by the backend before it is embedded or scored. Keep well inside the caps if exact-input fidelity matters for your use case.

Current limitations​

These are current observable constraints, not future guarantees:

  • No version prefix: routes are root-mounted and not namespaced under /api/v1.
  • No pagination controls: list routes return the current full list for the caller or filter. There are no cursor, limit, or offset parameters.
  • No rate-limit headers: responses do not include X-RateLimit-* or Retry-After as a standard contract.
  • No request ID header: responses do not include a standard request/correlation ID header. If your gateway adds one, treat it as gateway-specific.
  • No WebSocket route: streaming uses SSE on POST /query/stream; there is no WebSocket API.
  • No app-level CORS: cross-origin browser deployments require gateway/BFF CORS handling.
  • Configuration-gated documentation: /openapi.json, /docs, and /redoc require api_docs_enabled=true, currently enabled in production.