# REST API reference

Base URL: `https://your-firewall.example.com`. All endpoints below are relative to it.

This is a field-level reference. For task-oriented walkthroughs, see [Direct API](/docs/guides/direct-api), [Transparent proxy](/docs/guides/transparent-proxy), and [Detect a compromised agent](/docs/guides/detect-compromised-agent).

## Authentication

Machine-facing endpoints (everything except the dashboard-scoped table at the end of this page) authenticate with a **firewall API key** — `sfw_live_xxxxxxxx…` or `sfw_test_xxxxxxxx…`. Send it as:

- `X-SFW-Key: sfw_live_xxxxxxxx…` — preferred, especially on the proxy where `Authorization` carries your upstream provider's key.
- `Authorization: Bearer sfw_live_xxxxxxxx…` — accepted anywhere except the proxy, where `Authorization` is reserved for the provider key.

If both are present, `X-SFW-Key` wins.

`auth.enabled` defaults to **off** on a self-hosted firewall. With auth off, `/v1/inspect` and every other API-key endpoint run **anonymously** (single implicit tenant) — no key required. When auth is turned on, `auth.allow_anonymous_inspect` (default `true`) keeps unauthenticated calls working unless you disable it; an unrecognized or malformed key always gets `401`.

Dashboard-scoped endpoints (keys, per-key/tenant config, metrics, the Clerk webhook) authenticate differently — see the [Dashboard-scoped endpoints](#dashboard-scoped-clerk-jwt-endpoints) table.

## Conventions

- Every response carries `x-request-id` (echoes an inbound `X-Request-Id` or mints one) and `x-processing-time-ms`.
- Request bodies over **256 KB** (`Content-Length` based) are rejected before parsing with `413`:

  ```json
  { "error": "request body too large", "limit_bytes": 262144 }
  ```

- All request/response bodies are JSON unless noted.

## Core inspection

API-key auth. This is the primary integration surface for calling code that controls its own request lifecycle — see [Direct API](/docs/guides/direct-api) for the full lifecycle walkthrough.

### `POST /v1/inspect`

Inspect one piece of text for PII and prompt injection / drift, and get back a pseudonymized version plus a verdict.

**Request** (`InspectRequest`):

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `input_text` | string | yes | Minimum 1 character. |
| `system_goal` | string | no | The LLM's intended goal/system prompt, used as the semantic-drift baseline. |
| `request_id` | string | no | Caller-supplied correlation id. |
| `session_id` | string | no | Correlates turns of one conversation for cross-turn injection-pressure tracking. `None` = stateless, the default. |
| `metadata` | `dict[string,string]` | no | Freeform, stored with the metrics event. |

```bash
curl -s -X POST https://your-firewall.example.com/v1/inspect \
  -H "X-SFW-Key: sfw_live_xxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "input_text": "Email john.smith@acme.com about the invoice.",
    "system_goal": "You are a customer support agent."
  }'
```

**Response** (`Verdict`):

| Field | Type | Notes |
| --- | --- | --- |
| `action` | `"allow"` \| `"flag"` \| `"block"` | The recommended decision. |
| `confidence` | number, 0–1 | Agreement across detection channels, not severity — a clean verdict is `1.0`. |
| `issues` | array of `Issue` | Empty when clean. |
| `sanitized_input` | string \| `null` | PII-pseudonymized text; `null` if no PII was found. |
| `pii_mapping` | `dict[string,string]` \| `null` | `{pseudonym: original}`. Keep this client-side to restore real values later. |
| `processing_time_ms` | number | Server-side processing time. |
| `dry_run` | boolean | `true` when the firewall is in monitor mode — `action` is still the real would-be decision, but nothing was enforced. |

Each `Issue`:

| Field | Type | Notes |
| --- | --- | --- |
| `type` | `"pii"` \| `"injection"` \| `"drift"` | |
| `severity` | `"low"` \| `"medium"` \| `"high"` \| `"critical"` | May be escalated one rank when ≥2 channels corroborate. |
| `detail` | string | Human-readable reason. |
| `span` | `{start, end}` \| omitted | Character offsets, raw view only. |
| `view` | string \| omitted | The normalized view the issue was found in, e.g. `base64-decoded`, `unicode-fold`. Omitted for the raw input. |
| `channel` | string \| omitted | The detector that fired: `heuristic`, `classifier`, `llm_judge`, `semantic_drift`, `pii`, `session_pressure`, `coercion`. |

```json
{
  "action": "flag",
  "confidence": 0.75,
  "issues": [
    {
      "type": "pii",
      "severity": "low",
      "detail": "EMAIL_ADDRESS detected",
      "span": { "start": 6, "end": 27 },
      "channel": "pii"
    }
  ],
  "sanitized_input": "Email alex.morgan@example.com about the invoice.",
  "pii_mapping": { "alex.morgan@example.com": "john.smith@acme.com" },
  "processing_time_ms": 42.1,
  "dry_run": false
}
```

Pseudonyms are **context-preserving fakes** — realistic names, `example.com` emails, `555`-prefixed phone numbers — not bracket placeholders, so the model reasons about them correctly instead of hallucinating.

### `POST /v1/inspect/response`

Inspect an LLM's **output** for signs an injection succeeded (persona shift, prompt/credential leak, safety-bypass confirmation). Same `Verdict` shape as above.

**Request:**

| Field | Type | Required |
| --- | --- | --- |
| `response_text` | string | yes |
| `system_goal` | string | no |

```bash
curl -s -X POST https://your-firewall.example.com/v1/inspect/response \
  -H "X-SFW-Key: sfw_live_xxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{"response_text": "<the model reply to check>", "system_goal": "You are a customer support agent."}'
```

Returns **501** with `{"detail": "response anomaly engine not enabled on this firewall"}` if the response engine is disabled on this deployment.

### `POST /v1/inspect/async`

Dispatch an inspection and get the verdict via webhook instead of in the response — for callers that don't want to block on the pipeline.

**Request:**

| Field | Type | Required |
| --- | --- | --- |
| `input_text` | string | yes |
| `system_goal` | string | no |
| `request_id` | string | no — used as the task id if provided |
| `metadata` | `dict[string,string]` | no |
| `webhook_url` | string | yes |

**Response:** `{"task_id": "…", "status": "processing"}`

Returns **501** with `{"detail": "Async dispatch not configured"}` if async dispatch isn't wired up on this deployment.

## PII round-trip

Public — no auth required on these three routes.

### `POST /v1/deanonymize`

Restore original values in text that carries pseudonyms from a prior `pii_mapping`.

**Request:** `{ "text": "…", "pii_mapping": { "pseudonym": "original", ... } }`
**Response:** `{ "text": "…with real values restored…" }`

```bash
curl -s -X POST https://your-firewall.example.com/v1/deanonymize \
  -H "Content-Type: application/json" \
  -d '{"text": "Email alex.morgan@example.com", "pii_mapping": {"alex.morgan@example.com": "john.smith@acme.com"}}'
```

### Session-scoped mapping store — `/v1/mappings`

Bridges the stateless PreToolUse/PostToolUse hook lifecycle: one hook stores the mapping produced by inspection, the other retrieves it to de-anonymize tool output. **In-memory, 1-hour TTL** — not durable across a restart, and not a substitute for holding the mapping yourself when you can.

| Endpoint | Request | Response |
| --- | --- | --- |
| `POST /v1/mappings` | `{ "session_id", "pii_mapping", "merge": true }` — `merge` (default `true`) merges into any existing mapping for the session; `false` replaces it. | `{ "session_id", "mapping_count" }` |
| `GET /v1/mappings/{session_id}` | — | `{ "session_id", "pii_mapping" }`, or **404** if no mapping exists for that session. |
| `DELETE /v1/mappings/{session_id}` | — | `{ "deleted": true\|false }` |

## Agent Security tool registry

API-key auth. Lets an integrating app declare its own tools' security profiles under **its own key** — separate from the dashboard's tenant-admin `/v1/keys/{id}/config`, which can edit any key. All three endpoints operate on the caller's authenticated key only; there is no way to target another key's registry from these routes. See [Detect a compromised agent](/docs/guides/detect-compromised-agent#agent-security-the-action-gate) for how the registry feeds the action gate.

| Endpoint | Notes |
| --- | --- |
| `GET /v1/tools` | Returns `{ "tools": {...}, "default": {...} \| null }` — the caller key's raw override, not the tenant-merged effective view. |
| `POST /v1/tools` | Body: `{ "tools": { "<name>": { "output_trust": "trusted"\|"untrusted", "action_risk": "low"\|"high" } }, "default": {...}, "replace": false }`. Merges per-tool by default; `replace: true` swaps the whole set. `default` is applied only when the key is present in the body (even as `null`, to clear it). **400** if there is nothing to register (no non-empty tool profile and no `default`). Response echoes the registry plus `count`. |
| `DELETE /v1/tools` | Clears the key's tool registry (tools + default), preserving its other config overrides. **204** on success. |

```bash
curl -s -X POST https://your-firewall.example.com/v1/tools \
  -H "X-SFW-Key: sfw_live_xxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "tools": {
      "web_search": { "output_trust": "untrusted", "action_risk": "low" },
      "send_email": { "output_trust": "trusted",   "action_risk": "high" }
    },
    "default": { "output_trust": "untrusted", "action_risk": "low" }
  }'
```

Both **503** (`"config store not configured"` — the runtime-config store isn't wired on this deployment) and **403** (`"per-key registration requires an authenticated API key"` — the caller is anonymous) are possible; 403 is a client auth condition, not a server outage.

## Transparent proxy

`ANY /proxy/openai/{path}` and `ANY /proxy/anthropic/{path}` — a transparent reverse proxy in front of the OpenAI and Anthropic APIs. Point your SDK's `base_url` at it; request and response bodies are otherwise the provider's native shape. See [Transparent proxy](/docs/guides/transparent-proxy) for a full walkthrough.

### Two-keys model

| Header | Carries | Behavior |
| --- | --- | --- |
| `X-SFW-Key` | Your firewall key | Authenticates you to the firewall; stripped before the request is forwarded upstream. |
| `Authorization` (OpenAI) / `x-api-key` (Anthropic) | Your provider key | Forwarded to the provider untouched; the firewall never stores it. |

### Optional headers

| Header | Purpose |
| --- | --- |
| `X-SFW-Session-Id` | Stable conversation id for multi-turn injection-pressure tracking and the agent action gate. ≤ 200 chars, no `\|` or control characters; anything else is silently ignored (never an error). |
| `X-Upstream-Url` | Override the upstream base URL — route through an OpenAI-compatible gateway (OpenRouter, Requesty, a Bedrock gateway, …). |
| `X-Provider` | Select a named upstream from `proxy.providers` in server config, as an alternative to `X-Upstream-Url`. |
| `X-SFW-Trace-Id` / `X-Correlation-Id` / `X-Request-Id` | Supply your own correlation id (sanitized, ≤ 200 chars); otherwise one is minted. Reflected back as `X-SFW-Trace-Id`. |

### Response headers

| Header | When |
| --- | --- |
| `X-SFW-Trace-Id` | Always. |
| `X-SFW-Dry-Run: true` | The firewall is in monitor mode — nothing below was enforced. |
| `X-SFW-Response-Action` | The output verdict (or action-gate verdict) was non-`allow` but not enforced as a block (e.g. `flag`). |

### Blocks

An **input** block or a non-streaming **output**/action-gate block comes back as a `403` with a provider-shaped error body plus a `firewall` extension object: `{ "issues": [...], "direction": "input"|"output", "confidence": ..., "trace_id": "..." }`.

| Direction | Provider | `error.type` | `error.code` |
| --- | --- | --- | --- |
| Input | OpenAI | `firewall_block` | `content_policy_violation` |
| Input | Anthropic | `request_blocked` | — |
| Output / action gate | OpenAI | `firewall_response_block` | `output_anomaly` |
| Output / action gate | Anthropic | `response_blocked` | — |

```json
{
  "error": {
    "message": "Request blocked by semantic firewall",
    "type": "firewall_block",
    "code": "content_policy_violation"
  },
  "firewall": {
    "issues": [ { "type": "injection", "severity": "high", "detail": "..." } ],
    "direction": "input",
    "confidence": 1.0,
    "trace_id": "a1b2c3d4"
  }
}
```

Other proxy-specific error codes:

| Status | `error.code` | When |
| --- | --- | --- |
| `415` | `multimodal_not_inspected` | `proxy.reject_multimodal` is on and the request carries non-text content blocks (images, audio, …) the firewall can't inspect. |
| `400` | `firewall_streaming_not_permitted` | The request streams (`stream: true`) while Agent Security's action gate is active and `agent_security.reject_streaming` is on — the gate can't inspect a streamed tool call, so the deployment fails closed instead of silently skipping the gate. |

Streaming responses cannot be retroactively blocked (tokens are already delivered); an output or action-gate finding on a stream is recorded and surfaced via `X-SFW-Response-Action` only, never as a `403`.

## Utility / public

| Endpoint | Auth | Response |
| --- | --- | --- |
| `GET /v1/health` | none | `{"status": "healthy"}` |
| `GET /v1/config` | none | Snapshot of enabled engines, proxy status, and the action policy — `{"engines": {...}, "proxy": {"enabled": bool}, "action_policy": {...}}`. |
| `GET /install.sh` | none | POSIX shell installer for the `sfw` CLI, `text/x-shellscript`. `curl -fsSL https://your-firewall.example.com/install.sh \| sh`. |
| `GET /cli/manifest.json` | none | `{"server_version", "server_url", "install", "artifacts": [...]}`. |
| `GET /cli/{artifact}` | none | Downloads a CLI binary. `artifact` must match `sfw-(darwin\|linux\|windows)-(amd64\|arm64)[.exe]`; **404** on any other name, **503** if this deployment doesn't serve binaries. |

## Dashboard-scoped (Clerk JWT) endpoints

These are called by the dashboard UI with a Clerk session token (`Authorization: Bearer <clerk-jwt>`), not documented here for direct machine use. Roles are `owner` / `admin` / `member`, scoped per tenant.

| Path | Purpose |
| --- | --- |
| `POST /v1/keys`, `GET /v1/keys`, `GET /v1/keys/{id}`, `DELETE /v1/keys/{id}` | API key CRUD. Reading/deleting another user's key requires `admin`. |
| `GET`/`PUT`/`DELETE /v1/keys/{id}/config`, `GET /v1/keys/{id}/config/history`, `GET /v1/keys/{id}/export`, `POST /v1/keys/{id}/playground` | Per-key runtime config: view, edit, clear, audit history, export, and a live test-inspect against the key's effective config. |
| `GET`/`PUT /v1/tenant/config`, `GET /v1/tenant/config/history` | Tenant-wide runtime config (the fallback every key inherits from). `PUT` requires `admin`. |
| `GET /v1/metrics/summary`, `/events`, `/timeseries`, `/events/{id}`, `/top-issues` | Dashboard activity, charts, and event detail. |
| `POST /v1/webhooks/clerk` | Clerk user/org lifecycle webhook, Svix-signature verified — not a dashboard-session call. |

## PII entities detected

The default entity set (`engines.pii.entities`):

`PERSON`, `EMAIL_ADDRESS`, `PHONE_NUMBER`, `US_SSN`, `CREDIT_CARD`, `LOCATION`, `IP_ADDRESS`, `IBAN_CODE`, `URL`, `ORGANIZATION`

Configurable per key or tenant via the config override's `pii.entities`.
