OGuardAI
API Reference

API Reference

Complete API reference for all OGuardAI v1 endpoints including transform, rehydrate, detect, policy evaluation, health, and capabilities

All endpoints are served under the /v1 prefix. Request and response bodies are JSON (Content-Type: application/json).

Detected values are replaced with semantic tokens. The emitted form is always three segments, {{type:id:cap}} (for example {{email:e_001:5873cc5722d8}}): a lowercase type label, an id stable within a session, and a per-token capability cap. Every transform emits the cap, RAG or not. The :cap segment is matched exactly at rehydrate and fails closed: a capless {{type:id}} token, like a wrong cap, never resolves, so a guessed id cannot restore a value. In the default per-request scheme the id is a prefix_NNN counter (e_001, p_001) and the cap is 12 random hex characters; RAG calls that pass a corpus_id instead derive a deterministic corpus-scoped identity (a 24-hex id suffix and a 32-hex cap), so the same value maps to the same token across documents and queries. Token examples in this reference show the full emitted form, because only that form round-trips.

Endpoint Table

MethodPathDescriptionAuth Required
POST/v1/transformTransform text, replacing entities with semantic tokensYes (if enabled)
POST/v1/transform/streamStreaming transform (SSE)Yes (if enabled)
POST/v1/transform/fileTransform an uploaded file (multipart)Yes (if enabled)
POST/v1/transform/imageTransform text detected in an imageYes (if enabled)
POST/v1/rehydrateRestore semantic tokens in LLM output to original valuesYes (if enabled)
POST/v1/rehydrate/streamStreaming rehydrate (SSE)Yes (if enabled)
POST/v1/rehydrate/fileRehydrate an uploaded file (multipart)Yes (if enabled)
POST/v1/detectDetect entities without transformingYes (if enabled)
POST/v1/batch/transformBatch transform multiple inputs in one requestYes (if enabled)
POST/v1/batch/detectBatch detect entities in multiple inputsYes (if enabled)
POST/v1/evaluate-policyEvaluate policy against entities (dry run)Yes (if enabled)
POST/v1/rag/ingestIngest a document for RAG with PII protectionYes (if enabled)
POST/v1/rag/ingest/batchIngest multiple documents in one requestYes (if enabled)
POST/v1/rag/queryQuery RAG index with PII-safe contextYes (if enabled)
POST/v1/rag/contextRetrieve RAG context chunks (tokenized)Yes (if enabled)
POST/v1/rag/answerGenerate a RAG answer with automatic rehydrationYes (if enabled)
POST/v1/rag/deleteDelete ingested RAG documentsYes (if enabled)
POST/v1/revokeRevoke a single entity (makes it non-restorable)Yes (if enabled)
POST/v1/revoke/bulkRevoke multiple entities in one requestYes (if enabled)
GET/v1/revocations/countGet the count of revoked entitiesYes (if enabled)
POST/v1/sessions/statusReport a session's lifecycle state (active/stateless)Yes (session scope)
DELETE/v1/sessionsInvalidate a server-held sessionYes (session scope)
POST/v1/redact/imageRedact PII regions in an imageYes (if enabled)
POST/v1/admin/policy/validateValidate policy configuration filesYes (admin scope)
POST/v1/admin/policy/reloadHot-reload the policy directory (atomic swap, fail-closed)Yes (admin scope)
POST/v1/entity-typesValidate a custom entity type (validate-only, no persistence)Yes (transform scope)
POST/v1/patternsValidate a custom regex pattern (validate-only, no persistence)Yes (transform scope)
GET/v1/healthHealth checkYes (requires auth when enabled)
GET/v1/capabilitiesList entity types, languages, detectorsYes (requires auth when enabled)
GET/livezLiveness probe (always public, no auth)No
GET/readyzReadiness probe (always public, no auth)No
GET/v1/diagnosticsRuntime diagnostics (detector mode, config, versions)Yes (Admin scope required)
GET/metricsPrometheus metrics endpointYes (Admin scope required)

Port note: Examples below use port 3000 (the server default). The Docker quick start (deploy/docker/docker-compose.yml) also publishes port 3000; the HA compose stack (deploy/docker/docker-compose.ha.yml) fronts the server with nginx on port 8080. For other deployments, substitute the appropriate host and port.

Round-trip field flow

The protect then restore round trip uses a different field name at each step, because the text changes role as it moves through the pipeline:

StepEndpointYou sendYou get back
1. ProtectPOST /v1/transforminput (your raw text)safe_text (tokenized) plus session_state
2. Generateyour LLMsafe_textthe model reply, still tokenized
3. RestorePOST /v1/rehydrateoutput (the model reply) plus session_staterestored_text

The request field is input on transform but output on rehydrate because rehydrate's input is the LLM's output. The text field is safe_text after transform and restored_text after rehydrate. Carry the session_state from step 1 into step 3: it holds the token map that makes restoration deterministic.

Authentication

API Key

Send the key in the X-API-Key header:

curl -H "X-API-Key: your-key-here" http://localhost:3000/v1/transform ...

JWT Bearer Token

Send a JWT in the Authorization header:

curl -H "Authorization: Bearer eyJhbG..." http://localhost:3000/v1/transform ...

In the jwt auth mode, tokens are validated with a configured shared secret (HMAC: HS256, HS384, or HS512). Asymmetric and JWKS verification are not part of this mode; use the oidc mode below for those.

OIDC (JWKS)

For asymmetric tokens issued by an OIDC provider, set auth.mode: oidc and configure [auth.oidc] with issuer, jwks_url, audience, tenant_claim, and the allowlisted asymmetric algorithms (RS*, ES*, or PS* families). The server fetches and caches the provider's JWKS, verifies the token signature against it, pins the algorithm allowlist (rejecting alg: none and HMAC-in-asymmetric confusion), and requires the tenant claim on every token. Send the token the same way as a JWT:

curl -H "Authorization: Bearer eyJhbG..." http://localhost:3000/v1/transform ...

Scopes

