Sentinel exposes endpoints across four groups: public, API key, operator, and internal.
| Endpoint group | Auth method | Header |
|---|---|---|
Public (/health*, /metrics*) |
None | — |
API key (/v1/moderate*, /v1/appeals) |
API key | X-API-Key |
Operator (/admin/*) |
OAuth bearer token | Authorization: Bearer <token> |
Internal (/internal/*) |
OAuth bearer token | Authorization: Bearer <token> |
Returns API health status.
Response 200 OK
{"status": "ok"}Liveness probe for process health.
Response 200 OK
{"status": "ok"}Readiness probe for downstream dependencies (lexicon, and optionally Postgres/Redis when configured).
Response 200 OK (ready)
{"status": "ready", "checks": {"lexicon": "ok", "db": "ok", "redis": "ok"}}Response 503 Service Unavailable (degraded)
{"status": "degraded", "checks": {"lexicon": "ok", "db": "error", "redis": "ok"}}Returns runtime counters in JSON.
Response 200 OK
{
"action_counts": {"ALLOW": 150, "REVIEW": 23, "BLOCK": 7},
"http_status_counts": {"200": 180, "400": 2, "429": 1},
"latency_ms_buckets": {"le_50ms": 100, "le_100ms": 50, "le_150ms": 20},
"validation_error_count": 2
}Returns Prometheus exposition text (text/plain).
Primary moderation endpoint.
Authentication: X-API-Key header
Request body
{
"text": "Content to moderate",
"context": {
"source": "forum-post",
"locale": "ke",
"channel": "politics"
},
"request_id": "client-correlation-id"
}| Field | Type | Required | Constraints |
|---|---|---|---|
text |
string | Yes | 1-5000 chars |
context.source |
string | No | max 100 |
context.locale |
string | No | max 20 |
context.channel |
string | No | max 50 |
request_id |
string | No | max 128 |
Response 200 OK
{
"toxicity": 0.9,
"labels": ["INCITEMENT_VIOLENCE"],
"action": "BLOCK",
"reason_codes": ["R_INCITE_CALL_TO_HARM"],
"evidence": [
{
"type": "lexicon",
"match": "kill",
"severity": 3,
"lang": "en",
"match_id": null,
"similarity": null,
"span": null,
"confidence": null
}
],
"language_spans": [{"start": 0, "end": 26, "lang": "en"}],
"model_version": "sentinel-multi-v2",
"lexicon_version": "hatelex-v2.1",
"pack_versions": {"en": "pack-en-0.1", "sw": "pack-sw-0.1", "sh": "pack-sh-0.1"},
"policy_version": "policy-2026.11",
"latency_ms": 12
}Response headers
| Header | Description |
|---|---|
X-Request-ID |
Request correlation ID |
X-Cache |
HIT or MISS when result caching is enabled |
X-RateLimit-Limit |
Max requests per window |
X-RateLimit-Remaining |
Remaining requests in current window |
X-RateLimit-Reset |
Seconds until window resets |
| Field | Type |
|---|---|
toxicity |
float (0..1) |
labels |
enum[] (ETHNIC_CONTEMPT, INCITEMENT_VIOLENCE, HARASSMENT_THREAT, DOGWHISTLE_WATCH, DISINFO_RISK, BENIGN_POLITICAL_SPEECH) |
action |
ALLOW | REVIEW | BLOCK |
reason_codes |
string[] (R_[A-Z0-9_]+) |
evidence |
EvidenceItem[] |
language_spans |
LanguageSpan[] |
model_version |
string |
lexicon_version |
string |
pack_versions |
object |
policy_version |
string |
latency_ms |
integer |
EvidenceItem fields
| Field | Type | Notes |
|---|---|---|
type |
lexicon | vector_match | model_span |
required |
match |
string or null | optional |
severity |
int (1..3) or null | optional |
lang |
string or null | optional |
match_id |
string or null | optional |
similarity |
float (0..1) or null | vector matches |
span |
string or null | model-derived evidence |
confidence |
float (0..1) or null | model-derived evidence |
Moderation error responses
| Status | error_code |
Meaning |
|---|---|---|
| 400 | HTTP_400 |
Invalid request payload |
| 401 | HTTP_401 |
Missing or invalid API key |
| 429 | HTTP_429 |
Rate limited |
| 500 | HTTP_500 |
Internal server error |
| 503 | HTTP_503 |
API key auth not configured on server |
Batch moderation for up to 50 items in one request. Rate limiting is applied per item (a 50-item batch costs 50).
Authentication: X-API-Key header
Request body
{
"items": [
{"request_id": "req-1", "text": "We should discuss policy peacefully."},
{"request_id": "req-2", "text": "They should kill them now."}
]
}Response 200 OK
{
"items": [
{"request_id": "req-1", "result": {"action": "ALLOW"}, "error": null},
{"request_id": "req-2", "result": {"action": "BLOCK"}, "error": null}
],
"total": 2,
"succeeded": 2,
"failed": 0
}Errors for individual items are returned inline on items[].error; the overall response stays 200 unless authentication/rate limiting fails.
Submit an appeal for a prior moderation decision. This endpoint stores the original decision snapshot so reviewers can reconstruct context later.
Authentication: X-API-Key header
Request body
{
"decision_request_id": "client-request-id-123",
"original_action": "BLOCK",
"original_reason_codes": ["R_INCITE_CALL_TO_HARM"],
"original_model_version": "sentinel-multi-v2",
"original_lexicon_version": "hatelex-v2.1",
"original_policy_version": "policy-2026.11",
"original_pack_versions": {"en": "pack-en-0.1"},
"reason": "I am disputing this decision"
}Response 201 Created
{"appeal_id": 42, "status": "submitted", "request_id": "client-request-id-123"}Update the effective electoral phase in-process.
OAuth scope: admin:policy:write
Request body
{"phase": "voting_day"}To clear the override and return to env/config resolution:
{"phase": null}Response 200 OK
{
"effective_phase": "voting_day",
"effective_policy_version": "policy-2026.11@voting_day",
"actor": "admin-dashboard",
"limitation": "in-process only; multi-worker and multi-replica deployments require a shared store"
}submitted -> triaged -> in_review -> resolved_upheld
-> resolved_reversed
-> resolved_modified
-> rejected_invalid
Create an appeal.
OAuth scope: admin:appeal:write
Request body
{
"original_decision_id": "decision-uuid",
"request_id": "request-uuid",
"original_action": "BLOCK",
"original_reason_codes": ["R_INCITE_CALL_TO_HARM"],
"original_model_version": "sentinel-multi-v2",
"original_lexicon_version": "hatelex-v2.1",
"original_policy_version": "policy-2026.11",
"original_pack_versions": {"en": "pack-en-0.1"},
"rationale": "User disputed the decision"
}Response 200 OK: AdminAppealRecord
List appeals.
OAuth scope: admin:appeal:read
Query params: status, request_id, limit (1..200, default 50)
Response 200 OK
{
"total_count": 1,
"items": [
{
"id": 1,
"status": "submitted",
"request_id": "request-uuid",
"original_decision_id": "decision-uuid",
"original_action": "BLOCK",
"original_reason_codes": ["R_INCITE_CALL_TO_HARM"],
"original_model_version": "sentinel-multi-v2",
"original_lexicon_version": "hatelex-v2.1",
"original_policy_version": "policy-2026.11",
"original_pack_versions": {"en": "pack-en-0.1"},
"submitted_by": "admin-dashboard",
"reviewer_actor": null,
"resolution_code": null,
"resolution_reason_codes": null,
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z",
"resolved_at": null
}
]
}Transition an appeal.
OAuth scope: admin:appeal:write
Path param: appeal_id (integer >= 1)
Request body
{
"to_status": "in_review",
"rationale": "Escalating",
"resolution_code": null,
"resolution_reason_codes": null
}Response 200 OK: updated AdminAppealRecord
Get full reconstruction for one appeal.
OAuth scope: admin:appeal:read
Path param: appeal_id (integer >= 1)
Response 200 OK
{
"appeal": {"id": 1, "status": "in_review"},
"timeline": [{"id": 2, "appeal_id": 1, "from_status": "submitted", "to_status": "triaged", "actor": "admin-dashboard", "rationale": "valid", "created_at": "2026-01-15T11:00:00Z"}],
"artifact_versions": {
"model": "sentinel-multi-v2",
"lexicon": "hatelex-v2.1",
"policy": "policy-2026.11",
"pack": {"en": "pack-en-0.1"}
},
"original_reason_codes": ["R_INCITE_CALL_TO_HARM"],
"resolution": {
"status": null,
"resolution_code": null,
"resolution_reason_codes": null,
"reviewer_actor": null,
"resolved_at": null
}
}Live audit stream of recent moderation outcomes, formatted as Server-Sent Events (SSE). Events include provenance and decision fields and intentionally omit user text.
OAuth scope: admin:transparency:read
Query params: cursor (integer, default 0)
Response 200 OK (text/event-stream)
data: {"timestamp":"2026-02-20T00:00:00+00:00","action":"ALLOW","labels":["BENIGN_POLITICAL_SPEECH"],"reason_codes":["R_ALLOW_NO_POLICY_MATCH"],"latency_ms":12,"deployment_stage":"supervised","lexicon_version":"hatelex-v2.1","policy_version":"policy-2026.11"}
Aggregate appeals report.
OAuth scope: admin:transparency:read
Query params: created_from, created_to (ISO-8601 datetime)
Response 200 OK
{
"generated_at": "2026-02-13T12:00:00Z",
"total_appeals": 42,
"open_appeals": 5,
"resolved_appeals": 37,
"backlog_over_72h": 2,
"reversal_rate": 0.15,
"mean_resolution_hours": 18.5,
"status_counts": {
"submitted": 3,
"triaged": 1,
"in_review": 1,
"resolved_upheld": 20,
"resolved_reversed": 5,
"resolved_modified": 7,
"rejected_invalid": 5
},
"resolution_counts": {
"resolved_upheld": 20,
"resolved_reversed": 5,
"resolved_modified": 7
}
}Raw appeals export.
OAuth scope: admin:transparency:export
Extra scope when include_identifiers=true: admin:transparency:identifiers
Query params: created_from, created_to, include_identifiers (default false), limit (1..5000, default 200)
Response 200 OK
{
"generated_at": "2026-02-13T12:00:00Z",
"include_identifiers": false,
"total_count": 1,
"records": [
{
"appeal_id": 1,
"status": "resolved_upheld",
"original_action": "BLOCK",
"original_reason_codes": ["R_INCITE_CALL_TO_HARM"],
"resolution_status": "resolved_upheld",
"resolution_code": "decision_correct",
"resolution_reason_codes": ["R_INCITE_CALL_TO_HARM"],
"artifact_versions": {
"model": "sentinel-multi-v2",
"lexicon": "hatelex-v2.1",
"policy": "policy-2026.11",
"pack": {"en": "pack-en-0.1"}
},
"request_id": null,
"original_decision_id": null,
"transition_count": 3,
"created_at": "2026-01-15T10:30:00Z",
"resolved_at": "2026-01-16T14:00:00Z"
}
]
}Returns actor identity and scopes.
OAuth scope: admin:proposal:read
Response 200 OK
{
"status": "ok",
"actor_client_id": "admin-dashboard",
"scopes": ["admin:proposal:read", "admin:proposal:review"]
}Submit a review action.
OAuth scope: admin:proposal:review
Path param: proposal_id (integer >= 1)
Request body
{
"action": "approve",
"rationale": "Reviewed and accepted"
}action values: submit_review, approve, reject, request_changes, promote
Response 200 OK
{
"proposal_id": 12,
"action": "approve",
"actor": "admin-dashboard",
"status": "accepted",
"rationale": "Reviewed and accepted"
}Queue metrics snapshot.
OAuth scope: internal:queue:read
Response 200 OK
{
"queue_depth_by_priority": {"critical": 0, "urgent": 1, "standard": 3, "batch": 9},
"sla_breach_count_by_priority": {"critical": 0, "urgent": 0, "standard": 0, "batch": 1},
"actor_client_id": "ops-service"
}All API errors use this shape:
{
"error_code": "HTTP_400",
"message": "Invalid request payload (1 validation error(s))",
"request_id": "abc-123"
}| Header | Description |
|---|---|
X-RateLimit-Limit |
Max requests per window |
X-RateLimit-Remaining |
Remaining requests in current window |
X-RateLimit-Reset |
Seconds until window resets |
Retry-After |
Seconds to wait (429 only) |