Skip to content

Generic incoming webhook → Slack relay with bot-managed sources (Stripe support included) #189

Description

@keithah

Summary

Add a generic incoming webhook relay that posts to a Slack channel and lets sources be added/removed via the /kodiai Slack slash command — no redeployment required.

kodiai already has a generic webhook-to-Slack relay (POST /webhooks/slack/relay/:sourceId) but it requires a normalized custom payload format and is configured through a static SLACK_WEBHOOK_RELAY_SOURCES env var that requires a restart to change. This adds a flexible, DB-backed relay that accepts any webhook payload, with optional provider-specific handling (Stripe being the first provider preset with proper signature verification and pretty formatting).

User Flow

Generic webhook (any service):

/kodiai webhook add ci-alerts #deploys --secret mysecret
→ ✓ Added. Endpoint: POST https://<host>/webhooks/relay/ci-alerts
  Auth: X-Relay-Secret: mysecret

External service fires → POST /webhooks/relay/ci-alerts
→ Slack #deploys: "ci-alerts · deploy.completed\n{...payload summary...}"

Stripe preset:

/kodiai webhook add stripe prod #payments whsec_abc123
→ ✓ Added. Endpoint: POST https://<host>/webhooks/relay/prod

Stripe fires webhook → POST /webhooks/relay/prod
→ Slack #payments: "*payment_intent.succeeded*\nAmount: $49.99 · customer@example.com\n<https://dashboard.stripe.com/events/evt_xxx|Open in Stripe>"

Design

Auth Types

Type How it works
none No verification (for dev/internal use)
header_secret Custom header must equal stored secret value
stripe Stripe Stripe-Signature HMAC with timestamp replay protection
hmac Generic HMAC (configurable algorithm, header, secret) — future

Provider Presets

Presets bundle auth config + message formatting for a known service. First-party:

  • stripe: Stripe signature verification + human-readable Slack message (type, amount, customer, dashboard link)
  • generic (default): Header secret auth + JSON payload summary

Adding a new provider in the future means implementing an auth verifier and a message formatter for that service.

Slash Commands

/kodiai webhook add <id> <#channel> [--event-types <type,...>]        # generic, no auth
/kodiai webhook add <id> <#channel> --secret <value>                  # generic + header secret
/kodiai webhook add stripe <id> <#channel> <whsec_...> [event-types]  # Stripe preset
/kodiai webhook list                                                    # show all sources
/kodiai webhook remove <id>                                            # delete source

Event Type Filtering

All sources support optional --event-types filtering. For generic webhooks, the relay looks for type, event, or event_type in the top-level payload. For Stripe, it uses event.type.

Implementation

New Files

src/db/migrations/046-relay-sources.sql

CREATE TABLE relay_sources (
  id TEXT PRIMARY KEY,
  provider TEXT NOT NULL DEFAULT 'generic',   -- 'generic' | 'stripe'
  target_channel TEXT NOT NULL,
  auth_type TEXT NOT NULL DEFAULT 'none',     -- 'none' | 'header_secret' | 'stripe'
  auth_header_name TEXT,                      -- for header_secret
  auth_secret TEXT,                           -- for header_secret and stripe
  filter_event_types TEXT[] NOT NULL DEFAULT '{}',
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

src/db/relay-source-store.ts
Store with insert(), getById(), getAll(), delete() — same pattern as webhook-queue-store.ts.

src/relay/auth.ts
Auth verification dispatch:

  • none → always passes
  • header_secret → timing-safe compare of configured header value
  • stripe → Stripe Stripe-Signature algorithm (timestamp + HMAC-SHA256, replay window 300s)

src/relay/format.ts
Message formatting dispatch by provider:

  • stripe → human-readable (type, amount/currency, customer, dashboard link)
  • generic → provider name + event type extracted from payload + truncated JSON summary

src/routes/relay-webhooks.ts
Hono route factory createRelayWebhookRoutes(deps):

  • Pre-body rate limit (sourceId + IP)
  • Raw body read
  • DB lookup → 404 if unknown
  • Auth verification → 401 on failure
  • Post-verify rate limit (sourceId)
  • Extract event type from payload (provider-aware)
  • Apply filter_event_types → 202 {verdict: "suppress"}
  • Format message → post to channel
  • Return 202 {ok: true, verdict: "accept", sourceId, eventType, channel}

Modified Files

src/slack/slash-command-handler.ts
Add relaySourceStore dep. New webhook subcommand (see Slash Commands above).

src/routes/slack-commands.ts
Thread relaySourceStore through to handleKodiaiCommand.

src/index.ts

  • Instantiate createRelaySourceStore({ sql, logger })
  • Mount app.route("/webhooks/relay", createRelayWebhookRoutes(...))
  • Pass store into createSlackCommandRoutes

Relationship to Existing Relay

The existing POST /webhooks/slack/relay/:sourceId (env-var based, custom payload format) is left as-is. The new /webhooks/relay/:sourceId is a separate path. Existing relay sources continue working without any migration.

Testing

  • Unit tests: auth verification (each type), message formatting (generic + stripe), slash command handler, route handler
  • Manual smoke test with curl for each auth type
  • End-to-end: Stripe test-mode webhook → staging URL → Slack channel

Out of Scope

  • Migrating existing env-var relay sources to the new DB table
  • Storing secrets in Azure Key Vault (plain DB column is acceptable for this internal tool)
  • A web UI for managing sources

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions