Skip to main content

OpenAPI — HTTP surface

The webhook ingestion endpoints and the local web-UI routes. Validated against OpenAPI 3.1.

⬇ Download the raw openapi.yaml

openapi: 3.1.0

# switchboard — HTTP surface: webhook ingestion + local web UI.
# Authored by hand (see ADR-0001): the endpoints are webhook receivers (raw body + HMAC)
# and server-rendered HTML, neither of which is model-driven, so there is no framework
# generating this spec. It is the contract the code session implements against.
#
# Trust model (ADR-0003, ADR-0012): every webhook is self-managed and served at
# /webhooks/w/{token}. Signed source types are verified-or-401; `generic` and `telemetry` webhooks
# are authenticated by the unguessable token in their URL.
#
# Live-update stream schema (SSE): docs/reference/asyncapi.yaml.
# MCP tool contract: docs/openspec/specs/mcp-tools/spec.md. Vended MCP endpoints are served over
# Streamable HTTP at /mcp/{endpoint} (ADR-0017; SPEC-0014), not described here.

info:
title: switchboard HTTP API
version: 0.1.0
description: |
Inbound webhook ingestion and the local-only web UI for **switchboard**.

## Three groups of endpoints
- **Webhook ingestion** (`/webhooks/w/{token}`): producers POST to the ingest URL an endpoint's
`create_webhook` returned. Signed source types are verified-or-rejected (401, not persisted);
`generic` and `telemetry` webhooks are authenticated by the unguessable token in their URL.
- **Operator API** (`/api/v1/*`): OAuth-guarded (operator grants) — register agents, vend
endpoints, list what you own. The `switchboard` CLI is its reference client.
- **Web UI** (`/`, `/log`, `/settings`) + the SSE stream (`/events`):
server-rendered HTML (html/template + HTMX) plus a `text/event-stream` for live updates.

## Authentication posture (ADR-0001, brief §8)
The service is **localhost-bound by default and ships no in-app auth for the UI**. If it is
ever exposed on the homelab LAN it MUST sit behind Caddy `forward_auth` — auth is provided by
the reverse proxy, not built into this app for the MVP. The UI/SSE/health endpoints below are
therefore marked `security: []` (no in-app auth) **by design**, not by oversight.

For **webhook** endpoints, "authentication" means per-provider signature verification
(`signed` providers) or a shared-secret token that authenticates the caller (`generic` and
`telemetry`). See ADR-0003.
license:
name: MIT
url: https://opensource.org/license/mit
contact:
name: Joe Stump

servers:
- url: http://127.0.0.1:8080
description: Local, loopback-bound default (MVP)
- url: https://webhooks.stump.rocks
description: Optional homelab exposure — MUST be behind Caddy forward_auth (ADR-0001)

tags:
- name: ingestion
description: Inbound webhook receivers. See ADR-0003 for the trust model.
- name: ui
description: Server-rendered web UI (html/template + HTMX). HTML responses.
- name: sse
description: Live-update event stream (Server-Sent Events). Payload schema in asyncapi.yaml.
- name: ops
description: Health/readiness.
- name: operator-api
description: >-
The /api/v1 operator surface (ADR-0023): register agents and vend endpoints as the
signed-in human. Every request requires an OPERATOR OAuth bearer — an OAuth token minted
by the authorization-code + PKCE flow with resource = `<base>/api` (ADR-0019), which the
`switchboard` CLI obtains gh-style. There is no static API token.

paths:
/webhooks/w/{token}:
post:
tags: [ingestion]
operationId: ingestWebhook
summary: Receive a delivery on a self-managed webhook
description: |
The one ingestion receiver (ADR-0012). `{token}` is the unguessable ingest token minted by
`create_webhook` / `rotate_webhook`; it routes the delivery to exactly one webhook, owned by
one endpoint. The webhook's source type fixes how the delivery is verified (ADR-0003):

- **Signed source types** (`github`, `gitea`, `stripe`, `slack`, `cairn`): the raw request
body is read and the provider's signature is verified **before any parsing**, in constant
time, against the secret switchboard minted. A missing, malformed, or failing signature
returns **401** and the payload is **NOT persisted**. Stripe, Slack, and Cairn additionally
enforce a timestamp freshness window (default 300s) to blunt replay.
- **`generic` and `telemetry`** (`trust_mode=token`): the token in the URL authenticates the
caller; the body is not verified, and the event persists with `verified=false`.

On success the event is persisted, routing rules pick a queue (or drop the delivery), and one
todo is created per target endpoint — the webhook's owner plus any route fan-out (ADR-0022).
A redelivery with the same delivery id (or body) collapses onto the existing todo.
security: []
parameters:
- name: token
in: path
required: true
description: The webhook's ingest token, from the `ingest_url` that `create_webhook` returned.
schema: { type: string }
requestBody:
required: true
description: Raw provider payload (bytes preserved for HMAC verification).
content:
application/json:
schema:
type: object
additionalProperties: true
application/x-www-form-urlencoded:
schema:
type: object
additionalProperties: true
responses:
'202':
description: Accepted. The body lists the todos the delivery produced, or says it was dropped.
content:
application/json:
schema:
$ref: '#/components/schemas/IngestAck'
'401':
description: |
Signature missing, malformed, or failed verification. **Payload not persisted.**
content:
application/json:
schema: { $ref: '#/components/schemas/IngestError' }
'404':
description: Unknown ingest token — the URL is wrong, or the webhook was rotated or deleted.
content:
application/json:
schema: { $ref: '#/components/schemas/IngestError' }
'413':
description: Body exceeds 5 MiB.
content:
application/json:
schema: { $ref: '#/components/schemas/IngestError' }
'429':
description: Rate limited. Honor `Retry-After`.
'503':
description: |
The webhook cannot produce work: no live target endpoint remains, or a signed webhook
has no stored secret. Nothing is persisted, so the producer retries.
content:
application/json:
schema: { $ref: '#/components/schemas/IngestError' }

/:
get:
tags: [ui]
operationId: getDashboard
summary: Dashboard / status screen (UI screen 1)
description: |
Server-rendered dashboard: connection/health strip (live via SSE — `● receiving` / `○ idle`),
recent event count by provider, and last-event timestamp per provider.
security: []
responses:
'200':
description: HTML page.
content:
text/html:
schema: { type: string }

