---
title: HTTP API (/v1)
path: reference/http-api
status: published
---

# HTTP API (/v1)

The ScaiLog server is a FastAPI app that exposes one versioned surface under
`/v1`, plus a Prometheus `/metrics` endpoint. Every `/v1` route except the
health/status/report-key reads requires a bearer token; authorization is by
capability, derived from the caller's role. This page enumerates every route
in `server/app.py`: method, path, purpose, the capability it requires, and the
fields that matter in the request and response.

## Authentication

Every protected endpoint expects an `Authorization: Bearer <token>` header. The
token is either a built-in API key (prefix `slk_`, the base mechanism) or, when
the optional OIDC plugin is configured, a ScaiKey-issued id_token. API keys are
verified locally; OIDC tokens are validated against the configured issuer and
mapped to a role via the `scailog_role` claim.

```bash
curl -sS http://127.0.0.1:8080/v1/query?tenant=default \
  -H "Authorization: Bearer slk_read_XXXXXXXXXXXX"
```

A missing or malformed header returns `401`. A valid token whose role lacks the
required capability returns `403` (`role '<role>' lacks capability '<cap>'`). A
token scoped to another tenant or service returns `403`, except where the
handler deliberately returns `404` to keep a resource indistinguishable from
absent (single-entry reads, key revocation).

## Roles and capabilities

Capabilities are the unit of authorization; roles are fixed bundles of them.

| Role | Capabilities |
| --- | --- |
| `log_writer` | `ingest` |
| `log_reader` | `query` |
| `log_reader_plus` | `query`, `decrypt_one` |
| `log_admin` | `query`, `decrypt_one`, `admin` |
| `dpo` | `ingest`, `query`, `decrypt_one`, `admin`, `dpo` |

The bootstrap key minted on first start is a `dpo` key scoped to all tenants
(`*`). `dpo` is a full superset and includes `ingest` so a standalone operator
can push directly. Full role/key mechanics are in
[Configuration](/docs/scailog/reference/configuration) and the auth model.

## Endpoints at a glance

| Method | Path | Capability | Purpose |
| --- | --- | --- | --- |
| POST | `/v1/ingest` | `ingest` | Accept a batch or JSON list of log entries |
| POST | `/v1/agent/enroll` | `ingest` | Return the unwrapped tenant KEK to an agent |
| POST | `/v1/agent/dek` | `ingest` | Mint/return a wrapped per-subject DEK |
| GET | `/v1/whoami` | any authenticated | Resolve the caller's role, scope, capabilities |
| GET | `/v1/tenants` | `query` | List tenant ids visible to the key |
| GET | `/v1/query` | `query` | Query stored entries (PI as placeholders) |
| GET | `/v1/tail` | `query` | Server-sent-events live tail |
| GET | `/v1/entries/{event_id}` | `query` | Fetch one entry; `decrypt_one` reveals PI |
| GET | `/v1/entries/{event_id}/explain` | `query` | Explain the policy decision for an entry |
| GET | `/v1/sites` | `query` | List observed call sites |
| POST | `/v1/sites/manifest` | `admin` | Upload a CI call-site manifest |
| POST | `/v1/dsar` | `dpo` | Run a DSAR and return a signed report |
| POST | `/v1/erase` | `dpo` | Crypto-shred a subject; return a signed receipt |
| GET | `/v1/receipts/{receipt_id}` | `dpo` | Fetch a stored erasure receipt |
| GET | `/v1/policies` | `admin` | Get the active policy + version history |
| PUT | `/v1/policies` | `admin` | Validate and store a new policy version |
| GET | `/v1/keys` | `admin` | List API keys (tenant-scoped) |
| POST | `/v1/keys` | `admin` | Create an API key (returns plaintext once) |
| DELETE | `/v1/keys/{key_id}` | `admin` | Revoke an API key |
| GET | `/v1/audit/findings` | `admin` | List audit findings by status |
| POST | `/v1/audit/findings/{finding_id}/resolve` | `admin` | Resolve a finding |
| POST | `/v1/audit/redact` | `dpo` | Scrub audit-found untagged PI at a call site |
| GET | `/v1/health` | none | Liveness probe |
| GET | `/v1/status` | none | Counters + active report public key |
| GET | `/v1/report-key` | none | The active report public key |
| GET | `/v1/report-keys` | none | All report public keys (active + retired) |
| POST | `/v1/report-keys/rotate` | `dpo` | Rotate the report signing key |
| GET | `/metrics` | none | Prometheus text-format counters |

## Ingest and agent

These are the write path. Agents authenticate with an `ingest`-capable API key
and never touch OIDC, keeping ingestion bootable when identity services are
down.

### POST /v1/ingest

Accepts log entries. If the request carries an `x-slwp-batch-id` header the body
is decoded as an SLWP batch (zstd/NDJSON) and its checksum is verified;
otherwise the body is JSON `{"entries": [...]}`. Batch ingestion is idempotent:
a batch id already seen returns `{"status": "duplicate"}`, and a checksum
mismatch returns `400 BATCH_CHECKSUM_MISMATCH`.

