SPEC-0025: Inbound Webhook Alert Ingestion
Overview
Defines the inbound webhook endpoint that allows external monitoring and alerting tools (UptimeKuma, Grafana Alertmanager, PagerDuty, Healthchecks.io, or any generic HTTP webhook) to push alerts into Claude Ops and trigger an automated investigation session. An LLM intermediary synthesizes the arbitrary payload into a focused, plain-language investigation prompt before the session is started. Sessions triggered via webhook are identified with trigger = "alert" throughout the system. See ADR-0024.
Requirements
Requirement: Endpoint Registration
The system MUST expose a POST /api/v1/webhook endpoint. The endpoint MUST be registered in the server's route table alongside existing API routes.
Scenario: Endpoint exists
- WHEN a client sends
POST /api/v1/webhookwith a valid bearer token and any non-empty body - THEN the server returns
202 Acceptedwith a JSON body containingsession_idandstatus: "triggered"
Scenario: Method not allowed
- WHEN a client sends
GET /api/v1/webhook - THEN the server returns
405 Method Not Allowed
Requirement: Bearer Token Authentication
The endpoint MUST require a valid bearer token. The token MUST be read from the CLAUDEOPS_CHAT_API_KEY environment variable on every request (supporting key rotation without restart). Comparison MUST be performed in constant time to prevent timing attacks.
Scenario: Valid token
- WHEN the
Authorizationheader containsBearer <valid-key>andCLAUDEOPS_CHAT_API_KEYis set to<valid-key> - THEN the request proceeds to payload processing
Scenario: Invalid token
- WHEN the
Authorizationheader contains an incorrect bearer token - THEN the server returns
401 Unauthorizedwith an error body and MUST NOT trigger a session
Scenario: Key not configured
- WHEN
CLAUDEOPS_CHAT_API_KEYis unset or empty - THEN the server returns
503 Service Unavailableindicating the webhook endpoint is disabled
Scenario: Missing Authorization header
- WHEN the request contains no
Authorizationheader - THEN the server returns
401 Unauthorized
Requirement: Universal Payload Acceptance
The endpoint MUST accept any non-empty request body regardless of Content-Type. This MUST include but is not limited to: application/json, application/x-www-form-urlencoded, and text/plain. The raw body bytes MUST be passed to the LLM synthesis step as a UTF-8 string. The endpoint MUST NOT reject a request solely because the body does not conform to a known schema.
Scenario: JSON payload (UptimeKuma)
- WHEN the body is
{"heartbeat": {"status": 0, "msg": "Connection timeout"}, "monitor": {"name": "Gitea", "url": "https://gitea.stump.wtf"}} - THEN the payload is accepted and passed to LLM synthesis without modification
Scenario: Plain text payload
- WHEN the body is
Alert: disk usage on ie01 is at 95% - THEN the payload is accepted and passed to LLM synthesis
Scenario: Empty body
- WHEN the request body is empty or whitespace-only
- THEN the server returns
400 Bad Requestand MUST NOT trigger a session
Requirement: LLM Prompt Synthesis
The system MUST call an LLM to convert the raw webhook payload into a focused plain-language investigation prompt before triggering a session. The synthesis model MUST be configurable via the CLAUDEOPS_WEBHOOK_MODEL environment variable and MUST default to claude-haiku-4-5-20251001. The system prompt for the synthesis call MUST instruct the model to produce a single, actionable investigation brief that a Claude Ops agent can act on directly.
Scenario: Successful synthesis
- WHEN the LLM synthesis call succeeds
- THEN the returned text is used verbatim as the session prompt passed to
TriggerAdHoc
Scenario: Synthesis failure
- WHEN the LLM synthesis API call returns an error or times out
- THEN the server returns
502 Bad Gatewaywith a descriptive error and MUST NOT trigger a session
Scenario: Synthesis produces empty result
- WHEN the LLM returns an empty or whitespace-only response
- THEN the server returns
502 Bad Gatewayand MUST NOT trigger a session
Scenario: Custom synthesis model
- WHEN
CLAUDEOPS_WEBHOOK_MODELis set toclaude-sonnet-4-6 - THEN the synthesis step uses that model instead of the default haiku model
Requirement: Session Triggering
After successful synthesis, the system MUST invoke TriggerAdHoc(prompt, startTier, "alert") with the synthesized prompt and trigger = "alert". The startTier MUST default to 1. An optional tier field in a JSON request body MAY override startTier to 2 or 3; if present, the value MUST be clamped to the configured MaxTier. The resulting session MUST be indistinguishable from a manually triggered session in all respects except the trigger field.
Scenario: Default tier
- WHEN the webhook payload contains no tier preference
- THEN the session starts at Tier 1
Scenario: Tier override via JSON
- WHEN the webhook body is JSON and contains
{"tier": 2, ...remaining payload...} - THEN the session starts at Tier 2 and the tier field is excluded from the payload passed to LLM synthesis
Scenario: Tier clamped to MaxTier
- WHEN the webhook body specifies
tier: 5andMaxTieris3 - THEN the session starts at Tier 3
Requirement: Alert Trigger Type
Sessions triggered via the webhook endpoint MUST be stored with trigger = "alert" in the database. The dashboard, session list, and REST API MUST surface this value. All existing query and filter surfaces that accept trigger values MUST treat "alert" as a valid value.
Scenario: Trigger label stored
- WHEN a webhook session is created
- THEN
SELECT trigger FROM sessions WHERE id = ?returns"alert"
Scenario: Dashboard displays alert trigger
- WHEN the session detail page renders a webhook-triggered session
- THEN the trigger label reads
"alert"(distinct from"scheduled","manual","api","escalation")
Requirement: Busy Response
The endpoint MUST return 409 Conflict when TriggerAdHoc reports that a session is already running. The response body MUST be a JSON object describing why the request could not be fulfilled; it SHOULD NOT be an empty body.
Scenario: Session already running
- WHEN a webhook alert arrives while a session is already running
- THEN the server returns
409 Conflictwith{"error": "session already running", "message": "..."}
Requirement: Webhook Model Configuration
The synthesis model MUST be independently configurable from other models in the system. The CLAUDEOPS_WEBHOOK_MODEL environment variable MUST be read on every request (supporting rotation without restart). The --webhook-model CLI flag MUST default to claude-haiku-4-5-20251001.
Scenario: Default model used
- WHEN
CLAUDEOPS_WEBHOOK_MODELis not set - THEN synthesis uses
claude-haiku-4-5-20251001
Scenario: Runtime model override
- WHEN
CLAUDEOPS_WEBHOOK_MODELis updated and the next request arrives - THEN the new model is used without restarting the container
Requirement: Response Format
On success, the endpoint MUST return 202 Accepted with Content-Type: application/json. The response body MUST include session_id (integer) and status (string, value "triggered"). It SHOULD include tier (the tier at which the session was started).
Scenario: Success response shape
- WHEN a session is triggered successfully
- THEN the response body is
{"session_id": <N>, "status": "triggered", "tier": <T>}