The runtime supports 11 scopes:

ScopeGrants Access To
transform/v1/transform, /v1/transform/stream, /v1/transform/file, /v1/transform/image, /v1/redact/image, /v1/entity-types, /v1/patterns
rehydrate/v1/rehydrate, /v1/rehydrate/stream, /v1/rehydrate/file
detect/v1/detect
policy/v1/evaluate-policy
batch/v1/batch/transform, /v1/batch/detect
rag/v1/rag/ingest, /v1/rag/ingest/batch, /v1/rag/query, /v1/rag/context, /v1/rag/answer
revoke/v1/revoke, /v1/revoke/bulk, /v1/rag/delete, scoped to the caller's own tenant
global_revokeElevation required in addition to revoke when the caller has no tenant binding, to write an unscoped revocation that suppresses a value at rehydration for every tenant. admin implies it
session/v1/sessions/status, DELETE /v1/sessions
detect_valuesPermission to receive raw detected values from /v1/detect and /v1/batch/detect. Without it, detection returns type, span, and confidence only.
adminAll endpoints including /v1/admin/policy/validate, /v1/revocations/count, and config management

admin implies every other scope, so an admin key reaches all endpoints. /v1/revocations/count stays admin-only; it returns a scope-local count (the caller's own tenant, or the unscoped partition for a platform admin), never a cross-tenant total.

Migration (granular route scopes): the batch, RAG, and revoke endpoints now require their own scope instead of reusing transform. A key that previously relied on transform to reach /v1/batch/* or /v1/rag/*, or on admin to revoke, must be granted batch, rag, or revoke. This is a deliberate least-privilege change: a key now gets exactly the route classes it is granted.

When auth is disabled (default for development), all endpoints are accessible without credentials.

Idempotent retries (Idempotency-Key)

The mutating, non-streaming endpoints accept an optional Idempotency-Key request header so a network retry cannot execute the same mutation twice: POST /v1/transform, POST /v1/batch/transform, POST /v1/rag/ingest, POST /v1/rag/ingest/batch, POST /v1/rag/delete, POST /v1/revoke, POST /v1/revoke/bulk, and DELETE /v1/sessions. Requests without the header, and every read, detect, or streaming route, are never deduplicated.

curl -X POST http://localhost:3000/v1/revoke \
  -H "X-API-Key: your-key" \
  -H "Idempotency-Key: 7f3d2c1e-revoke-julia" \
  -H "Content-Type: application/json" \
  -d '{ "entity_type": "email", "value": "julia@example.com" }'

The contract:

  • The key must be 1 to 255 printable ASCII characters; anything else is rejected with 400 GUARDAI_IDEMPOTENCY_KEY_INVALID.
  • The first request with a key executes normally. A retry with the same key, method, path, and body within the retention window replays the stored response byte-identically, stamped with the response header Idempotency-Replayed: true.
  • Reusing a key with a different method, path, or body is a client bug and returns 409 GUARDAI_IDEMPOTENCY_KEY_REUSE; the request is never silently re-run.
  • A concurrent duplicate while the first attempt is still running returns 409 GUARDAI_IDEMPOTENCY_IN_PROGRESS.
  • Only 2xx responses are recorded. A failed attempt releases the key, so an identical retry may re-execute.
  • If the mutation succeeded but its response was too large to cache for replay, a retry gets 409 GUARDAI_IDEMPOTENCY_RESULT_UNAVAILABLE: the operation completed, the result is not replayable, do not retry blindly.
  • If the store is at capacity the request is rejected with 503 GUARDAI_IDEMPOTENCY_STORE_FULL before the mutation runs, so it is safe to retry once the store has room.

Keys are partitioned by the authenticated tenant, so one caller's key can never collide with another's. The store is configured under idempotency: backend (memory, the default, per replica; or redis, shared across replicas via session.redis_url), ttl_seconds (how long a completed response is replayed, default 86400), and in_progress_ttl_seconds (how long an abandoned in-flight claim is held before it becomes reclaimable, default 60).

POST /v1/transform

Transform input text, replacing detected entities with semantic tokens.

Request

{
  "input": "Contact Julia Schneider at julia@example.com",
  "input_type": "text",
  "policy": "default",
  "language": "de",
  "session_state": "(optional sealed blob from previous transform)",
  "detectors": ["builtin_regex"],
  "context": {
    "destination": "external_llm",
    "provider": "openai",
    "caller_role": "support_agent",
    "caller_purpose": "customer inquiry response"
  },
  "trace_id": "550e8400-e29b-41d4-a716-446655440000"
}
FieldTypeRequiredDescription
inputstringYes (for text)Raw input text
input_typestringNo"text" (default), "json", or "chat_messages"
input_jsonany JSON valueFor JSON modeStructured JSON with field-level transformation. Accepts any JSON value: object, array, string, number, boolean, or null.
input_messagesarrayFor chat modeOpenAI-format message array; each message carries role plus content and optional tool fields (tool_calls, tool_call_id, name). See Chat input mode.
languagestringNoISO 639-1 hint (defaults to detector.default_language, English if unset; not auto-detected)
policystringNoPolicy name (uses server default if omitted)
session_idstringNoExisting session ID for multi-turn continuity
session_statestringNoSealed session blob from previous transform
detectorsstring[]NoSelect detectors: ["builtin_regex"], ["python_ner"], or both. Defaults to server config.
custom_patternsarrayNoPer-request custom regex detectors, each {entity_type, pattern, confidence?, context_words?}. Validated on use and not persisted; additive (they only add detections).
protection_overridesobjectNoPer-entity-type restore-mode override: maps an entity type to one of full, partial, masked, formatted, abstract, none
contextobjectNodestination, provider, caller_role, caller_purpose. This object rejects unknown fields, so there is no model or max_tokens key.
trace_idstringNoClient-supplied correlation ID. The server generates a UUID v4 if omitted; pass the same value into the rehydrate call to correlate the full transform to LLM to rehydrate lifecycle in logs and audit.

Exactly one input source is permitted: input (text), input_json, or input_messages.

Response

{
  "safe_text": "Contact Julia Schneider at {{email:e_001:5873cc5722d8}}",
  "session_id": "01916a3e-7b2c-7000-8000-000000000001",
  "session_state": "eyJ2IjoxLCJzaWQiOi...",
  "entity_context": [
    {
      "token": "{{email:e_001:5873cc5722d8}}",
      "type": "email"
    }
  ],
  "entities": [
    {
      "token": "{{email:e_001:5873cc5722d8}}",
      "type": "email",
      "protection_level": "2",
      "action_applied": "tokenize",
      "confidence": 0.99,
      "detector": "builtin_regex",
      "span": { "start": 27, "end": 42 }
    }
  ],
  "stats": {
    "entities_detected": 1,
    "entities_transformed": 1,
    "entities_blocked": 0,
    "detection_time_ms": 1.2,
    "transform_time_ms": 0.3
  }
}
FieldDescription
safe_textTransformed text with semantic tokens. Send this to the LLM.
safe_jsonPresent only for JSON input: the tokenized document with the same shape as input_json. See JSON input mode.
safe_messagesPresent only for chat input: the tokenized message array with the same shape as input_messages. See Chat input mode.
session_idSession identifier
session_stateSealed session blob. Pass this to rehydrate.
entity_contextSafe metadata for the LLM system prompt: token, type, and any policy-exposed gender, formality, language, role (for example "role": "patient"), or belongs_to. Never contains raw values.
entitiesFull diagnostic metadata. For debugging, NOT for the LLM.
entity_summaryOptional map of entity type to count for this request. Omitted when not populated.
statsPerformance metrics
detector_modeThe detector mode used for this request: builtin_regex, builtin_and_ner, or python_ner. Explains why a given entity type may or may not have been detected.
warningsAdvisories about degraded detection coverage, for example "NER sidecar not active". This is the honest signal that the "raw PII never reaches the model" property is scoped to detected entities: with the NER sidecar off, person, company, and location names are not detected and can pass through.
policy_versionVersion of the policy applied during this transform, for correlating results with a specific policy version.
trace_idUUID correlating this transform with its rehydrate call in logs and audit. Echoes the request trace_id when supplied, otherwise a server-generated value.

Fields are omitted when not set. safe_json and safe_messages are covered under the JSON and chat input modes below.

Entity metadata fields (gender, formality, language, role, belongs_to) appear only when the policy exposes them (metadata_policy.expose_*) and a value was inferred. role is the policy-defined entity role, for example labelling a person as the patient or the doctor (see the extensibility guide); belongs_to links an entity to the person or entity it belongs to (the relational side of the role taxonomy). Both are always declared labels, never raw values.

JSON input mode

Set input_type to "json" and pass the document as input_json. Any JSON value is accepted (an object, an array, or a bare string, number, boolean, or null). Each string value and each object key is detected and tokenized independently, and the response adds safe_json: the same structure with PII replaced by {{type:id:cap}} tokens. safe_text is the serialized safe_json, so you can send either to the LLM. The same value in two fields receives the same token id, and one sealed session_state rehydrates the whole document.

Request:

{
  "input_type": "json",
  "input_json": {
    "customer": "Julia Schneider",
    "email": "julia@example.com",
    "card": 4539148803436467,
    "notes": "VIP account"
  }
}

Response:

{
  "safe_json": {
    "customer": "{{person:p_001:9f2c4a7b1e8d}}",
    "email": "{{email:e_001:5873cc5722d8}}",
    "card": "{{credit_card:cc_001:7d1a3f5b9c2e}}",
    "notes": "VIP account"
  },
  "safe_text": "{\"customer\":\"{{person:p_001:9f2c4a7b1e8d}}\",\"email\":\"{{email:e_001:5873cc5722d8}}\",\"card\":\"{{credit_card:cc_001:7d1a3f5b9c2e}}\",\"notes\":\"VIP account\"}",
  "session_id": "01916a3e-7b2c-7000-8000-000000000001",
  "session_state": "eyJ2IjoxLCJzaWQiOi..."
}

Notes:

  • The structure is always preserved: same keys, same array lengths, same nesting. A structural mismatch fails closed (the request errors) rather than returning a partially tokenized object.
  • A numeric leaf that is itself PII (for example a credit-card number sent as a JSON number) is tokenized to a string token, so its type changes from number to string in safe_json. A number with no detected PII is returned unchanged as a number. Booleans and null always pass through.
  • Object keys are detected too: a key that is itself PII (for example an object keyed by an email address) is tokenized or redacted; non-PII keys pass through unchanged.
  • Limits: a JSON document may not exceed 64 levels of nesting, 10,000 scannable slots, or 1 MB of total scanned text. Exceeding any limit fails closed.

Chat input mode

Set input_type to "chat_messages" and pass an OpenAI-format message array as input_messages. The whole conversation is protected in one call: every string-bearing field of every message (content, multimodal text parts, tool-call arguments) is detected and tokenized, and the response adds safe_messages with the same shape. The same value across messages receives the same token id, and one sealed session_state rehydrates the whole conversation. safe_text is the serialized safe_messages.

Request:

{
  "input_type": "chat_messages",
  "input_messages": [
    { "role": "system", "content": "You are a helpful assistant." },
    { "role": "user", "content": "My email is julia@example.com" }
  ]
}

Response:

{
  "safe_messages": [
    { "role": "system", "content": "You are a helpful assistant." },
    { "role": "user", "content": "My email is {{email:e_001:5873cc5722d8}}" }
  ],
  "session_id": "01916a3e-7b2c-7000-8000-000000000001",
  "session_state": "eyJ2IjoxLCJzaWQiOi..."
}

Notes:

  • Each message must be an object with a non-empty string role and a content that is a string, a content-part array, or null. Only role, content, name, tool_calls, and tool_call_id are supported; a message carrying any other text-bearing field is rejected so raw data cannot ride through an unscanned field.
  • Tool-call arguments is a JSON string and is protected structurally: it is parsed, its values are tokenized, and it is re-serialized as valid JSON. A non-JSON arguments string is rejected.
  • Non-PII fields pass through unchanged, so a static system prompt is returned as sent.

POST /v1/rehydrate

Restore semantic tokens in LLM output to their original values.

Request

{
  "output": "Dear {{person:p_001:9f2c4a7b1e8d}}, your order {{order:o_001:4b8e2d6f0a1c}} has shipped.",
  "session_state": "eyJ2IjoxLCJzaWQiOi...",
  "output_channel": "customer_email",
  "restore_mode": "formatted",
  "restore_overrides": {
    "e_001": "none"
  }
}
FieldTypeRequiredDescription
outputstringYesLLM output containing {{type:id:cap}} tokens as emitted by transform (a capless token does not resolve)
session_idstringNoSession ID from transform response
session_statestringYesSealed session blob from transform response
restore_modestringNoDefault restore mode: full, partial, masked, formatted, abstract, none
restore_overridesobjectNoPer-token overrides keyed by token ID
output_channelstringNouser_output, internal_summary, customer_email, tool_payload, export, log_safe
trace_idstringNoCorrelation ID. Pass the trace_id from the transform response to correlate the full transform to LLM to rehydrate lifecycle in logs and audit.

Response

{
  "restored_text": "Dear Frau Julia Schneider, your order ORD-2026-4892 has shipped.",
  "tokens_resolved": 2,
  "tokens_unresolved": [],
  "stats": {
    "rehydrate_time_ms": 0.8
  },
  "trace_id": "550e8400-e29b-41d4-a716-446655440000"
}

Restore Modes

ModeBehaviorPerson ExampleEmail Example
fullComplete original valueJulia Schneiderjulia@example.com
partialDeterministic subsetJ. Schneiderj***@example.com
maskedCharacter masking******* *********j*******************e
formattedOriginal + contextual formattingFrau Julia Schneiderjulia@example.com (email)
abstractSemantic description(female customer)(email on file)
noneRemoved / redacted[REDACTED][REDACTED]

POST /v1/detect

Detect entities in text without performing transformation.

Request

{
  "input": "Email: test@example.com, SSN: 123-45-6789",
  "language": "en",
  "detectors": ["builtin_regex", "python_ner"],
  "entity_types": ["email", "ssn"],
  "threshold": 0.5,
  "policy": "healthcare",
  "include_values": true
}
FieldTypeRequiredDescription
inputstringYesText to scan
languagestringNoISO 639-1 hint (defaults to detector.default_language, English if unset; not auto-detected)
detectorsstring[]NoSelect detectors: builtin_regex, python_ner, or both. Defaults to server config.
custom_patternsarrayNoPer-request custom regex detectors ({entity_type, pattern, ...}), validated on use and not persisted.
entity_typesstring[]NoRestrict the result to these entity types
thresholdnumberNoMinimum confidence to include an entity
policystringNoWhen set, the policy's custom_patterns and detection control also apply, so detect-only sees the same domain entities a transform would (including per-policy detection.ner_labels, and required_for fail-closed). Omitted means raw built-in detection. The same field exists on POST /v1/batch/detect.
include_valuesbooleanNoWhether each entity carries its raw value. Returning raw values requires the detect_values scope. false always suppresses them (empty string). true requires the detect_values scope, else the request fails with 403. Omitted resolves to the server's detect.include_values_default, which itself defaults to true only under dev auth; in production the raw value stays suppressed unless the caller holds detect_values. The same flag and gating apply to POST /v1/batch/detect.

Response

{
  "entities": [
    {
      "type": "email",
      "value": "test@example.com",
      "span": { "start": 7, "end": 23 },
      "confidence": 1.0,
      "detector": "builtin_regex"
    },
    {
      "type": "ssn",
      "value": "123-45-6789",
      "span": { "start": 30, "end": 41 },
      "confidence": 1.0,
      "detector": "builtin_regex"
    }
  ],
  "stats": {
    "detection_time_ms": 0.9,
    "detectors_used": ["builtin_regex"],
    "detector_mode": "builtin_regex"
  },
  "warnings": ["NER sidecar not active"]
}

Each detected entity may also carry metadata (gender, formality, language, role, belongs_to) when a policy exposes it. stats.detector_mode reports the mode used (builtin_regex, builtin_and_ner, or python_ner). Top-level warnings carries the same honest degraded-coverage advisory as transform, for example when the NER sidecar is not active. All three are omitted when empty.

POST /v1/evaluate-policy

Evaluate a policy against entities without performing transformation or creating a session. Useful for previewing what actions would be taken. The optional output_channel (default user_output) previews the channel-resolved restore_mode each entity would get, clamped by the safe-sink ceiling exactly as rehydrate does.

Request

{
  "policy": "strict-pii",
  "entities": [
    { "type": "person", "value": "Julia Schneider" },
    { "type": "ssn", "value": "123-45-6789" }
  ],
  "output_channel": "customer_email"
}

Response

{
  "decisions": [
    {
      "entity_type": "person",
      "action": "tokenize",
      "protection_level": "1",
      "rule_matched": "strict-pii/person",
      "restore_mode": "masked",
      "reason": "Level 1 entity, masked restore for customer_email channel"
    },
    {
      "entity_type": "ssn",
      "action": "redact",
      "protection_level": "1",
      "rule_matched": "strict-pii/ssn",
      "restore_mode": "none"
    }
  ],
  "policy_version": "1.0.0",
  "warnings": ["output_channel filtering previewed; restore_mode is channel-resolved"]
}

Each decision may include an optional reason explaining the resolved action or restore mode. The response may also carry top-level warnings advising about unsupported or unimplemented request fields. Both are omitted when empty.

POST /v1/batch/transform

Transform many inputs in one request. Requires the batch scope. Accepts up to max_batch_size items (see capabilities).

Request

{
  "session_mode": "independent",
  "policy": "default",
  "items": [
    { "text": "Hello Julia Schneider", "language": "de" },
    { "text": "Contact julia@example.com", "policy": "gdpr-strict" },
    { "input_type": "json", "input_json": { "email": "julia@example.com" } }
  ]
}
FieldTypeRequiredDescription
itemsarrayYesThe inputs to transform. Each item carries its own input source and optional overrides.
session_modestringNoindependent (default): each item gets its own session. shared: all items share one session, so the token map carries forward across items.
policystringNoPolicy applied to every item; an item's own policy overrides it.

Each item (BatchTransformItem) supports:

FieldTypeRequiredDescription
textstringFor text itemsPlain-text input
input_typestringNotext (default), json, or chat_messages; a batch can mix item types
input_jsonany JSON valueFor JSON itemsStructured JSON input (object, array, string, number, boolean, or null)
input_messagesarrayFor chat itemsOpenAI-format message array
policystringNoPer-item policy override
languagestringNoPer-item language hint (ISO 639-1). Language is set per item, not once for the whole batch.

Response

{
  "results": [
    {
      "index": 0,
      "safe_text": "Hello {{person:p_001:a1b2c3d4e5f6}}",
      "session_state": "eyJ2IjoxLCJ...",
      "entities_detected": 1,
      "entities_transformed": 1
    },
    {
      "index": 2,
      "safe_text": "{\"email\":\"{{email:e_001:0f9e8d7c6b5a}}\"}",
      "safe_json": { "email": "{{email:e_001:0f9e8d7c6b5a}}" },
      "entities_detected": 1,
      "entities_transformed": 1
    }
  ],
  "stats": { "total_items": 3, "total_entities": 2, "total_time_ms": 15.5 }
}

Each result carries its index (matching the request order), safe_text, and entities_detected / entities_transformed. A JSON item also returns safe_json; a chat item returns safe_messages. If a single item fails, its result carries an error string and an empty safe_text while the rest of the batch still processes. stats aggregates total_items, total_entities, and total_time_ms.

Batch works on every session backend. With the default sealed backend each result carries a session_state blob; with a server-side backend (memory or redis) each result instead carries a session_id, and the token map lives server-side. In shared mode only the last successful result carries the combined session_state or session_id for the whole batch.

POST /v1/batch/detect

Detect entities across many inputs in one request. Requires the batch scope.

Request

{
  "policy": "default",
  "include_values": false,
  "items": [
    { "text": "Julia Schneider works at Firma GmbH", "language": "de", "policy": "german-support" },
    { "text": "Contact julia@example.com" }
  ]
}
FieldTypeRequiredDescription
itemsarrayYesEach item has text (required), optional language, and optional per-item policy.
include_valuesbooleanNoWhether each result carries the raw detected value. Raw values require the detect_values scope: false always suppresses them, true requires the scope (403 without it), and an omitted value resolves to detect.include_values_default (which defaults to true only under dev auth). In production the raw value stays suppressed unless the caller holds detect_values.
policystringNoPolicy applied to every item; an item's own policy overrides it.

Response

{
  "results": [
    {
      "index": 0,
      "entities": [
        { "type": "person", "value": "", "span": { "start": 0, "end": 15 }, "confidence": 0.95, "detector": "python_ner" }
      ]
    }
  ],
  "stats": { "total_items": 2, "total_entities": 1, "total_time_ms": 3.2 }
}

The value fields are empty strings because the request set include_values: false. That is also what an api-key caller sees by default in production: the raw detected value comes back only when the resolved include_values is true, which requires the detect_values scope. Each result carries its index and its detected entities (the same shape as /v1/detect). Per-item warnings mirror the single-item detect advisory (for example, the NER sidecar did not run), so a batch never hides degraded coverage. A failed item carries an error string and empty entities.

POST /v1/rag/ingest

Chunk a document and tokenize each chunk for safe vector-store ingestion. Requires the rag scope. The five RAG endpoints share one identity concept: corpus_id. When you pass the same corpus_id on ingest, query, and context, the same raw value tokenizes to the same token id across every document and query in that corpus, which is what lets a query entity align with an ingested document entity. Absent corpus_id, ids are session-scoped counters with random caps and cross-document alignment does not hold.

Request

{
  "text": "James Rodriguez opened case CR-4821 about his account.",
  "chunking_strategy": "paragraph",
  "chunk_size": 1000,
  "chunk_overlap": 100,
  "policy": "default",
  "language": "en",
  "corpus_id": "support-kb-2026",
  "document_metadata": { "document_id": "doc-001", "classification": "internal", "owner": "admin", "tags": ["support"] }
}
FieldTypeRequiredDescription
textstringYesDocument text to chunk and transform
chunking_strategystringNoparagraph (default), sentence, fixed, or sliding_window
chunk_sizenumberNoMax chunk size in characters (for fixed / sliding_window)
chunk_overlapnumberNoOverlap in characters (for sliding_window)
policystringNoPolicy for transformation
languagestringNoISO 639-1 detection hint
document_metadataobjectNodocument_id, classification, owner, tags for access control and filtering
corpus_idstringNoDeterministic cross-document token identity (see above)

Response

{
  "chunks": [
    {
      "chunk_index": 0,
      "safe_text": "{{person:p_8a4f2c9d1e6b3a7f5c0d2e8b:1f3e5d7c9b2a4e6f8d0c1b3a5e7f9d2c}} opened case CR-4821 about his account.",
      "entity_count": 1,
      "start_offset": 0,
      "end_offset": 54,
      "chunk_session_state": "eyJ2Ijox...chunk0"
    }
  ],
  "session_state": "eyJ2IjoxLCJz...",
  "total_entities": 1
}

Because this request passed a corpus_id, the token id and cap are corpus-derived (a 24-hex id suffix and a 32-hex cap) rather than the shorter p_001 per-request form; the same person yields the same token in every document and query of this corpus. CR-4821 stays raw here: a domain reference like a case number is tokenized only when the policy defines a matching custom_patterns entry (custom types render as {{custom:<name>:x_...:cap}} tokens), and detecting the person name requires the NER detector. Each chunk returns safe_text (store this in the vector DB) plus a per-chunk chunk_session_state, the sealed blob that holds only that chunk's tokens. Carry those back later as document_sessions on /v1/rag/context. The top-level session_state is shared across chunks, and warnings may advise that the NER sidecar did not run for a chunk (so undetected person/company/location may remain).

POST /v1/rag/ingest/batch

Ingest multiple documents in one request. Requires the rag scope. Each item is a full /v1/rag/ingest request body and is processed exactly like a single ingest, yielding its own chunks and its own sealed session, so documents never share a token map. The batch accepts up to max_batch_size items, and the rate limiter charges one token per document, not one per request. One item's failure never fails the batch.

Request

{
  "items": [
    { "text": "First support case ...", "chunking_strategy": "paragraph", "corpus_id": "support-kb-2026" },
    { "text": "Second support case ...", "corpus_id": "support-kb-2026" }
  ]
}

Response

{
  "results": [
    { "index": 0, "result": { "chunks": ["..."], "session_state": "eyJ2Ijox...", "total_entities": 2 } },
    { "index": 1, "error": "chunk_size must be at least 100" }
  ]
}

Each result carries its index (matching the request order) and exactly one of result (the same shape as a single /v1/rag/ingest response for that document) or error (a per-item failure message; the other items are unaffected).

POST /v1/rag/query

Tokenize a user query before vector search. Requires the rag scope.

Request

{
  "query": "What is James Rodriguez's account status?",
  "policy": "default",
  "language": "en",
  "corpus_id": "support-kb-2026"
}

Pass the same corpus_id used at ingest so the query's person token derives the same corpus-scoped id and cap as the document's.

Response

{
  "safe_query": "What is {{person:p_8a4f2c9d1e6b3a7f5c0d2e8b:1f3e5d7c9b2a4e6f8d0c1b3a5e7f9d2c}}'s account status?",
  "session_state": "eyJ2IjoxLCJxdWVye...",
  "entities_detected": 1
}

Send safe_query to your vector store; carry session_state into the following /v1/rag/context call.

POST /v1/rag/context

Tokenize the retrieved chunks and merge them into the query session. Requires the rag scope.

Request

{
  "chunks": ["Raw retrieved chunk about James Rodriguez ..."],
  "session_state": "eyJ2IjoxLCJxdWVye...",
  "policy": "default",
  "language": "en",
  "access_level": "internal",
  "chunk_classifications": ["internal"],
  "document_sessions": ["eyJ2Ijox...chunk0"],
  "corpus_id": "support-kb-2026"
}
FieldTypeRequiredDescription
chunksstring[]YesRetrieved chunks (raw text)
session_statestringYesSession from the preceding /v1/rag/query
policystringNoPolicy for transformation
languagestringNoISO 639-1 detection hint
access_levelstringNoMax classification to admit. Chunks above this level are dropped. Levels are the deployment's rag.classification_levels lattice (default public < internal < confidential < restricted); an unknown label ranks most-restrictive (fail closed).
chunk_classificationsstring[]NoPer-chunk classification labels, parallel to chunks. A missing entry is treated as most-restrictive when filtering is active.
document_sessionsstring[]NoPer-chunk chunk_session_state blobs from ingest, parallel to chunks. When set, its length must equal chunks; each admitted chunk's tokens merge into the context.
corpus_idstringNoMust match the query session's corpus, or the request is rejected (fail closed), so a caller cannot mix identity domains across query and context.

Response

{
  "safe_chunks": ["{{person:p_8a4f2c9d1e6b3a7f5c0d2e8b:1f3e5d7c9b2a4e6f8d0c1b3a5e7f9d2c}} ..."],
  "session_state": "eyJ2IjoxLCJjdHh0...",
  "entities_detected": 3,
  "chunks_filtered": 0
}

safe_chunks are the tokenized chunks for the LLM prompt (filtered chunks are omitted). chunks_filtered counts how many were dropped by access_level (omitted when zero).

POST /v1/rag/answer

Rehydrate the LLM's answer using the accumulated RAG session. Requires the rag scope.

Request

{
  "answer": "The account for {{person:p_8a4f2c9d1e6b3a7f5c0d2e8b:1f3e5d7c9b2a4e6f8d0c1b3a5e7f9d2c}} is active.",
  "session_state": "eyJ2IjoxLCJjdHh0...",
  "output_channel": "user_output"
}

output_channel (default user_output) selects the restore rules, exactly as /v1/rehydrate.

Response

{
  "restored_answer": "The account for James Rodriguez is active.",
  "tokens_resolved": 1,
  "tokens_unresolved": []
}

POST /v1/rag/delete

Forget every value in a session, so any later rehydrate resolves it to [DELETED]. Requires the revoke scope. To erase one document, pass that document's ingest session_state.

Request

{ "session_state": "eyJ2IjoxLCJp..." }

Response

{
  "deleted": true,
  "session_invalidated": true,
  "entities_purged": 5,
  "message": "Session a1b2c3d4e5f60718 invalidated (5 entities). Application should delete corresponding vector store chunks.",
  "manifest": {
    "session_fingerprint": "a1b2c3d4e5f60718",
    "entities_revoked": 5,
    "timestamp_unix": 1700000000,
    "status": "completed"
  }
}

The manifest is a PII-free deletion receipt for a GDPR / audit trail. It carries no raw session id, token, or value: the erased corpus/document is identified only by the one-way session_fingerprint (the same fingerprint the audit log records), entities_revoked is the number of values revoked from the store (0 for an empty corpus, which is still a completed success), and timestamp_unix is when the deletion was recorded. status is completed.

POST /v1/transform/image

Extract text from an uploaded image with OCR, then tokenize any detected PII. Requires the transform scope. The official server Docker image bundles Tesseract with English, German, and Arabic language data, so this endpoint works out of the box in that image. From-source builds need Tesseract installed on the host. When Tesseract is absent, the endpoint fails closed with 503 GUARDAI_OCR_UNAVAILABLE while the text and JSON endpoints keep working.

Request

Multipart form (Content-Type: multipart/form-data):

FieldRequiredDescription
imageYesImage file (PNG, JPEG, TIFF, BMP)
languageNoOCR language hint
curl -H "X-API-Key: your-key" \
  -F "image=@scan.png" -F "language=de" \
  http://localhost:3000/v1/transform/image

Response

{
  "safe_text": "Rechnung an {{person:p_001:9f2c4a7b1e8d}}, {{email:e_001:5873cc5722d8}}",
  "entities": [
    {
      "entity_type": "person",
      "token": "{{person:p_001:9f2c4a7b1e8d}}",
      "bounding_box": { "left": 120, "top": 48, "width": 180, "height": 22 },
      "confidence": 0.94
    }
  ],
  "bounding_boxes": [ { "left": 120, "top": 48, "width": 180, "height": 22 } ],
  "session_id": "01916a3e-7b2c-7000-8000-000000000001",
  "session_state": "eyJ2IjoxLCJ..."
}

safe_text is the OCR text with tokens in place. Each entities item maps a detected token to its bounding_box (pixel coordinates) in the image; bounding_boxes is the flat list of those regions. warnings carries the same NER-sidecar advisory as text transform.

POST /v1/redact/image

Return the uploaded image with PII regions blacked out. Requires the transform scope. Redaction blacks out every detected PII region regardless of policy action (a policy allow is still visually redacted), and it drops the policy's min_confidence floor so it does not under-redact. It honors the tenant policy's custom_patterns and required_for (fail closed if a required NER detector is down). Same 503 GUARDAI_OCR_UNAVAILABLE behavior when Tesseract is absent.

Request

Multipart form with an image file (PNG, JPEG, TIFF, BMP).

curl -H "X-API-Key: your-key" \
  -F "image=@scan.png" \
  http://localhost:3000/v1/redact/image --output redacted.png

Response

200 OK with Content-Type: image/png: the raw redacted image bytes (not JSON).

POST /v1/transform/stream and POST /v1/rehydrate/stream

Stream the result as Server-Sent Events. transform/stream requires the transform scope, rehydrate/stream the rehydrate scope. Both run the full pipeline first, then stream the finished text in token-boundary-aware chunks: transform streams safe_text, rehydrate streams restored_text. Request bodies are the same JSON as /v1/transform and /v1/rehydrate.

Each SSE data: frame is a JSON object. Content frames carry { "chunk": "...", "complete": false }. The final frame carries "complete": true plus the session metadata: transform adds session_state, session_id, and stats; rehydrate adds tokens_resolved, tokens_unresolved, and stats. If a detection error occurs mid-stream, a single frame { "error": "...", "complete": true } is emitted and the connection closes; partial results are not recoverable.

POST /v1/transform/file and POST /v1/rehydrate/file

Process an uploaded document. transform/file requires the transform scope, rehydrate/file the rehydrate scope.

transform/file takes a multipart form with file (required), plus optional policy and language fields. Supported extensions are pdf, txt, csv, html, md, and docx. It extracts the text and runs the standard transform, returning JSON:

{
  "safe_text": "...tokenized document text...",
  "entities": [ /* diagnostic entity list */ ],
  "session_id": "01916a3e-...",
  "session_state": "eyJ2IjoxLCJ...",
  "original_format": "Pdf",
  "original_size": 84213,
  "stats": { "entities_detected": 3, "entities_transformed": 3 }
}