/events:
get:
tags: [sse]
operationId: streamEvents
summary: Server-Sent Events stream for live UI updates
description: |
`text/event-stream` consumed by the browser via `htmx-ext-sse`. Emits named SSE events
(`event-received`, `status`) plus keep-alive comments. **Message payload schemas are
defined in `docs/reference/asyncapi.yaml`**, not here. Reconnection cadence is advertised via
the SSE `retry:` field (configurable — `sse_retry_ms`, Settings screen).
security: []
parameters:
- name: Last-Event-ID
in: header
required: false
description: Standard SSE resume header; server may replay missed events since this id.
schema: { type: string }
responses:
'200':
description: An open event stream.
content:
text/event-stream:
schema:
type: string
description: See asyncapi.yaml for the `event-received` and `status` message shapes.

/log:
get:
tags: [ui]
operationId: getEventLog
summary: Webhook log / history (UI screen 2)
description: |
Paginated table of received events (provider icon, event type, timestamp, verify status,
payload size). Rendered as a full HTML page, or — when requested by HTMX (`HX-Request: true`)
— as a table-fragment for cursor pagination. Ordering is `received_at DESC, id DESC`.
security: []
parameters:
- { $ref: '#/components/parameters/FilterProvider' }
- { $ref: '#/components/parameters/FilterEventType' }
- { $ref: '#/components/parameters/FilterSince' }
- { $ref: '#/components/parameters/Cursor' }
- { $ref: '#/components/parameters/Limit' }
responses:
'200':
description: HTML page or HTMX fragment.
content:
text/html:
schema: { type: string }

/log/{id}:
get:
tags: [ui]
operationId: getEventDetail
summary: Event detail (UI screen 2, row → detail)
description: |
Full detail for one event: raw payload, **sanitized** headers (signature/secret headers
redacted — ADR-0002/ADR-0003), and verification result. Rendered as HTML.
security: []
parameters:
- name: id
in: path
required: true
schema: { type: integer, format: int64 }
responses:
'200':
description: HTML detail view.
content:
text/html:
schema: { type: string }
'404':
description: No event with that id.
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }

/settings:
get:
tags: [ui]
operationId: getSettings
summary: Settings (UI screen 4)
description: Retention policy, SSE reconnect behavior, and general app config.
security: []
responses:
'200':
description: HTML page.
content:
text/html:
schema: { type: string }
post:
tags: [ui]
operationId: updateSettings
summary: Update settings (HTMX)
description: |
Persists retention + SSE settings to the `settings` table. Values take effect on the next
prune cycle / SSE reconnect. Returns the re-rendered settings fragment.
security: []
requestBody:
required: true
content:
application/x-www-form-urlencoded:
schema: { $ref: '#/components/schemas/Settings' }
responses:
'200':
description: Re-rendered settings fragment (HTML).
content:
text/html:
schema: { type: string }
'422':
description: Invalid setting value.
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }

/api/v1/agents:
get:
tags: [operator-api]
operationId: listAgents
summary: List the authenticated operator's registered agents
security:
- operatorOAuth: []
responses:
'200':
description: The operator's agents, creation order unspecified.
content:
application/json:
schema:
type: array
items: { $ref: '#/components/schemas/AgentOut' }
'401':
description: Missing, expired, revoked, or non-operator bearer.
content:
application/json:
schema: { $ref: '#/components/schemas/Problem' }

/api/v1/endpoints:
post:
tags: [operator-api]
operationId: vendEndpoint
summary: Register an agent and vend its endpoint in one call
description: |
The ADR-0023 one-call mint: agent + scoped endpoint (MCP URL + bearer credential) +
queue + token-trust ingestion webhook, returned in one response. The credential appears
exactly once, here — it is never retrievable again (SPEC-0007).
security:
- operatorOAuth: []
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/VendEndpointIn' }
responses:
'201':
description: Vended — every credential the operator needs in one response.
content:
application/json:
schema: { $ref: '#/components/schemas/VendEndpointOut' }
'400':
description: Invalid body, missing name, an unknown verb or preset, or a replay target the SSRF guard refuses.
content:
application/json:
schema: { $ref: '#/components/schemas/Problem' }
'401':
description: Missing, expired, revoked, or non-operator bearer.
content:
application/json:
schema: { $ref: '#/components/schemas/Problem' }
get:
tags: [operator-api]
operationId: listEndpoints
summary: List the authenticated operator's vended endpoints (never credentials)
security:
- operatorOAuth: []
responses:
'200':
description: The operator's vended endpoints. Tokens are NOT included — they are revealed once at mint.
content:
application/json:
schema:
type: array
items: { $ref: '#/components/schemas/EndpointOut' }
'401':
description: Missing, expired, revoked, or non-operator bearer.
content:
application/json:
schema: { $ref: '#/components/schemas/Problem' }

/api/v1/endpoints/{ref}:
patch:
tags: [operator-api]
operationId: editEndpoint
summary: Edit an endpoint's settings (its default claim lease)
description: |
Edits the settings of an endpoint you own that are not scope (ADR-0043). Today that is
`default_lease_ttl_seconds`: the lease a `claim`, `claim_next` or `heartbeat` on the
endpoint gets when the call passes no `lease_ttl_seconds`. An integer from 60 to 86400 sets
it; `null` resets it to the server default (300). Out of range is refused, never clamped.
The key is required, and any other key is refused: scope is fixed at vend (SPEC-0007).
The edit applies to claims and heartbeats made after it; leases already granted keep their
expiry. Draws from the per-human write bucket (429 with `Retry-After` when spent).
security:
- operatorOAuth: []
parameters:
- name: ref
in: path
required: true
description: The endpoint's slug (as `endpoint list` prints it) or its id.
schema: { type: string }
example: my-agent-k3x9
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/EditEndpointIn' }
responses:
'200':
description: The endpoint with its new setting.
content:
application/json:
schema: { $ref: '#/components/schemas/EndpointOut' }
'400':
description: '`invalid_argument`: an absent key, a value out of range or not a whole number, an unknown key, or a body that does not decode.'
content:
application/json:
schema: { $ref: '#/components/schemas/APIError' }
'401':
description: Missing, expired, revoked, or non-operator bearer.
content:
application/json:
schema: { $ref: '#/components/schemas/Problem' }
'404':
description: '`not_found`: no endpoint by that slug or id belongs to the caller (unknown and another human''s are the same answer).'
content:
application/json:
schema: { $ref: '#/components/schemas/APIError' }
'409':
description: '`conflict`: the endpoint is revoked; `state` says so.'
content:
application/json:
schema: { $ref: '#/components/schemas/APIError' }
'413':
description: The body is over 64 KiB.
content:
application/json:
schema: { $ref: '#/components/schemas/APIError' }
'429':
description: '`rate_limited`: the per-human write bucket is spent.'
content:
application/json:
schema: { $ref: '#/components/schemas/APIError' }

