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 uses a separate event contract linked below. 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.
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. The current review environment is:

EnvironmentBase URLStatus
Review staginghttps://rag-aceh-ai-eng.reviewstagepro.comLive, non-production, shared-key authentication
ProductionSupplied during production rolloutNot represented by the staging origin

All public routes are mounted at the base URL root. There is currently no /api, /api/v1, tenant, or version prefix.

BASE_URL=https://rag-aceh-ai-eng.reviewstagepro.com
GET ${BASE_URL}/healthz
POST ${BASE_URL}/query
POST ${BASE_URL}/query/stream
POST ${BASE_URL}/search

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

Use absolute paths in frontend code when calling through the same origin:

await fetch("/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, /openapi.json, and non-production /docs Swagger UI plus /docs/oauth2-redirect do not require the API key.

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.jsonEvery environmentMachine-readable current schema. Generate clients from this per target environment when possible.
/docsNon-production onlySwagger UI for interactive exploration and quick request trials.
RedocNot availableThere is no Redoc route.

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: user-123" \
-H "X-User-Role: user" \
-d '{
"query": "Apa isi Qanun Aceh tentang Baitul Mal?",
"conversation_id": null,
"collection_ids": null
}'
type Citation = {
document_id: number;
document_title: string;
chunk_id: number;
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: number;
message_id: number;
citations: Citation[];
};

export async function askQuestion(query: string): Promise<QueryResponse> {
const response = await fetch("/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.

The server runs the full answer pipeline before sending the first SSE byte. Setup failures therefore return ordinary JSON error responses with HTTP statuses. Once streaming begins, the successful frame sequence is:

message.start
citation*
answer.delta* OR guard.blocked
done

A framing fault after streaming starts sends terminal error instead of done. guard.blocked is a normal policy outcome, not a transport or server error.

See Query stream and pre-stream vs in-stream failures.

Citation object

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

FieldTypeAlways present?
document_idnumberYes
document_titlestringYes
chunk_idnumberYes
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. Kafka/DMS partner ingestion is documented separately at Partner events. Partner backends use the configured doc.uploaded input topic and stage-specific status destinations (doc.extracted, doc.ocr.pending, doc.ocr.done, doc.chunked, doc.embedded, and doc.indexed) instead of HTTP routes for object-storage ingestion handoff.

Endpoint index

MethodPathPurposeDetailed contract
GET/healthzReadiness and environment metadata.This page: health and discovery.
GET/openapi.jsonCurrent OpenAPI schema.This page: OpenAPI and Swagger.
GET/docsSwagger UI outside production.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.
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.
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.
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.

:::warning /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. :::

The Government Policy Guard still applies. Every response is inspected before it is returned; a violating generation is replaced by a refusal and policy_guard.action is blocked with the categories that fired, with truncated: false and finish_reason: "blocked" (a refusal is complete, not a short draft to retry with a bigger budget).

Bounds fail explicitly rather than silently:

ConditionStatus
Assembled input over the configured character budget, or rejected by the serving runtime as longer than its context window413
max_tokens over the configured ceiling (never a silent clamp), or a budget too small for the reasoning model to reach any answer text400
Unknown model (call GET /models)400
Malformed body, including an empty stop sequence item422
Model returned no text for a non-budget reason, or the model endpoint is unavailable502
Audit log unreachable — the generation ran but could not be recorded503

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

Every call writes one policy-guard row to the audit log, and the endpoint fails closed on that write. /generate persists no conversation and no message, so the audit row is the only durable record a generation ever leaves; returning text that could not be audited would defeat the control. An unreachable audit store is therefore an explicit 503 — retry it — not a silently unaudited 200.

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}'
{
"text": "Draf surat tugas ...",
"model": "qwen3.5-27b",
"finish_reason": "stop",
"truncated": false,
"grounded": false,
"policy_guard": { "action": "allowed", "classifier": "deterministic-rules-v2", "categories": [] }
}

GET /models lists the models the engine is configured to serve, per role, so you discover ids instead of hard-coding them. usable_for_generation marks the ids POST /generate accepts in model. This reports configuration, not liveness: it does not probe the serving runtimes, so a configured model that is temporarily down is still listed, and pinning a model that is not actually served returns 502. Use /healthz for readiness.

Health and discovery

GET /healthz returns 200 when the API process is ready to serve requests.

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

Use /healthz for load balancer and client readiness checks. Use /openapi.json to discover the schema for the target environment.

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.
  • No Redoc route: use /openapi.json and non-production /docs.