OpenAPI — HTTP surface
The webhook ingestion endpoints and the local web-UI routes. Validated against OpenAPI 3.1.
⬇ Download the raw openapi.yaml · view on GitHub
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): signed providers are verified-or-401; the generic endpoint is
# a shared-secret token (or open); Redis is a separate pull ingestion path (see
# docs/reference/asyncapi.yaml, not here — it has no HTTP endpoint).
#
# 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**.
## Two groups of endpoints
- **Webhook ingestion** (`/webhooks/*`): providers POST here. Signed providers are
verified-or-rejected (401, not persisted); the generic endpoint uses a shared-secret token (or open).
- **Web UI** (`/`, `/log`, `/providers`, `/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`). See ADR-0003.
license:
name: MIT
url: https://github.com/joestump/switchboard/blob/main/LICENSE
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.
paths:
/webhooks/{provider}:
post:
tags: [ingestion]
operationId: ingestSignedWebhook
summary: Receive a signed provider webhook (GitHub / Stripe / Slack)
description: |
Receiver for a **signed** provider. The raw request body is read and the provider's
signature is verified **before any parsing** (a re-serialize would break the HMAC).
- **Verification is mandatory.** A missing, malformed, or failing signature returns **401**
and the payload is **NOT persisted** — only a redacted rejection is logged (ADR-0003).
- On success the event is normalized, persisted (`verified=true`, `trust_mode=signed`),
broadcast over SSE, and exposed to the MCP tools.
- Signature comparison is constant-time; Stripe/Slack additionally enforce a timestamp
freshness window (default 300s) to blunt replay.
Provider-specific handshakes: Slack `url_verification` returns **200** echoing the
`challenge` value instead of persisting an event.
security: []
parameters:
- name: provider
in: path
required: true
description: The signed provider slug.
schema:
type: string
enum: [github, stripe, slack]
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: Verified and persisted.
content:
application/json:
schema:
$ref: '#/components/schemas/IngestAck'
'200':
description: Provider handshake handled (e.g. Slack `url_verification` challenge echo).
content:
application/json:
schema:
type: object
additionalProperties: true
'400':
description: Malformed request (unreadable body, missing required headers).
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
'401':
description: |
Signature missing, malformed, or failed verification. **Payload not persisted.**
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
'403':
description: Provider is configured but disabled.
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
'404':
description: Unknown/unconfigured provider slug.
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
'413':
description: Body exceeds the configured maximum size.
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
/webhooks/generic/{name}:
post:
tags: [ingestion]
operationId: ingestGenericWebhook
summary: Receive a token-authenticated (or open) generic webhook — incl. Docker Hub
description: |
Catch-all receiver for senders with **no signature scheme at all** — self-hosted /
homelab-internal tooling and **Docker Hub** (which has no native webhook signing).
- **Shared-secret token (or open).** Events are persisted with `verified=false`,
`trust_mode=token`, `verify_detail="token ok"`, and are labeled
per their trust level everywhere in the UI and API (ADR-0003).
- **Opt-in and disabled by default.** A generic provider does not exist until an operator
explicitly creates one; an unknown `{name}` returns 404.
- Requires a configured **shared-secret token** the caller presents — `Authorization: Bearer`
(or a header) preferred, `?token=`/path token as a URL fallback (Docker Hub). It authenticates
the caller but **not** the body; constant-time compare; required by default. A bad/missing
token returns 403 (ADR-0003).
- Appropriate **only on a trusted network** already isolated by Caddy `forward_auth` /
UniFi segmentation.
security: []
parameters:
- name: name
in: path
required: true
description: Operator-assigned generic provider name (e.g. `dockerhub`, `homelab`).
schema: { type: string, pattern: '^[a-z0-9][a-z0-9_-]{0,63}$' }
- name: token
in: query
required: false
description: Shared-secret token (URL fallback; authenticates the caller, not the body). May instead be a path token.
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
additionalProperties: true
responses:
'202':
description: Accepted and persisted (token-authenticated).
content:
application/json:
schema: { $ref: '#/components/schemas/IngestAck' }
'403':
description: Shared token missing or incorrect, or provider disabled.
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
'404':
description: No generic provider configured under this name.
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
'413':
description: Body exceeds the configured maximum size.
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
/:
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' }
/providers:
get:
tags: [ui]
operationId: getProviders
summary: Provider config (UI screen 3)
description: |
Lists configured providers with family (`webhook` / `queue`) and trust_mode (`signed` / `token` / `open` / `queue`), the
webhook URL path or Redis channel, secret/verification status
(`configured` / `missing` / `none-by-design` — the secret itself is **never** shown), and an
enable/disable toggle. HTML page.
security: []
responses:
'200':
description: HTML page.
content:
text/html:
schema: { type: string }
/providers/{name}/enabled:
post:
tags: [ui]
operationId: setProviderEnabled
summary: Enable/disable a provider (HTMX toggle)
description: Flips `providers.enabled`. Returns the re-rendered toggle fragment (HTMX).
security: []
parameters:
- name: name
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
application/x-www-form-urlencoded:
schema:
type: object
properties:
enabled: { type: boolean }
required: [enabled]
responses:
'200':
description: Re-rendered toggle fragment (HTML).
content:
text/html:
schema: { type: string }
'404':
description: No such provider.
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' }
/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 provider (`github`, `stripe`, `slack`, `generic:<name>`, `redis:<channel>`).
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` = shared-secret-authenticated webhook (caller, not body); `open` = unchecked
webhook (trusted-network only); `queue` = pull adapter, trust = broker connection.
enum: [signed, token, open, queue]
ProviderFamily:
type: string
description: Ingestion family (ADR-0003) — webhook (push) or queue (pull).
enum: [webhook, queue]
SecretStatus:
type: string
description: Runtime secret status (configured/missing/none-by-design). Never the secret value.
enum: [configured, missing, none-by-design]
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"], description: Null for Redis-ingested events. }
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).
ProviderStatus:
type: object
required: [name, kind, trust_mode, enabled, secret_status]
properties:
name: { type: string, example: 'generic:dockerhub' }
family: { $ref: '#/components/schemas/ProviderFamily' }
trust_mode: { $ref: '#/components/schemas/TrustMode' }
enabled: { type: boolean }
secret_status: { $ref: '#/components/schemas/SecretStatus' }
path:
type: [string, "null"]
description: Webhook route path for HTTP providers (null for redis).
example: /webhooks/generic/dockerhub
channel:
type: [string, "null"]
description: Redis channel/stream for redis providers (null for HTTP).
example: deploys
IngestAck:
type: object
description: Acknowledgement returned to the provider on a successfully received event.
required: [id, provider, trust_mode, verified, received_at]
properties:
id: { type: integer, format: int64 }
provider: { type: string }
trust_mode: { $ref: '#/components/schemas/TrustMode' }
verified: { type: boolean }
received_at: { type: string, format: date-time }
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 }
replay_default_target:
type: [string, "null"]
format: uri
description: Default target for MCP `replay_webhook_event` when no target_url is given.
Health:
type: object
required: [status]
properties:
status: { type: string, enum: [ok, degraded, error] }
db: { type: string, enum: [ok, error] }
redis: { type: string, enum: [ok, disabled, 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 }
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.'
genericToken:
type: apiKey
in: query
name: token
description: Shared-secret token — authenticates the caller, not the body (ADR-0003).
# 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: []