/api/v1/endpoints/{ref}/scope/widen:
post:
tags: [operator-api]
operationId: widenEndpointScope
summary: Add to an endpoint's scope without re-vending it (additive only)
description: |
Widens the grant of an active endpoint you own by adding to it: queues its webhooks may route to,
ingest source types its agents may create webhooks for, MCP verbs, and a larger ceiling on
self-managed webhooks (ADR-0049). It exists because a webhook belongs to the endpoint that created
it, so a re-vend to add one route would mint a new ingest URL and signing secret and need an edit
in the producer's hook configuration — for a change that cannot break a route that works today.

The request can only add. Arrays are unioned, so a removal has no spelling here, and any key other
than the five below — including a bare `webhook_queues`, which would be a replacement — is refused
with 400. `webhook_max` is a ceiling to raise: a value below the endpoint's current one is refused,
never clamped, because lowering it strands webhooks that already exist.

Without `confirm: true` this is a **preview**: the response reports `before`, `after`, the fields
that would move and `requires_confirmation: true`, and nothing is written. The endpoint's
credential is never read or changed — the token revealed at vend keeps working, which is the point.

Draws from the per-human write bucket (429 with `Retry-After` when spent). Every field that
actually moves is recorded in `endpoint_scope_widenings` in the same transaction as the change.
security:
- operatorOAuth: []
parameters:
- name: ref
in: path
required: true
description: The endpoint's slug (as `endpoint list` prints it) or its id.
schema: { type: string }
example: my-agent-k3x9
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/WidenEndpointScopeIn' }
responses:
'200':
description: The before and after scope; `applied` says whether the widen was written.
content:
application/json:
schema: { $ref: '#/components/schemas/WidenEndpointScopeOut' }
'400':
description: '`invalid_argument`: an unknown or removal key, an empty addition, an unknown queue, source type or verb, a `webhook_max` that lowers the ceiling, or a body that does not decode.'
content:
application/json:
schema: { $ref: '#/components/schemas/APIError' }
'401':
description: Missing, expired, revoked, or non-operator bearer.
content:
application/json:
schema: { $ref: '#/components/schemas/Problem' }
'404':
description: '`not_found`: no endpoint by that slug or id belongs to the caller (unknown and another human''s are the same answer).'
content:
application/json:
schema: { $ref: '#/components/schemas/APIError' }
'409':
description: '`conflict`: the endpoint is revoked; `state` says so.'
content:
application/json:
schema: { $ref: '#/components/schemas/APIError' }
'413':
description: The body is over 64 KiB.
content:
application/json:
schema: { $ref: '#/components/schemas/APIError' }
'429':
description: '`rate_limited`: the per-human write bucket is spent.'
content:
application/json:
schema: { $ref: '#/components/schemas/APIError' }

/api/v1/endpoints/{ref}/scope/widenings:
get:
tags: [operator-api]
operationId: listEndpointScopeWidenings
summary: The audit trail of scope widenings for one endpoint
description: |
Every additive scope change this endpoint has had, newest first: the field, the value it replaced,
the value it became, and when. A widen that changed nothing writes no row, so this is a record of
changes rather than of calls (ADR-0049). Reaching it needs the same ownership as widening.
security:
- operatorOAuth: []
parameters:
- name: ref
in: path
required: true
description: The endpoint's slug (as `endpoint list` prints it) or its id.
schema: { type: string }
example: my-agent-k3x9
responses:
'200':
description: The widening history, newest first.
content:
application/json:
schema: { $ref: '#/components/schemas/EndpointScopeWideningsOut' }
'401':
description: Missing, expired, revoked, or non-operator bearer.
content:
application/json:
schema: { $ref: '#/components/schemas/Problem' }
'404':
description: '`not_found`: no endpoint by that slug or id belongs to the caller.'
content:
application/json:
schema: { $ref: '#/components/schemas/APIError' }

