Download OpenAPI specification:
Reduced-surface v1 proposal — ~20 operations, best practices intact.
Status: alternative design proposal. Parallels the maximalist v1
in PR #124 (https://github.com/SonnyLabs/sonnylabs/pull/124, on its
own branch and not merged into main) — same principles,
substantially smaller surface. Explicitly designed so every response
shape here is forward-compatible with the maximalist: future drafts
add fields and endpoints, never remove them.
Non-negotiables kept:
Resource-oriented with opaque ULIDs.
Single hot-path endpoint POST /v1/scans, discriminated on
kind: content | toolset.
RFC 9457 application/problem+json errors with stable
dot-namespaced codes.
Idempotency-Key on every POST.
Cursor pagination on every list.
Per-endpoint scope declarations — every operation publishes
its required scope via an x-required-scopes vendor extension
(applies the fix from #125 from day one). The bearerAuth
security arrays are kept empty because bearerAuth is an HTTP
bearer scheme, not OAuth2 / OpenID Connect — per OAS, scope
lists on non-OAuth2 schemes are either disallowed (3.0) or only
"documentational role names" (3.1), which many popular
generators drop. x-required-scopes carries the machine-
readable requirement uniformly and is echoed in the
corresponding 403 problem+json detail field at runtime.
x-required-scopes: [] (empty array) is a deliberate
authorization choice, not an omission. It means "any
authenticated principal may call this; no scope check is
performed." It applies to identity/discovery endpoints
(getMe, listDetectors) where every authenticated key
legitimately needs the answer to make subsequent scoped
requests. Operations missing the extension entirely should
fail spec lint — making the unscoped intent explicit closes
the ambiguity Codex flagged on listDetectors.
HMAC-signed, retried, persisted outbound webhooks.
Date-versioned within /v1 via Sonny-Api-Version.
Rate-limit headers (RFC-draft RateLimit-* + Retry-After).
X-Request-Id echoed on every response.
Deferred from the maximalist draft:
project resource (tenancy is org + API key only).Agent + versioned tool_inventory
resources) — scan context still accepts a free-form agent_id.policy revisions and agent-scoped policies. Policy
simulation has been pulled forward into this spec
(POST /v1/policies/{policy_id}:simulate) per the
observe→tune→enforce workflow customers asked for; revisions
and agent scoping remain deferred.Job resource.surfaces / decisions / detections analytics
rollups — GET /v1/audit-events covers the need for early
customers; dashboards aggregate client-side.compliance tag./v1/internal/* HTTP admin surface.Each deferral can land additively without wire breaks.
Security posture baked in from day one (applies fixes #125–#130):
security scopes on every operation.pointer content type intentionally omitted — reduces SSRF
surface to zero for the hot path. Content is in-request only
(text, image_base64). Maximalist can add pointer later with
an explicit allowlist.https:// only, private address space rejected
(RFC1918 / link-local / cloud metadata IPs — enforced by server,
documented here).secret, webhook signing secret) are returned
exactly once at creation. Subsequent GETs never echo them.AuditEvent shape covers both detection and
management-plane events via kind — "who revoked this API key"
is queryable from day one.org_id and
is filtered via Postgres row-level security. Credentials never
cross tenants.Strict-Transport-Security,
Content-Security-Policy, X-Content-Type-Options,
Referrer-Policy, Permissions-Policy) are set by the gateway
per configurable allowlist. Default allowlist: none (machine API).
Dashboard origins are configured per deployment.Out of scope for minimal v1 (tracked in issues, not modeled here):
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",
- "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" "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_member| 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"
}, - "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"
}, - "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 |
| 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"
}, - "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"
}, - "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"
}
}| 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"
}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"
}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