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
Summary
Add a generic incoming webhook relay that posts to a Slack channel and lets sources be added/removed via the
/kodiaiSlack 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 staticSLACK_WEBHOOK_RELAY_SOURCESenv 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):
Stripe preset:
Design
Auth Types
noneheader_secretstripeStripe-SignatureHMAC with timestamp replay protectionhmacProvider 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 summaryAdding a new provider in the future means implementing an auth verifier and a message formatter for that service.
Slash Commands
Event Type Filtering
All sources support optional
--event-typesfiltering. For generic webhooks, the relay looks fortype,event, orevent_typein the top-level payload. For Stripe, it usesevent.type.Implementation
New Files
src/db/migrations/046-relay-sources.sqlsrc/db/relay-source-store.tsStore with
insert(),getById(),getAll(),delete()— same pattern aswebhook-queue-store.ts.src/relay/auth.tsAuth verification dispatch:
none→ always passesheader_secret→ timing-safe compare of configured header valuestripe→ StripeStripe-Signaturealgorithm (timestamp + HMAC-SHA256, replay window 300s)src/relay/format.tsMessage formatting dispatch by provider:
stripe→ human-readable (type, amount/currency, customer, dashboard link)generic→ provider name + event type extracted from payload + truncated JSON summarysrc/routes/relay-webhooks.tsHono route factory
createRelayWebhookRoutes(deps):filter_event_types→ 202{verdict: "suppress"}{ok: true, verdict: "accept", sourceId, eventType, channel}Modified Files
src/slack/slash-command-handler.tsAdd
relaySourceStoredep. Newwebhooksubcommand (see Slash Commands above).src/routes/slack-commands.tsThread
relaySourceStorethrough tohandleKodiaiCommand.src/index.tscreateRelaySourceStore({ sql, logger })app.route("/webhooks/relay", createRelayWebhookRoutes(...))createSlackCommandRoutesRelationship to Existing Relay
The existing
POST /webhooks/slack/relay/:sourceId(env-var based, custom payload format) is left as-is. The new/webhooks/relay/:sourceIdis a separate path. Existing relay sources continue working without any migration.Testing
curlfor each auth typeOut of Scope