/api/v1/endpoints/{ref}/todos:
post:
tags: [operator-api]
operationId: pushTodo
summary: Hand an endpoint you own a todo and ring its doorbell
description: |
The operator's hand (ADR-0040): mints one todo on the named endpoint and rings its
doorbell exactly as a verified delivery does. The hand-off is recorded as a delivery event
of source, family and trust mode `operator`, verified, whose `verify_detail` names the
authenticated human — so history and the board show who asked. Only the endpoint's owner
may push, only onto one of its vended queues; the title and payload reach the agent as
untrusted content like any todo's.
security:
- operatorOAuth: []
parameters:
- name: ref
in: path
required: true
description: The endpoint's slug (as `endpoint list` prints it) or its id.
schema: { type: string }
example: my-agent-k3x9
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/PushTodoIn' }
responses:
'201':
description: |
The todo. `created` is false when `key` matched a live todo on the endpoint: the
existing todo is returned, nothing was minted, nothing was rung.
content:
application/json:
schema: { $ref: '#/components/schemas/PushTodoOut' }
'400':
description: Invalid body, empty or over-long title, a queue outside the endpoint's vended scope, or no queue on an endpoint that drains several.
content:
application/json:
schema: { $ref: '#/components/schemas/Problem' }
'401':
description: Missing, expired, revoked, or non-operator bearer.
content:
application/json:
schema: { $ref: '#/components/schemas/Problem' }
'404':
description: No endpoint by that slug or id belongs to the caller (unknown and another operator's are the same answer).
content:
application/json:
schema: { $ref: '#/components/schemas/Problem' }
'409':
description: The endpoint is revoked; nothing could ever drain the work.
content:
application/json:
schema: { $ref: '#/components/schemas/Problem' }

/api/v1/webhooks:
get:
tags: [operator-api]
operationId: listWebhooks
summary: List the webhooks your endpoints own (never ingest URLs or secrets)
description: |
Every webhook whose owning endpoint belongs to the authenticated human, active endpoints
first, a revoked endpoint's included (SPEC-0035). Ingest URLs and signing secrets are never
returned: they are revealed once, to the owning endpoint, at create or rotate.
security:
- operatorOAuth: []
responses:
'200':
description: The webhooks in the caller's reach.
content:
application/json:
schema:
type: object
required: [webhooks]
properties:
webhooks:
type: array
items: { $ref: '#/components/schemas/WebhookListOut' }
'401':
description: Missing, expired, revoked, or non-operator bearer (an sbk_ endpoint credential included).
'429':
description: The caller's per-human read budget is spent; see Retry-After.
content:
application/json:
schema: { $ref: '#/components/schemas/APIError' }

/api/v1/webhooks/{webhook_id}/provider-connection:
parameters:
- name: webhook_id
in: path
required: true
description: A webhook in the caller's reach (see listWebhooks).
schema: { type: string, format: uuid }
put:
tags: [operator-api]
operationId: putWebhookProviderConnection
summary: Say where this webhook's provider lives, and store its read-only token
description: |
Where a reconcile read would dial for this webhook, and the read-only credential that
authenticates it (ADR-0052, #614). `provider_base_url` and `provider_repo` are a pair replaced
whole on every write; `provider_base_url: ""` clears both, and naming one without the other is
`invalid_argument`. The base URL must be `https` (loopback `http` excepted) and, when
`SWITCHBOARD_PROVIDER_READ_ALLOWED_ORIGINS` is set, exactly one of the listed origins — a prefix
never matches. `provider_read_token` is three-state: the key omitted keeps the stored token, `""`
clears it, a value replaces it. A non-empty token requires `SWITCHBOARD_SECRET_ENCRYPTION_KEY`, is
encrypted at rest like a signing secret, and is never returned by any response. Agents read these
fields on `list_webhooks` and have no verb that writes them: whoever supplies the token decides
where it is sent. Nothing dials out yet — the reconcile pass is #616.
security:
- operatorOAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
provider_base_url: { type: string, example: https://gitea.stump.rocks/api/v1 }
provider_repo: { type: string, example: stump.wtf/switchboard }
provider_read_token:
type: string
description: Omit the key to keep the stored token, `""` to clear it, a value to replace it.
responses:
'200':
description: Saved; what a reconcile read would use, with the token itself withheld.
content:
application/json:
schema: { $ref: '#/components/schemas/ProviderConnectionOut' }
'400':
description: The base URL is malformed, or the address names only one half of the pair.
content:
application/json:
schema: { $ref: '#/components/schemas/APIError' }
'401':
description: Missing, expired, revoked, or non-operator bearer.
'403':
description: The base URL's origin is outside SWITCHBOARD_PROVIDER_READ_ALLOWED_ORIGINS.
content:
application/json:
schema: { $ref: '#/components/schemas/APIError' }
'404':
description: No such webhook in the caller's reach.
content:
application/json:
schema: { $ref: '#/components/schemas/APIError' }
'503':
description: A token was supplied but SWITCHBOARD_SECRET_ENCRYPTION_KEY is not configured.
content:
application/json:
schema: { $ref: '#/components/schemas/APIError' }

/api/v1/webhooks/{webhook_id}/rules:
parameters:
- name: webhook_id
in: path
required: true
description: A webhook whose owning endpoint belongs to the caller (see listWebhooks).
schema: { type: string, format: uuid }
get:
tags: [operator-api]
operationId: getWebhookRules
summary: Read a webhook's routing rules, default, params and grant
description: |
The shape `list_webhook_rules` returns (ADR-0024, SPEC-0020), from the same code. The
response is also a valid body for putWebhookRules.
security:
- operatorOAuth: []
responses:
'200':
description: The webhook's routing configuration and what its actions may reach.
content:
application/json:
schema: { $ref: '#/components/schemas/WebhookRules' }
'401':
description: Missing, expired, revoked, or non-operator bearer.
'404':
description: No such webhook in the caller's reach (another human's and an unknown id are the same answer).
content:
application/json:
schema: { $ref: '#/components/schemas/APIError' }
put:
tags: [operator-api]
operationId: putWebhookRules
summary: Replace a webhook's routing configuration
description: |
`set_webhook_rules` through the same save path: validation against the grant, a dry run
against the webhook's 50 latest deliveries, and a row-locked write that answers conflict if
the stored rules changed meanwhile. Any failure keeps the previous configuration. A body
without a `params` key KEEPS the stored params; `"params": null` clears them (the MCP
`set_webhook_rules` now gives an omitted `params` the same keep answer, and clears only on
an explicit `"params": {}`). Read-only fields of a GET response
are ignored.
security:
- operatorOAuth: []
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/WebhookRulesIn' }
responses:
'200':
description: Saved; the stored configuration.
content:
application/json:
schema: { $ref: '#/components/schemas/WebhookRules' }
'400':
description: |
The body does not decode or has no `rules`, a rule is invalid (`invalid_rule`,
`invalid_expression`, `forbidden_function`, `too_many_rules`, `invalid_params`), or a rule
faults on a recent delivery (`invalid_argument`, naming the rule and the events).
content:
application/json:
schema: { $ref: '#/components/schemas/APIError' }
'401':
description: Missing, expired, revoked, or non-operator bearer.
'403':
description: An action names a queue or endpoint outside the grant (`forbidden`).
content:
application/json:
schema: { $ref: '#/components/schemas/APIError' }
'404':
description: No such webhook in the caller's reach.
content:
application/json:
schema: { $ref: '#/components/schemas/APIError' }
'409':
description: The webhook's endpoint is revoked (`state` names it), or the rules changed while the save was being checked (`conflict`).
content:
application/json:
schema: { $ref: '#/components/schemas/APIError' }
'413':
description: The body exceeds 256 KiB.
'429':
description: The caller's per-human write budget is spent; see Retry-After.
'503':
description: The rule evaluator is unavailable, so the save could not be checked (`unavailable`); retry.
content:
application/json:
schema: { $ref: '#/components/schemas/APIError' }

/api/v1/webhooks/{webhook_id}/rules/test:
parameters:
- name: webhook_id
in: path
required: true
schema: { type: string, format: uuid }
post:
tags: [operator-api]
operationId: testWebhookRules
summary: Dry-run routing against a stored delivery or a sample payload
description: |
`test_webhook_rules`: evaluates the saved rules, or candidate `rules`/`default_action`/
`params`, against one of THIS webhook's stored deliveries (`event_id`) or a sample `payload`,
through the evaluator the receiver uses. Saves nothing. Candidates are validated like a save.
security:
- operatorOAuth: []
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/WebhookRulesTestIn' }
responses:
'200':
description: Where the delivery would go, the trace, and (unless omitted) the envelope the rules saw.
content:
application/json:
schema: { $ref: '#/components/schemas/WebhookRulesTestOut' }
'400':
description: Not exactly one of event_id or payload, or an invalid candidate.
content:
application/json:
schema: { $ref: '#/components/schemas/APIError' }
'401':
description: Missing, expired, revoked, or non-operator bearer.
'403':
description: A candidate action names a queue or endpoint outside the grant.
'404':
description: No such webhook in the caller's reach, or the event is not one of this webhook's deliveries.
content:
application/json:
schema: { $ref: '#/components/schemas/APIError' }

/api/v1/webhooks/{webhook_id}/routes:
parameters:
- name: webhook_id
in: path
required: true
description: A webhook whose owning endpoint belongs to the caller (see listWebhooks).
schema: { type: string, format: uuid }
get:
tags: [operator-api]
operationId: listWebhookRoutes
summary: List the endpoints a webhook delivers to
description: |
`list_webhook_routes` (ADR-0022), from the same code: the owning endpoint, which is always a
target, and the explicit routes, newest grant first. A route whose target was revoked or
whose friendship lapsed is still listed so it can be removed; deliveries skip it.
security:
- operatorOAuth: []
responses:
'200':
description: The webhook's fan-out set.
content:
application/json:
schema: { $ref: '#/components/schemas/WebhookRoutes' }
'401':
description: Missing, expired, revoked, or non-operator bearer.
'404':
description: No such webhook in the caller's reach (another human's and an unknown id are the same answer).
content:
application/json:
schema: { $ref: '#/components/schemas/APIError' }
post:
tags: [operator-api]
operationId: addWebhookRouteFromBody
summary: Add a route, naming the target in the body
description: |
The same operation as addWebhookRoute, for a client that holds the target id in a body as
`add_webhook_route`'s arguments do.
security:
- operatorOAuth: []
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/WebhookRouteIn' }
responses:
'200':
description: The route exists (idempotent).
content:
application/json:
schema: { $ref: '#/components/schemas/WebhookRouteAdded' }
'400':
description: The body does not decode, or has no target_endpoint_id (`invalid_argument`).
content:
application/json:
schema: { $ref: '#/components/schemas/APIError' }
'401':
description: Missing, expired, revoked, or non-operator bearer.
'403':
description: The target is not routable (`forbidden`); see addWebhookRoute.
content:
application/json:
schema: { $ref: '#/components/schemas/APIError' }
'404':
description: No such webhook in the caller's reach.
content:
application/json:
schema: { $ref: '#/components/schemas/APIError' }
'409':
description: The webhook's endpoint is not active (`state` names it).
content:
application/json:
schema: { $ref: '#/components/schemas/APIError' }
'429':
description: The caller's per-human write budget is spent; see Retry-After.

/api/v1/webhooks/{webhook_id}/routes/{endpoint_id}:
parameters:
- name: webhook_id
in: path
required: true
schema: { type: string, format: uuid }
- name: endpoint_id
in: path
required: true
description: The target endpoint's id (slugs are resolved by the CLI, not here).
schema: { type: string, format: uuid }
put:
tags: [operator-api]
operationId: addWebhookRoute
summary: Make an endpoint a delivery target of a webhook
description: |
`add_webhook_route`, through the same checks: the target must be an active endpoint of the
caller, or of a human the caller may deliver to across an approved friend edge (ADR-0010).
Adding a route that exists changes nothing. Once routed, the endpoint is in the webhook's
`grant.endpoints`, so its rules may name it.
security:
- operatorOAuth: []
responses:
'200':
description: The route exists (idempotent).
content:
application/json:
schema: { $ref: '#/components/schemas/WebhookRouteAdded' }
'401':
description: Missing, expired, revoked, or non-operator bearer.
'403':
description: |
The target is not routable (`forbidden`): unknown, malformed, revoked, or another human's
without an approved edge. Every reason is the same body.
content:
application/json:
schema: { $ref: '#/components/schemas/APIError' }
'404':
description: No such webhook in the caller's reach.
content:
application/json:
schema: { $ref: '#/components/schemas/APIError' }
'409':
description: The webhook's endpoint is not active (`state` names it).
content:
application/json:
schema: { $ref: '#/components/schemas/APIError' }
'429':
description: The caller's per-human write budget is spent; see Retry-After.
delete:
tags: [operator-api]
operationId: removeWebhookRoute
summary: Stop delivering a webhook to an endpoint
description: |
`remove_webhook_route`. Succeeds whether or not the route existed; the owning endpoint
always stays a target. Works on a revoked endpoint's webhook too, since its routes still
deliver.
security:
- operatorOAuth: []
responses:
'200':
description: The route is gone (idempotent).
content:
application/json:
schema: { $ref: '#/components/schemas/WebhookRouteRemoved' }
'401':
description: Missing, expired, revoked, or non-operator bearer.
'404':
description: No such webhook in the caller's reach.
content:
application/json:
schema: { $ref: '#/components/schemas/APIError' }
'429':
description: The caller's per-human write budget is spent; see Retry-After.

/healthz:
get:
tags: [ops]
operationId: getHealth
summary: Liveness/readiness probe
description: |
JSON health check — process up, DB reachable, ingestion paths' status. Public (no auth) —
required so a supervisor/reverse proxy can probe without credentials.
security: []
responses:
'200':
description: Healthy.
content:
application/json:
schema: { $ref: '#/components/schemas/Health' }
'503':
description: Unhealthy (e.g. DB unreachable).
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }

