Provider-aware rich webhook messages (Slack / Teams / Discord)¶
Motivation¶
The opt-in webhook channel shipped a plain {text, content} payload — readable
by all three major providers but visually flat (no status colour, no
"Open in dashboard" button, no field layout). This pass routes each terminal-job
notification through a provider-aware builder so the message renders natively
where the operator reads it, without breaking the existing generic fallback
that custom Logic Apps subscribers parse.
User-facing change¶
Webhook messages posted by the dashboard now render as:
| Provider | Format | Render |
|---|---|---|
| Slack | Block Kit (header + section + actions button) | Status header, title + program/db, Open in dashboard button |
| Teams (incoming webhook) | Legacy MessageCard | Coloured banner, status fact row, Open in dashboard action |
| Discord | Embed + content | Coloured embed with title + program/db/error fields |
| Logic Apps / other allowlisted | {text, content} legacy shape |
Unchanged — preserves backward compatibility with custom integrators |
The deep-link is {DASHBOARD_PUBLIC_URL}/blast/jobs/{job_id} resolved through
the existing api.services.control_plane_url.resolve_control_plane_url() (env
override → operator-configured custom domain → Container App FQDN → empty). When
no base URL can be resolved the message is sent without the button rather
than embedding a broken link.
The Settings → Webhooks Send test button now sends the same rich payload shape, so an operator sees the actual rendered look in their tool.
Design¶
detect_provider(url)classifies the host against the same SSRF allowlist (hooks.slack.com/*.webhook.office.com/*.webhook.office365.us/discord.com/discordapp.com); everything else (including*.logic.azure.comandWEBHOOK_ALLOWED_HOSTSextras) falls through to generic.- One builder per provider —
_build_slack_payload,_build_teams_payload,_build_discord_payload, plus the legacy{text, content}for generic. A single colour palette + emoji map drives all three so the status visual is consistent. build_message(state, *, url="")keeps the old single-arg call working (returns generic) — onlydispatch_job_webhooksand theSend testendpoint pass the URL.
Hardening¶
- Title is capped at 240 chars and
error_codeat 200 chars before going into any payload, so a runaway error string cannot blow past provider field limits. post_webhooknow logs theRetry-Afterheader on a429rate-limit response (one-liner WARNING, no body) so an operator can correlate a 429 burst with their job workload.- No payload field is ever populated with a SAS token or subscription id —
message content is derived strictly from
JobStatecolumns the dashboard UI already shows.
Trade-off: Microsoft Teams Connector deprecation¶
Microsoft is sunsetting Office 365 Connectors (the classic incoming-webhook
endpoint that accepts MessageCard) in favour of the Workflows app, which
takes Adaptive Cards via its own webhook endpoint. Today the dashboard's
allowlist still accepts the classic *.webhook.office.com host, and existing
operator subscriptions there continue to receive MessageCard. When the connector
finally retires, the dispatch contract here is unchanged: swap _build_teams_payload
for an Adaptive Card builder pointed at the Workflows webhook URL the operator
will configure. The change note will live alongside this one when the migration
ships.
API / IaC diff summary¶
api/tasks/webhooks.py:build_messagegains an optionalurl=kwarg and a provider switch; new helpersdetect_provider,_resolve_job_url,_message_fields,_summary_text,_build_slack_payload,_build_teams_payload,_build_discord_payload.post_webhooklogs 429Retry-After.api/routes/settings/webhooks.py:/testnow callsbuild_messagewith the configured URL so the test render matches the real notification shape.docs/operate/feature-gates.md:WEBHOOK_NOTIFICATIONS_ENABLEDdescription updated to reflect the provider-aware payload + deep link.- No new dependency, no IaC change, no new gate.
Validation evidence¶
uv run pytest -q api/tests/test_webhooks_task.py api/tests/test_settings_webhooks.py api/tests/test_webhooks_pref.py— 37 passed (provider detection, Slack Block Kit shape, Teams MessageCard shape + colour + action URL, Discord embed shape, generic backward-compat, missing-base-URL no-button, generic-test message + sweep + cap unchanged).uv run ruff check api— all checks passed.uv run pytest -q api/tests— full suite (will be re-run in the commit step).