```http
POST /v1/ingest
Authorization: Bearer slk_agent_XXXXXXXXXXXX
Content-Type: application/json

{"entries": [{"tenant": "default", "service": "billing",
              "site": "BILLING-CHARGE-OK", "level": "info",
              "msg": "charge captured", "ts": "2026-07-13T10:00:00Z"}]}
```

Response reports counts and per-entry rejections (each with a `code` such as
`SUBJECT_REQUIRED`, `SITE_INVALID`, `RESERVED_FIELD`, and a `detail`):

```json
{"accepted": 1, "rejected": [{"event_id": "...", "code": "SITE_INVALID", "detail": "..."}]}
```

### POST /v1/agent/enroll

Returns the unwrapped tenant KEK (base64) to an authenticated agent. The KEK
lives only in agent memory. Body: `{"tenant": "<id>"}`. Response:
`{"tenant": "<id>", "kek": "<base64>"}`. The tenant is created on first use.

### POST /v1/agent/dek

Mints on first sight and returns the **wrapped** per-subject DEK; the agent
unwraps it with its in-memory KEK. Body: `{"tenant": "<id>", "subject": "<id>"}`.
Response: `{"key_ref": "...", "wrapped_dek": "<base64>"}`. An already-erased
subject returns `410`.

## Query and read

The read path never bulk-decrypts PI. Query results carry encrypted PI as
placeholders; the only decryption points are a single-entry read by a
`decrypt_one` principal (audited) and a DSAR run.

### GET /v1/whoami

Returns the calling principal so a client can validate a key and drive
RBAC-aware UI. Requires only a valid token (no specific capability).

```json
{"key_id": "...", "role": "log_admin", "tenant_scope": "default",
 "service_scope": ["billing"], "capabilities": ["admin", "decrypt_one", "query"]}
```

### GET /v1/tenants

Distinct tenant ids visible to the key (for a tenant switcher). A tenant-scoped
key sees only its own tenant. Response: `{"tenants": ["default", ...]}`.

### GET /v1/query

Query stored entries. Query parameters: `tenant` (required), `service`, `site`
(the `call_site_id`), `level`, `since`, `until`, `subject`, `q` (FTS over
`message` + plaintext fields), `limit` (default `100`). A `subject`-filtered
query requires `dpo`. A `service` filter is rejected with `403` if the key is
not scoped to that service.

```bash
curl -sS "http://127.0.0.1:8080/v1/query?tenant=default&level=error&limit=50" \
  -H "Authorization: Bearer slk_read_XXXXXXXXXXXX"
```

Response: `{"entries": [<entry>...], "count": <n>}`. Each entry includes
`event_id`, `tenant`, `service`, `environment`, `host`, `call_site_id`,
`level`, `ts`, `ingested_at`, `message`, `fields`, `data_subject_id`,
`trace_id`, `request_id`, and `erased`. If the entry has PI, a `pi` object maps
each field name to `{"type", "action", "value"}` where an encrypted value is
`"[ENCRYPTED]"`.

### GET /v1/tail

A live tail as `text/event-stream` (SSE). Query parameters: `tenant`
(required), `service`, `level`, `q`. Each event is a `data:` line carrying the
same entry shape as `/v1/query`. The stream polls roughly once a second and
de-dupes across poll boundaries.

### GET /v1/entries/{event_id}

Fetch one entry. Requires `tenant` as a query parameter. Returns `404` if the
entry is absent or the key is not scoped to its service (indistinguishable). If
the caller has `decrypt_one` and the entry has PI, a non-erased subject, and a
subject id, the response adds a `pi_values` object with the decrypted field
values — this single-entry decrypt is counted for audit.

### GET /v1/entries/{event_id}/explain

Explains why an entry was stored as it was: the resolved policy version and,
per PI field, its `pi_type`, `stored_action`, `resolved_action`, `rule_index`,
and `reason`. Requires `tenant`. Response includes `event_id`, `tenant`,
`service`, `environment`, `call_site_id`, `policy_version`, `policy_found`, and
`fields`.

### GET /v1/sites

Lists observed call sites. Query parameters: `tenant` (required), `service`,
`auto_only` (bool), `pi_only` (bool). Response `{"sites": [...]}`; each site
carries `service`, `call_site_id`, `hit_count`, `is_auto`, `declares_pi`, and
(when a manifest was pushed) `declared_pi`, `manifest_version`, `owner`.

### POST /v1/sites/manifest

Uploads a CI call-site manifest so `scailog sites --diff` can flag drift.
Requires `admin`. Body: `{"service": "...", "version": "...", "sites": [...],
"tenant": "...", "owner": "..."}` (`tenant` defaults to the key's tenant scope
or `default`). Response: `{"upserted": <n>, "service": "..."}`.

## DSAR, erasure, and receipts

These are the GDPR endpoints and all require the `dpo` capability. DSAR and
erasure return Ed25519-signed documents that embed the signing key's public key
and `key_id`, so they stay verifiable across key rotations.

### POST /v1/dsar