components:
parameters:
FilterProvider:
name: provider
in: query
required: false
description: Filter by source type (`github`, `gitea`, `stripe`, `slack`, `cairn`, `generic`, `telemetry`).
schema: { type: string }
FilterEventType:
name: event_type
in: query
required: false
description: Filter by provider-declared event type (e.g. `push`, `payment_intent.succeeded`).
schema: { type: string }
FilterSince:
name: since
in: query
required: false
description: Lower time bound — ISO-8601 timestamp or an event id.
schema:
oneOf:
- { type: string, format: date-time }
- { type: integer, format: int64 }
Cursor:
name: cursor
in: query
required: false
description: Opaque pagination cursor encoding the last `(received_at, id)` seen.
schema: { type: string }
Limit:
name: limit
in: query
required: false
description: Max rows to return.
schema: { type: integer, minimum: 1, maximum: 200, default: 50 }

schemas:
TrustMode:
type: string
description: >-
How the event's trust is established (ADR-0003). `signed` = HMAC-verified webhook;
`token` = webhook authenticated by its unguessable ingest URL (caller, not body);
`operator` = a todo pushed by the signed-in operator over `/api/v1`.
enum: [signed, token, operator]

EventSummary:
type: object
description: Compact event row (list views, SSE `event-received`, MCP `list`). No payload/headers.
required: [id, provider, trust_mode, verified, payload_size, received_at]
properties:
id: { type: integer, format: int64, example: 4213 }
provider: { type: string, example: github }
event_type: { type: [string, "null"], example: push }
trust_mode: { $ref: '#/components/schemas/TrustMode' }
verified:
type: boolean
description: true ONLY for a signed provider whose signature passed.
example: true
payload_size: { type: integer, description: Raw body size in bytes, example: 8421 }
received_at: { type: string, format: date-time, example: '2026-07-05T18:03:21.442Z' }

