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).
Note: every
spanthis API returns ({ "start": ..., "end": ... }on transform entities, detect entities, and batch detect entities) is a pair of UTF-8 byte offsets into the exact request text, not code-point or UTF-16 indices.endis exclusive. Slice the original bytes, or convert to your language's index unit before slicing: Python string slicing indexes code points and JavaScript and Java index UTF-16 code units, so on any non-ASCII payload a direct slice by these numbers lands on the wrong characters. The RAG chunkstart_offset/end_offsetare byte offsets into the submitted document text on the same basis. When a topical redaction rule matched the document, a chunk overlapping a redacted region reports that region's full original extent.
Detected values are replaced with semantic tokens. The emitted form is always three segments, {{type:id:cap}} (for example {{email:e_001:5873cc5722d8aa11bb22cc33dd44ee55}}): 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 32 random hex characters (16 random bytes); 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
| Method | Path | Description | Auth Required |
|---|---|---|---|
POST | /v1/transform | Transform text, replacing entities with semantic tokens | Yes (if enabled) |
POST | /v1/transform/stream | Streaming transform (SSE) | Yes (if enabled) |
POST | /v1/transform/file | Transform an uploaded file (multipart) | Yes (if enabled) |
POST | /v1/transform/image | Transform text detected in an image | Yes (if enabled) |
POST | /v1/rehydrate | Restore semantic tokens in LLM output to original values | Yes (if enabled) |
POST | /v1/rehydrate/metadata | Same restore as /v1/rehydrate, returning only counts and unresolved token ids; the restored text is never serialized | Yes (if enabled) |
POST | /v1/rehydrate/stream | Streaming rehydrate (SSE) | Yes (if enabled) |
POST | /v1/rehydrate/file | Restore a file transform's tokenized output; a JSON alias of /v1/rehydrate (RehydrateRequest body, no file upload) | Yes (if enabled) |
POST | /v1/output-guard | Scan model-generated output for new sensitive values without a session | Yes (if enabled) |
POST | /v1/detect | Detect entities without transforming | Yes (if enabled) |
POST | /v1/batch/transform | Batch transform multiple inputs in one request | Yes (if enabled) |
POST | /v1/batch/detect | Batch detect entities in multiple inputs | Yes (if enabled) |
POST | /v1/evaluate-policy | Evaluate policy against entities (dry run) | Yes (if enabled) |
POST | /v1/rag/ingest | Ingest a document for RAG with PII protection | Yes (if enabled) |
POST | /v1/rag/ingest/batch | Ingest multiple documents in one request | Yes (if enabled) |
POST | /v1/rag/query | Query RAG index with PII-safe context | Yes (if enabled) |
POST | /v1/rag/context | Retrieve RAG context chunks (tokenized) | Yes (if enabled) |
POST | /v1/rag/answer | Restore tokens in a caller-supplied RAG answer (it does not generate one) | Yes (if enabled) |
POST | /v1/rag/answer/metadata | Same restore as /v1/rag/answer, returning only counts and unresolved token ids; the restored answer is never serialized | Yes (if enabled) |
POST | /v1/rag/delete | Revoke every value carried in a RAG session (erasure) | Yes (if enabled) |
POST | /v1/revoke | Revoke a single entity (makes it non-restorable) | Yes (if enabled) |
POST | /v1/revoke/bulk | Revoke multiple entities in one request | Yes (if enabled) |
GET | /v1/revocations/count | Get the count of revoked entities | Yes (if enabled) |
POST | /v1/sessions/status | Report a session's lifecycle state (active/stateless) | Yes (session scope) |
DELETE | /v1/sessions | Invalidate a server-held session | Yes (session scope) |
POST | /v1/redact/image | Redact PII regions in an image | Yes (if enabled) |
POST | /v1/admin/policy/validate | Validate policy configuration files | Yes (admin scope) |
POST | /v1/admin/policy/reload | Hot-reload the policy directory (atomic swap, fail-closed) | Yes (admin scope) |
POST | /v1/entity-types | Validate a custom entity type (validate-only, no persistence) | Yes (transform scope) |
POST | /v1/patterns | Validate a custom regex pattern (validate-only, no persistence) | Yes (transform scope) |
GET | /v1/health | Health check | Yes (requires auth when enabled) |
GET | /v1/capabilities | List entity types, languages, detectors | Yes (requires auth when enabled) |
GET | /livez | Liveness probe (always public, no auth) | No |
GET | /readyz | Readiness probe (always public, no auth) | No |
GET | /v1/diagnostics | Runtime diagnostics (detector mode, config, versions) | Yes (Admin scope required) |
GET | /metrics | Prometheus metrics endpoint | Yes (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 two-instance 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:
| Step | Endpoint | You send | You get back |
|---|---|---|---|
| 1. Protect | POST /v1/transform | input (your raw text) | safe_text (tokenized) plus session_state |
| 2. Generate | your LLM | safe_text | the model reply, still tokenized |
| 3. Restore | POST /v1/rehydrate | output (the model reply) plus session_state | restored_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:
| Scope | Grants 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/metadata, /v1/rehydrate/stream, /v1/rehydrate/file, /v1/output-guard |
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, /v1/rag/answer/metadata |
revoke | /v1/revoke, /v1/revoke/bulk, /v1/rag/delete, scoped to the caller's own tenant |
global_revoke | Elevation 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_values | Permission to receive raw detected values from /v1/detect and /v1/batch/detect. Without it, detection returns type, span, and confidence only. |
admin | All 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 ontransformto reach/v1/batch/*or/v1/rag/*, or onadminto revoke, must be grantedbatch,rag, orrevoke. 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/transform/file,
POST /v1/transform/image, POST /v1/redact/image, 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": "erika.mustermann@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,
Content-Type, 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, Content-Type, or body is a client bug and returns
409 GUARDAI_IDEMPOTENCY_KEY_REUSE; the request is never silently re-run. Send the retry with the sameContent-Typeas the original request. - 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 result was not retained for replay (too large, or a binary body
such as a redacted image), a retry gets
409 GUARDAI_IDEMPOTENCY_RESULT_UNAVAILABLE: the operation completed, the result is not replayable, do not retry blindly. POST /v1/redact/imagenever retains its redacted image in the store, so a same-key retry returns409 GUARDAI_IDEMPOTENCY_RESULT_UNAVAILABLErather than the image; resend under a fresh key when you need the image again. JSON responses replay unchanged.- If the store is at capacity the request is rejected with
503 GUARDAI_IDEMPOTENCY_STORE_FULLbefore the mutation runs, so it is safe to retry once the store has room. - A keyed
/v1/batch/transformor/v1/rag/ingest/batchretried after an abort resumes on the Redis idempotency backend inindependentsession mode: items that already committed replay from their checkpoints and only the remaining items execute, each checkpointed in the same synchronous step as its commit; an item whose process died between the two executes once more on the retry. A keyed/v1/batch/transforminsharedsession mode does not resume after an abort: every item continues the session the previous item advanced, so no later attempt can safely redo or skip an item whose checkpoint never landed. A same-key retry of an aborted shared-mode batch returns409 GUARDAI_IDEMPOTENCY_RESULT_UNAVAILABLEfor the whole request without executing anything, and further retries replay that outcome; resend under a fresh key. A shared-mode batch whose every item was checkpointed still replays its combined result. A retry that is refused before any item runs (a rate limit, an entitlement) while a committed prefix exists returns503 GUARDAI_IDEMPOTENCY_INDETERMINATEand keeps that prefix for the next same-key retry. A converged independent-mode retry yields one audit event and one session per item. The memory backend supports the same mechanism within one process but does not survive a restart.
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 Erika Mustermann at erika.mustermann@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"
}| Field | Type | Required | Description |
|---|---|---|---|
input | string | Yes (for text) | Raw input text |
input_type | string | No | "text" (default), "json", or "chat_messages" |
input_json | any JSON value | For JSON mode | Structured JSON with field-level transformation. Accepts any JSON value: object, array, string, number, boolean, or null. |
input_messages | array | For chat mode | OpenAI-format message array; each message carries role plus content and optional tool fields (tool_calls, tool_call_id, name). See Chat input mode. |
language | string | No | ISO 639-1 hint (defaults to detector.default_language, English if unset; not auto-detected) |
policy | string | No | Policy name (uses server default if omitted) |
session_id | string | No | Existing session ID for multi-turn continuity |
session_state | string | No | Sealed session blob from previous transform |
detectors | string[] | No | Select detectors: ["builtin_regex"], ["python_ner"], or both. Defaults to server config. |
custom_patterns | array | No | Per-request custom regex detectors, each {entity_type, pattern, confidence?, context_words?}. Validated on use and not persisted; additive (they only add detections). Each pattern must have a bounded match length and must not match arbitrary text, or the request is rejected with 400; see Custom pattern constraints. |
protection_overrides | object | No | Per-entity-type restore-mode override: maps an entity type to one of full, partial, masked, formatted, abstract, none |
context | object | No | destination, provider, caller_role, caller_purpose. This object rejects unknown fields, so there is no model or max_tokens key. |
trace_id | string | No | Client-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 Erika Mustermann at {{email:e_001:5873cc5722d8aa11bb22cc33dd44ee55}}",
"session_id": "01916a3e-7b2c-7000-8000-000000000001",
"session_state": "eyJ2IjoxLCJzaWQiOi...",
"entity_context": [
{
"token": "{{email:e_001:5873cc5722d8aa11bb22cc33dd44ee55}}",
"type": "email"
}
],
"entities": [
{
"token": "{{email:e_001:5873cc5722d8aa11bb22cc33dd44ee55}}",
"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
}
}| Field | Description |
|---|---|
safe_text | Transformed text with semantic tokens. Send this to the LLM. |
safe_json | Present only for JSON input: the tokenized document with the same shape as input_json. See JSON input mode. |
safe_messages | Present only for chat input: the tokenized message array with the same shape as input_messages. See Chat input mode. |
session_id | Session identifier |
session_state | Sealed session blob. Pass this to rehydrate. |
entity_context | Safe 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. |
entities | Full diagnostic metadata. For debugging, NOT for the LLM. Each span is a UTF-8 byte range into input with an exclusive end. |
entity_summary | Optional map of entity type to count for this request. Omitted when not populated. |
stats | Performance metrics |
detector_mode | The 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. |
warnings | Advisories 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_version | Version of the policy applied during this transform, for correlating results with a specific policy version. |
trace_id | UUID 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": "Erika Mustermann",
"email": "erika.mustermann@example.com",
"card": 4111111111111111,
"notes": "VIP account"
}
}Response:
{
"safe_json": {
"customer": "{{person:p_001:9f2c4a7b1e8daa11bb22cc33dd44ee55}}",
"email": "{{email:e_001:5873cc5722d8aa11bb22cc33dd44ee55}}",
"card": "{{credit_card:cc_001:7d1a3f5b9c2eaa11bb22cc33dd44ee55}}",
"notes": "VIP account"
},
"safe_text": "{\"customer\":\"{{person:p_001:9f2c4a7b1e8daa11bb22cc33dd44ee55}}\",\"email\":\"{{email:e_001:5873cc5722d8aa11bb22cc33dd44ee55}}\",\"card\":\"{{credit_card:cc_001:7d1a3f5b9c2eaa11bb22cc33dd44ee55}}\",\"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 andnullalways 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 erika.mustermann@example.com" }
]
}Response:
{
"safe_messages": [
{ "role": "system", "content": "You are a helpful assistant." },
{ "role": "user", "content": "My email is {{email:e_001:5873cc5722d8aa11bb22cc33dd44ee55}}" }
],
"session_id": "01916a3e-7b2c-7000-8000-000000000001",
"session_state": "eyJ2IjoxLCJzaWQiOi..."
}Notes:
- Each message must be an object with a non-empty string
roleand acontentthat is a string, a content-part array, or null. Onlyrole,content,name,tool_calls, andtool_call_idare supported; a message carrying any other text-bearing field is rejected so raw data cannot ride through an unscanned field. - Tool-call
argumentsis a JSON string and is protected structurally: it is parsed, its values are tokenized, and it is re-serialized as valid JSON. A non-JSONargumentsstring 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:9f2c4a7b1e8daa11bb22cc33dd44ee55}}, your order {{order:o_001:4b8e2d6f0a1caa11bb22cc33dd44ee55}} has shipped.",
"session_state": "eyJ2IjoxLCJzaWQiOi...",
"output_channel": "customer_email",
"restore_mode": "formatted",
"restore_overrides": {
"e_001": "none"
}
}| Field | Type | Required | Description |
|---|---|---|---|
output | string | Yes | LLM output containing {{type:id:cap}} tokens as emitted by transform (a capless token does not resolve) |
session_id | string | No | Session ID from transform response |
session_state | string | Yes | Sealed session blob from transform response |
restore_mode | string | No | Default restore mode: full, partial, masked, formatted, abstract, none |
restore_overrides | object | No | Per-token overrides keyed by token ID |
output_channel | string | No | user_output, internal_summary, customer_email, tool_payload, export, log_safe |
trace_id | string | No | Correlation 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 Erika Mustermann, 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
| Mode | Behavior | Person Example | Email Example |
|---|---|---|---|
full | Complete original value | Erika Mustermann | erika.mustermann@example.com |
partial | Deterministic subset | E. Mustermann | e***@example.com |
masked | Character masking | ******* ********* | e*******************e |
formatted | Original + contextual formatting | Frau Erika Mustermann | erika.mustermann@example.com (email) |
abstract | Semantic description | (female customer) | (email on file) |
none | Removed / redacted | [REDACTED] | [REDACTED] |
POST /v1/output-guard
Scan model-generated output for sensitive values the model produced, with no session. Every detected value counts as new, because there is no session to exempt against. The deployment's output-protection posture and the resolved policy's egress obligations govern the verdict, exactly as the transparent proxy applies on a sessionless egress. Use it from an SDK or an agent framework that has model output but never opened a transform session.
The output text is never written to a log or an error body: only the policy identifier (fingerprinted), the channel, and the trigger flag are recorded.
Request
{
"output": "Please reach escalation@backoffice.example to proceed.",
"policy": "german-support",
"output_channel": "customer_email"
}| Field | Type | Required | Description |
|---|---|---|---|
output | string | Yes | Model-generated text to scan |
policy | string | No | Policy governing the egress. Omitted resolves to the tenant default, then the deployment default; its custom_patterns and required-detector gates apply to the scan |
output_channel | string | No | user_output (default), internal_summary, customer_email, tool_payload, export, log_safe, or a custom channel. An untrusted channel raises the guard's action floor |
Response
{
"safe_output": "Please reach [EMAIL] to proceed.",
"output_guard_triggered": true
}| Field | Type | Description |
|---|---|---|
safe_output | string | The output after the posture applied (a masked value renders as a [TYPE] label). Equal to the input when nothing was detected |
output_guard_triggered | boolean | True when the guard found at least one new entity in the output |
A content block answers 422 GUARDAI_OUTPUT_BLOCKED. A scanner outage, and a deployment whose output
guard is disabled (output_protection.enabled: false), answer 503 GUARDAI_OUTPUT_GUARD_UNAVAILABLE:
a dedicated guard call cannot be honored by a disabled guard, so it fails closed rather than echoing
the text. The route is a replayable read; it mints no session and mutates no store, so a network retry
is safe.
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
}| Field | Type | Required | Description |
|---|---|---|---|
input | string | Yes | Text to scan |
language | string | No | ISO 639-1 hint (defaults to detector.default_language, English if unset; not auto-detected) |
detectors | string[] | No | Select detectors: builtin_regex, python_ner, or both. Defaults to server config. |
custom_patterns | array | No | Per-request custom regex detectors ({entity_type, pattern, ...}), validated on use and not persisted. Same bounded-match and discriminating-pattern rules as transform; see Custom pattern constraints. |
entity_types | string[] | No | Restrict the result to these entity types |
threshold | number | No | Minimum confidence to include an entity |
policy | string | No | When 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_values | boolean | No | Whether 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 span is a UTF-8 byte range into the submitted input, with an exclusive end. The example above
is ASCII, so the numbers happen to match character positions; on any non-ASCII text they do not. Slice the
request bytes, not the decoded string.
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": "Erika Mustermann" },
{ "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 Erika Mustermann", "language": "de" },
{ "text": "Contact erika.mustermann@example.com", "policy": "gdpr-strict" },
{ "input_type": "json", "input_json": { "email": "erika.mustermann@example.com" } }
]
}| Field | Type | Required | Description |
|---|---|---|---|
items | array | Yes | The inputs to transform. Each item carries its own input source and optional overrides. |
session_mode | string | No | independent (default): each item gets its own session. shared: all items share one session, so the token map carries forward across items. |
policy | string | No | Policy applied to every item; an item's own policy overrides it. |
In shared session mode the credential's policy entitlement is decided before any item executes, against every policy the batch can run under: each item's own policy (or the batch-level policy) and, for any item that omits both, the deployment default. An item that relies on inheriting the shared session's policy must therefore also be entitled to the default; a batch that should run entirely under one named policy sets it at the batch level.
Each item (BatchTransformItem) supports:
| Field | Type | Required | Description |
|---|---|---|---|
text | string | For text items | Plain-text input |
input_type | string | No | text (default), json, or chat_messages; a batch can mix item types |
input_json | any JSON value | For JSON items | Structured JSON input (object, array, string, number, boolean, or null) |
input_messages | array | For chat items | OpenAI-format message array |
policy | string | No | Per-item policy override |
language | string | No | Per-item language hint (ISO 639-1). Language is set per item, not once for the whole batch. |
custom_patterns | array | No | Per-item custom regex detectors ({entity_type, pattern, confidence?, context_words?}), applied to this item only, mirroring the single /v1/transform. Same bounded-match and discriminating-pattern rules; see Custom pattern constraints. |
Response
{
"results": [
{
"index": 0,
"safe_text": "Hello {{person:p_001:a1b2c3d4e5f6aa11bb22cc33dd44ee55}}",
"session_state": "eyJ2IjoxLCJ...",
"entities_detected": 1,
"entities_transformed": 1
},
{
"index": 2,
"safe_text": "{\"email\":\"{{email:e_001:0f9e8d7c6b5aaa11bb22cc33dd44ee55}}\"}",
"safe_json": { "email": "{{email:e_001:0f9e8d7c6b5aaa11bb22cc33dd44ee55}}" },
"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": "Erika Mustermann works at Musterfirma GmbH", "language": "de", "policy": "german-support" },
{ "text": "Contact erika.mustermann@example.com" }
]
}| Field | Type | Required | Description |
|---|---|---|---|
items | array | Yes | Each item has text (required), optional language, and optional per-item policy. |
include_values | boolean | No | Whether 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. |
policy | string | No | Policy 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": "Max Mustermann 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"] }
}| Field | Type | Required | Description |
|---|---|---|---|
text | string | Yes | Document text to chunk and transform |
chunking_strategy | string | No | paragraph (default), sentence, fixed, or sliding_window |
chunk_size | number | No | Max chunk size in UTF-8 bytes (for fixed / sliding_window). Chunking never splits a multi-byte character, so a chunk may end a byte or two short of the limit. |
chunk_overlap | number | No | Overlap in UTF-8 bytes (for sliding_window); must be less than chunk_size |
policy | string | No | Policy for transformation |
language | string | No | ISO 639-1 detection hint |
document_metadata | object | No | classification is validated against the deployment lattice and sealed into each chunk session for context access filtering. document_id, owner, and tags are propagated verbatim to each returned chunk for caller-side filtering and traceability; they are not evaluated by the runtime and are returned as sent, so do not place values there that must not appear in a response. |
corpus_id | string | No | Deterministic 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). Composing document_sessions into a context request requires a
corpus_id shared by ingest, query, and context: without one each session mints ids from its own
counter, so independently ingested documents can collide on the same id for different values. A
/v1/rag/context call that carries document_sessions without a corpus_id is rejected with 400.
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 Max Mustermann'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 Max Mustermann ..."],
"session_state": "eyJ2IjoxLCJxdWVye...",
"policy": "default",
"language": "en",
"access_level": "internal",
"chunk_classifications": ["internal"],
"document_sessions": ["eyJ2Ijox...chunk0"],
"corpus_id": "support-kb-2026"
}| Field | Type | Required | Description |
|---|---|---|---|
chunks | string[] | Yes | Retrieved chunks (raw text) |
session_state | string | Yes | Session from the preceding /v1/rag/query |
policy | string | No | Policy for transformation |
language | string | No | ISO 639-1 detection hint |
access_level | string | No | Max 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_classifications | string[] | No | Per-chunk classification labels, parallel to chunks. A missing entry is treated as most-restrictive when filtering is active. |
document_sessions | string[] | No | Per-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_id | string | No | Must 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.
This endpoint does not call a model and does not generate an answer. You call your LLM yourself, then
post its tokenized reply here as answer; the endpoint restores the tokens in it and returns
restored_answer. answer is required. /v1/rag/answer/metadata runs the identical restore but
returns only tokens_resolved and tokens_unresolved: the restored answer is dropped inside the
runtime and never serialized, so a caller outside the trust boundary (the MCP server) never receives
restored PII.
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 Max Mustermann 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,
and timestamp_unix is when the deletion was recorded.
status is the terminal outcome of a success. Every success state returns 200; none is a retry signal.
| Status | What it means | Retry? |
|---|---|---|
completed | At least one entity value was revoked. | No, the erasure is done |
partial | No entity value existed to revoke, but a server-side session record was removed. A real change with nothing to forget. | No, there was nothing left to erase |
no_op | Nothing to revoke and no server-side session to remove, so the delete had no effect. It stays distinct from completed so the receipt cannot claim an erasure that never happened. | No, repeating it changes nothing |
Failure responses
A delete can also fail. Each failure is retryable and converges on a keyed retry (send the same
Idempotency-Key); none pins a committed error.
| Code | Status | What it means |
|---|---|---|
GUARDAI_SESSION_REMOVAL_FAILED | 503 | Every value was revoked and durably persisted, but the server-side session record could not be removed. The values were erased and fail-closed, and the encrypted record is retained until its expiry (its values already resolve to [DELETED]). Retry to converge |
GUARDAI_REVOCATION_PERSIST_FAILED | 500 | The revocations were rolled back after a pre-commit persistence failure, so nothing was erased. Retry to re-run the erasure |
GUARDAI_REVOCATION_STATE_INCONSISTENT | 500 | A rollback after a persistence failure was incomplete, so a digest may survive without an audit record. Retry to converge the store and record the audit |
Audit reconstruction
Each attempt shares one trace_id across its audit records: one rag_delete_intent (write-ahead)
followed by at most one rag_delete terminal. A terminal with no failure is a fully completed
erasure; a terminal that names a failure reason (session_removal_failed, persistence_failed, or
rollback_incomplete) records what survived. An intent with no terminal is an attempt that was cut off
before it finished, and the next attempt's terminal is authoritative. There is no server-side
reconciliation worker: convergence requires a keyed retry, and a retained encrypted record expires at
the session TTL.
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. The native release binary needs 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):
| Field | Required | Description |
|---|---|---|
image | Yes | Image file (PNG, JPEG, TIFF, BMP) |
language | No | OCR language hint |
curl -H "X-API-Key: your-key" \
-F "image=@scan.png" -F "language=de" \
http://localhost:3000/v1/transform/imageThe form is strict: an unknown field, a nameless part, or a repeated field is rejected with
400 GUARDAI_BAD_REQUEST before entitlement or OCR runs, and the language control field is capped at
255 bytes. A blank language falls back to the server default, matching the JSON transform body.
If OCR yields no readable text, or cannot read the text with enough confidence to prove it free of
PII, the endpoint refuses with 422 GUARDAI_OCR_UNVERIFIABLE rather than release unverified pixels. A
language the resolver cannot recognize is rejected with 400 GUARDAI_OCR_LANGUAGE_UNRECOGNIZED (use
an ISO or Tesseract code such as de, en, or chi_tra), so an image is never scanned with the
wrong model.
Response
{
"safe_text": "Rechnung an {{person:p_001:9f2c4a7b1e8daa11bb22cc33dd44ee55}}, {{email:e_001:5873cc5722d8aa11bb22cc33dd44ee55}}",
"entities": [
{
"entity_type": "person",
"token": "{{person:p_001:9f2c4a7b1e8daa11bb22cc33dd44ee55}}",
"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 ner_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), plus an optional language control. The form
is strict: an unknown field, a nameless part, or a repeated field is rejected with 400 GUARDAI_BAD_REQUEST
before entitlement or OCR runs, and language is capped at 255 bytes. As with /v1/transform/image, an
image whose text cannot be read or verified free of PII is refused with 422 GUARDAI_OCR_UNVERIFIABLE,
and an unrecognized language with 400 GUARDAI_OCR_LANGUAGE_UNRECOGNIZED.
curl -H "X-API-Key: your-key" \
-F "image=@scan.png" \
http://localhost:3000/v1/redact/image --output redacted.pngResponse
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. The form is strict: an unknown field, a nameless part, or a repeated field is rejected with
400 GUARDAI_BAD_REQUEST before extraction runs, and each control field is capped at 255 bytes. policy
and language carry the same meaning as in the JSON transform body: an empty policy is treated as a
named policy (and rejected as unknown), and a blank language falls back to the server default. Supported
extensions are pdf, txt, csv, html, htm, md, and docx. A PDF or DOCX is also recognized by
its magic bytes, and that signature wins over a disagreeing extension. A PDF is read from its embedded text
layer only, so a scanned page yields no text and belongs on /v1/transform/image.
The endpoint extracts the text and runs the standard transform over the whole document in one pass,
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.
Document failures are classified, not all blamed on the upload
A failed extraction returns one of three statuses, and they mean different things. Do not treat every non-200 as a bad file:
| Status | Code | What it means | Retry? |
|---|---|---|---|
400 | GUARDAI_BAD_REQUEST | The format is unsupported or the document is malformed. The upload is at fault. | No, send a different file |
413 | GUARDAI_PAYLOAD_TOO_LARGE | A configured safety ceiling refused it (upload size, or an extraction resource limit). The document may be perfectly valid. | Only after raising the limit or shrinking the file |
503 | GUARDAI_DOCUMENT_EXTRACTION_UNAVAILABLE | The runtime could not perform the extraction at all. The document is not implicated. | Yes |
The 503 body is a fixed message; the cause is recorded in the server logs rather than returned,
because the underlying error can carry filesystem paths and document fragments.
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.
If a compensating rollback after a failed durable persist is incomplete, /v1/revoke and
/v1/revoke/bulk fail closed with 500 GUARDAI_REVOCATION_STATE_INCONSISTENT rather than acknowledge
an inconsistent state. A retry is safe: the idempotent re-insert converges the store and records the
audit.
POST /v1/revoke
{ "entity_type": "email", "value": "erika.mustermann@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": "erika.mustermann@example.com" },
{ "entity_type": "person", "value": "Erika Mustermann" }
]
}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.
On the shared (Redis) backend, a store that still holds revocations recorded before per-scope indexing
returns 409 GUARDAI_REVOCATION_COUNT_UNAVAILABLE rather than a possibly wrong number. One unattributable
digest breaks the count invariant, so this degrade is store-wide (every tenant's count returns 409, not
just the affected scope) until those digests drain: they re-index when each value is revoked again, or an
operator clears the store (which drops every tenant's revocations, so prefer draining by re-revoke).
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" }.
POST /v1/entity-types and POST /v1/patterns
Both endpoints are validate-only. Nothing is persisted: they run the same name and pattern
compilation a transform runs and report whether it would be accepted, so a custom detector can be
checked before it is deployed. Pass the validated pattern inline as custom_patterns on the next
transform or detect request.
Request
{ "name": "ticket_ref" }{
"custom_pattern": {
"entity_type": "ticket_ref",
"pattern": "TKT-[0-9]{1,10}",
"confidence": 0.9,
"context_words": ["ticket"]
}
}The first body is POST /v1/entity-types (name only), the second is POST /v1/patterns. A name must
match [a-z0-9_-]+ and must not shadow a built-in entity type. confidence defaults to 0.8,
context_words to empty, and value_group restricts the tokenized span to one capture group.
Response
{
"custom_pattern": {
"entity_type": "ticket_ref",
"pattern": "TKT-[0-9]{1,10}",
"confidence": 0.9,
"context_words": ["ticket"],
"value_group": null
},
"entity_type": "custom:ticket_ref",
"scope": "request",
"persisted": false,
"max_patterns_per_request": 128
}scope: "request" and persisted: false state the contract plainly: validation does not register
anything, so every request that wants the detector must carry it. /v1/entity-types returns the
name, its custom:<name> form, and the x_ token id prefix policy-defined custom types share.
Custom pattern constraints
A refused pattern is a 400 carrying the reason: GUARDAI_BAD_REQUEST from POST /v1/patterns,
GUARDAI_INVALID_INPUT when it arrives inline on transform or detect. Two rules decide it. They
apply identically to this endpoint, to inline custom_patterns, and to a policy's custom_patterns
at load:
- Bounded match length. An open-ended quantifier (
+,*,{n,}) is refused:TKT-[0-9]+is rejected,TKT-[0-9]{1,10}is accepted. A streamed response withholds a trailing window of at most 8192 bytes while an entity assembles, so a match that can outgrow the window would put the leading bytes of a real value on the wire before it could be masked. This is a trust-boundary requirement, not a performance limit. Length is measured on normalized text, where a whitespace run is already collapsed, so\s*between two parts of a pattern stays bounded; a class mixing whitespace with other characters ([:\s]*) does not. The pattern source is capped at 2000 bytes. - Discriminating shape. A pattern that matches the empty string or arbitrary text (
.*,\w+,\p{L}+, or a bare\d{1,10}) is refused, because it would mask everything rather than an identifier.
The error names the entity type and the rule, never the pattern text, since an operator-authored
regex can itself contain a literal value. max_patterns_per_request is the ceiling on the merged
set (a policy's patterns plus the inline ones) a single request may carry. Full authoring guidance:
Extending Entities.
GET /v1/health
Returns runtime health status plus the deployment's licence. 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", "message": "builtin_and_ner (full entity detection)" },
"session": { "status": "healthy", "message": "sealed" },
"policies": { "status": "healthy", "message": "4 policies loaded" }
},
"license": {
"status": "valid",
"edition": "business",
"release_date": "2026-07-29",
"license_id": "lic-000007",
"customer_id": "nordwind",
"customer_name": "Musterversicherung AG",
"updates_until": "2027-01-15",
"declared_edition": "business",
"contract": {
"deployment_allowance": 2,
"support_level": "standard",
"reference": "ORD-4471"
}
}
}Status values: healthy, degraded, unhealthy. The detector component reports the CONFIGURED
detector mode, not live sidecar reachability, so it does not flip on an NER outage.
The license block
Verification is not operative in this release. No verifying key is compiled into the binary, so the server cannot check a licence file at all: a correctly signed licence and a forged one are indistinguishable to it. Any licence it can read reports
"status": "malformed"with areasonnaming the missing key,"edition": "developer", and no identity fields. Thevalidexample above is what the block reports once a release ships the key. Only a licence file that cannot be read or is oversized refuses a non-dev start.
The server optionally loads a signed licence file (GUARDAI_LICENSE_FILE), resolves it once at
startup, and reports the result here. Resolution checks an Ed25519 signature against a compiled-in
key; the check is local, so the runtime never contacts Oronts and an air-gapped deployment behaves
identically.
Nothing is gated by the licence. No capability is withheld and no request takes a different path because of it. Do not branch client behaviour on this block; read it for inventory and support.
| Field | Always present | Description |
|---|---|---|
status | Yes | absent, valid, version_cutoff, or malformed |
edition | Yes | The edition in effect (developer, business, enterprise). Anything unusable resolves DOWN to developer, never up |
release_date | Yes | This binary's compiled-in release date, the value updates_until is compared against |
reason | No | Why the licence could not be used; present only when status is malformed. In this release it names the missing verifying key |
license_id, customer_id, customer_name | No | Identity recorded in the licence, present once its claims have survived verification. Admin-scoped callers only |
updates_until | No | The last release date the subscription covers. Admin-scoped callers only |
declared_edition | No | The edition the licence claims, reported alongside edition so a downgrade past a version cutoff is legible. Admin-scoped callers only |
contract | No | Recorded contract terms: deployment_allowance, support_level, reference. Admin-scoped callers only |
The identity and contract fields (license_id, customer_id, customer_name, updates_until,
declared_edition, contract) are a per-deployment operator fact, so the server discloses them only
to an admin-scoped caller. A non-admin credential gets the verdict fields (status, edition,
release_date) an integration needs, with the identity and contract fields omitted.
The block never carries the signature, key material, or entitlement flags. Absent fields are omitted
rather than sent as null, so an unlicensed deployment reports {"status": "absent", "edition": "developer", "release_date": "..."} and nothing else.
version_cutoff means the running binary was published after updates_until. Enforcement is on the
VERSION axis, comparing the compiled-in release date against updates_until, never a wall clock, so
no running process changes behaviour because a date passed. That grading sits behind verification, so
neither valid nor version_cutoff is reachable until a release ships a verifying key. See
Licensing for the start behaviour behind each status.
GET /v1/capabilities
Returns supported entity types, languages, available detectors, restore modes, the batch size limit,
and the per-session budget (max_session_entities, max_session_bytes). 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_session_entities": 20000,
"max_session_bytes": 8388608,
"detection_floor": { "entity_types": ["email", "person", "company", "location"], "ner_required": true }
}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).
max_session_entities and max_session_bytes report the per-session budget that backs
422 GUARDAI_SESSION_TOO_LARGE, so a client can size its session before a transform is refused. An
optional gender_inference object is present only when a name-gender overlay is installed.
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_UNAVAILABLE (a sidecar outage under a policy
that requires NER) and GUARDAI_DETECTION_FAILED (a capability rejection) for sidecar issues.
detection_floor reports the types a request under the default policy cannot succeed without, and whether
that policy fails closed when NER did not run (ner_required). It is derived from the same predicates the
request path enforces, so a builtin-only server reports ner_required: false with only the regex types,
while a server whose default policy floors NER (for example default-ner-required) reports ner_required: true with person, company, and location included. Informational; a server predating the field omits it.
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.
| Code | HTTP Status | Description |
|---|---|---|
GUARDAI_INVALID_INPUT | 400 | Malformed request, missing fields, type mismatch |
GUARDAI_BAD_REQUEST | 400 | Generic bad request (e.g., missing field) |
GUARDAI_UNAUTHORIZED | 401 | Missing or invalid credentials |
GUARDAI_FORBIDDEN | 403 | Credentials valid but insufficient scope |
GUARDAI_AUTH_FAILED | 401 | Authentication is enabled but no credentials provided |
GUARDAI_INVALID_TOKEN | 401 | JWT or session token is malformed or expired |
GUARDAI_AUTH_UNAVAILABLE | 503 | The verifier could not obtain a usable signing-key set: the issuer was unreachable or answered with a non-2xx status, the body was unreadable, oversized, or not a key set, the refresh timed out, or a recent failure is still inside oidc.refetch_cooldown_secs. The credential was never judged; retry once the issuer is reachable. |
GUARDAI_POLICY_DENIED | 403 | A 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_EXPIRED | 410 | Session TTL has elapsed, sealed blob failed integrity check, or envelope version unsupported |
GUARDAI_SESSION_REPLAY | 409 | A transform continuation was replayed: its sealed request counter did not advance past the highest seen for that session |
GUARDAI_SESSION_ROLLBACK | 409 | A sealed session was presented at an earlier state than the highest already seen for it; rejected fail-closed |
GUARDAI_SESSION_TOO_LARGE | 422 | A transform would grow one session past its per-session budget (limits.max_session_entities or limits.max_session_bytes); the request may be tiny yet the session is full, so rotate or split the session rather than retry smaller |
GUARDAI_IDEMPOTENCY_KEY_INVALID | 400 | The Idempotency-Key header is not 1 to 255 printable ASCII characters |
GUARDAI_IDEMPOTENCY_KEY_REUSE | 409 | An Idempotency-Key was reused with a different method, path, Content-Type, or body |
GUARDAI_IDEMPOTENCY_IN_PROGRESS | 409 | A request with this Idempotency-Key is still in progress |
GUARDAI_IDEMPOTENCY_RESULT_UNAVAILABLE | 409 | The idempotent operation completed but its result was not retained for replay (too large, or a binary body such as a redacted image); do not retry blindly |
GUARDAI_IDEMPOTENCY_STORE_FULL | 503 | The idempotency store is at capacity; the mutation did not run and may be retried |
GUARDAI_IDEMPOTENCY_PARTITION_FULL | 429 | Too many in-flight idempotent requests for this caller; the mutation did not run and may be retried once the caller's keys drain (a caller-scoped flood, not server-wide overload) |
GUARDAI_IDEMPOTENCY_DEADLINE_EXCEEDED | 503 | A claimed idempotent request outran its processing deadline; the outcome is indeterminate, the claim self-heals at lease expiry, and it may be retried shortly |
GUARDAI_DEADLINE_EXCEEDED | 503 | A RAG ingest exceeded the configured per-document wall-clock budget named in the message (rag.max_ingest_duration_secs); the trip is load dependent, nothing was sealed, in-flight detection was cancelled, and the request may be retried, split, or the budget raised |
GUARDAI_REPLAY_STORE_UNAVAILABLE | 503 | The replay store could not be reached; the continuation is rejected fail-closed rather than risk accepting a replay |
GUARDAI_REPLAY_STORE_AT_CAPACITY | 503 | The replay store is at capacity; the continuation is rejected fail-closed and may be retried once it drains |
GUARDAI_OUTPUT_BLOCKED | 422 | Output guard judged the response and refused it: newly generated PII in the model output, or a raw session value echoed instead of its token. The proxy also emits it as a streaming SSE terminal (200) when it blocks streamed model output |
GUARDAI_OUTPUT_GUARD_UNAVAILABLE | 503 | The output scan could not run, so the content was never judged; retriable once the scanner is available. The proxy also emits it as a streaming SSE terminal (200) when a streamed scan or restore could not complete |
GUARDAI_TOKEN_REPAIR_FAILED | 422 | Token repair could not resolve tokens in LLM output |
GUARDAI_DETECTION_FAILED | 500 | Detection failed for a reason that reproduces on retry: a detector errored, or the sidecar responded but cannot serve the required labels (a capability rejection) |
GUARDAI_DETECTION_UNAVAILABLE | 503 | A required detection could not run because its dependency was unavailable or degraded (a NER sidecar outage, transport error, timeout, or degraded response under a policy that requires NER-backed detection), so the content was never judged; retriable once detection recovers |
GUARDAI_RATE_LIMITED | 429 | Request rate exceeds configured limit |
GUARDAI_PAYLOAD_TOO_LARGE | 413 | Request payload exceeds configured size limit |
GUARDAI_AUDIT_UNAVAILABLE | 503 | Audit log unavailable; request rejected fail-closed (applies when audit.strict is on) |
GUARDAI_OCR_UNAVAILABLE | 503 | Tesseract OCR is not installed |
GUARDAI_OCR_UNVERIFIABLE | 422 | The image yielded no readable text, or its text could not be read with enough confidence to verify it free of PII, so it is refused rather than released unverified |
GUARDAI_OCR_LANGUAGE_UNRECOGNIZED | 400 | The requested OCR language is not a recognized ISO or Tesseract code, so the image would be scanned with the wrong model |
GUARDAI_OCR_FAILED | 500 | OCR processing failed |
GUARDAI_REDACTION_FAILED | 500 | Image redaction failed |
GUARDAI_IMAGE_MULTIPAGE_UNSUPPORTED | 422 | Image redaction does not support multipage documents; use /v1/transform/image for multipage text protection |
GUARDAI_REVOCATION_LOCK_POISONED | 500 | Revocation lock failure |
GUARDAI_REVOCATION_PERSIST_FAILED | 500 | Revocation persistence failed (rolled back) |
GUARDAI_REVOCATION_LIMIT_EXCEEDED | 422 | A revoke would grow the persisted revocation store past its configured cardinality bound (revocation_max_persisted); retrying never clears it |
GUARDAI_REVOCATION_PERSIST_SATURATED | 503 | Concurrent revocation persistence writes are at their configured bound (revocation_persist_max_in_flight); the revoke was refused before mutating state and is safe to retry shortly |
GUARDAI_REVOCATION_COUNT_UNAVAILABLE | 409 | A scoped revocation count cannot be answered because the store holds digests recorded before per-scope indexing existed; re-revoke the values or clear the store |
GUARDAI_REVOCATION_STATE_INCONSISTENT | 500 | A revocation rollback was incomplete after a failed persist; a digest may survive without an audit record. Retry to converge the store and record the audit |
GUARDAI_SESSION_REMOVAL_FAILED | 503 | A RAG delete revoked and durably persisted every value, but the server-side session record could not be removed and survives until its expiry. Its values already resolve to [DELETED]; retry to converge |
GUARDAI_TENANT_SCOPED | 403 | Operation requires tenant scope |
GUARDAI_CAPACITY_EXHAUSTED | 503 | A document or image request could not be admitted within server.admission_queue_timeout_ms (the shared upload-byte budget, or a heavy-op permit for document extraction, OCR, or image redaction); refused before work with a Retry-After header and safe to retry |
GUARDAI_SERVICE_SHUTTING_DOWN | 503 | The service is shutting down and is not accepting new requests; retry shortly against a live instance |
GUARDAI_STREAM_UNPROCESSABLE | 200 (SSE terminal) | A proxy streaming code, not a buffered HTTP status. A streamed upstream event could not be protected (a malformed, ambiguous, unknown, unsupported, incomplete, or over-buffer event), so the stream ends with this terminal SSE error event instead of a clean terminal. The reason labels are listed in the proxy streaming guide |
GUARDAI_STREAM_TRUNCATED | 200 (SSE terminal) | A proxy streaming code, not a buffered HTTP status. A streamed upstream response ended before its completion terminal (a disconnect or transport error), surfaced as a terminal SSE error event so a truncated stream is not read as complete |
GUARDAI_INTERNAL_ERROR | 500 | Unexpected server error |
GUARDAI_INTERNAL | 500 | Unexpected server error; a variant spelling of GUARDAI_INTERNAL_ERROR emitted by some internal paths |
Note: The two output guard codes are not interchangeable.
503 GUARDAI_OUTPUT_GUARD_UNAVAILABLEmeans the scan itself could not run or could not complete (no output NER scanner is installed where the policy requires one, the scanner answered without running the required pass, a detector errored, or the guard could not render a replacement), so the content was never judged and the same request may succeed once the deployment is fixed.422 GUARDAI_OUTPUT_BLOCKEDmeans the content was judged and refused, so retrying the same content will not help. Both withhold the response; only the 503 is worth retrying. The restore endpoints that run the output guard are/v1/rehydrate,/v1/rehydrate/metadata,/v1/rehydrate/stream,/v1/rehydrate/file,/v1/rag/answer, and/v1/rag/answer/metadata.
For machine-readable schemas, see schemas/openapi.yaml (the canonical description of every route the
server mounts) and the per-endpoint JSON Schema files under schemas/api/. The
API Explorer lists which endpoints have a dedicated schema file.
Multi-Tenant SaaS
How a SaaS platform serves three customers with different data protection policies using a single OGuardAI deployment
API Explorer
An interactive-style overview of every OGuardAI v1 endpoint, with method, purpose, required scope, and a copy-paste curl example built from the real request shape.