rehydrate/file delegates to the standard text rehydrate; send the RehydrateRequest JSON body (it does not take a file). session_state is required.

POST /v1/revoke, POST /v1/revoke/bulk, GET /v1/revocations/count

Revoke entity values so any later rehydrate resolves them to [DELETED]. Only an HMAC-SHA-256 digest of each value is stored; no raw PII is persisted. /v1/revoke and /v1/revoke/bulk require the revoke scope; /v1/revocations/count requires the admin scope and returns a scope-local count (the caller's own tenant, or the unscoped partition for a platform admin), never a cross-tenant total.

POST /v1/revoke

{ "entity_type": "email", "value": "julia@example.com" }

Response:

{
  "revoked": true,
  "entity_type": "email",
  "total_revoked": 1,
  "message": "Entity value revoked. Future rehydration will return [DELETED] for this value."
}

total_revoked (the global total) is present only for unscoped platform admins; it is omitted for a tenant-scoped caller so the cross-tenant revocation volume is not disclosed.

POST /v1/revoke/bulk

{
  "entities": [
    { "entity_type": "email", "value": "julia@example.com" },
    { "entity_type": "person", "value": "Julia Schneider" }
  ]
}

Response: { "revoked_count": 2, "total_revoked": 5 } (again, total_revoked is admin-only).

GET /v1/revocations/count

Returns { "total_revoked": 42 }, where the value is the number of revocations in the caller's own scope. A tenant-authenticated caller sees only its own tenant's revocations; an unscoped platform-admin caller sees only the unscoped (global-scope) revocations. The store partitions digests by tenant, so one tenant can never observe another tenant's revocation volume through this count. Requires the admin scope.

POST /v1/sessions/status, DELETE /v1/sessions

Inspect or invalidate a session by its lifecycle. Both require the session scope. The session is identified in the JSON body (never the URL): pass exactly one of session_id or the sealed session_state blob, so a bearer credential never lands in an access log. A missing or cross-tenant session is a non-disclosing 404 GUARDAI_SESSION_NOT_FOUND, so a caller cannot probe another tenant's session ids.

POST /v1/sessions/status

Reports whether a session is live and, when it is, its backend, expiry, and entity count.

{ "session_state": "eyJ2IjoxLCJz..." }

Response:

{
  "status": "active",
  "backend": "sealed",
  "expires_at": 1700003600,
  "entity_count": 3
}

status is one of active, stateless, deleted, or not_found. backend is the active session backend (sealed, memory, or redis). expires_at (Unix seconds) and entity_count are present only for a live session. On the sealed backend, where the caller holds the only copy of the state, the status is stateless.

DELETE /v1/sessions

Removes a server-held session (memory or redis backend). Mapped entity values are not revoked here; use /v1/revoke or /v1/rag/delete for value erasure.

{ "session_id": "01916a3e-7b2c-7000-8000-000000000001" }

Response:

{ "deleted": true, "status": "deleted" }

deleted is true only when a server-held session was actually removed. The sealed backend has no server-side state to remove, so it returns { "deleted": false, "status": "stateless" }.

GET /v1/health

Returns runtime health status. Requires auth when auth is enabled. Use /livez or /readyz for unauthenticated probes.

{
  "status": "healthy",
  "version": "0.1.0",
  "uptime_seconds": 3600.5,
  "components": {
    "detector": { "status": "healthy" },
    "session": { "status": "healthy" },
    "policies": { "status": "healthy" }
  }
}

Status values: healthy, degraded, unhealthy.

GET /v1/capabilities

Returns supported entity types, languages, available detectors, restore modes, and the batch size limit. Requires auth when auth is enabled.

{
  "entity_types": [
    { "name": "person", "protection_level": "2", "source": "ner", "id_prefix": "p_" },
    { "name": "email", "protection_level": "2", "source": "builtin", "id_prefix": "e_" },
    { "name": "ssn", "protection_level": "1", "source": "builtin", "id_prefix": "ss_" }
  ],
  "languages": [
    { "code": "de", "name": "German", "detection_support": "full", "rehydration_support": "full" }
  ],
  "detectors": [
    { "name": "builtin_regex", "type": "builtin", "entity_types": ["email", "phone", "ssn"] }
  ],
  "restore_modes": ["full", "partial", "masked", "formatted", "abstract", "none"],
  "ner_active": true,
  "max_batch_size": 100
}

max_batch_size is the maximum number of items accepted by /v1/batch/transform and /v1/batch/detect in a single request (a ceiling of 100, lowerable via limits.max_batch_size).

Each entity_types entry also carries a source field that tells the fixed built-in floor apart from the policy or config-extensible surface: builtin (a universal-PII-format regex detector), builtin_custom (a built-in custom detector such as money or de_tax_id), ner (detected by the NER model, for example person, company, location), config_custom (added by this deployment's detector config), or policy_custom (defined by a loaded policy's custom_patterns). An optional description may accompany a type, and each language may report rehydration_support alongside detection_support. ner_active reflects the configured mode, not live sidecar reachability; monitor GUARDAI_DETECTION_FAILED for sidecar outages.

Error Codes

All error responses follow this format:

{
  "error": "Human-readable description",
  "code": "ERROR_CODE",
  "details": "optional additional context",
  "request_id": "550e8400-e29b-41d4-a716-446655440000"
}

Every error response carries a request_id (a fresh UUID generated per error), so a caller can correlate a failure with the server logs. details and request_id are omitted when null.

CodeHTTP StatusDescription
GUARDAI_INVALID_INPUT400Malformed request, missing fields, type mismatch
GUARDAI_BAD_REQUEST400Generic bad request (e.g., missing field)
GUARDAI_UNAUTHORIZED401Missing or invalid credentials
GUARDAI_FORBIDDEN403Credentials valid but insufficient scope
GUARDAI_AUTH_FAILED401Authentication is enabled but no credentials provided
GUARDAI_INVALID_TOKEN401JWT or session token is malformed or expired
GUARDAI_POLICY_DENIED403A redacted entity's rule sets on_redact: reject. Routine redaction returns 200 with the value redacted; this code is the opt-in hard-fail.
GUARDAI_SESSION_EXPIRED410Session TTL has elapsed, sealed blob failed integrity check, or envelope version unsupported
GUARDAI_SESSION_REPLAY409A transform continuation was replayed: its sealed request counter did not advance past the highest seen for that session
GUARDAI_IDEMPOTENCY_KEY_INVALID400The Idempotency-Key header is not 1 to 255 printable ASCII characters
GUARDAI_IDEMPOTENCY_KEY_REUSE409An Idempotency-Key was reused with a different method, path, or body
GUARDAI_IDEMPOTENCY_IN_PROGRESS409A request with this Idempotency-Key is still in progress
GUARDAI_IDEMPOTENCY_RESULT_UNAVAILABLE409The idempotent operation completed but its response was too large to cache for replay; do not retry blindly
GUARDAI_IDEMPOTENCY_STORE_FULL503The idempotency store is at capacity; the mutation did not run and may be retried
GUARDAI_REPLAY_STORE_UNAVAILABLE503The replay store could not be reached; the continuation is rejected fail-closed rather than risk accepting a replay
GUARDAI_OUTPUT_BLOCKED422Output guard blocked the response due to hallucinated PII
GUARDAI_TOKEN_REPAIR_FAILED422Token repair could not resolve tokens in LLM output
GUARDAI_DETECTION_FAILED500Detector service is down or detection failed
GUARDAI_RATE_LIMITED429Request rate exceeds configured limit
GUARDAI_PAYLOAD_TOO_LARGE413Request payload exceeds configured size limit
GUARDAI_AUDIT_UNAVAILABLE503Audit log unavailable; request rejected fail-closed (applies when audit.strict is on)
GUARDAI_OCR_UNAVAILABLE503Tesseract OCR is not installed
GUARDAI_OCR_FAILED500OCR processing failed
GUARDAI_REDACTION_FAILED500Image redaction failed
GUARDAI_REVOCATION_LOCK_POISONED500Revocation lock failure
GUARDAI_REVOCATION_PERSIST_FAILED500Revocation persistence failed (rolled back)
GUARDAI_TENANT_SCOPED403Operation requires tenant scope
GUARDAI_INTERNAL_ERROR500Unexpected server error

For full JSON schemas, see the product specification.