EventDetail:
allOf:
- $ref: '#/components/schemas/EventSummary'
- type: object
description: Full event record (detail view, MCP `get`).
properties:
verify_detail:
type: [string, "null"]
example: hmac-sha256 ok
external_id:
type: [string, "null"]
description: Provider delivery id used for idempotency.
example: 8f6c...-de
content_type: { type: [string, "null"], example: application/json }
source_ip: { type: [string, "null"] }
headers:
type: object
description: |
SANITIZED request headers. Signature/secret headers
(X-Hub-Signature-256, Stripe-Signature, X-Slack-Signature, Authorization, token)
are redacted to `«redacted»`.
additionalProperties: { type: string }
payload:
type: string
description: Raw body exactly as received (bytes preserved for replay).

IngestAck:
type: object
description: |
Acknowledgement returned to the producer on an accepted delivery. A routed delivery yields
one todo per target endpoint; `id`/`queue` name the first (the owner's, unless a rule
narrowed it out) and are absent when `todos` is empty.
required: [todos, created, trust_mode, verified]
properties:
todos:
type: array
items:
type: object
required: [id, endpoint_id, queue, created]
properties:
id: { type: string, example: td_69315ae1-… }
endpoint_id: { type: string }
queue: { type: string, example: inbox }
created: { type: boolean, description: false when the delivery collapsed onto an existing todo. }
created: { type: integer, description: Count of newly created todos; 0 for a wholly idempotent redelivery. }
id: { type: string }
queue: { type: string }
trust_mode: { $ref: '#/components/schemas/TrustMode' }
verified: { type: boolean }
dropped: { type: boolean, description: true when a routing rule dropped the delivery (event recorded, no todo). }
repeat: { type: boolean, description: true when a `once` rule already made a todo about the same subject. }

IngestError:
type: object
required: [error]
properties:
error: { type: string, example: signature verification failed, description: Never contains secret or signature values. }

Settings:
type: object
description: Mutable app settings (Settings screen). Persisted in the `settings` table.
properties:
retention_max_age_days: { type: integer, minimum: 0, default: 30 }
retention_max_rows: { type: integer, minimum: 0, default: 50000 }
sse_retry_ms: { type: integer, minimum: 250, default: 3000 }

Health:
type: object
required: [status]
properties:
status: { type: string, enum: [ok, degraded, error] }
db: { type: string, enum: [ok, error] }
database: { type: string, enum: [ok, error] }
version: { type: string }

Problem:
type: object
description: RFC 9457 problem detail. `detail` never contains secret or full-signature values.
properties:
type: { type: string, format: uri, default: about:blank }
title: { type: string }
status: { type: integer }
detail: { type: string }
instance: { type: string }

VendEndpointIn:
type: object
required: [name]
properties:
name:
type: string
description: The agent name; the endpoint slug derives from it.
example: my-agent
queue:
type: string
default: inbox
description: The single scoped queue the endpoint drains and the webhook routes to.
example: inbox
replay_targets:
type: array
maxItems: 10
items: { type: string, format: uri }
description: >-
Replay destinations the endpoint owns (SPEC-0033 REQ "Owned Replay Targets"). The first
is the default for a `replay_webhook_event` that names no `target_url`. Each must be
https on a public address; a target the SSRF guard refuses fails the vend with 400 and
mints nothing.
example: ['https://consumer.example.com/hook']
verbs:
type: array
items: { type: string }
description: >-
The MCP verbs to grant (ADR-0047): verb names and/or the presets `todo` (the eight todo
verbs), `webhook-admin` (webhook lifecycle, routing, rules, trusted actors, event history)
and `all`, expanded server-side and de-duplicated in canonical order. Absent or empty
vends the default grant — the todo verbs plus the endpoint's own lease verbs — so a lane
credential no longer holds the webhook-administration surface by default. A name that is
neither a verb nor a preset fails the vend with 400 `invalid_argument` and mints nothing.
Notify-hook verbs (SPEC-0024) belong to no preset and are grantable by name only.
example: ['todo', 'add_webhook_route']
default_lease_ttl_seconds:
type: [integer, "null"]
minimum: 60
maximum: 86400
description: >-
The endpoint's default claim lease (ADR-0043): what a `claim`, `claim_next` or
`heartbeat` that passes no `lease_ttl_seconds` gets. Absent or null uses the server
default (300). Out of range fails the vend with 400 `invalid_argument` and mints nothing.
example: 3600

VendEndpointOut:
type: object
required: [agent_name, slug, mcp_url, token, queue, verbs, webhook]
properties:
agent_name: { type: string, example: my-agent }
slug: { type: string, example: my-agent-k3x9 }
mcp_url: { type: string, format: uri, example: 'https://sb.example.com/mcp/my-agent-k3x9' }
token:
type: string
description: The vended endpoint bearer credential (sbk_…). Shown ONCE — store it now.
queue: { type: string, example: inbox }
verbs:
type: array
items: { type: string }
description: The endpoint's verb allowlist (drain + webhook + event verbs).
replay_targets:
type: array
items: { type: string, format: uri }
description: The replay targets the endpoint owns, trimmed and de-duplicated, in order.
webhook: { $ref: '#/components/schemas/VendWebhookOut' }
mcp_json:
type: object
description: >-
The ready-to-paste `.mcp.json` stanza wiring an MCP client to `mcp_url` with `token`
embedded as the bearer — byte-identical to the web reveal's wiring. Present on every
successful vend.
additionalProperties: true
expires_at: { type: [string, "null"], format: date-time }
default_lease_ttl_seconds:
type: [integer, "null"]
description: The endpoint's own default lease in seconds, or null for the server default.
effective_lease_ttl_seconds:
type: integer
description: The lease a claim or heartbeat with no `lease_ttl_seconds` gets.
example: 300

VendWebhookOut:
type: object
required: [webhook_id, ingest_url, trust_mode]
properties:
webhook_id: { type: string }
ingest_url: { type: string, format: uri }
trust_mode:
type: string
enum: [token]
description: Always token — the unguessable ingest URL is the credential (ADR-0003).

