Download OpenAPI specification:
Real-time AI firewall — inspect prompts and responses for prompt injection, PII, toxicity, and policy violations.
The Sonny Labs API is a content firewall for LLM applications. POST a user prompt before it reaches your model and the API returns a decision your handler can act on. The same surface scans model outputs for observability, though we recommend gating responses on the inbound decision only — see the quickstart for the canonical integration shape.
POST /v1/scans, discriminated on
kind: content | toolset.application/problem+json. Every
problem carries a stable, dot-namespaced code for programmatic
handling.Idempotency-Key is accepted (and recommended) on every
POST. Retries with the same key are deduplicated server-side.RateLimit-*) on every
response; Retry-After on 429.X-Request-Id is echoed on every response — include it when
contacting support.Every request needs a bearer credential — either a scoped API key
(sk_live_... / sk_test_...) minted from the dashboard, or an
SSO session JWT for dashboard-driven flows.
Each operation declares the scope it requires via the
x-required-scopes extension shown in the operation details
below. A credential lacking the scope is rejected with a 403
problem+json whose detail echoes the missing scope.
x-required-scopes: [] means "any authenticated principal may
call this; no scope check is performed" — used on identity and
discovery endpoints (getMe, listDetectors) where every
authenticated caller legitimately needs the answer.
The /v1 namespace is stable. Within v1, pin to a dated revision
of the contract by sending Sonny-Api-Version: 2026-05-01 (or any
later supported date). Omit the header to track the latest stable
revision.
The same API runs in the Sonny Labs SaaS at
https://api.sonnylabs.ai and self-hosted in your own VPC via the
Helm chart. Behavior is identical across both — only the base URL
changes.
text and
image_base64; we do not fetch external URLs on your behalf.https:// only; private address ranges
(RFC 1918, link-local, cloud metadata IPs) are refused at
registration time.secret, webhook signing secret) are
returned exactly once at creation. Subsequent GETs never echo
them — rotate or recreate if lost.org_id and the API strictly isolates credentials across
organizations.Identity endpoint. Every authenticated principal can call this
regardless of scope — x-required-scopes: [] declares the
unscoped intent explicitly so no SDK or audit tool treats the
absence as an oversight.
| Sonny-Api-Version | string^\d{4}-\d{2}-\d{2}$ Pin a date version (e.g. |
{- "type": "api_key",
- "org_id": "string",
- "org_display_name": "string",
- "actor_id": "string",
- "scopes": [
- "scans:read"
], - "deployment_mode": "saas"
}| cursor | string Opaque cursor from a previous page. |
| limit | integer [ 1 .. 200 ] Default: 50 |
| Sonny-Api-Version | string^\d{4}-\d{2}-\d{2}$ Pin a date version (e.g. |
{- "data": [
- {
- "id": "string",
- "name": "string",
- "prefix": "sk_live",
- "last_four": "stri",
- "scopes": [
- "scans:read"
], - "environment": "test",
- "last_used_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z",
- "expires_at": "2019-08-24T14:15:22Z",
- "revoked_at": "2019-08-24T14:15:22Z"
}
], - "next_cursor": "string"
}Plaintext secret is returned exactly once on this response.
It cannot be retrieved later — rotate the key if lost.
| Sonny-Api-Version | string^\d{4}-\d{2}-\d{2}$ Pin a date version (e.g. |
| Idempotency-Key | string <= 255 characters Client-generated key. Server stores key + body-hash for 24h and
replays the original response on reuse. Mismatched body with the
same key returns 409 |
| name required | string [ 1 .. 120 ] characters |
| scopes required | Array of strings (ApiKeyScope) non-empty Items Enum: "scans:read" "scans:write" "policies:read" "policies:write" "webhooks:read" "webhooks:write" "api_keys:read" "api_keys:write" "audit:read" "analytics:read" "labeled_events:read" "labeled_events:write" "labeled_events:delete" "redteam:read" "redteam:write" "mcp:invoke" "admin" |
| environment | string Default: "live" Enum: "test" "live" |
| expires_at | string or null <date-time> Optional UTC timestamp at which the key should stop authenticating. SOC2-aligned defaults:
|
| no_expiry | boolean Default: false Explicit opt-out: when |
{- "name": "string",
- "scopes": [
- "scans:read"
], - "environment": "test",
- "expires_at": "2019-08-24T14:15:22Z",
- "no_expiry": false
}{- "id": "string",
- "name": "string",
- "prefix": "sk_live",
- "last_four": "stri",
- "scopes": [
- "scans:read"
], - "environment": "test",
- "last_used_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z",
- "expires_at": "2019-08-24T14:15:22Z",
- "revoked_at": "2019-08-24T14:15:22Z",
- "secret": "string"
}| api_key_id required | string^ak_[0-9A-HJKMNP-TV-Z]{26}$ |
| Sonny-Api-Version | string^\d{4}-\d{2}-\d{2}$ Pin a date version (e.g. |
{- "title": "API key expired",
- "status": 401,
- "code": "auth.api_key.expired",
- "detail": "API key has expired.",
- "instance": "/v1/scans"
}SOC2-aligned rotation: a fresh plaintext secret is minted, the old
key is revoked immediately (no grace period), and the new row
carries rotated_from = <old key id> so lineage queries can join
keys to their predecessor. The new key inherits the old key's
name, scopes, and expires_at — expires_at is not
silently extended; rotate again or create a new key if a longer
lifetime is required. The plaintext is returned exactly once;
subsequent reads only surface the prefix + last_four (the same
contract as POST /v1/api-keys). Returns 404 when the supplied id
is unknown to the caller's org and 409 when the key has already
been revoked (rotation is not idempotent — a revoked key is
terminal).
| api_key_id required | string^ak_[0-9A-HJKMNP-TV-Z]{26}$ |
| Sonny-Api-Version | string^\d{4}-\d{2}-\d{2}$ Pin a date version (e.g. |
| Idempotency-Key | string <= 255 characters Client-generated key. Server stores key + body-hash for 24h and
replays the original response on reuse. Mismatched body with the
same key returns 409 |
{- "id": "string",
- "name": "string",
- "prefix": "sk_live",
- "last_four": "stri",
- "scopes": [
- "scans:read"
], - "environment": "test",
- "last_used_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z",
- "expires_at": "2019-08-24T14:15:22Z",
- "revoked_at": "2019-08-24T14:15:22Z",
- "secret": "string"
}Returns invites that are still actionable: not accepted, not
revoked, not expired. The raw token is never returned — it is
delivered exactly once in the accept_url of the original
POST /v1/invites response.
| cursor | string Opaque cursor from a previous page. |
| limit | integer [ 1 .. 200 ] Default: 50 |
| Sonny-Api-Version | string^\d{4}-\d{2}-\d{2}$ Pin a date version (e.g. |
{- "data": [
- {
- "id": "string",
- "email": "string",
- "role": "admin",
- "expires_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z"
}
], - "next_cursor": "string"
}Creates a pending invite and returns a one-time accept_url that
the inviter shares with the invitee out-of-band (Slack, iMessage,
the inviter's own email client). The plaintext token embedded in
the URL is generated server-side and stored only as a SHA-256
hash, so the URL cannot be retrieved later via
GET /v1/invites — surface it to the user the moment this call
returns. Only the invited email address can ultimately accept
the invite (enforced at POST /v1/invites/{token}:accept).
Optional email side-channel: when the deployment sets
INVITES_SEND_EMAIL=true and a MailSender is configured, the
same accept URL is also dispatched in an email to the invitee.
The wire response is identical either way — the email is purely
a delivery convenience.
| Sonny-Api-Version | string^\d{4}-\d{2}-\d{2}$ Pin a date version (e.g. |
| Idempotency-Key | string <= 255 characters Client-generated key. Server stores key + body-hash for 24h and
replays the original response on reuse. Mismatched body with the
same key returns 409 |
| email required | string <email> <= 320 characters |
| role required | string (InviteRole) Enum: "admin" "member" Role granted to the invitee on accept. |
{- "email": "user@example.com",
- "role": "admin"
}{- "invite": {
- "id": "string",
- "email": "string",
- "role": "admin",
- "expires_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z"
},
}Idempotent: returns 204 whether the invite was pending and is now revoked, or was already revoked / accepted on a prior call. A 404 is returned only when the id is unknown to the caller's org.
| invite_id required | string^inv_[0-9A-HJKMNP-TV-Z]{26}$ |
| Sonny-Api-Version | string^\d{4}-\d{2}-\d{2}$ Pin a date version (e.g. |
{- "title": "API key expired",
- "status": 401,
- "code": "auth.api_key.expired",
- "detail": "API key has expired.",
- "instance": "/v1/scans"
}Any authenticated principal can call this — x-required-scopes: []
declares the unscoped intent so a brand-new user with zero
provisioned scopes can complete onboarding. The {token} path
parameter is the plaintext value the inviter received from
POST /v1/invites and shared with the invitee; the server looks
it up by SHA-256 hash. Behaviour:
404 invites.not_found410 invites.expired403 invites.email_mismatch. Forwarded
URLs are only usable by the intended recipient.email claim) →
403 invites.principal_email_unavailable409 invites.already_member502 invites.upstream_failure
and no local DB row is written so a retry is safe.| token required | string^[A-Za-z0-9_-]{43}$ Plaintext invite token from the invitation email. 32 random
bytes encoded as URL-safe base64 without padding (43 chars).
The server stores only |
| Sonny-Api-Version | string^\d{4}-\d{2}-\d{2}$ Pin a date version (e.g. |
{- "user": {
- "id": "string",
- "email": "string",
- "role": "string",
- "created_at": "2019-08-24T14:15:22Z"
}, - "organization": {
- "id": "string",
- "slug": "string",
- "display_name": "string",
- "created_at": "2019-08-24T14:15:22Z"
}, - "role": "admin"
}Org-scoped membership directory used by the dashboard's
Settings/Members surface. Tenant isolation is enforced by V1
RLS on auth.users (the calling principal's app.current_org_id
GUC pins the visible row set); the query is also predicate-scoped
by org_id as defence in depth. Cursors are opaque base64-JSON
of (created_at, user_id) so pagination is stable under
concurrent inserts. A tampered cursor returns 400 with
code=users.invalid_cursor. Sort order is created_at desc, id desc.
| cursor | string Opaque cursor from a previous page. |
| limit | integer [ 1 .. 200 ] Default: 50 |
| Sonny-Api-Version | string^\d{4}-\d{2}-\d{2}$ Pin a date version (e.g. |
{- "data": [
- {
- "id": "string",
- "email": "string",
- "role": "string",
- "created_at": "2019-08-24T14:15:22Z"
}
], - "next_cursor": "string"
}Single hot-path endpoint. Supports SSE streaming via
Accept: text/event-stream for long content — findings stream
as they complete, terminal decision event closes the stream.
| Sonny-Api-Version | string^\d{4}-\d{2}-\d{2}$ Pin a date version (e.g. |
| Idempotency-Key | string <= 255 characters Client-generated key. Server stores key + body-hash for 24h and
replays the original response on reuse. Mismatched body with the
same key returns 409 |
| kind required | string |
| surface required | string (Surface) Enum: "user_message" "assistant_output" "tool_result" "tool_params" "document" "agent_message" "mcp_resource" "mcp_tool_description" |
required | object or object or object (ContentContainer) |
object (ScanContext) | |
object (ScanOptions) |
{- "kind": "content",
- "surface": "user_message",
- "content": {
- "type": "text",
- "text": "string"
}, - "context": {
- "agent_id": "string",
- "session_id": "string",
- "mcp_server": "string",
- "trace_id": "string",
- "end_user_id": "string"
}, - "options": {
- "capture": false,
- "detectors": [
- "string"
], - "tier": "fast",
- "policy_id": "string"
}
}{- "id": "string",
- "created": "2019-08-24T14:15:22Z",
- "kind": "content",
- "surface": "user_message",
- "context": {
- "agent_id": "string",
- "session_id": "string",
- "mcp_server": "string",
- "trace_id": "string",
- "end_user_id": "string",
- "end_user_id_hash": "stringstringstringstringstringstringstringstringstringstringstri"
}, - "findings": [
- {
- "detector": "pii",
- "items": [
- {
- "text": "string",
- "label": "string",
- "confidence": 1,
- "source": "regex",
- "span": [
- 0,
- 0
]
}
], - "frameworks": {
- "owasp_agentic": [
- "string"
], - "owasp_llm": [
- "string"
], - "mitre_atlas": [
- "string"
]
}
}
], - "decision": {
- "action": "blocked",
- "reason": "score_threshold",
- "policy_id": "string",
- "policy_revision_id": "string",
- "matched_rule_id": "string",
- "triggering_finding": "string"
}, - "content_stored": true,
- "content": {
- "type": "text",
- "text": "string"
}
}| agent_id | string <= 255 characters |
| session_id | string <= 255 characters |
| end_user_id | string <= 255 characters Filter to scans for one end user. The server SHA-256s the
raw identifier and matches it against the persisted hash —
the raw value never lands in a query log or the database.
Mutually compatible with |
| end_user_id_hash | string = 64 characters ^[0-9a-f]{64}$ Filter by the SHA-256 hex of an end-user identifier. Use
this form when the caller only has the hash (e.g. dashboard
drill-down from a scan-detail row, which never received the
raw value). For SDK callers that hold the raw identifier,
|
| action | string (Action) Enum: "blocked" "flagged" "warned" "allowed" |
| surface | string (Surface) Enum: "user_message" "assistant_output" "tool_result" "tool_params" "document" "agent_message" "mcp_resource" "mcp_tool_description" |
| since | string <date-time> |
| until | string <date-time> |
| cursor | string Opaque cursor from a previous page. |
| limit | integer [ 1 .. 200 ] Default: 50 |
| Sonny-Api-Version | string^\d{4}-\d{2}-\d{2}$ Pin a date version (e.g. |
{- "data": [
- {
- "id": "string",
- "created": "2019-08-24T14:15:22Z",
- "kind": "content",
- "surface": "user_message",
- "context": {
- "agent_id": "string",
- "session_id": "string",
- "mcp_server": "string",
- "trace_id": "string",
- "end_user_id": "string",
- "end_user_id_hash": "stringstringstringstringstringstringstringstringstringstringstri"
}, - "findings": [
- {
- "detector": "pii",
- "items": [
- {
- "text": "string",
- "label": "string",
- "confidence": 1,
- "source": "regex",
- "span": [
- 0,
- 0
]
}
], - "frameworks": {
- "owasp_agentic": [
- "string"
], - "owasp_llm": [
- "string"
], - "mitre_atlas": [
- "string"
]
}
}
], - "decision": {
- "action": "blocked",
- "reason": "score_threshold",
- "policy_id": "string",
- "policy_revision_id": "string",
- "matched_rule_id": "string",
- "triggering_finding": "string"
}, - "content_stored": true,
- "content": {
- "type": "text",
- "text": "string"
}
}
], - "next_cursor": "string"
}Raw content is present in the response only if options.capture
was true at scan time. Subject to the configured retention
window (default 30 days).
| scan_id required | string^(scan_)?[0-9A-HJKMNP-TV-Z]{26}$ |
| Sonny-Api-Version | string^\d{4}-\d{2}-\d{2}$ Pin a date version (e.g. |
{- "id": "string",
- "created": "2019-08-24T14:15:22Z",
- "kind": "content",
- "surface": "user_message",
- "context": {
- "agent_id": "string",
- "session_id": "string",
- "mcp_server": "string",
- "trace_id": "string",
- "end_user_id": "string",
- "end_user_id_hash": "stringstringstringstringstringstringstringstringstringstringstri"
}, - "findings": [
- {
- "detector": "pii",
- "items": [
- {
- "text": "string",
- "label": "string",
- "confidence": 1,
- "source": "regex",
- "span": [
- 0,
- 0
]
}
], - "frameworks": {
- "owasp_agentic": [
- "string"
], - "owasp_llm": [
- "string"
], - "mitre_atlas": [
- "string"
]
}
}
], - "decision": {
- "action": "blocked",
- "reason": "score_threshold",
- "policy_id": "string",
- "policy_revision_id": "string",
- "matched_rule_id": "string",
- "triggering_finding": "string"
}, - "content_stored": true,
- "content": {
- "type": "text",
- "text": "string"
}
}| cursor | string Opaque cursor from a previous page. |
| limit | integer [ 1 .. 200 ] Default: 50 |
| Sonny-Api-Version | string^\d{4}-\d{2}-\d{2}$ Pin a date version (e.g. |
{- "data": [
- {
- "id": "string",
- "revision_id": "string",
- "name": "string",
- "mode": "enforce",
- "rules": [
- {
- "id": "string",
- "name": "string",
- "when": {
- "all_of": [
- null
]
}, - "then": {
- "action": "blocked",
- "notify": [
- "string"
]
}
}
], - "default_action": "blocked",
- "detector_config": {
- "property1": {
- "enabled": true,
- "block_above": 1,
- "flag_above": 1,
- "warn_above": 1
}, - "property2": {
- "enabled": true,
- "block_above": 1,
- "flag_above": 1,
- "warn_above": 1
}
}, - "allowed_mcp_servers": [
- "string"
], - "blocked_mcp_servers": [
- "string"
], - "allowed_mcp_tools": [
- {
- "server": "string",
- "tool_name": "string"
}
], - "blocked_mcp_tools": [
- {
- "server": "string",
- "tool_name": "string"
}
], - "enabled": true,
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}
], - "next_cursor": "string"
}| Sonny-Api-Version | string^\d{4}-\d{2}-\d{2}$ Pin a date version (e.g. |
| Idempotency-Key | string <= 255 characters Client-generated key. Server stores key + body-hash for 24h and
replays the original response on reuse. Mismatched body with the
same key returns 409 |
| name required | string <= 120 characters |
| mode | string Default: "enforce" Enum: "enforce" "observe"
|
required | Array of objects (PolicyRule) <= 64 items |
| default_action required | string (Action) Enum: "blocked" "flagged" "warned" "allowed" |
object | |
| allowed_mcp_servers | Array of strings <= 200 items [ items <= 255 characters ] |
| blocked_mcp_servers | Array of strings <= 200 items [ items <= 255 characters ] |
Array of objects (McpToolRef) <= 500 items | |
Array of objects (McpToolRef) <= 500 items |
{- "name": "string",
- "mode": "enforce",
- "rules": [
- {
- "id": "string",
- "name": "string",
- "when": {
- "all_of": [
- null
]
}, - "then": {
- "action": "blocked",
- "notify": [
- "string"
]
}
}
], - "default_action": "blocked",
- "detector_config": {
- "property1": {
- "enabled": true,
- "block_above": 1,
- "flag_above": 1,
- "warn_above": 1
}, - "property2": {
- "enabled": true,
- "block_above": 1,
- "flag_above": 1,
- "warn_above": 1
}
}, - "allowed_mcp_servers": [
- "string"
], - "blocked_mcp_servers": [
- "string"
], - "allowed_mcp_tools": [
- {
- "server": "string",
- "tool_name": "string"
}
], - "blocked_mcp_tools": [
- {
- "server": "string",
- "tool_name": "string"
}
]
}{- "id": "string",
- "revision_id": "string",
- "name": "string",
- "mode": "enforce",
- "rules": [
- {
- "id": "string",
- "name": "string",
- "when": {
- "all_of": [
- null
]
}, - "then": {
- "action": "blocked",
- "notify": [
- "string"
]
}
}
], - "default_action": "blocked",
- "detector_config": {
- "property1": {
- "enabled": true,
- "block_above": 1,
- "flag_above": 1,
- "warn_above": 1
}, - "property2": {
- "enabled": true,
- "block_above": 1,
- "flag_above": 1,
- "warn_above": 1
}
}, - "allowed_mcp_servers": [
- "string"
], - "blocked_mcp_servers": [
- "string"
], - "allowed_mcp_tools": [
- {
- "server": "string",
- "tool_name": "string"
}
], - "blocked_mcp_tools": [
- {
- "server": "string",
- "tool_name": "string"
}
], - "enabled": true,
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}| policy_id required | string^pol_[0-9A-HJKMNP-TV-Z]{26}$ |
| Sonny-Api-Version | string^\d{4}-\d{2}-\d{2}$ Pin a date version (e.g. |
{- "id": "string",
- "revision_id": "string",
- "name": "string",
- "mode": "enforce",
- "rules": [
- {
- "id": "string",
- "name": "string",
- "when": {
- "all_of": [
- null
]
}, - "then": {
- "action": "blocked",
- "notify": [
- "string"
]
}
}
], - "default_action": "blocked",
- "detector_config": {
- "property1": {
- "enabled": true,
- "block_above": 1,
- "flag_above": 1,
- "warn_above": 1
}, - "property2": {
- "enabled": true,
- "block_above": 1,
- "flag_above": 1,
- "warn_above": 1
}
}, - "allowed_mcp_servers": [
- "string"
], - "blocked_mcp_servers": [
- "string"
], - "allowed_mcp_tools": [
- {
- "server": "string",
- "tool_name": "string"
}
], - "blocked_mcp_tools": [
- {
- "server": "string",
- "tool_name": "string"
}
], - "enabled": true,
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}Full replace, not patch. Minimal version intentionally has no
revision history — mutations are destructive. AuditEvent of
kind: management records the change.
| policy_id required | string^pol_[0-9A-HJKMNP-TV-Z]{26}$ |
| Sonny-Api-Version | string^\d{4}-\d{2}-\d{2}$ Pin a date version (e.g. |
| Idempotency-Key | string <= 255 characters Client-generated key. Server stores key + body-hash for 24h and
replays the original response on reuse. Mismatched body with the
same key returns 409 |
| name required | string <= 120 characters |
| mode | string Default: "enforce" Enum: "enforce" "observe"
|
required | Array of objects (PolicyRule) <= 64 items |
| default_action required | string (Action) Enum: "blocked" "flagged" "warned" "allowed" |
object | |
| allowed_mcp_servers | Array of strings <= 200 items [ items <= 255 characters ] |
| blocked_mcp_servers | Array of strings <= 200 items [ items <= 255 characters ] |
Array of objects (McpToolRef) <= 500 items | |
Array of objects (McpToolRef) <= 500 items | |
| enabled | boolean |
{- "name": "string",
- "mode": "enforce",
- "rules": [
- {
- "id": "string",
- "name": "string",
- "when": {
- "all_of": [
- null
]
}, - "then": {
- "action": "blocked",
- "notify": [
- "string"
]
}
}
], - "default_action": "blocked",
- "detector_config": {
- "property1": {
- "enabled": true,
- "block_above": 1,
- "flag_above": 1,
- "warn_above": 1
}, - "property2": {
- "enabled": true,
- "block_above": 1,
- "flag_above": 1,
- "warn_above": 1
}
}, - "allowed_mcp_servers": [
- "string"
], - "blocked_mcp_servers": [
- "string"
], - "allowed_mcp_tools": [
- {
- "server": "string",
- "tool_name": "string"
}
], - "blocked_mcp_tools": [
- {
- "server": "string",
- "tool_name": "string"
}
], - "enabled": true
}{- "id": "string",
- "revision_id": "string",
- "name": "string",
- "mode": "enforce",
- "rules": [
- {
- "id": "string",
- "name": "string",
- "when": {
- "all_of": [
- null
]
}, - "then": {
- "action": "blocked",
- "notify": [
- "string"
]
}
}
], - "default_action": "blocked",
- "detector_config": {
- "property1": {
- "enabled": true,
- "block_above": 1,
- "flag_above": 1,
- "warn_above": 1
}, - "property2": {
- "enabled": true,
- "block_above": 1,
- "flag_above": 1,
- "warn_above": 1
}
}, - "allowed_mcp_servers": [
- "string"
], - "blocked_mcp_servers": [
- "string"
], - "allowed_mcp_tools": [
- {
- "server": "string",
- "tool_name": "string"
}
], - "blocked_mcp_tools": [
- {
- "server": "string",
- "tool_name": "string"
}
], - "enabled": true,
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}| policy_id required | string^pol_[0-9A-HJKMNP-TV-Z]{26}$ |
| Sonny-Api-Version | string^\d{4}-\d{2}-\d{2}$ Pin a date version (e.g. |
{- "title": "API key expired",
- "status": 401,
- "code": "auth.api_key.expired",
- "detail": "API key has expired.",
- "instance": "/v1/scans"
}Answers "what would this policy do to this scan" without persisting anything or changing state. Intended for the observe → tune → enforce workflow: run in observe mode, collect a week of data, simulate candidate policy edits against captured scans before committing.
| policy_id required | string^pol_[0-9A-HJKMNP-TV-Z]{26}$ |
| Sonny-Api-Version | string^\d{4}-\d{2}-\d{2}$ Pin a date version (e.g. |
| Idempotency-Key | string <= 255 characters Client-generated key. Server stores key + body-hash for 24h and
replays the original response on reuse. Mismatched body with the
same key returns 409 |
| scan_id required | string |
{- "surface": "user_message",
- "findings": [
- {
- "detector": "pii",
- "items": [
- {
- "text": "string",
- "label": "string",
- "confidence": 1,
- "source": "regex",
- "span": [
- 0,
- 0
]
}
], - "frameworks": {
- "owasp_agentic": [
- "string"
], - "owasp_llm": [
- "string"
], - "mitre_atlas": [
- "string"
]
}
}
]
}{- "decision": {
- "action": "blocked",
- "reason": "score_threshold",
- "policy_id": "string",
- "policy_revision_id": "string",
- "matched_rule_id": "string",
- "triggering_finding": "string"
}, - "matched_rule": {
- "rule_id": "string",
- "rule_name": "string"
}, - "diff_from_current": {
- "previous_action": "blocked",
- "previous_revision_id": "string",
- "new_action": "blocked"
}
}HMAC-SHA256 signature verification for outbound event deliveries. The verification helpers ship in the Python and TypeScript SDKs today; outbound delivery is rolling out in a future release.
| cursor | string Opaque cursor from a previous page. |
| limit | integer [ 1 .. 200 ] Default: 50 |
| Sonny-Api-Version | string^\d{4}-\d{2}-\d{2}$ Pin a date version (e.g. |
{- "data": [
- {
- "id": "string",
- "description": "string",
- "events": [
- "scan.blocked"
], - "include_content": false,
- "active": true,
- "last_failure_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z"
}
], - "next_cursor": "string"
}Signing secret is returned exactly once on this response.
Rotate via :rotate-secret — losing it requires rotation. URL
must be https://; RFC1918 / link-local / cloud-metadata
addresses are rejected server-side.
| Sonny-Api-Version | string^\d{4}-\d{2}-\d{2}$ Pin a date version (e.g. |
| Idempotency-Key | string <= 255 characters Client-generated key. Server stores key + body-hash for 24h and
replays the original response on reuse. Mismatched body with the
same key returns 409 |
| url required | string <uri> ^https:// Must be |
| description | string <= 500 characters |
| events required | Array of strings (WebhookEventType) non-empty Items Enum: "scan.blocked" "scan.flagged" "scan.warned" "scan.allowed" "webhook.failing" |
| include_content | boolean Default: false |
{- "description": "string",
- "events": [
- "scan.blocked"
], - "include_content": false
}{- "id": "string",
- "description": "string",
- "events": [
- "scan.blocked"
], - "include_content": false,
- "active": true,
- "last_failure_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z",
- "secret": "string"
}| webhook_id required | string^wh_[0-9A-HJKMNP-TV-Z]{26}$ |
| Sonny-Api-Version | string^\d{4}-\d{2}-\d{2}$ Pin a date version (e.g. |
{- "id": "string",
- "description": "string",
- "events": [
- "scan.blocked"
], - "include_content": false,
- "active": true,
- "last_failure_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z"
}| webhook_id required | string^wh_[0-9A-HJKMNP-TV-Z]{26}$ |
| Sonny-Api-Version | string^\d{4}-\d{2}-\d{2}$ Pin a date version (e.g. |
{- "title": "API key expired",
- "status": 401,
- "code": "auth.api_key.expired",
- "detail": "API key has expired.",
- "instance": "/v1/scans"
}Old secret remains valid for grace_seconds (default 24h).
New plaintext secret is returned once.
| webhook_id required | string^wh_[0-9A-HJKMNP-TV-Z]{26}$ |
| Sonny-Api-Version | string^\d{4}-\d{2}-\d{2}$ Pin a date version (e.g. |
| Idempotency-Key | string <= 255 characters Client-generated key. Server stores key + body-hash for 24h and
replays the original response on reuse. Mismatched body with the
same key returns 409 |
| grace_seconds | integer [ 0 .. 604800 ] Default: 86400 |
{- "grace_seconds": 86400
}{- "webhook_id": "string",
- "secret": "string",
- "grace_expires_at": "2019-08-24T14:15:22Z"
}Enterprise procurement (Indicium, KPMG, Experian) requires SIEM-integration debuggability — "why didn't this event reach my receiver?" Answerable without a support ticket.
| webhook_id required | string^wh_[0-9A-HJKMNP-TV-Z]{26}$ |
| status | string (WebhookDeliveryStatus) Enum: "pending" "succeeded" "failed" "dead_lettered" |
| since | string <date-time> |
| cursor | string Opaque cursor from a previous page. |
| limit | integer [ 1 .. 200 ] Default: 50 |
| Sonny-Api-Version | string^\d{4}-\d{2}-\d{2}$ Pin a date version (e.g. |
{- "data": [
- {
- "id": "string",
- "webhook_id": "string",
- "event_id": "string",
- "event_type": "scan.blocked",
- "status": "pending",
- "attempt": 1,
- "next_attempt_at": "2019-08-24T14:15:22Z",
- "response": {
- "status_code": 0,
- "duration_ms": 0,
- "body_preview": "string"
}, - "error": "string",
- "created_at": "2019-08-24T14:15:22Z",
- "delivered_at": "2019-08-24T14:15:22Z"
}
], - "next_cursor": "string"
}| delivery_id required | string^whd_[0-9A-HJKMNP-TV-Z]{26}$ |
| Sonny-Api-Version | string^\d{4}-\d{2}-\d{2}$ Pin a date version (e.g. |
| Idempotency-Key | string <= 255 characters Client-generated key. Server stores key + body-hash for 24h and
replays the original response on reuse. Mismatched body with the
same key returns 409 |
{- "id": "string",
- "webhook_id": "string",
- "event_id": "string",
- "event_type": "scan.blocked",
- "status": "pending",
- "attempt": 1,
- "next_attempt_at": "2019-08-24T14:15:22Z",
- "response": {
- "status_code": 0,
- "duration_ms": 0,
- "body_preview": "string"
}, - "error": "string",
- "created_at": "2019-08-24T14:15:22Z",
- "delivered_at": "2019-08-24T14:15:22Z"
}Set kind=detection to see traffic scans, kind=management to
see administrative mutations (API key revocations, policy
edits, webhook changes). Response supports Accept: text/csv
up to 10,000 rows per page — async export is deferred.
| kind | string (AuditEventKind) Enum: "detection" "management" |
| actor_id | string <= 255 characters |
| agent_id | string <= 255 characters |
| surface | string (Surface) Enum: "user_message" "assistant_output" "tool_result" "tool_params" "document" "agent_message" "mcp_resource" "mcp_tool_description" |
| action | string (Action) Enum: "blocked" "flagged" "warned" "allowed" |
| detector | string |
| resource_type | string Enum: "api_key" "policy" "webhook" |
| resource_id | string |
| since | string <date-time> |
| until | string <date-time> |
| cursor | string Opaque cursor from a previous page. |
| limit | integer [ 1 .. 10000 ] Default: 50 Page size. Default 50. Cap is raised to 10,000 on this
endpoint (vs. the global 200) so |
| Sonny-Api-Version | string^\d{4}-\d{2}-\d{2}$ Pin a date version (e.g. |
{- "data": [
- {
- "id": "string",
- "kind": "detection",
- "created": "2019-08-24T14:15:22Z",
- "actor_id": "string",
- "request_id": "string",
- "scan_id": "string",
- "agent_id": "string",
- "session_id": "string",
- "surface": "user_message",
- "detector": "string",
- "score": 0,
- "action": "blocked",
- "owasp_tags": [
- "string"
], - "mitre_tags": [
- "string"
], - "resource_type": "api_key",
- "resource_id": "string",
- "change_summary": "string"
}
], - "next_cursor": "string"
}Replaces v0's /v1/framework-mappings. Framework mappings are
embedded per detector. Today's bundles ship a small, bounded
set (tens of detectors), but the endpoint is cursor-paginated
so custom / per-org detector catalogs can land later without
a wire break.
Discovery endpoint. Every authenticated principal can list the
catalog regardless of scope — provisioning a detectors:read
scope just to enumerate available detectors creates noise
without a meaningful privilege boundary (the catalog is not
sensitive). x-required-scopes: [] declares the unscoped
intent explicitly.
| cursor | string Opaque cursor from a previous page. |
| limit | integer [ 1 .. 200 ] Default: 50 |
| Sonny-Api-Version | string^\d{4}-\d{2}-\d{2}$ Pin a date version (e.g. |
{- "data": [
- {
- "name": "string",
- "version": "string",
- "model": "string",
- "kind": "score",
- "enabled": true,
- "frameworks": {
- "owasp_agentic": [
- "string"
], - "owasp_llm": [
- "string"
], - "mitre_atlas": [
- "string"
]
}, - "surfaces_supported": [
- "user_message"
], - "thresholds": {
- "block_above": 0.1,
- "flag_above": 0.1,
- "warn_above": 0.1
}
}
], - "next_cursor": "string"
}Returns the total scan count and per-action breakdown
(allowed / flagged / warned / blocked) for the
requested window, plus a delta vs. the immediately-preceding
window of equal length (e.g. last 7 days vs. the 7 days before
that). Used by the analytics KPI tiles. Counts are org-scoped
via RLS.
| since required | string <date-time> Inclusive start of the window (RFC3339). |
| until required | string <date-time> Exclusive end of the window (RFC3339). |
| Sonny-Api-Version | string^\d{4}-\d{2}-\d{2}$ Pin a date version (e.g. |
{- "window": {
- "since": "2019-08-24T14:15:22Z",
- "until": "2019-08-24T14:15:22Z"
}, - "total": 0,
- "allowed": 0,
- "flagged": 0,
- "warned": 0,
- "blocked": 0,
- "prior_period_delta": {
- "total_pct": 0.1,
- "blocked_pct": 0.1
}
}Returns a contiguous time-series of action counts bucketed at
the requested granularity. Buckets are aligned to the bucket
boundary in UTC (e.g. bucket=1h aligns to top-of-hour) and
zero-count buckets are emitted so the chart has no gaps. The
backend caps the number of returned points to a fixed maximum
per bucket size (validated server-side); requests that would
exceed it are rejected with 400 analytics.window_too_wide.
| since required | string <date-time> |
| until required | string <date-time> |
| bucket required | string (AnalyticsBucket) Enum: "15m" "1h" "1d" Aggregation bucket width for time-series analytics endpoints.
The server caps the maximum window length per bucket value
(smaller buckets ⇒ tighter window cap) so any response stays
bounded — requests outside the cap are rejected with
|
| Sonny-Api-Version | string^\d{4}-\d{2}-\d{2}$ Pin a date version (e.g. |
{- "points": [
- {
- "t": "2019-08-24T14:15:22Z",
- "allowed": 0,
- "flagged": 0,
- "warned": 0,
- "blocked": 0
}
]
}Returns total scan counts grouped by the Surface enum for the
requested window. Surfaces with zero traffic in the window are
omitted from items (callers render "no data" client-side
rather than zero bars).
| since required | string <date-time> |
| until required | string <date-time> |
| Sonny-Api-Version | string^\d{4}-\d{2}-\d{2}$ Pin a date version (e.g. |
{- "items": [
- {
- "surface": "user_message",
- "count": 0
}
]
}Returns the top-N detectors by trigger count over the requested
window, ordered by count desc. Ties break on detector name
(ascending) for stable rendering. Each row carries the detector
name and its category for grouping in the UI.
| since required | string <date-time> |
| until required | string <date-time> |
| limit | integer [ 1 .. 50 ] Default: 10 Maximum rows to return. Default 10, server-capped at 50. |
| Sonny-Api-Version | string^\d{4}-\d{2}-\d{2}$ Pin a date version (e.g. |
{- "items": [
- {
- "detector": "string",
- "category": "string",
- "count": 0
}
]
}Returns p50 / p95 / p99 hot-path latency in milliseconds per
bucket. Sourced from the same aggregate rollups that back the
operator Grafana dashboards; the SaaS dashboard surfaces them
to operators so they can correlate detector latency with the
scan-volume time-series above. Buckets align to UTC
boundaries. Buckets with zero traffic are OMITTED (no row in
points) — unlike the scan time-series, where zero-count
buckets are emitted as 0. Callers tolerate the gaps when
rendering.
| since required | string <date-time> |
| until required | string <date-time> |
| bucket required | string (AnalyticsBucket) Enum: "15m" "1h" "1d" Aggregation bucket width for time-series analytics endpoints.
The server caps the maximum window length per bucket value
(smaller buckets ⇒ tighter window cap) so any response stays
bounded — requests outside the cap are rejected with
|
| Sonny-Api-Version | string^\d{4}-\d{2}-\d{2}$ Pin a date version (e.g. |
{- "points": [
- {
- "t": "2019-08-24T14:15:22Z",
- "p50_ms": 0,
- "p95_ms": 0,
- "p99_ms": 0
}
]
}Self-serve org creation for a brand-new WorkOS-authenticated user
whose JWT carries no org_id claim — i.e. they have signed in
but their WorkOS user is not yet a member of any Organization.
Without this endpoint such a user is locked out: every other
/v1/* operation requires a tenant-scoped principal, and the
only way to obtain one is to already belong to an org.
Flow on success (SaaS):
display_name.owner.org_id claim.GET /v1/me — triggers JIT
provisioning of the local auth.organizations +
auth.users rows in the AuthenticationFilter before the
controller runs. There is NO ordering dependency on
/v1/me; SDKs can call POST /v1/scans (or any other
scoped endpoint) immediately after re-auth and it Just
Works. (Pre-fix the local rows were provisioned only on
/v1/me, which 75% of new orgs in a 2-day window
happened to hit a events.inspection_events_org_id_fkey
violation against — see the OrgRowProvisioner contract
in the auth module for the corrected handoff.)Self-hosted deployments do NOT support this endpoint — org
provisioning is admin-driven through the customer's IdP. The
endpoint returns 501 onboarding.unsupported_in_self_hosted
when app.deployment-mode=self_hosted.
Authorisation: x-required-scopes: [] (any authenticated
caller). Restricting the endpoint to admin-scoped callers would
chicken-and-egg the bootstrap case it exists to solve.
Idempotency: WorkOS Organization names are NOT unique, so an
unintended retry would silently create a duplicate org. The
backend treats (external_subject, request_body) within a 24h
window as the dedup key when an Idempotency-Key is supplied;
without one, callers MUST avoid retrying on transport errors.
| Sonny-Api-Version | string^\d{4}-\d{2}-\d{2}$ Pin a date version (e.g. |
| Idempotency-Key | string <= 255 characters Client-generated key. Server stores key + body-hash for 24h and
replays the original response on reuse. Mismatched body with the
same key returns 409 |
| display_name required | string [ 1 .. 160 ] characters |
{- "display_name": "string"
}{- "id": "string",
- "slug": "string",
- "display_name": "string",
- "created_at": "2019-08-24T14:15:22Z"
}Permanently deletes the calling user's organization, all data scoped to it (scans, events, policies, API keys, invites, audit events, etc.), and the corresponding WorkOS Organization. Used by the dashboard's Settings → Danger Zone "Disband organization" flow when the operator wants to wipe a personal org and start over with a fresh one.
Authorisation is intentionally narrower than the standard
x-required-scopes machinery can express: the caller must be the
org's owner (auth.users.role = 'owner') AND the org's sole
member. Both conditions are enforced service-side after scope
admission. Any other shape — non-owner caller, another member
present, pending invite outstanding — returns 409 Conflict with a
problem code naming the specific guard that tripped, so the
dashboard can surface actionable guidance instead of a generic
"forbidden". The sole-member constraint exists because v1 has no
member-transfer or pre-disband handoff UX; relaxing it would let
an owner orphan their teammates.
Self-hosted deployments do NOT support this endpoint — org
teardown there is administrator-driven through the customer's IdP
and infra teardown plan. Returns 501 organizations.delete.unsupported_in_self_hosted when
app.deployment-mode=self_hosted.
After a successful delete the caller's WorkOS session still holds
the (now-gone) org_id claim. The next authenticated request
falls into the orgless first-login flow and the dashboard prompts
them to create a fresh org via POST /v1/organizations (the
usual self-serve onboarding surface). Forcing a re-auth on the
client side is optional but recommended — the stale claim does
not authorise anything.
Idempotency: the operation deletes the calling user's row last,
so a retry that arrives before the WorkOS session has refreshed
will 401 (auth.unauthenticated) on the auth filter, not 404 on
this endpoint. The caller should treat the first 204 as
authoritative and stop retrying.
| Sonny-Api-Version | string^\d{4}-\d{2}-\d{2}$ Pin a date version (e.g. |
{- "title": "API key expired",
- "status": 401,
- "code": "auth.api_key.expired",
- "detail": "API key has expired.",
- "instance": "/v1/scans"
}Labeled events that feed model training and evaluation — a unified shape for red-team findings, sampled production traffic, customer feedback, and reviewer-curated examples. Sensitive content is redacted before storage and gated on consent.
Append a batch of up to 100 labeled events. The server applies the
org's redaction policy (/v1/org/redaction-policy) to every
event's input, output, and comparator payload BEFORE
insertion; the wire payload carries raw values and the stored row
records the active redaction_version. Idempotency-Key replay
semantics are the same as every other state-changing POST in v1.
Used by every source (redteam, production_sample, feedback,
reviewed); the source discriminator on each event determines
the source-specific extension fields the server expects to find
in source_data.
| Sonny-Api-Version | string^\d{4}-\d{2}-\d{2}$ Pin a date version (e.g. |
| Idempotency-Key | string <= 255 characters Client-generated key. Server stores key + body-hash for 24h and
replays the original response on reuse. Mismatched body with the
same key returns 409 |
required | Array of objects (LabeledEventInput) [ 1 .. 100 ] items |
{- "events": [
- {
- "source": "redteam",
- "observed_at": "2019-08-24T14:15:22Z",
- "input": "string",
- "output": "string",
- "verdict": "allowed",
- "verdict_confidence": 1,
- "verdict_reason": "string",
- "severity": "critical",
- "framework_refs": [
- {
- "framework": "owasp-llm-top-10",
- "id": "LLM01"
}
], - "comparator": {
- "input_findings": [
- {
- "detector": "pii",
- "items": [
- {
- "text": "string",
- "label": "string",
- "confidence": 1,
- "source": "regex",
- "span": [
- 0,
- 0
]
}
], - "frameworks": {
- "owasp_agentic": [
- "string"
], - "owasp_llm": [
- "string"
], - "mitre_atlas": [
- "string"
]
}
}
], - "response_findings": [
- {
- "detector": "pii",
- "items": [
- {
- "text": "string",
- "label": "string",
- "confidence": 1,
- "source": "regex",
- "span": [
- 0,
- 0
]
}
], - "frameworks": {
- "owasp_agentic": [
- "string"
], - "owasp_llm": [
- "string"
], - "mitre_atlas": [
- "string"
]
}
}
], - "would_have_blocked": true
}, - "source_data": {
- "run_id": "string",
- "iteration_index": 0,
- "attack_class": "prompt_injection",
- "attack_subclass": "string",
- "corpus_entry_id": "string",
- "tokens_used": {
- "property1": 0,
- "property2": 0
}
}, - "training_consent": false,
- "evidence_blob_keys": [
- {
- "kind": "string",
- "s3_key": "string"
}
]
}
]
}{- "accepted": 0,
- "event_ids": [
- "string"
]
}Cursor-paginated read of the substrate. Filterable by source,
severity, training_consent, and observation-time bounds.
Sort order is observed_at desc, event_id desc.
| cursor | string Opaque cursor from a previous page. |
| limit | integer [ 1 .. 200 ] Default: 50 |
| source | string (LabeledEventSource) Enum: "redteam" "production_sample" "feedback" "reviewed" Discriminator for the source that wrote the row. Each value pairs
with a |
| severity | string (Severity) Enum: "critical" "high" "medium" "low" "info" |
| training_consent | boolean |
| observed_after | string <date-time> |
| observed_before | string <date-time> |
| Sonny-Api-Version | string^\d{4}-\d{2}-\d{2}$ Pin a date version (e.g. |
{- "data": [
- {
- "event_id": "string",
- "source": "redteam",
- "observed_at": "2019-08-24T14:15:22Z",
- "input_redacted": "string",
- "output_redacted": "string",
- "verdict": "allowed",
- "verdict_confidence": 1,
- "verdict_reason": "string",
- "severity": "critical",
- "framework_refs": [
- {
- "framework": "owasp-llm-top-10",
- "id": "LLM01"
}
], - "comparator": {
- "input_findings": [
- {
- "detector": "pii",
- "items": [
- {
- "text": "string",
- "label": "string",
- "confidence": 1,
- "source": "regex",
- "span": [
- 0,
- 0
]
}
], - "frameworks": {
- "owasp_agentic": [
- "string"
], - "owasp_llm": [
- "string"
], - "mitre_atlas": [
- "string"
]
}
}
], - "response_findings": [
- {
- "detector": "pii",
- "items": [
- {
- "text": "string",
- "label": "string",
- "confidence": 1,
- "source": "regex",
- "span": [
- 0,
- 0
]
}
], - "frameworks": {
- "owasp_agentic": [
- "string"
], - "owasp_llm": [
- "string"
], - "mitre_atlas": [
- "string"
]
}
}
], - "would_have_blocked": true
}, - "source_data": {
- "run_id": "string",
- "iteration_index": 0,
- "attack_class": "prompt_injection",
- "attack_subclass": "string",
- "corpus_entry_id": "string",
- "tokens_used": {
- "property1": 0,
- "property2": 0
}
}, - "training_consent": true,
- "redaction_version": "redactor-v1.0.0",
- "redaction_policy_id": "string",
- "evidence_blob_keys": [
- {
- "kind": "string",
- "s3_key": "string"
}
], - "created_at": "2019-08-24T14:15:22Z"
}
], - "next_cursor": "string"
}Returns the full row, including the source-specific
source_data payload (which list responses omit for size). Use
this to drill into a single event from the dashboard's list
view.
| event_id required | string^lev_[0-9A-HJKMNP-TV-Z]{26}$ Labeled-event identifier. Prefix |
| Sonny-Api-Version | string^\d{4}-\d{2}-\d{2}$ Pin a date version (e.g. |
{- "event_id": "string",
- "source": "redteam",
- "observed_at": "2019-08-24T14:15:22Z",
- "input_redacted": "string",
- "output_redacted": "string",
- "verdict": "allowed",
- "verdict_confidence": 1,
- "verdict_reason": "string",
- "severity": "critical",
- "framework_refs": [
- {
- "framework": "owasp-llm-top-10",
- "id": "LLM01"
}
], - "comparator": {
- "input_findings": [
- {
- "detector": "pii",
- "items": [
- {
- "text": "string",
- "label": "string",
- "confidence": 1,
- "source": "regex",
- "span": [
- 0,
- 0
]
}
], - "frameworks": {
- "owasp_agentic": [
- "string"
], - "owasp_llm": [
- "string"
], - "mitre_atlas": [
- "string"
]
}
}
], - "response_findings": [
- {
- "detector": "pii",
- "items": [
- {
- "text": "string",
- "label": "string",
- "confidence": 1,
- "source": "regex",
- "span": [
- 0,
- 0
]
}
], - "frameworks": {
- "owasp_agentic": [
- "string"
], - "owasp_llm": [
- "string"
], - "mitre_atlas": [
- "string"
]
}
}
], - "would_have_blocked": true
}, - "source_data": {
- "run_id": "string",
- "iteration_index": 0,
- "attack_class": "prompt_injection",
- "attack_subclass": "string",
- "corpus_entry_id": "string",
- "tokens_used": {
- "property1": 0,
- "property2": 0
}
}, - "training_consent": true,
- "redaction_version": "redactor-v1.0.0",
- "redaction_policy_id": "string",
- "evidence_blob_keys": [
- {
- "kind": "string",
- "s3_key": "string"
}
], - "created_at": "2019-08-24T14:15:22Z"
}Hard-deletes the row. Designed for one-off operator removal
(mistakenly-ingested run, customer right-to-delete request).
Bulk delete by date range / source is tracked separately — see
docs/design/redteam-ingest.md §8.
| event_id required | string^lev_[0-9A-HJKMNP-TV-Z]{26}$ Labeled-event identifier. Prefix |
| Sonny-Api-Version | string^\d{4}-\d{2}-\d{2}$ Pin a date version (e.g. |
{- "title": "API key expired",
- "status": 401,
- "code": "auth.api_key.expired",
- "detail": "API key has expired.",
- "instance": "/v1/scans"
}Operator uploads a screenshot / HAR / log bundle via the returned
presigned URL, then references the resulting s3_key in a
subsequent appendLabeledEvents call via
evidence_blob_keys[].s3_key. The upload URL is time-bounded
(default 15 minutes) and pinned to the supplied SHA-256 + size.
| Sonny-Api-Version | string^\d{4}-\d{2}-\d{2}$ Pin a date version (e.g. |
| Idempotency-Key | string <= 255 characters Client-generated key. Server stores key + body-hash for 24h and
replays the original response on reuse. Mismatched body with the
same key returns 409 |
| kind required | string <= 64 characters Operator-visible category for the artefact ( |
| sha256 required | string^[a-f0-9]{64}$ Lowercase hex SHA-256 of the artefact bytes. The server pins the upload URL to this digest so the upload is content- addressable. |
| size_bytes required | integer [ 1 .. 104857600 ] Artefact size in bytes. Hard-cap 100 MB; revisit when a customer hits the limit. |
{- "kind": "string",
- "sha256": "string",
- "size_bytes": 1
}{- "s3_key": "string",
- "expires_at": "2019-08-24T14:15:22Z"
}Redteam-specific helpers — run-level scope (start/finalize) and deduplicated findings — that sit on top of the labeled-events substrate. Operator-driven; not a customer-facing product.
Starts a run record. The redteam CLI calls this once at run start
and uses the returned run_id as the source_data.run_id on
every subsequent per-event appendLabeledEvents call. The run is
in state running until finalizeRedteamRun is called.
| Sonny-Api-Version | string^\d{4}-\d{2}-\d{2}$ Pin a date version (e.g. |
| Idempotency-Key | string <= 255 characters Client-generated key. Server stores key + body-hash for 24h and
replays the original response on reuse. Mismatched body with the
same key returns 409 |
| scope_hash required | string^[a-f0-9]{64}$ |
object | |
| upload_enabled required | boolean |
| training_consent required | boolean Per-run training consent. Propagated onto every event row
written under this |
| written_authorization required | boolean The operator confirmed in writing that the target's owner
authorised the run. The CLI refuses to start without it; the
backend rejects creation with |
| attached_authenticated required | boolean |
| started_at required | string <date-time> |
required | object |
required | object |
| redteam_version required | string |
| operator_id | string or null |
{- "scope_hash": "string",
- "scope_redacted": { },
- "upload_enabled": true,
- "training_consent": true,
- "written_authorization": true,
- "attached_authenticated": true,
- "started_at": "2019-08-24T14:15:22Z",
- "model_versions": {
- "property1": "string",
- "property2": "string"
}, - "corpus_hashes": {
- "property1": "string",
- "property2": "string"
}, - "redteam_version": "string",
- "operator_id": "string"
}{- "run_id": "string",
- "state": "running",
- "scope_hash": "string",
- "scope_redacted": { },
- "upload_enabled": true,
- "training_consent": true,
- "written_authorization": true,
- "attached_authenticated": true,
- "started_at": "2019-08-24T14:15:22Z",
- "finished_at": "2019-08-24T14:15:22Z",
- "verdict": "critical",
- "verdict_reason": "string",
- "model_versions": {
- "property1": "string",
- "property2": "string"
}, - "corpus_hashes": {
- "property1": "string",
- "property2": "string"
}, - "redteam_version": "string",
- "operator_id": "string",
- "created_at": "2019-08-24T14:15:22Z"
}Returns the run record including its lifecycle state, the
pinned model versions and corpus hashes, and (once finalized)
the operator's terminal verdict. Per-event probes live in
events.labeled_events and are queried separately via
listLabeledEvents filtered by source_data.run_id.
| run_id required | string^rtr_[0-9A-HJKMNP-TV-Z]{26}$ Redteam-run identifier. Prefix |
| Sonny-Api-Version | string^\d{4}-\d{2}-\d{2}$ Pin a date version (e.g. |
{- "run_id": "string",
- "state": "running",
- "scope_hash": "string",
- "scope_redacted": { },
- "upload_enabled": true,
- "training_consent": true,
- "written_authorization": true,
- "attached_authenticated": true,
- "started_at": "2019-08-24T14:15:22Z",
- "finished_at": "2019-08-24T14:15:22Z",
- "verdict": "critical",
- "verdict_reason": "string",
- "model_versions": {
- "property1": "string",
- "property2": "string"
}, - "corpus_hashes": {
- "property1": "string",
- "property2": "string"
}, - "redteam_version": "string",
- "operator_id": "string",
- "created_at": "2019-08-24T14:15:22Z"
}Transitions the run to state finished. The verdict is the
operator's overall judgement of the run; the comparator's
per-event verdict lives on each labeled_events row. A finalized
run is terminal — subsequent calls return 409
redteam.run_already_finalized.
| run_id required | string^rtr_[0-9A-HJKMNP-TV-Z]{26}$ Redteam-run identifier. Prefix |
| Sonny-Api-Version | string^\d{4}-\d{2}-\d{2}$ Pin a date version (e.g. |
| Idempotency-Key | string <= 255 characters Client-generated key. Server stores key + body-hash for 24h and
replays the original response on reuse. Mismatched body with the
same key returns 409 |
| finished_at required | string <date-time> |
| verdict required | string (Severity) Enum: "critical" "high" "medium" "low" "info" |
| verdict_reason | string or null <= 4096 characters |
{- "finished_at": "2019-08-24T14:15:22Z",
- "verdict": "critical",
- "verdict_reason": "string"
}{- "run_id": "string",
- "state": "running",
- "scope_hash": "string",
- "scope_redacted": { },
- "upload_enabled": true,
- "training_consent": true,
- "written_authorization": true,
- "attached_authenticated": true,
- "started_at": "2019-08-24T14:15:22Z",
- "finished_at": "2019-08-24T14:15:22Z",
- "verdict": "critical",
- "verdict_reason": "string",
- "model_versions": {
- "property1": "string",
- "property2": "string"
}, - "corpus_hashes": {
- "property1": "string",
- "property2": "string"
}, - "redteam_version": "string",
- "operator_id": "string",
- "created_at": "2019-08-24T14:15:22Z"
}Findings are the operator's rollup across per-iteration probe
events. Each finding cites a representative_event_id pointing
back at the canonical labeled_events row that exhibits the
attack class. Calling this after finalizeRedteamRun returns
409 redteam.run_already_finalized.
| run_id required | string^rtr_[0-9A-HJKMNP-TV-Z]{26}$ Redteam-run identifier. Prefix |
| Sonny-Api-Version | string^\d{4}-\d{2}-\d{2}$ Pin a date version (e.g. |
| Idempotency-Key | string <= 255 characters Client-generated key. Server stores key + body-hash for 24h and
replays the original response on reuse. Mismatched body with the
same key returns 409 |
required | Array of objects (RedteamFindingInput) [ 1 .. 100 ] items |
{- "findings": [
- {
- "attack_class": "string",
- "severity": "critical",
- "title": "string",
- "description": "string",
- "framework_refs": [
- {
- "framework": "owasp-llm-top-10",
- "id": "LLM01"
}
], - "representative_event_id": "string",
- "remediation_class": "string"
}
]
}{- "accepted": 0,
- "finding_ids": [
- "string"
]
}Org-level configuration surfaces (redaction policy, training- consent defaults, ...). Admin-scoped writes; reads are unscoped so any authenticated principal in the org can see the active policy that applies to their data.
Every authenticated principal in the org can read the active
policy — knowing what redaction applies to your own data is not
a privileged disclosure. x-required-scopes: [] declares the
unscoped intent explicitly.
| Sonny-Api-Version | string^\d{4}-\d{2}-\d{2}$ Pin a date version (e.g. |
{- "policy_id": "string",
- "field_rules": {
- "property1": "redact",
- "property2": "redact"
}, - "default_obfuscation": "redact",
- "override_allowed_for_reviewers": true,
- "redaction_version": "redactor-v1.0.0",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}Replaces the active policy. The new row records its own
redaction_version so previously-captured labeled_events rows
retain provenance to the policy they were redacted under. Future
re-redact passes (when a stricter pipeline ships) can re-redact
only rows captured under earlier versions.
| Sonny-Api-Version | string^\d{4}-\d{2}-\d{2}$ Pin a date version (e.g. |
| Idempotency-Key | string <= 255 characters Client-generated key. Server stores key + body-hash for 24h and
replays the original response on reuse. Mismatched body with the
same key returns 409 |
required | object |
| default_obfuscation required | string (RedactionObfuscation) Enum: "redact" "hash" "pass_through" How the redaction pipeline treats a field. |
| override_allowed_for_reviewers required | boolean |
| redaction_version required | string (RedactionVersion) ^redactor-v\d+\.\d+\.\d+$ Version identifier of the redaction policy that was applied to
the row at insert time. Pattern |
{- "field_rules": {
- "property1": "redact",
- "property2": "redact"
}, - "default_obfuscation": "redact",
- "override_allowed_for_reviewers": true,
- "redaction_version": "redactor-v1.0.0"
}{- "policy_id": "string",
- "field_rules": {
- "property1": "redact",
- "property2": "redact"
}, - "default_obfuscation": "redact",
- "override_allowed_for_reviewers": true,
- "redaction_version": "redactor-v1.0.0",
- "created_at": "2019-08-24T14:15:22Z",
- "updated_at": "2019-08-24T14:15:22Z"
}Pre-v1 liveness path, kept as a real endpoint for any caller
still pointing at /health. Functionally identical to
/healthz. New integrations should use /healthz (k8s-style
naming) — this path is documented here so the spec reflects
every real endpoint the server serves, not deprecated.
{- "status": "ok"
}Signed HMAC-SHA256: Sonny-Signature: t=<unix>,v1=<hex> over
{t}.{body}. Replay window: 5 minutes. Delivery schedule: one
initial attempt at scan time, then up to 6 retries at 30s, 2m,
10m, 1h, 6h, 24h after the initial (7 attempts total). Failure
on attempt 7 dead-letters the delivery.
include_content is off by default — events carry a compact
scan summary without raw content. Customers fetch full scans via
GET /v1/scans/{id} if they need more.
Authentication: receivers authenticate deliveries by
verifying Sonny-Signature — SonnyLabs does not present a
Bearer token. Explicitly cleared with security: [] so
generated receivers don't demand one.
| Sonny-Signature required | string Example: t=1712345678,v1=abc123... |
| Sonny-Event-Id required | string |
| Sonny-Api-Version required | string Example: 2026-06-01 |
| type required | string (WebhookEventType) Enum: "scan.blocked" "scan.flagged" "scan.warned" "scan.allowed" "webhook.failing" |
| id required | string |
| created required | string <date-time> |
| api_version required | string^\d{4}-\d{2}-\d{2}$ |
| data required | object Shape depends on |
{- "type": "scan.blocked",
- "id": "string",
- "created": "2019-08-24T14:15:22Z",
- "api_version": "string",
- "data": { }
}null