Routing Accessibility Alerts to Slack and Email

To notify teams about routed accessibility findings without training them to ignore the channel, fan out each disposition into batched alerts — an immediate Slack message to the owning team’s channel for blocking findings, and a once-daily email digest for everything else — keyed on the owning team so no one receives another team’s noise. This page is the notification layer of the remediation ticket routing workflow, part of the accessibility compliance baseline for enterprise web ops; it covers alert fan-out, batching, and severity-based channel selection, distinct from resolving who owns a finding.

When This Applies

Alert routing matters once findings are already owned and ticketed but humans still need a timely, non-overwhelming signal. The conditions:

  • Blocking findings need to reach the owning team in seconds — a ticket alone sits unread until someone opens the board.
  • A nightly scan produces hundreds of non-blocking findings, and one message per finding would bury the channel and get muted within a sprint.
  • Different severities warrant different urgency and different destinations: a critical regression pings an on-call channel, a minor finding waits for a digest.
  • Multiple teams share the pipeline, so a single global #accessibility channel means every team scrolls past every other team’s findings.

If your pipeline files tickets and nobody needs a real-time nudge — a small team that reviews its board every morning — you may not need alerting at all. Notifications earn their keep when latency-to-awareness or channel targeting is the bottleneck, not ticket creation.

Minimal Reproducible Example

The failure mode is one webhook call per finding to one shared channel. It looks fine on a ten-finding demo and becomes unusable at scale.

# alert_naive.py — one message per finding to one channel. Causes alert fatigue.
import requests

WEBHOOK = "https://hooks.slack.com/services/T000/B000/xxx"

def alert(finding):
    requests.post(WEBHOOK, json={
        "text": f":warning: {finding['rule_id']} on {finding['route']}"
    })

# A nightly scan with 400 non-blocking findings posts 400 messages
# to #accessibility at 2am. By morning the channel is muted for good.
for f in nightly_findings:      # 400 items
    alert(f)                    # 400 separate Slack posts

Two things break here. There is no batching, so volume scales with findings and the channel becomes noise; and there is no severity or ownership routing, so a keyboard trap for the checkout team and a minor contrast nit for marketing land identically in one firehose nobody can triage.

Correct Implementation

A durable notifier separates two paths by severity: blocking findings post immediately to the owning team’s channel, and everything else accumulates into a per-team daily digest. Batching collapses volume; channel selection comes from the ownership resolution that already ran, so each team sees only its own findings.

# notifier.py — severity-routed, batched, per-team accessibility alerts.
import os
import json
import requests
from collections import defaultdict

class Notifier:
    def __init__(self, slack_webhooks: dict[str, str], smtp_send):
        # slack_webhooks maps a team channel -> its webhook URL, so each
        # team's alerts go to its own channel, never a shared firehose.
        self.slack_webhooks = slack_webhooks
        self.smtp_send = smtp_send
        self._digest: dict[str, list] = defaultdict(list)   # channel -> findings

    def alert(self, owner, finding, blocking: bool):
        """Immediate Slack for blocking; accumulate the rest for the digest."""
        if blocking:
            self._post_slack(owner, finding)     # real-time, critical/serious
        else:
            self._digest[owner.channel].append((owner, finding))  # batch it

    def _post_slack(self, owner, finding):
        webhook = self.slack_webhooks.get(owner.channel)
        if not webhook:
            # Missing channel config must fail loud, not swallow the alert.
            raise KeyError(f"no Slack webhook for {owner.channel}")
        payload = {
            "text": f":no_entry: *Blocking a11y regression* — {finding['rule_id']}",
            "blocks": [{
                "type": "section",
                "text": {"type": "mrkdwn", "text": (
                    f"*{finding['rule_id']}* ({finding['impact']}) "
                    f"on `{finding['route']}`\n"
                    f"Owner: *{owner.team}* · "
                    f"<{finding['ticket_url']}|open ticket>"
                )},
            }],
        }
        r = requests.post(webhook, json=payload, timeout=10)
        r.raise_for_status()

    def flush_digests(self):
        """Send one grouped email per team at the end of the run."""
        for channel, items in self._digest.items():
            team = items[0][0].team
            self.smtp_send(
                to=f"{team}@example.com",
                subject=f"[a11y] {len(items)} findings for {team}",
                body=self._render_digest(items),
            )
        self._digest.clear()

    @staticmethod
    def _render_digest(items) -> str:
        # Group by rule so a recurring defect is one line, not fifty.
        by_rule = defaultdict(int)
        for _owner, f in items:
            by_rule[f["rule_id"]] += 1
        lines = [f"- {rule}: {count} occurrence(s)"
                 for rule, count in sorted(by_rule.items())]
        return "Non-blocking accessibility findings:\n" + "\n".join(lines)