AgentOut:
type: object
required: [id, name]
properties:
id: { type: string }
name: { type: string, example: my-agent }
description: { type: string }

PushTodoIn:
type: object
required: [title]
properties:
title:
type: string
maxLength: 200
description: The doorbell's one line. Detail belongs in `payload`.
example: look at PR 7
queue:
type: string
description: One of the endpoint's vended queues. Optional when the endpoint drains exactly one.
example: reviews
kind:
type: string
default: operator
description: What `list_todos` reports as the todo's kind.
payload:
description: Any JSON, handed to the agent verbatim.
example: { pr: 7 }
key:
type: string
maxLength: 256
description: Idempotency key, scoped to the endpoint — the same key again returns the existing live todo.
example: pr-7

PushTodoOut:
type: object
required: [id, endpoint_id, slug, queue, state, created, event_id]
properties:
id: { type: string, example: td_5f1c… }
endpoint_id: { type: string }
slug: { type: string, example: my-agent-k3x9 }
queue: { type: string, example: reviews }
state: { type: string, example: pending }
created:
type: boolean
description: False when `key` matched a live todo — nothing minted, nothing rung.
event_id:
type: integer
description: The `operator` delivery event the todo was born from.

EndpointOut:
type: object
description: A vended endpoint WITHOUT credentials — tokens are revealed once at mint (SPEC-0007).
required: [slug, agent_name, state, queues, verbs]
properties:
slug: { type: string, example: my-agent-k3x9 }
agent_name: { type: string, example: my-agent }
state: { type: string, enum: [active, revoked], example: active }
queues: { type: array, items: { type: string } }
verbs: { type: array, items: { type: string } }
replay_targets:
type: array
items: { type: string, format: uri }
description: The endpoint's owned replay targets, returned only to its owner.
expires_at: { type: [string, "null"], format: date-time }
default_lease_ttl_seconds:
type: [integer, "null"]
description: The endpoint's own default lease in seconds (ADR-0043), or null for the server default.
example: 3600
effective_lease_ttl_seconds:
type: integer
description: The lease a claim or heartbeat with no `lease_ttl_seconds` gets.
example: 3600

EditEndpointIn:
type: object
required: [default_lease_ttl_seconds]
additionalProperties: false
properties:
default_lease_ttl_seconds:
type: [integer, "null"]
minimum: 60
maximum: 86400
description: The new default lease in seconds, or null to use the server default (300).
example: 3600

WidenEndpointScopeIn:
type: object
additionalProperties: false
description: >
Additions to an endpoint's scope (ADR-0049). At least one is required; an array of only blanks or
an empty body is refused. Any other key — a replacement list, a removal — is refused by a decoder
that rejects unknown fields, which is what makes this route unable to narrow a grant.
properties:
add_webhook_queues:
type: array
items: { type: string }
description: Queues its webhooks may newly route to. The routing grant is a union computed on read, so the next delivery on a new queue is honored without a restart.
example: [merge]
add_webhook_source_types:
type: array
items: { type: string }
description: Ingest source types its agents may newly create webhooks for, validated against the server's set.
example: [github]
add_scope_verbs:
type: array
items: { type: string }
description: MCP verbs to add, resolved by the same resolver `POST /api/v1/endpoints` uses for `verbs`, so a widen cannot name a verb the vend path would refuse.
example: [list_webhooks]
webhook_max:
type: integer
minimum: 0
description: A ceiling on self-managed webhooks to RAISE. A value below the endpoint's current ceiling is refused, never clamped.
example: 2
confirm:
type: boolean
default: false
description: '`false` (absent) previews and writes nothing; `true` applies the change and records it.'

EndpointScope:
type: object
description: The part of an endpoint's grant a widen can reach, plus the queues it drains — which a widen never touches.
properties:
scope_queues:
type: array
items: { type: string }
description: 'The queues the endpoint may claim. Read-only here: what an endpoint may take is not an additive change to a route.'
scope_verbs: { type: array, items: { type: string } }
webhook_max: { type: integer }
webhook_source_types: { type: array, items: { type: string } }
webhook_queues: { type: array, items: { type: string } }

EndpointScopeWidening:
type: object
properties:
field:
type: string
enum: [webhook_queues, webhook_source_types, webhook_max, scope_verbs]
before:
description: The value it replaced — an array of names, or the ceiling.
oneOf:
- type: array
items: { type: string }
- type: integer
after:
description: The value it became.
oneOf:
- type: array
items: { type: string }
- type: integer
created_at: { type: string, format: date-time, description: Absent on a preview, which has written nothing. }

WidenEndpointScopeOut:
type: object
required: [endpoint_id, slug, applied, requires_confirmation, changed, before, after, widenings]
properties:
endpoint_id: { type: string, format: uuid }
slug: { type: string }
agent_name: { type: string }
applied: { type: boolean, description: '`false` on a preview.' }
requires_confirmation: { type: boolean, description: '`true` on a preview: pass `confirm: true` to apply.' }
changed: { type: boolean, description: '`false` when every named value was already granted — a successful no-op that wrote no history row.' }
before: { $ref: '#/components/schemas/EndpointScope' }
after: { $ref: '#/components/schemas/EndpointScope' }
widenings:
type: array
items: { $ref: '#/components/schemas/EndpointScopeWidening' }
description: The fields this call moved (or would move), and their history already on record.

EndpointScopeWideningsOut:
type: object
required: [endpoint_id, slug, widenings]
properties:
endpoint_id: { type: string, format: uuid }
slug: { type: string }
agent_name: { type: string }
widenings:
type: array
items: { $ref: '#/components/schemas/EndpointScopeWidening' }
description: Newest first.

APIError:
type: object
description: The human API's error shape (SPEC-0035). `code` is the code the matching MCP verb returns.
required: [error, code]
properties:
error: { type: string, description: 'Human-readable; names the offending rule where there is one.' }
code: { type: string, example: not_found }
state: { type: string, description: 'On a 409 for a revoked endpoint: its state.', example: revoked }