Runs a Data Subject Access Request and returns a signed report. Body:
`{"tenant": "...", "subject": "..."}`. The report includes `report_id`, `type`
(`"dsar"`), `tenant`, `subject`, `requested_by`, `generated_at`, `entry_count`,
`time_range`, `call_sites`, `subject_erased`, `entries`, and a `signature`.

### POST /v1/erase

Crypto-shreds a subject (destroys the per-subject DEK) and tombstones or
deletes affected entries. Body requires `confirm: true` (else `400`); optional
`mode` is `"tombstone"` (default) or `"delete"`. Idempotent: re-erasing an
already-erased subject returns the original receipt. The signed receipt
includes `receipt_id`, `type` (`"erasure"`), `tenant`, `subject`,
`requested_by`, `requested_at`, `key_destroyed_at`, `entries_tombstoned`,
`entries_deleted`, `erasure_mode`, `absolute_after`, and a `signature`.

```http
POST /v1/erase
Authorization: Bearer slk_dpo_XXXXXXXXXXXX
Content-Type: application/json

{"tenant": "default", "subject": "user-42", "confirm": true, "mode": "tombstone"}
```

### GET /v1/receipts/{receipt_id}

Returns a stored erasure receipt document. `404` if unknown.

## Policies

Policy management requires `admin`. Policies are declarative YAML, validated on
write and versioned forever.

### GET /v1/policies

Returns the active policy for a tenant. Query parameter `tenant` (required).
Response: `{"version": <n>, "yaml": "...", "history": [...]}`.

### PUT /v1/policies

Validates and stores a new policy version. Body: `{"tenant": "...", "yaml":
"..."}`. Malformed YAML or an invalid action/pi_type returns `400 invalid
policy: ...`. Response: `{"version": <n>, "warnings": [...]}`.

## API keys

Key management requires `admin`. A tenant-scoped admin is contained to its own
tenant and cannot widen service scope; only a `dpo` may mint a key carrying the
`dpo` capability.

### GET /v1/keys

Lists keys. A tenant-scoped admin sees only its tenant's keys. Response
`{"keys": [...]}` with `key_id`, `prefix`, `role`, `tenant_scope`,
`service_scope`, `created_at`, `expires_at`, `revoked_at`.

### POST /v1/keys

Creates a key and returns the plaintext **once**. Body: `{"role": "...",
"tenant_scope": "...", "service_scope": [...], "expires_at": "...",
"rate_limit_per_min": <n>, "ip_allowlist": [...]}` (only `role` is required;
scopes default to the creator's). Response: `{"key_id": "...", "key":
"slk_...", "note": "shown once — store it now"}`.

### DELETE /v1/keys/{key_id}

Revokes a key. `404` if unknown, already revoked, or outside the caller's
tenant scope. Response: `{"revoked": "<key_id>"}`.

## Audit

Audit-finding management requires `admin`; the redaction endpoint rewrites
stored personal data and therefore requires `dpo`.

### GET /v1/audit/findings

Lists findings. Query parameters: `tenant` (required), `status` (default
`"open"`). Response: `{"findings": [...]}`.

### POST /v1/audit/findings/{finding_id}/resolve

Resolves a finding. Body: `{"tenant": "...", "action": "...", "status":
"..."}` (`status` defaults to `resolved`; `tenant` falls back to the key's
scope). `404` if not found. Response: `{"resolved": "<finding_id>"}`.

### POST /v1/audit/redact

Targeted rewrite of audit-found untagged PI: scrubs named plaintext fields to
`[AUDIT_REDACTED]` across one call site. Requires `dpo`. Body: `{"tenant": "...",
"service": "...", "call_site_id": "...", "fields": ["..."], "finding_id":
"..."}`. `service` and `fields` are required (`400` otherwise). Response:
`{"call_site_id", "service", "fields", "affected", "resolved_finding"}`.

## Health, status, and report keys

These reads are unauthenticated. The report-key endpoints publish the public
keys used to sign DSAR reports and erasure receipts, so anyone can verify a
downloaded document offline.

### GET /v1/health

Liveness. Response: `{"status": "ok", "ts": "<rfc3339>"}`.

### GET /v1/status

Response: `{"status": "ok", "counters": {...}, "report_public_key": "...",
"ts": "<rfc3339>"}`.

### GET /v1/report-key

The active report public key (back-compat). Response: `{"alg": "Ed25519",
"public_key": "...", "key_id": "..."}`. Prefer `/v1/report-keys` for verifying
documents that may have been signed by a now-retired key.

### GET /v1/report-keys

All published report public keys, active and retired, for offline verification
across rotations. Response: `{"alg": "Ed25519", "active": "<key_id>", "keys":
[{"key_id", "status", "created_at", "retired_at", "public_key"}...]}`.

### POST /v1/report-keys/rotate

Rotates the report signing key: mints a new active key and retires (but keeps
publishing) the old one. Requires `dpo`. Response: `{"active": "<new_id>",
"retired": "<old_id>", "keys": [...]}`.

### GET /metrics

Prometheus text exposition (`text/plain; version=0.0.4`) of the server
counters. Unauthenticated.