The digest deliberately groups occurrences by rule_id so a design-system defect appearing fifty times becomes one line reading color-contrast: 50 occurrence(s), not fifty rows. That single change is what keeps a daily email skimmable. The blocking path stays per-finding because a blocking regression is rare and each one deserves an individual, actionable message with a ticket link.

Wire it into the routing run so the immediate alerts fire during the pass and the digests flush once at the end:

# In the routing run: alerts happen inline, digests flush once.
notifier = Notifier(SLACK_WEBHOOKS, smtp_send)
route_run(findings, ownership_map, tracker, notifier)   # calls notifier.alert(...)
notifier.flush_digests()                                # one email per team, once

Store each channel’s webhook as a secret, not in the repo, and key the map by the same owner.channel the resolver emits so a new team is onboarded by adding one map entry plus one secret — no code change.

Pipeline Integration Note

Notification is the terminal fan-out of routing: the severity gate in remediation ticket routing calls notifier.alert with the blocking flag it already computed, so alert urgency inherits directly from the same impact policy that decides block-versus-backlog. The owner.channel used for targeting comes straight out of mapping violations to component owners, which means channel selection is free once ownership is resolved. Alerts reference the ticket created by the remediation routing from violation to issue tracker layer, so a Slack message is a pointer to durable tracked work rather than the record itself — the notification can be missed without losing the finding.

Gotchas

  • Webhook rate limits drop your loudest alerts first. Slack throttles a webhook to roughly one message per second, so a burst of blocking alerts can silently 429. Queue immediate posts through a small rate-limited sender with retry-on-429, and never let a dropped alert pass silently — a blocking finding that fails to notify must at least log an error the run surfaces.
  • Digest timezones misfire for distributed teams. A digest flushed at the pipeline’s midnight lands mid-afternoon for an offshore team and gets buried. Send per-team digests on each team’s own local morning by reading a timezone from the ownership map, or the digest is technically delivered but practically unread.
  • Retried CI runs double-send alerts. A re-run of the same commit re-emits every alert, so a flaky pipeline trains teams to distrust the channel. Guard immediate posts with the finding’s dedupe fingerprint and a short-lived sent-set so a given blocking finding alerts once per genuine occurrence, not once per retry.

Frequently Asked Questions

How do I stop nightly accessibility alerts from becoming noise?

Batch everything that is not blocking into a single per-team daily digest and group its entries by rule id, so a defect appearing fifty times is one line rather than fifty messages. Reserve immediate, individual messages for critical and serious findings that genuinely need a real-time response, which keeps the channel signal high enough that people still read it.

Should critical findings go to Slack or email?

Send critical and serious findings to Slack immediately, because a ticket and an email both sit unread until someone checks them, whereas a channel ping reaches the owning team in seconds. Route non-blocking findings to a batched email digest instead, so the two severities use transports matched to their urgency rather than blasting everything through one channel.

How do I route each team's alerts to its own channel?

Key a webhook map on the same channel field the ownership resolver already emits for each finding, and look the webhook up per finding at send time. Onboarding a new team then means adding one ownership-map entry and one webhook secret with no code change, and no team ever scrolls past another team’s findings in a shared firehose.