WebhookListOut:
type: object
required: [webhook_id, endpoint_id, endpoint_slug, endpoint_state, agent_name, source_type, target_queue, trust_mode, rule_count, has_default_action, has_params, created_at]
properties:
webhook_id: { type: string, format: uuid }
endpoint_id: { type: string, format: uuid }
endpoint_slug: { type: string, example: forge-router-ab12cd34 }
endpoint_state: { type: string, enum: [active, revoked] }
agent_name: { type: string }
source_type: { type: string, example: gitea }
target_queue: { type: string, example: triage }
trust_mode: { type: string, enum: [signed, token] }
rule_count: { type: integer }
has_default_action: { type: boolean }
has_params: { type: boolean }
created_at: { type: string, format: date-time }
rotated_at: { type: string, format: date-time }
provider_base_url: { type: string, description: Where a reconcile read would dial (ADR-0052). Absent when unset. }
provider_repo: { type: string, example: stump.wtf/switchboard }
provider_read_token_set: { type: boolean, description: A sealed read token is stored. The token itself is never returned. }
provider_connection_state: { type: string, enum: [unset, awaiting_credential, ready] }

ProviderConnectionOut:
type: object
description: What a reconcile read would use for this webhook. The token itself is never present.
required: [webhook_id, provider_read_token_set, provider_connection_state]
properties:
webhook_id: { type: string, format: uuid }
provider_base_url: { type: string, example: https://gitea.stump.rocks/api/v1 }
provider_repo: { type: string, example: stump.wtf/switchboard }
provider_read_token_set: { type: boolean }
provider_connection_state: { type: string, enum: [unset, awaiting_credential, ready] }
provider_connection_set_at: { type: string, format: date-time }

RuleAction:
type: object
description: Exactly one of queue or drop. The queue-only flags are ADR-0025's.
properties:
queue: { type: string }
drop: { type: boolean }
endpoints: { type: array, items: { type: string } }
exclusive: { type: boolean }
once: { type: boolean }
work_order: { type: boolean, description: 'Attach a switchboard-authored work order. Only the flag is input; the contents are computed at ingest.' }

Rule:
type: object
required: [expr, action]
properties:
id: { type: string, description: Minted when omitted. }
name: { type: string }
expr: { type: string, description: 'jq over the routing envelope; the first output decides.' }
action: { $ref: '#/components/schemas/RuleAction' }

WebhookRules:
type: object
required: [webhook_id, source_type, target_queue, rules, grant]
properties:
webhook_id: { type: string, format: uuid }
source_type: { type: string }
target_queue: { type: string }
default_action: { $ref: '#/components/schemas/RuleAction' }
rules: { type: array, items: { $ref: '#/components/schemas/Rule' } }
params: { type: object, additionalProperties: true, description: 'Bound as $params in every rule.' }
grant:
type: object
properties:
queues: { type: array, items: { type: string } }
endpoints: { type: array, items: { type: string } }

WebhookRoutes:
type: object
required: [webhook_id, owner_endpoint_id, routes]
properties:
webhook_id: { type: string, format: uuid }
owner_endpoint_id: { type: string, format: uuid, description: 'Always a delivery target, implicitly and unremovably.' }
routes:
type: array
description: Explicit additional targets, newest grant first.
items:
type: object
required: [target_endpoint_id, granted_at]
properties:
target_endpoint_id: { type: string, format: uuid }
granted_at: { type: string, format: date-time }

WebhookRouteIn:
type: object
required: [target_endpoint_id]
properties:
target_endpoint_id: { type: string, format: uuid }

WebhookRouteAdded:
type: object
required: [webhook_id, target_endpoint_id, routed]
properties:
webhook_id: { type: string, format: uuid }
target_endpoint_id: { type: string, format: uuid }
routed: { type: boolean, description: 'Always true: the route exists, whether this call created it or not.' }

WebhookRouteRemoved:
type: object
required: [webhook_id, target_endpoint_id, removed]
properties:
webhook_id: { type: string, format: uuid }
target_endpoint_id: { type: string }
removed: { type: boolean, description: 'Always true: the route is gone, whether this call removed it or not.' }

WebhookRulesIn:
type: object
required: [rules]
description: A whole configuration. A GET response is accepted as it stands (read-only fields are ignored).
properties:
rules: { type: array, items: { $ref: '#/components/schemas/Rule' }, description: 'The complete ordered list; [] removes every rule.' }
default_action: { $ref: '#/components/schemas/RuleAction' }
params:
type: [object, "null"]
additionalProperties: true
description: Omit to keep the stored params; null clears them.

WebhookRulesTestIn:
type: object
description: Exactly one of event_id or payload.
properties:
event_id: { type: integer }
payload: { description: Any JSON, routed as if it had just arrived and verified. }
headers: { type: object, additionalProperties: { type: string } }
rules: { type: array, items: { $ref: '#/components/schemas/Rule' } }
default_action: { $ref: '#/components/schemas/RuleAction' }
params: { type: object, additionalProperties: true }
omit_envelope: { type: boolean }

WebhookRulesTestOut:
type: object
required: [decision, trace]
properties:
decision:
type: object
properties:
drop: { type: boolean }
queue: { type: string }
endpoints: { type: array, items: { type: string } }
disposition: { type: string, enum: [routed, dropped, faulted] }
faulted: { type: boolean, description: 'Blocking: the delivery would be recorded and routed nowhere.' }
unavailable: { type: boolean }
fault: { type: object, additionalProperties: true }
trace: { type: object, additionalProperties: true }
once_key: { type: string }
work_order: { type: object, additionalProperties: true }
envelope: { type: object, additionalProperties: true }

securitySchemes:
# Documented for clarity only — these are per-provider signature headers, verified by the app
# (ADR-0003), NOT bearer credentials the client obtains. UI/SSE endpoints have no in-app auth
# by design (localhost + Caddy forward_auth). See the info.description security posture.
githubSignature:
type: apiKey
in: header
name: X-Hub-Signature-256
description: HMAC-SHA256 of the raw body, `sha256=` prefixed.
stripeSignature:
type: apiKey
in: header
name: Stripe-Signature
description: '`t=` timestamp + `v1=` HMAC-SHA256 over "{t}.{body}".'
slackSignature:
type: apiKey
in: header
name: X-Slack-Signature
description: '`v0=` HMAC-SHA256 over "v0:{ts}:{body}"; paired with X-Slack-Request-Timestamp.'
operatorOAuth:
type: http
scheme: bearer
bearerFormat: opaque
description: |
An OPERATOR OAuth grant (ADR-0019/ADR-0023): an opaque access token minted by the
authorization-code + PKCE flow with resource = `<base>/api`, bound to the signed-in human.
Endpoint-bound tokens and static sbk_ credentials do NOT resolve here. The `switchboard`
CLI performs this flow (gh-style) and stores the rotating pair locally.

# No global security requirement: webhook auth is per-provider signature verification (above),
# and UI/SSE/health endpoints are intentionally unauthenticated at the app layer (ADR-0001).
security: []