SonnyLabs API v1 (minimal proposal) (1.0.0-minimal-draft.1)

Download OpenAPI specification:

URL: https://sonnylabs.ai License: LicenseRef-Proprietary

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:

  • Durable project resource (tenancy is org + API key only).
  • Multi-step agent model (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.
  • Batch scans, async audit exports, and the Job resource.
  • Separate surfaces / decisions / detections analytics rollups — GET /v1/audit-events covers the need for early customers; dashboards aggregate client-side.
  • Watermark apply/verify and the compliance tag.
  • Scan outcome reporting endpoint.
  • Bundle reload over HTTP — self-hosted operators use an admin CLI.
  • /v1/internal/* HTTP admin surface.

Each deferral can land additively without wire breaks.

Security posture baked in from day one (applies fixes #125–#130):

  • Per-endpoint 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.
  • Webhook URLs: https:// only, private address space rejected (RFC1918 / link-local / cloud metadata IPs — enforced by server, documented here).
  • Secrets (API key secret, webhook signing secret) are returned exactly once at creation. Subsequent GETs never echo them.
  • Payload-size limits documented on the schemas themselves, not only enforced at the edge.
  • Unified AuditEvent shape covers both detection and management-plane events via kind — "who revoked this API key" is queryable from day one.
  • Tenant isolation: every tenant-scoped row carries org_id and is filtered via Postgres row-level security. Credentials never cross tenants.
  • CORS and security headers (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):

  • mTLS for self-hosted, step-up auth for destructive admin ops, OIDC JWTs from customer IdPs. API keys + WorkOS-issued JWTs cover initial launch; other auth paths land additively.
  • Billing / usage metering, SSO configuration, BYOK residency.

auth

Caller identity.

Return the resolved principal

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.

Authorizations:
bearerAuth
header Parameters
Sonny-Api-Version
string^\d{4}-\d{2}-\d{2}$

Pin a date version (e.g. 2026-06-01). Unpinned clients get latest stable. Retirement: 6-month deprecation via Sunset + Deprecation response headers.

Responses

Response samples

Content type
application/json
{
  • "type": "api_key",
  • "org_id": "string",
  • "actor_id": "string",
  • "scopes": [
    ],
  • "deployment_mode": "saas"
}

api-keys

Machine credentials.

List API keys in the caller's org

Authorizations:
bearerAuth
query Parameters
cursor
string

Opaque cursor from a previous page.

limit
integer [ 1 .. 200 ]
Default: 50
header Parameters
Sonny-Api-Version
string^\d{4}-\d{2}-\d{2}$

Pin a date version (e.g. 2026-06-01). Unpinned clients get latest stable. Retirement: 6-month deprecation via Sunset + Deprecation response headers.

Responses

Response samples

Content type
application/json
{
  • "data": [
    ],
  • "next_cursor": "string"
}

Create a scoped API key

Plaintext secret is returned exactly once on this response. It cannot be retrieved later — rotate the key if lost.

Authorizations:
bearerAuth
header Parameters
Sonny-Api-Version
string^\d{4}-\d{2}-\d{2}$

Pin a date version (e.g. 2026-06-01). Unpinned clients get latest stable. Retirement: 6-month deprecation via Sunset + Deprecation response headers.

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 idempotency.key_reuse_mismatch.

Request Body schema: application/json
required
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:

  • Omitted or null (and no_expiry is false/absent) → server defaults to now() + 1 year.
  • Provided → must be strictly in the future. Values more than 2 years from now() are clamped down to now() + 2 years. A value in the past is rejected with 400 validation.error.
  • Mutually exclusive with no_expiry: true — sending both is a 400 validation.error.
no_expiry
boolean
Default: false

Explicit opt-out: when true, the key is created with no expiry (the persisted auth.api_keys.expires_at is NULL) and the auth-time enforcer will never reject it on age. This is a deliberate SOC2-discouraged escape hatch for legacy / break-glass integrations; the response body's expires_at comes back as null so dashboards can flag the row in their audit views. Mutually exclusive with expires_at.

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "scopes": [
    ],
  • "environment": "test",
  • "expires_at": "2019-08-24T14:15:22Z",
  • "no_expiry": false
}

Response samples

Content type
application/json
{
  • "id": "string",
  • "name": "string",
  • "prefix": "sk_live",
  • "last_four": "stri",
  • "scopes": [
    ],
  • "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"
}

Revoke an API key

Authorizations:
bearerAuth
path Parameters
api_key_id
required
string^ak_[0-9A-HJKMNP-TV-Z]{26}$
header Parameters
Sonny-Api-Version
string^\d{4}-\d{2}-\d{2}$

Pin a date version (e.g. 2026-06-01). Unpinned clients get latest stable. Retirement: 6-month deprecation via Sunset + Deprecation response headers.

Responses

Response samples

Content type
application/problem+json
{}

Rotate an API key (immediate revoke of the old key)

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_atexpires_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).

Authorizations:
bearerAuth
path Parameters
api_key_id
required
string^ak_[0-9A-HJKMNP-TV-Z]{26}$
header Parameters
Sonny-Api-Version
string^\d{4}-\d{2}-\d{2}$

Pin a date version (e.g. 2026-06-01). Unpinned clients get latest stable. Retirement: 6-month deprecation via Sunset + Deprecation response headers.

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 idempotency.key_reuse_mismatch.

Responses

Response samples

Content type
application/json
{
  • "id": "string",
  • "name": "string",
  • "prefix": "sk_live",
  • "last_four": "stri",
  • "scopes": [
    ],
  • "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"
}

invites

Org membership invites.

List pending invites in the caller's org

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.

Authorizations:
bearerAuth
query Parameters
cursor
string

Opaque cursor from a previous page.

limit
integer [ 1 .. 200 ]
Default: 50
header Parameters
Sonny-Api-Version
string^\d{4}-\d{2}-\d{2}$

Pin a date version (e.g. 2026-06-01). Unpinned clients get latest stable. Retirement: 6-month deprecation via Sunset + Deprecation response headers.

Responses

Response samples

Content type
application/json
{
  • "data": [
    ],
  • "next_cursor": "string"
}

Invite a user to the caller's org

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.

Authorizations:
bearerAuth
header Parameters
Sonny-Api-Version
string^\d{4}-\d{2}-\d{2}$

Pin a date version (e.g. 2026-06-01). Unpinned clients get latest stable. Retirement: 6-month deprecation via Sunset + Deprecation response headers.

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 idempotency.key_reuse_mismatch.

Request Body schema: application/json
required
email
required
string <email> <= 320 characters
role
required
string (InviteRole)
Enum: "admin" "member"

Role granted to the invitee on accept.

Responses

Request samples

Content type
application/json
{
  • "email": "user@example.com",
  • "role": "admin"
}

Response samples

Content type
application/json
{
  • "invite": {
    },
  • "accept_url": "http://example.com"
}

Revoke a pending invite

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.

Authorizations:
bearerAuth
path Parameters
invite_id
required
string^inv_[0-9A-HJKMNP-TV-Z]{26}$
header Parameters
Sonny-Api-Version
string^\d{4}-\d{2}-\d{2}$

Pin a date version (e.g. 2026-06-01). Unpinned clients get latest stable. Retirement: 6-month deprecation via Sunset + Deprecation response headers.

Responses

Response samples

Content type
application/problem+json
{}

Accept an invite using its one-time token

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:

  • Token unknown / revoked → 404 invites.not_found
  • Token expired → 410 invites.expired
  • Caller's verified email does not match the invited email (case-insensitive) → 403 invites.email_mismatch. Forwarded URLs are only usable by the intended recipient.
  • Caller's principal carries no verifiable email (e.g. an API-key bearer or a JWT without an email claim) → 403 invites.principal_email_unavailable
  • Caller's external_subject already a member of the invite's org → idempotent return of the existing membership
  • Caller's external_subject already a member of a DIFFERENT org → 409 invites.already_member
  • Otherwise → user record is created in the invite's org and the invite is marked accepted in a single transaction
Authorizations:
bearerAuth
path Parameters
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 sha256(token) and never returns the plaintext from any other endpoint.

header Parameters
Sonny-Api-Version
string^\d{4}-\d{2}-\d{2}$

Pin a date version (e.g. 2026-06-01). Unpinned clients get latest stable. Retirement: 6-month deprecation via Sunset + Deprecation response headers.

Responses

Response samples

Content type
application/json
{
  • "user": {
    },
  • "organization": {
    },
  • "role": "admin"
}

users

Org membership directory.

List users in the caller's org

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.

Authorizations:
bearerAuth
query Parameters
cursor
string

Opaque cursor from a previous page.

limit
integer [ 1 .. 200 ]
Default: 50
header Parameters
Sonny-Api-Version
string^\d{4}-\d{2}-\d{2}$

Pin a date version (e.g. 2026-06-01). Unpinned clients get latest stable. Retirement: 6-month deprecation via Sunset + Deprecation response headers.

Responses

Response samples

Content type
application/json
{
  • "data": [
    ],
  • "next_cursor": "string"
}

scans

Hot-path detection. One endpoint, discriminated by kind.

Run a scan (content or toolset)

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.

Authorizations:
bearerAuth
header Parameters
Sonny-Api-Version
string^\d{4}-\d{2}-\d{2}$

Pin a date version (e.g. 2026-06-01). Unpinned clients get latest stable. Retirement: 6-month deprecation via Sunset + Deprecation response headers.

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 idempotency.key_reuse_mismatch.

Request Body schema: application/json
required
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)

Responses

Request samples

Content type
application/json
Example
{
  • "kind": "content",
  • "surface": "user_message",
  • "content": {
    },
  • "context": {
    },
  • "options": {
    }
}

Response samples

Content type
{
  • "id": "string",
  • "created": "2019-08-24T14:15:22Z",
  • "kind": "content",
  • "surface": "user_message",
  • "context": {
    },
  • "findings": [
    ],
  • "decision": {
    },
  • "content_stored": true,
  • "content": {
    }
}

List scans with filters

Authorizations:
bearerAuth
query Parameters
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
header Parameters
Sonny-Api-Version
string^\d{4}-\d{2}-\d{2}$

Pin a date version (e.g. 2026-06-01). Unpinned clients get latest stable. Retirement: 6-month deprecation via Sunset + Deprecation response headers.

Responses

Response samples

Content type
application/json
{
  • "data": [
    ],
  • "next_cursor": "string"
}

Retrieve a scan

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).

Authorizations:
bearerAuth
path Parameters
scan_id
required
string^scan_[0-9A-HJKMNP-TV-Z]{26}$
header Parameters
Sonny-Api-Version
string^\d{4}-\d{2}-\d{2}$

Pin a date version (e.g. 2026-06-01). Unpinned clients get latest stable. Retirement: 6-month deprecation via Sunset + Deprecation response headers.

Responses

Response samples

Content type
application/json
{
  • "id": "string",
  • "created": "2019-08-24T14:15:22Z",
  • "kind": "content",
  • "surface": "user_message",
  • "context": {
    },
  • "findings": [
    ],
  • "decision": {
    },
  • "content_stored": true,
  • "content": {
    }
}

policies

Rule sets applied to scans.

List policies

Authorizations:
bearerAuth
query Parameters
cursor
string

Opaque cursor from a previous page.

limit
integer [ 1 .. 200 ]
Default: 50
header Parameters
Sonny-Api-Version
string^\d{4}-\d{2}-\d{2}$

Pin a date version (e.g. 2026-06-01). Unpinned clients get latest stable. Retirement: 6-month deprecation via Sunset + Deprecation response headers.

Responses

Response samples

Content type
application/json
{
  • "data": [
    ],
  • "next_cursor": "string"
}

Create a policy

Authorizations:
bearerAuth
header Parameters
Sonny-Api-Version
string^\d{4}-\d{2}-\d{2}$

Pin a date version (e.g. 2026-06-01). Unpinned clients get latest stable. Retirement: 6-month deprecation via Sunset + Deprecation response headers.

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 idempotency.key_reuse_mismatch.

Request Body schema: application/json
required
name
required
string <= 120 characters
mode
string
Default: "enforce"
Enum: "enforce" "observe"

enforce: decision.action must be honored by clients. observe: detectors run and webhooks fire as normal, but the action is advisory — recommended for pilot rollouts where customers want to see what would happen before committing.

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

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "mode": "enforce",
  • "rules": [
    ],
  • "default_action": "blocked",
  • "detector_config": {
    },
  • "allowed_mcp_servers": [
    ],
  • "blocked_mcp_servers": [
    ],
  • "allowed_mcp_tools": [
    ],
  • "blocked_mcp_tools": [
    ]
}

Response samples

Content type
application/json
{
  • "id": "string",
  • "revision_id": "string",
  • "name": "string",
  • "mode": "enforce",
  • "rules": [
    ],
  • "default_action": "blocked",
  • "detector_config": {
    },
  • "allowed_mcp_servers": [
    ],
  • "blocked_mcp_servers": [
    ],
  • "allowed_mcp_tools": [
    ],
  • "blocked_mcp_tools": [
    ],
  • "enabled": true,
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z"
}

Get a policy

Authorizations:
bearerAuth
path Parameters
policy_id
required
string^pol_[0-9A-HJKMNP-TV-Z]{26}$
header Parameters
Sonny-Api-Version
string^\d{4}-\d{2}-\d{2}$

Pin a date version (e.g. 2026-06-01). Unpinned clients get latest stable. Retirement: 6-month deprecation via Sunset + Deprecation response headers.

Responses

Response samples

Content type
application/json
{
  • "id": "string",
  • "revision_id": "string",
  • "name": "string",
  • "mode": "enforce",
  • "rules": [
    ],
  • "default_action": "blocked",
  • "detector_config": {
    },
  • "allowed_mcp_servers": [
    ],
  • "blocked_mcp_servers": [
    ],
  • "allowed_mcp_tools": [
    ],
  • "blocked_mcp_tools": [
    ],
  • "enabled": true,
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z"
}

Replace a policy in place

Full replace, not patch. Minimal version intentionally has no revision history — mutations are destructive. AuditEvent of kind: management records the change.

Authorizations:
bearerAuth
path Parameters
policy_id
required
string^pol_[0-9A-HJKMNP-TV-Z]{26}$
header Parameters
Sonny-Api-Version
string^\d{4}-\d{2}-\d{2}$

Pin a date version (e.g. 2026-06-01). Unpinned clients get latest stable. Retirement: 6-month deprecation via Sunset + Deprecation response headers.

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 idempotency.key_reuse_mismatch.

Request Body schema: application/json
required
name
required
string <= 120 characters
mode
string
Default: "enforce"
Enum: "enforce" "observe"

enforce: decision.action must be honored by clients. observe: detectors run and webhooks fire as normal, but the action is advisory — recommended for pilot rollouts where customers want to see what would happen before committing.

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

Responses

Request samples

Content type
application/json
{
  • "name": "string",
  • "mode": "enforce",
  • "rules": [
    ],
  • "default_action": "blocked",
  • "detector_config": {
    },
  • "allowed_mcp_servers": [
    ],
  • "blocked_mcp_servers": [
    ],
  • "allowed_mcp_tools": [
    ],
  • "blocked_mcp_tools": [
    ],
  • "enabled": true
}

Response samples

Content type
application/json
{
  • "id": "string",
  • "revision_id": "string",
  • "name": "string",
  • "mode": "enforce",
  • "rules": [
    ],
  • "default_action": "blocked",
  • "detector_config": {
    },
  • "allowed_mcp_servers": [
    ],
  • "blocked_mcp_servers": [
    ],
  • "allowed_mcp_tools": [
    ],
  • "blocked_mcp_tools": [
    ],
  • "enabled": true,
  • "created_at": "2019-08-24T14:15:22Z",
  • "updated_at": "2019-08-24T14:15:22Z"
}

Delete a policy (soft-delete; disables)

Authorizations:
bearerAuth
path Parameters
policy_id
required
string^pol_[0-9A-HJKMNP-TV-Z]{26}$
header Parameters
Sonny-Api-Version
string^\d{4}-\d{2}-\d{2}$

Pin a date version (e.g. 2026-06-01). Unpinned clients get latest stable. Retirement: 6-month deprecation via Sunset + Deprecation response headers.

Responses

Response samples

Content type
application/problem+json
{}

Dry-run a policy against a past scan or synthetic findings

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.

Authorizations:
bearerAuth
path Parameters
policy_id
required
string^pol_[0-9A-HJKMNP-TV-Z]{26}$
header Parameters
Sonny-Api-Version
string^\d{4}-\d{2}-\d{2}$

Pin a date version (e.g. 2026-06-01). Unpinned clients get latest stable. Retirement: 6-month deprecation via Sunset + Deprecation response headers.

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 idempotency.key_reuse_mismatch.

Request Body schema: application/json
required
One of
scan_id
required
string

Responses

Request samples

Content type
application/json
{
  • "surface": "user_message",
  • "findings": [
    ]
}

Response samples

Content type
application/json
{
  • "decision": {
    },
  • "matched_rule": {
    },
  • "diff_from_current": {
    }
}

webhooks

Signed outbound event delivery.

List webhooks

Authorizations:
bearerAuth
query Parameters
cursor
string

Opaque cursor from a previous page.

limit
integer [ 1 .. 200 ]
Default: 50
header Parameters
Sonny-Api-Version
string^\d{4}-\d{2}-\d{2}$

Pin a date version (e.g. 2026-06-01). Unpinned clients get latest stable. Retirement: 6-month deprecation via Sunset + Deprecation response headers.

Responses

Response samples

Content type
application/json
{
  • "data": [
    ],
  • "next_cursor": "string"
}

Register a webhook

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.

Authorizations:
bearerAuth
header Parameters
Sonny-Api-Version
string^\d{4}-\d{2}-\d{2}$

Pin a date version (e.g. 2026-06-01). Unpinned clients get latest stable. Retirement: 6-month deprecation via Sunset + Deprecation response headers.

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 idempotency.key_reuse_mismatch.

Request Body schema: application/json
required
url
required
string <uri> ^https://

Must be https://. RFC1918, link-local, and cloud-metadata addresses are rejected server-side.

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

Responses

Request samples

Content type
application/json
{
  • "description": "string",
  • "events": [
    ],
  • "include_content": false
}

Response samples

Content type
application/json
{
  • "id": "string",
  • "description": "string",
  • "events": [
    ],
  • "include_content": false,
  • "active": true,
  • "last_failure_at": "2019-08-24T14:15:22Z",
  • "created_at": "2019-08-24T14:15:22Z",
  • "secret": "string"
}

Get a webhook (no secret)

Authorizations:
bearerAuth
path Parameters
webhook_id
required
string^wh_[0-9A-HJKMNP-TV-Z]{26}$
header Parameters
Sonny-Api-Version
string^\d{4}-\d{2}-\d{2}$

Pin a date version (e.g. 2026-06-01). Unpinned clients get latest stable. Retirement: 6-month deprecation via Sunset + Deprecation response headers.

Responses

Response samples

Content type
application/json
{
  • "id": "string",
  • "description": "string",
  • "events": [
    ],
  • "include_content": false,
  • "active": true,
  • "last_failure_at": "2019-08-24T14:15:22Z",
  • "created_at": "2019-08-24T14:15:22Z"
}

Delete a webhook

Authorizations:
bearerAuth
path Parameters
webhook_id
required
string^wh_[0-9A-HJKMNP-TV-Z]{26}$
header Parameters
Sonny-Api-Version
string^\d{4}-\d{2}-\d{2}$

Pin a date version (e.g. 2026-06-01). Unpinned clients get latest stable. Retirement: 6-month deprecation via Sunset + Deprecation response headers.

Responses

Response samples

Content type
application/problem+json
{}

Rotate a webhook's signing secret

Old secret remains valid for grace_seconds (default 24h). New plaintext secret is returned once.

Authorizations:
bearerAuth
path Parameters
webhook_id
required
string^wh_[0-9A-HJKMNP-TV-Z]{26}$
header Parameters
Sonny-Api-Version
string^\d{4}-\d{2}-\d{2}$

Pin a date version (e.g. 2026-06-01). Unpinned clients get latest stable. Retirement: 6-month deprecation via Sunset + Deprecation response headers.

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 idempotency.key_reuse_mismatch.

Request Body schema: application/json
optional
grace_seconds
integer [ 0 .. 604800 ]
Default: 86400

Responses

Request samples

Content type
application/json
{
  • "grace_seconds": 86400
}

Response samples

Content type
application/json
{
  • "webhook_id": "string",
  • "secret": "string",
  • "grace_expires_at": "2019-08-24T14:15:22Z"
}

List delivery attempts for a webhook

Enterprise procurement (Indicium, KPMG, Experian) requires SIEM-integration debuggability — "why didn't this event reach my receiver?" Answerable without a support ticket.

Authorizations:
bearerAuth
path Parameters
webhook_id
required
string^wh_[0-9A-HJKMNP-TV-Z]{26}$
query Parameters
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
header Parameters
Sonny-Api-Version
string^\d{4}-\d{2}-\d{2}$

Pin a date version (e.g. 2026-06-01). Unpinned clients get latest stable. Retirement: 6-month deprecation via Sunset + Deprecation response headers.

Responses

Response samples

Content type
application/json
{
  • "data": [
    ],
  • "next_cursor": "string"
}

Manually redeliver a failed or dead-lettered event

Authorizations:
bearerAuth
path Parameters
delivery_id
required
string^whd_[0-9A-HJKMNP-TV-Z]{26}$
header Parameters
Sonny-Api-Version
string^\d{4}-\d{2}-\d{2}$

Pin a date version (e.g. 2026-06-01). Unpinned clients get latest stable. Retirement: 6-month deprecation via Sunset + Deprecation response headers.

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 idempotency.key_reuse_mismatch.

Responses

Response samples

Content type
application/json
{
  • "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": {
    },
  • "error": "string",
  • "created_at": "2019-08-24T14:15:22Z",
  • "delivered_at": "2019-08-24T14:15:22Z"
}

audit

Unified query log for detection and management events.

Query audit events (detection and management, unified)

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.

Authorizations:
bearerAuth
query Parameters
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 text/csv exports can paginate densely when callers pass limit explicitly. The shared Limit parameter component is intentionally not referenced here for that reason.

header Parameters
Sonny-Api-Version
string^\d{4}-\d{2}-\d{2}$

Pin a date version (e.g. 2026-06-01). Unpinned clients get latest stable. Retirement: 6-month deprecation via Sunset + Deprecation response headers.

Responses

Response samples

Content type
{
  • "data": [
    ],
  • "next_cursor": "string"
}

detectors

Currently-loaded detector metadata.

List detectors currently available from the loaded bundle

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.

Authorizations:
bearerAuth
query Parameters
cursor
string

Opaque cursor from a previous page.

limit
integer [ 1 .. 200 ]
Default: 50
header Parameters
Sonny-Api-Version
string^\d{4}-\d{2}-\d{2}$

Pin a date version (e.g. 2026-06-01). Unpinned clients get latest stable. Retirement: 6-month deprecation via Sunset + Deprecation response headers.

Responses

Response samples

Content type
application/json
{
  • "data": [
    ],
  • "next_cursor": "string"
}

organizations

Self-serve org creation for first-login WorkOS users (SaaS only).

Create the calling user's first organization (self-serve onboarding)

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):

  1. Backend calls the WorkOS Organizations API to create a new Organization with the supplied display_name.
  2. Backend adds the calling user to the new Organization as a member with role=owner.
  3. Caller signs out + back in (or refreshes their session) so WorkOS issues a fresh JWT with the new org_id claim.
  4. The next authenticated request — ANY tenant-scoped endpoint, not just 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.

Authorizations:
bearerAuth
header Parameters
Sonny-Api-Version
string^\d{4}-\d{2}-\d{2}$

Pin a date version (e.g. 2026-06-01). Unpinned clients get latest stable. Retirement: 6-month deprecation via Sunset + Deprecation response headers.

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 idempotency.key_reuse_mismatch.

Request Body schema: application/json
required
display_name
required
string [ 1 .. 160 ] characters

Responses

Request samples

Content type
application/json
{
  • "display_name": "string"
}

Response samples

Content type
application/json
{
  • "id": "string",
  • "slug": "string",
  • "display_name": "string",
  • "created_at": "2019-08-24T14:15:22Z"
}

health

Liveness and readiness.

Legacy liveness probe

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.

Responses

Response samples

Content type
application/json
{
  • "status": "ok"
}

Liveness probe

Responses

Response samples

Content type
application/json
{
  • "status": "ok"
}

Readiness probe (bundle loaded + DB reachable)

Responses

Response samples

Content type
application/json
{
  • "status": "ok"
}

Outbound scan event (blocked / flagged / warned / allowed) Webhook

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.

header Parameters
Sonny-Signature
required
string
Example: t=1712345678,v1=abc123...
Sonny-Event-Id
required
string
Sonny-Api-Version
required
string
Example: 2026-06-01
Request Body schema: application/json
required
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.* carries a compact scan summary without raw content (unless include_content is on).

Responses

Request samples

Content type
application/json
{
  • "type": "scan.blocked",
  • "id": "string",
  • "created": "2019-08-24T14:15:22Z",
  • "api_version": "string",
  • "data": { }
}

Response samples

Content type
application/json
null