SPEC-0001: Webhook Ingestion (Push Adapters)
SPEC · SPEC-0001 · Status · amended · Date · 2026-07-21 · Implements · ADR-0003, ADR-0014, ADR-0022
Overview
Webhook ingestion is Switchboard's push ingestion family: inbound HTTP endpoints that receive
provider deliveries, verify their trust (per-provider signature or a shared-secret token), normalize
the delivery, persist an event for history, and enqueue a durable todo. It realizes the webhook half
of ADR-0003 (the webhook
provider family and its ordered trust modes signed / token / open) and the push family of
ADR-0014 (push vs. pull adapters sharing
one normalization contract into the todo queue).
A webhook is push: the sender initiates an HTTP request, and the "ack" is the HTTP response.
Verification happens at receive, before any parsing, against the raw request body. The three trust
modes are honest and ordered — signed (HMAC verified, verified=true) is strictly stronger than
token (shared secret authenticates the caller, not the body, verified=false), which is stronger
than open (no check, verified=false, off by default). The trust mode is stored on every event and
surfaced everywhere so a human never has to guess whether a delivery was authenticated.
As of ADR-0022, all webhook ingestion flows through agent self-managed webhooks
(ADR-0012) served at
POST /webhooks/w/{ingest_token} (internal/ingest/selfmanaged.go). Every webhook is owned by
exactly one vended MCP endpoint, and every todo produced by a delivery is pinned to that endpoint
(per the todo-queue spec "Endpoint Ownership" requirement). The
operator-configured signed receivers for GitHub/Stripe/Slack and the generic token/open receiver are
retired: they carried no endpoint owner and could not satisfy the tenant-isolation invariant.
Self-managed signed webhooks cover the same providers plus Gitea (the agent creates the webhook; switchboard
mints and holds the HMAC secret exactly as before). This capability covers only the push family;
pull (queue) ingestion is SPEC-0002.
Requirements
Requirement: Signed Webhook Verification
For a webhook provider declared signed (GitHub, Gitea, Stripe, Slack), the receiver MUST verify a
cryptographic signature over the raw request body before parsing the payload, using a
constant-time comparison. A missing, malformed, or failing signature MUST return HTTP 401 and MUST
NOT persist the payload; only a redacted rejection line MAY be logged (provider, event type if
known, source IP — never the secret or full signature). A verified request MUST persist the event
with trust_mode='signed', verified=true, and a verify_detail naming the scheme (e.g.
hmac-sha256 ok). Signature comparison MUST use a constant-time primitive (hmac.Equal), never a
plain byte equality.
Scenario: Valid GitHub signature is accepted
- WHEN a
POST /webhooks/githubrequest arrives whoseX-Hub-Signature-256header matches the HMAC-SHA256 of the raw body under the configured secret - THEN the event is persisted with
source='github',family='webhook',trust_mode='signed',verified=true,verify_detail='hmac-sha256 ok', a todo is created, and the response is HTTP 202 with{id, queue, verified: true}
Scenario: Valid Gitea self-managed signature is accepted
- WHEN a self-managed gitea webhook delivery arrives whose
X-Gitea-Signatureheader matches the bare hex HMAC-SHA256 of the raw body under the minted signing secret (nosha256=prefix) - THEN the event is persisted with
source='gitea',family='webhook',trust_mode='signed',verified=true,verify_detail='hmac-sha256 ok', a todo is created, and the response is HTTP 202 with{todos, created, verified: true, trust_mode: 'signed'}
Scenario: Missing or invalid signature is rejected without persisting
- WHEN a signed-provider request arrives with a missing, malformed, or non-matching signature
- THEN the response is HTTP 401, no event row and no todo are written, and a redacted rejection line (no secret, no full signature) MAY be logged
Scenario: Signature secret not configured
- WHEN a signed-provider request arrives but no signing secret is configured for that provider
- THEN the request is rejected without persisting (HTTP 503 for a globally-required-but-unset secret, or HTTP 404/403 for a provider that is not configured) and no signature is compared
Requirement: Replay-Window Enforcement for Timestamped Signatures
For signed providers whose scheme signs a timestamp (Stripe t=, Slack
X-Slack-Request-Timestamp), the receiver MUST reject a delivery whose signed timestamp is outside
a freshness tolerance (default 300 seconds) even when the HMAC is otherwise valid, returning HTTP
401 and persisting nothing. For providers whose scheme does not sign a timestamp (GitHub, Gitea), the
receiver MUST NOT fabricate a replay window.
Scenario: Stale Stripe timestamp is rejected
- WHEN a Stripe delivery presents a valid
v1=HMAC but at=timestamp more than the configured tolerance from now - THEN the response is HTTP 401 and no event is persisted
Scenario: GitHub delivery has no timestamp check
- WHEN a GitHub delivery with a valid signature arrives
- THEN it is accepted regardless of age (GitHub does not sign a timestamp) and no freshness window is applied
Requirement: Cairn Signed Deliveries
A self-managed webhook of source type cairn is signed. It MUST verify cairn's outbound
artifact.created deliveries (cairn ADR-0017 / SPEC-0012) as follows.
- Signature.
X-Cairn-Signature: sha256=<hex>MUST equal the HMAC-SHA256 of the raw request body under the minted secret, compared in constant time. - Replay defenses. Cairn signs no timestamp header, so replay defenses MUST come from the signed
body:
- It MUST carry a non-empty
event_id, which MUST be the delivery's idempotency key (<webhook-id>:<event_id>). - It MUST carry a
created_at(RFC 3339) within the replay tolerance of now, in either direction (default 300 seconds).
- It MUST carry a non-empty
- Event-id header. The unsigned
X-Cairn-Event-Idheader, when present, MUST equal the signedevent_id.
Failing any check MUST return HTTP 401 and persist nothing. The event's event_type MUST be the
signed body's kind. The todo title MUST name the kind, the artifact title, and the share type.
Scenario: Tampered cairn body is rejected
- WHEN a cairn delivery's body is altered after signing
- THEN the response is HTTP 401 and no event or todo is persisted
Scenario: Replay after the window is rejected
- WHEN a correctly signed cairn delivery arrives whose signed
created_atis outside the replay tolerance - THEN the response is HTTP 401 and nothing is persisted
Scenario: Replay inside the window collapses
- WHEN an identical signed cairn delivery is replayed within the window
- THEN it derives the same idempotency key from the signed
event_idand collapses onto the original todo
Scenario: Forged event-id header cannot mint a fresh dedup key
- WHEN a captured cairn delivery is replayed with a different
X-Cairn-Event-Idheader - THEN the response is HTTP 401 and nothing is persisted
Requirement: Shared-Secret Token Authentication for Unsigned Webhooks
For webhook providers with no signing scheme (Docker Hub, homelab/self-hosted senders) served via
the generic endpoint, the receiver MUST require a configured shared-secret token the caller
presents on every request, compared in constant time. The token SHOULD be presented in an HTTP
header (Authorization: Bearer <token> or a dedicated token header) and MAY fall back to a URL
token (?token= or a path token) for senders that can only be configured with a URL. A generic
provider MUST be disabled until a token is configured, and MUST reject a request with a
missing/incorrect token with HTTP 403 without persisting. An accepted token request MUST persist the
event with trust_mode='token', verified=false, and a verify_detail that states the caller is
authenticated but the body is not verified. token MUST NOT ever be presented as signed.
Scenario: Correct token is accepted as token trust
- WHEN a
POST /webhooks/generic/{name}request presents the configured shared secret (header or URL fallback) - THEN the event is persisted with
family='webhook',trust_mode='token',verified=false, a todo is created, and the response is HTTP 202
Scenario: Missing or wrong token is rejected
- WHEN a generic request presents a missing or incorrect token
- THEN the response is HTTP 403 and no event or todo is written
Scenario: Generic provider disabled until token set
- WHEN an operator configures a generic provider without a token
- THEN the provider is disabled and every request to it is rejected (403) until a token is set
Requirement: Explicit Open Trust Mode
A provider MAY be explicitly set to open (no verification) only by deliberate operator opt-in.
Open providers MUST default to disabled, MUST persist events with trust_mode='open',
verified=false, and a plain verify_detail (e.g. open — no verification), and MUST be labeled as
the loudest/weakest tier everywhere. The system MUST NOT default any provider to open.
Scenario: Open provider only exists when explicitly created
- WHEN no operator has explicitly created an
openprovider - THEN no
openprovider accepts deliveries; an unknown provider name returns HTTP 404
Scenario: Open delivery is labeled unverified
- WHEN a delivery arrives at an explicitly-created
openprovider - THEN the event is persisted with
trust_mode='open',verified=falseand labeled as unverified in the API and UI
Requirement: Idempotency Key Extraction and Dedup
Each accepted webhook delivery MUST derive an idempotency key and use it to dedup redeliveries into a
single todo. The key SHOULD be derived from a provider delivery id where one exists (GitHub
X-GitHub-Delivery, Gitea X-Gitea-Delivery, Stripe event id) and MUST fall back to a body hash
where the provider supplies no delivery id (Slack, generic). If a non-terminal todo already exists
in the target queue for the derived key, ingestion MUST return the existing todo and create nothing
new. Events MUST additionally dedup on (source, external_id) so a duplicate delivery does not
create a second event row.
Scenario: Redelivery of the same webhook creates one todo
- WHEN a provider redelivers a webhook with the same delivery id (e.g. GitHub retries the same
X-GitHub-Delivery) - THEN the second delivery matches the existing non-terminal todo, returns that todo, and creates no duplicate todo or event
Scenario: Distinct deliveries create distinct todos
- WHEN two deliveries carry different delivery ids
- THEN each derives a distinct idempotency key and each creates its own todo
Requirement: Header and Secret Sanitization Before Persist
Before an event's headers are persisted, the receiver MUST redact all signature-, token-, and
secret-bearing header values (X-Hub-Signature, X-Hub-Signature-256, X-Gitea-Signature,
Stripe-Signature, X-Slack-Signature, Authorization, Cookie, X-Api-Key, and any ?token=)
to a redaction placeholder. Full signatures, tokens, and secrets MUST NOT be logged or persisted in
full anywhere.
Scenario: Sensitive headers are redacted in storage
- WHEN a delivery carrying an
Authorizationheader and a signature header is accepted - THEN the persisted
headersJSON shows those values as«redacted»and no log line contains the full secret
Requirement: Enqueue Accepted Delivery as Endpoint-Owned Todo
Governing: ADR-0022, ADR-0007. An accepted delivery MUST normalize to the common todo shape and
create a durable todo pinned to the webhook's owning endpoint (endpoint_id from
endpoint_webhooks.endpoint_id), via the shared back-half contract. The todo MUST reference the
persisted event, carry a human-legible title, and be published to the live hub so any attached agent
session is nudged. Todo creation MUST be idempotent on (endpoint_id, idempotency_key) among
non-terminal rows (see the todo-queue spec "Per-Endpoint Idempotency and
Dedup"). The idempotency_key passed to the store MUST be namespaced by the target endpoint id so
that routed fan-out to N endpoints dedups per-target independently.
Scenario: Accepted delivery becomes a durable endpoint-owned todo
- WHEN a delivery passes verification on a self-managed webhook owned by endpoint A
- THEN an event row is inserted, a todo is created pinned to endpoint A (NOT to a global queue namespace) referencing that event, the todo is published to the hub if newly created, and the response is HTTP 202 with the todo id and queue
The 202 body MUST represent the FULL set of todos the delivery produced, since a routed delivery yields N:
{
"todos": [{"id": "td_…", "endpoint_id": "…", "queue": "reviews", "created": true}],
"created": 1,
"id": "td_…", "queue": "reviews",
"verified": true, "trust_mode": "signed"
}
id and queue MUST name the OWNING endpoint's todo, which the resolved target order puts
first — so an unrouted webhook (the common case) returns a body byte-identical to the
pre-fan-out shape. created is the count of newly-minted todos; a wholly idempotent
redelivery reports 0 while still returning the existing todos. endpoint_id is included
per todo so a fan-out is auditable: the producer already knows the webhook it posted to, and
the response is the only place the delivery says where the work actually landed.
Scenario: Routed delivery reports every todo it created
- WHEN a webhook routed to endpoints A and B receives one verified delivery
- THEN the 202 body's
todosarray MUST hold two entries — one per target, each naming itsendpoint_id— withcreated: 2, andid/queueMUST name the owner endpoint A's todo
Requirement: Deterministic Route Fan-Out (Token-Free)
Governing: ADR-0022. A webhook MAY be routed to N target endpoints via the webhook_routes table.
When a delivery arrives, the receiver MUST resolve the webhook's target endpoints and create one
todo per target endpoint, each pinned to that target, in a single transaction with the event row
(atomic across targets: all commit or none). If no routes are configured, the target set is the
singleton {webhook.endpoint_id}. If the resolved target set is EMPTY — the owning endpoint is not
resolvable — the receiver MUST treat the delivery as a misconfiguration and answer HTTP 503 with
nothing persisted, so the producer retries. It MUST NOT answer 202: a delivery that produces no work
anywhere has been dropped, and reporting success for it hides the broken webhook indefinitely.
Routing is deterministic and token-free: once a route exists,
every delivery fans out server-side without any agent spending model tokens on a create_for call.
Routes are populated by human-approved actions (friending, a future routing verb) — never by a
per-delivery agent decision.
After the target set is resolved and before anything is written, the webhook's routing rules
(SPEC-0020) choose the queue and MAY narrow the delivery to a subset of
those targets. They MAY also drop it: the event is persisted with its trace, no todo is created, and
the response is HTTP 202 with {"todos": [], "created": 0, "dropped": true}. Rules MUST NOT add a
target. The top-level id/queue name the first target's todo, which is the owner's unless a rule
narrowed the owner out.
Scenario: Single delivery, two routes, two todos
- WHEN a webhook with routes to endpoints A and B receives one verified delivery
- THEN exactly two todos are created — one pinned to A, one pinned to B — each independently claimable, and the event row commits atomically with both
Scenario: No routes configured falls back to owner
- WHEN a webhook with no rows in
webhook_routesreceives a delivery - THEN exactly one todo is created, pinned to the webhook's owning endpoint
Scenario: Unresolvable target set is refused, not dropped
- WHEN a delivery passes verification but the webhook resolves to no target endpoints
- THEN the response MUST be HTTP 503, no event row and no todo MUST be persisted, and the condition MUST be logged with the webhook id
Scenario: Route fan-out is token-free
- WHEN a webhook with a route to endpoint B receives a delivery and endpoint B's agent is idle (no model invocation in flight)
- THEN the todo pinned to endpoint B is still created and doorbelled; routing required no agent action and zero model tokens
Requirement: Error Handling Standards
Ingestion errors MUST be wrapped with context at each boundary (read, verify, insert event, create todo) and MUST NOT be silently swallowed. Domain rejections (bad signature, bad token, disabled provider, unknown provider, oversized body) MUST map to specific HTTP status codes (401 / 403 / 404 / 413) rather than a generic 500. Unexpected internal failures MUST return HTTP 500 without leaking internal detail, and MUST be logged with structured context.
Scenario: Store failure returns a clean 500
- WHEN the event insert or todo creation fails for an internal reason (e.g. database error)
- THEN the response is HTTP 500 with a generic message, and the failure is logged with structured context (never the payload secret)
Security Requirements
Authentication
All ingestion endpoints authenticate the delivery, not a Switchboard human/agent session. As of ADR-0022, the only ingestion endpoint is the self-managed webhook receiver; the operator-configured signed/token/open receivers are retired (they carried no endpoint owner).
| Endpoint | Auth | Justification |
|---|---|---|
POST /webhooks/w/{ingest_token} (signed source type) | Required (signed) | Per-provider HMAC-SHA256 over raw body verified constant-time against the secret switchboard minted and holds; fail ⇒ 401, no persist |
POST /webhooks/w/{ingest_token} (token source type) | Required (token) | The unguessable ingest token in the URL authenticates the caller; the body is not signature-verified; fail (unknown token) ⇒ 404 |
Rate Limiting
Rate limiting is deferred at the application layer for the MVP: the service is loopback/trusted-network
bound behind a reverse proxy (Caddy), where connection-level and proxy-level throttling apply. When
exposed publicly, per-source-IP rate limiting on the /webhooks/* routes SHOULD be added at the
proxy or middleware layer. Body-size bounding (below) provides the primary abuse mitigation in the
MVP.
Security Headers
All HTTP responses MUST include:
Content-Security-Policy:default-src 'none'(webhook endpoints return JSON, never HTML, so no script/style/image sources are needed)X-Frame-Options: DENYX-Content-Type-Options: nosniffReferrer-Policy: strict-origin-when-cross-origin
Request Body Size Limits
All endpoints accepting bodies MUST bound them with a limiting reader before verification. Default
limit: 5 MiB (matches maxBody in internal/ingest/ingest.go). A body exceeding the limit MUST
be rejected (HTTP 413) without persisting. The limiting reader MUST wrap the body so the raw bytes
used for HMAC verification are the same bounded bytes.
CSRF Protection
Webhook endpoints are machine-to-machine POSTs authenticated by signature or shared-secret token, not
by an ambient browser cookie, so classic form-CSRF does not apply: a cross-site request cannot forge a
valid HMAC or present the shared secret. No cookie or session credential is honored on /webhooks/*.
Redirect Validation
No webhook endpoint issues a redirect and none accepts a user-supplied redirect target. No user-supplied redirects exist in this capability.