Diagnostic Slack
The webhook posts to one channel whatever the payload says
The alert router has four destinations. Database alerts go to #db-oncall, deploy notices to #releases, security events to #sec-ops, and everything else to #alerts. The code sets a channel field on every payload, exactly as the tutorial it was written from does, and every send comes back ok.
Then somebody in #db-oncall mentions that they have not seen a database alert in a while, and somebody in #alerts mentions that the channel has become unreadable. All four streams have been arriving in one room since the migration off the old custom integration, eighteen months ago, and nothing failed to make that happen.
An incoming webhook created through the modern Slack app model is bound to one channel, chosen at the moment it was created, and that binding cannot be changed by the payload. The channel field is a leftover from custom integrations: on an app based webhook it does not choose anything. Depending on where the value came from you get silence — the message lands in the bound channel anyway — or a refusal.
This is the failure mode with the fewest symptoms in this section. Nothing errors. Slack answers each send with ok, the messages exist, they are readable, they are just all in one place. The people who would notice are the ones in the three channels that receive nothing, and an empty channel produces no signal at all. The people in the fourth channel notice something, but what they notice is noise, which reads as a volume problem rather than a routing problem.
The repair is not a better payload. It is a different mechanism: a bot token and chat.postMessage with an explicit channel per message, which is one credential that can reach any channel the bot is in. If webhooks have to stay, you need one webhook per destination and a routing table on your side, accepting that each URL is a separate secret with a separate lifetime. The script below establishes which of the two situations you are in without sending anything to any webhook URL.
The problem in plain words
The channel override was real. Custom integrations — the pre-app model, configured per workspace rather than per app — honoured a channel field, and roughly every tutorial, gist and Stack Overflow answer written before 2016 demonstrates it. That documentation did not go anywhere. It is still the top result, still copied into new code, and the code written from it still runs without error, which is the worst possible combination: a wrong instruction that produces no complaint.
Migrating off custom integrations is where this usually lands. The migration is presented as a credential swap: get a new URL, put it in the config, done. The URL works, the messages arrive, the deploy is called a success. What silently changed is that a routing scheme with four outputs now has one, and the evidence for that is spread across three channels that stopped receiving and one that started receiving four times as much.
There is a second shape, and it is the one that produces a symptom. Send a channel value that the webhook's own app cannot resolve, or a channel reference in a form the surface will not take, and instead of quietly ignoring it you get a refusal. That converts an invisible routing collapse into a visible failure, which sounds better and often is not, because the team then fixes the channel reference and restores the silence.
The third shape is the one people build on purpose after discovering the first two: one webhook per destination channel, held in a routing table. This works. It is also four bearer credentials instead of one, four things to rotate, four things to inventory, four things that die independently when someone leaves, and no method anywhere that lists them. It is a legitimate answer when a system genuinely cannot hold a token, and it is a poor answer for anything that can.
Why it happens
The finding is the presence of the field, not the response to it. If your sending code sets channel on an app based webhook payload at all, that is the whole diagnosis: the field is inert, so either it agrees with the binding and is decoration, or it disagrees and the intent is being lost. This is why the check reads what your code intends rather than what Slack answered. Slack's answer is ok in the interesting case.
Counting distinct destinations is what turns a style note into an outage report. One inert channel field that happens to name the bound channel is harmless. Four distinct values across a routing table means three destinations are receiving nothing and one is receiving everything, and the script reports that as collapsed with the count, because the number is the argument.
A channel reference has two forms and they do not compare directly. Your code may hold #db-oncall and your install record holds C07AB12CD. Comparing those as strings says they differ, which is useless. So references are normalised to a kind and a value first, names are folded and stripped of the leading hash, and the comparison happens between comparable things.
The corroboration comes from the channel, not from your logs. Your logs say what you sent, which is exactly the thing that is not in question. conversations.history on the bound channel says what arrived, and a sample in which several routing keys turn up in a single channel is the evidence that the fan out collapsed. That reading needs channels:history and nothing else, and it works even for sending code you cannot read.
Markers are supplied rather than guessed at, and matched in the order you give them. A script that tried to infer which team a message was meant for would be inventing the finding. Instead you pass the substrings that identify each stream — a service name, a prefix, an emoji — and the first marker that matches wins, deterministically, so two runs over the same history produce the same counts.
Nothing here sends anything to a webhook URL. The tempting probe is to send one message with a channel field and see where it lands, and that probe posts into a real channel in front of people, twice if you want a control. The two readings this script does — what the code intends and what the bound channel actually received — answer the same question with no audience.
The fix, as a flow
Nothing in the first three arrows fails, which is the whole point. No call was refused, no status code was wrong, no error was swallowed. The code asked for four channels, Slack accepted every request and answered each one with a real timestamp, and the messages all arrived in the same room because the destination was decided once, at creation, by whoever clicked through the install. The only red box is the last, and it is not a failure Slack ever reports: it is one room full of four teams' alerts and three teams seeing nothing. The fix branch sorts destinations rather than errors, and the row that carries the finding is the one where several routing keys turn up in a single channel's history.
How to fix it
Find the channel the webhook is actually bound to
It is in the OAuth install response, under incoming_webhook.channel and incoming_webhook.channel_id, and it should be in your install record. If it is not, conversations.info on the channel id names it. This is the one destination that exists, and everything else is compared against it.
Collect the destinations your sending code asks for
One row per send site: a name and the channel value it sets. This comes from the code, not from Slack, because Slack has no record of an intent it ignored. A grep for the payload builder is usually enough.
Say what each override field does, which is nothing
payload_overrides flags channel as inert on an app based webhook, and flags username, icon_emoji and icon_url as coming from the same custom integration era and not to be depended on. These are not errors and they are not features either.
Count the destinations against the binding
fanout_verdict answers collapsed when more than one destination is asked for, misdirected when exactly one is asked for and it is not the bound channel, aligned when the field is inert but harmless, and no-override when the code does not set the field at all, which is the correct shape.
Read the bound channel and see whose traffic is in it
Pass a marker or two per intended destination, then arrival_mix counts the sample. Several streams present in one channel is the corroboration, and it is the reading that convinces somebody who does not believe the code review.
Move the routing to a bot token, or accept one secret per room
chat.postMessage with a bot token takes an explicit channel per message and reaches any channel the bot is in, with one credential and a structured error when something is wrong. One webhook per channel is the fallback for systems that cannot hold a token, and it is a real cost: a separate secret per destination, each with its own way of dying.
How to check it worked
Once the sends carry no channel field, or once you have moved to chat.postMessage, run it again. The fan out row should read no-override and the arrival row should show one stream.
python3 slack_webhook_routing.py --sends sends.json --bound-channel C07AB12CD \
--markers '{"#db-oncall": ["pg-primary"], "#releases": ["deployed"], \
"#sec-ops": ["cve-"]}'
# bound C07AB12CD #alerts, from the install record
# override channel-override db-alerts sets channel=#db-oncall, which is inert on an
# app based webhook: the destination was fixed at creation
# override legacy-override release-notes sets username, from the same era
# fanout collapsed 4 distinct destinations are asked for and one channel
# receives all of them
# arrival mixed #db-oncall, #releases, #sec-ops traffic all arrived in
# #alerts across 200 messages read
# verdict 3 finding(s)
# repair: move to chat.postMessage with a bot token and an explicit channel id
# note: nothing was sent to any webhook URL to establish this
The full code
Two reads and no sends. payload_overrides names the fields that no longer do anything; normalise_channel_reference and same_destination make a #name and a C… id comparable before anything is compared; fanout_verdict counts the distinct destinations the code asks for against the one it has, and arrival_mix with evidence_verdict turn a page of the bound channel's history into the corroboration. Every one of them is pure, and the only network calls in the file are conversations.info and conversations.history.
"""Find out whether your webhook routing collapsed into a single channel.
Read only, and nothing is sent to any webhook URL. The obvious probe here is
to send one message with a channel field and see where it lands, which posts
into a real channel in front of real people - twice, if you want a control.
The two readings this script makes answer the same question with no audience:
what the sending code intends, and what the bound channel actually received.
An incoming webhook created through the app model is bound to one channel at
creation. The channel field in the payload is a custom integration leftover
and does not choose anything. So the finding is the presence of the field and
the number of distinct destinations behind it, not Slack's answer, which is ok.
Two reads: conversations.info to name the bound channel, and
conversations.history to see whose traffic is arriving in it.
"""
import argparse
import json
import logging
import os
import sys
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("slack_webhook_routing")
API = "https://slack.com/api/"
# Fields that a custom integration webhook honoured and an app based one does
# not. They are not errors, and they are not features either.
LEGACY_OVERRIDE_KEYS = ("username", "icon_emoji", "icon_url")
# Channel ids are upper case and start with one of these. Anything else in a
# channel field is a name, whether or not somebody remembered the hash.
ID_PREFIXES = ("C", "G", "D")
def payload_overrides(payload):
"""Which fields in this webhook payload no longer do anything? Pure.
Returns [(code, why), ...]. channel is the finding; the other three come
from the same era and are listed so that nobody rebuilds the routing on
top of them next.
"""
if not isinstance(payload, dict):
return [("not-an-object", "a webhook payload is a JSON object and this is not "
"one, so nothing about its routing can be read")]
out = []
if "channel" in payload:
out.append(("channel-override",
"channel=%s is inert on an app based webhook: the destination was "
"fixed when the webhook was created" % payload.get("channel")))
for key in LEGACY_OVERRIDE_KEYS:
if key in payload:
out.append(("legacy-override",
"%s comes from the same custom integration era as the channel "
"override and should not be depended on" % key))
return out
def normalise_channel_reference(ref):
"""Reduce a channel reference to (kind, value). Pure.
Kinds are id, name and empty. Names are folded and lose the leading hash,
because #DB-Oncall and db-oncall are the same room and comparing them as
written says otherwise. Ids are left exactly as they are, because they are
case sensitive identifiers rather than words.
An id is recognised by shape rather than by a lookup: nine characters or
more, upper case, alphanumeric, beginning with C, G or D, and containing at
least one digit. The digit is what keeps a shouted channel name out of the
id branch, where it would be compared against a channel id and never match.
"""
text = str(ref or "").strip()
if not text:
return ("empty", "")
if (len(text) >= 9 and text[0] in ID_PREFIXES and text.upper() == text
and text.isalnum() and any(c.isdigit() for c in text)):
return ("id", text)
return ("name", text.lstrip("#").lower())
def same_destination(ref, bound_id, bound_name):
"""Does this reference name the channel the webhook is bound to? Pure."""
kind, value = normalise_channel_reference(ref)
if kind == "empty":
return False
if kind == "id":
return value == str(bound_id or "").strip()
return value == str(bound_name or "").strip().lstrip("#").lower()
def fanout_verdict(sends, bound_id, bound_name):
"""How many destinations does the code ask for, and how many exist? Pure.
sends: [{"name": ..., "channel": ...}, ...] read from your own source.
Returns (state, detail, destinations).
no-override no send sets a channel field. The correct shape.
aligned one destination, and it is the bound channel. Inert but harmless.
misdirected one destination, and it is not the bound channel. Every message
is addressed to one room and lands in another.
collapsed more than one destination. This is the routing table that has
silently had three of its four outputs removed.
"""
destinations = []
for send in sends or []:
ref = send.get("channel") if isinstance(send, dict) else send
kind, value = normalise_channel_reference(ref)
if kind == "empty":
continue
key = value if kind == "id" else "#" + value
if key not in destinations:
destinations.append(key)
if not destinations:
return ("no-override", "no send sets a channel field, which is the only "
"correct shape for an app based webhook", destinations)
if len(destinations) > 1:
return ("collapsed", "%d distinct destinations are asked for and one channel "
"receives all of them" % len(destinations), destinations)
if same_destination(destinations[0], bound_id, bound_name):
return ("aligned", "the only destination asked for is the channel the webhook "
"is bound to, so the field is inert but harmless",
destinations)
return ("misdirected", "every message is addressed to %s and every message lands "
"in %s" % (destinations[0], bound_name or bound_id or
"the bound channel"), destinations)
def arrival_mix(messages, markers):
"""Whose traffic is in the bound channel? Pure.
markers: {destination: [substring, ...]}. Matching is case folded, and the
first destination whose marker matches wins, in the order given, so two
runs over the same history produce the same counts.
Returns (counts, unmatched).
"""
counts = {d: 0 for d in (markers or {})}
unmatched = 0
for m in messages or []:
text = str((m or {}).get("text") or "").lower()
hit = None
for dest, subs in (markers or {}).items():
if any(str(s).lower() in text for s in subs or []):
hit = dest
break
if hit is None:
unmatched += 1
else:
counts[hit] += 1
return (counts, unmatched)
def evidence_verdict(counts, unmatched):
"""Turn the arrival counts into the corroboration, or into nothing. Pure.
mixed is the finding: traffic for several intended destinations sitting in
one channel. single is what a correctly bound webhook looks like. none
means the markers did not match, which is a statement about the markers
rather than about the routing, and it says so.
"""
present = sorted(d for d, n in (counts or {}).items() if n)
if len(present) > 1:
return ("mixed", "%s traffic all arrived in one channel" % ", ".join(present),
present)
if len(present) == 1:
return ("single", "only %s traffic is present, which is what a correctly bound "
"webhook looks like" % present[0], present)
return ("none", "no marker matched any of the %d messages read; widen the sample or "
"correct the markers" % int(unmatched), present)
def read(session, method, token, params=None):
"""One GET. Slack answers 200 for failures too, so the body is the answer."""
r = session.get(API + method, headers={"Authorization": "Bearer " + token},
params=params or {}, timeout=30)
try:
return r.json()
except ValueError:
return {"ok": False, "error": "unparseable_body"}
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--sends", required=True,
help="JSON list of what your code sends: name and channel")
ap.add_argument("--bound-channel", default="",
help="the channel id from incoming_webhook.channel_id")
ap.add_argument("--bound-name", default="",
help="its name, if you have no token to resolve the id")
ap.add_argument("--markers", default="{}",
help="JSON object of destination to substrings that identify it")
ap.add_argument("--token-env", default="SLACK_BOT_TOKEN",
help="environment variable holding a read scoped bot token")
ap.add_argument("--limit", type=int, default=200,
help="messages of bound channel history to read")
args = ap.parse_args()
with open(args.sends, encoding="utf-8") as fh:
sends = json.load(fh)
markers = json.loads(args.markers or "{}")
findings = 0
token = os.environ.get(args.token_env)
session = requests.Session()
bound_name = args.bound_name
if token and args.bound_channel and not bound_name:
info = read(session, "conversations.info", token,
{"channel": args.bound_channel})
if info.get("ok") is True:
bound_name = "#" + str((info.get("channel") or {}).get("name") or "")
else:
log.warning("bound unavailable conversations.info answered %s",
info.get("error"))
log.info("bound %-14s %s, from the install record",
args.bound_channel or "unknown", bound_name or "name not resolved")
for send in sends:
for code, why in payload_overrides(send.get("payload", send)):
log.warning("override %-16s %s: %s", code,
send.get("name", "unnamed"), why)
findings += 1
state, detail, destinations = fanout_verdict(
[s.get("payload", s) for s in sends], args.bound_channel, bound_name)
emit = log.info if state in ("no-override", "aligned") else log.warning
emit("fanout %-14s %s", state, detail)
if state in ("collapsed", "misdirected"):
findings += 1
log.warning(" destinations %s", ", ".join(destinations))
if token and args.bound_channel and markers:
body = read(session, "conversations.history", token,
{"channel": args.bound_channel, "limit": args.limit})
if body.get("ok") is True:
counts, unmatched = arrival_mix(body.get("messages"), markers)
evidence, why, _present = evidence_verdict(counts, unmatched)
(log.warning if evidence == "mixed" else log.info)(
"arrival %-14s %s", evidence, why)
for dest, n in counts.items():
log.info(" %-14s %d message(s)", dest, n)
if evidence == "mixed":
findings += 1
else:
log.warning("arrival unavailable conversations.history answered %s",
body.get("error"))
else:
log.info("arrival skipped pass --markers and a token to corroborate "
"from the channel itself")
if not findings:
log.info("verdict clean no send tries to choose a channel a webhook "
"cannot choose")
return 0
log.warning("verdict %d finding(s)", findings)
log.warning(" repair: move to chat.postMessage with a bot token and an explicit "
"channel id per message, which reaches any channel the bot is in")
log.warning(" repair: if webhooks must stay, create one per destination and route "
"on your side, accepting one bearer secret per room")
log.warning(" repair: delete the channel field from the payloads either way; it "
"reads as intent and carries none")
log.warning(" note: nothing was sent to any webhook URL to establish this")
return 1
if __name__ == "__main__":
sys.exit(main())
/**
* Find out whether your webhook routing collapsed into a single channel.
*
* Read only, and nothing is sent to any webhook URL. The obvious probe is to
* send one message with a channel field and see where it lands, which puts a
* message into a real channel in front of real people. The two readings here
* answer the same question with no audience: what the sending code intends,
* and what the bound channel actually received.
*
* Two reads: conversations.info to name the bound channel, and
* conversations.history to see whose traffic is arriving in it.
*/
import { readFile } from 'node:fs/promises';
const API = 'https://slack.com/api/';
// Fields a custom integration webhook honoured and an app based one does not.
export const LEGACY_OVERRIDE_KEYS = ['username', 'icon_emoji', 'icon_url'];
// Channel ids are upper case and start with one of these.
export const ID_PREFIXES = ['C', 'G', 'D'];
/** Which fields in this webhook payload no longer do anything? Pure. */
export function payloadOverrides(payload) {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
return [['not-an-object', 'a webhook payload is a JSON object and this is not one, '
+ 'so nothing about its routing can be read']];
}
const out = [];
if ('channel' in payload) {
out.push(['channel-override', `channel=${payload.channel} is inert on an app based `
+ 'webhook: the destination was fixed when the webhook was created']);
}
for (const key of LEGACY_OVERRIDE_KEYS) {
if (key in payload) {
out.push(['legacy-override', `${key} comes from the same custom integration era `
+ 'as the channel override and should not be depended on']);
}
}
return out;
}
/**
* Reduce a channel reference to [kind, value]. Pure.
* Kinds are id, name and empty. Names fold case and lose the leading hash.
* An id is recognised by shape: nine characters or more, upper case,
* alphanumeric, beginning with C, G or D, and carrying at least one digit.
* The digit is what keeps a shouted channel name out of the id branch.
*/
export function normaliseChannelReference(ref) {
const text = String(ref ?? '').trim();
if (!text) return ['empty', ''];
const alnum = [...text].every((c) => /[a-z0-9]/i.test(c));
const hasDigit = [...text].some((c) => c >= '0' && c <= '9');
if (text.length >= 9 && ID_PREFIXES.includes(text[0]) && text === text.toUpperCase()
&& alnum && hasDigit) {
return ['id', text];
}
return ['name', text.replace(/^#+/, '').toLowerCase()];
}
/** Does this reference name the channel the webhook is bound to? Pure. */
export function sameDestination(ref, boundId, boundName) {
const [kind, value] = normaliseChannelReference(ref);
if (kind === 'empty') return false;
if (kind === 'id') return value === String(boundId ?? '').trim();
return value === String(boundName ?? '').trim().replace(/^#+/, '').toLowerCase();
}
/**
* How many destinations does the code ask for, and how many exist? Pure.
* Returns [state, detail, destinations]; no-override, aligned, misdirected,
* collapsed.
*/
export function fanoutVerdict(sends, boundId, boundName) {
const destinations = [];
for (const send of sends ?? []) {
const ref = (send && typeof send === 'object') ? send.channel : send;
const [kind, value] = normaliseChannelReference(ref);
if (kind === 'empty') continue;
const key = kind === 'id' ? value : `#${value}`;
if (!destinations.includes(key)) destinations.push(key);
}
if (!destinations.length) {
return ['no-override', 'no send sets a channel field, which is the only correct '
+ 'shape for an app based webhook', destinations];
}
if (destinations.length > 1) {
return ['collapsed', `${destinations.length} distinct destinations are asked for `
+ 'and one channel receives all of them', destinations];
}
if (sameDestination(destinations[0], boundId, boundName)) {
return ['aligned', 'the only destination asked for is the channel the webhook is '
+ 'bound to, so the field is inert but harmless', destinations];
}
return ['misdirected', `every message is addressed to ${destinations[0]} and every `
+ `message lands in ${boundName || boundId || 'the bound channel'}`, destinations];
}
/**
* Whose traffic is in the bound channel? Pure.
* The first destination whose marker matches wins, in the order given.
* Returns [counts, unmatched].
*/
export function arrivalMix(messages, markers) {
const counts = {};
for (const dest of Object.keys(markers ?? {})) counts[dest] = 0;
let unmatched = 0;
for (const m of messages ?? []) {
const text = String(m?.text ?? '').toLowerCase();
let hit = null;
for (const [dest, subs] of Object.entries(markers ?? {})) {
if ((subs ?? []).some((s) => text.includes(String(s).toLowerCase()))) {
hit = dest;
break;
}
}
if (hit === null) unmatched += 1;
else counts[hit] += 1;
}
return [counts, unmatched];
}
/** Turn the arrival counts into the corroboration, or into nothing. Pure. */
export function evidenceVerdict(counts, unmatched) {
const present = Object.entries(counts ?? {})
.filter(([, n]) => n).map(([d]) => d).sort();
if (present.length > 1) {
return ['mixed', `${present.join(', ')} traffic all arrived in one channel`,
present];
}
if (present.length === 1) {
return ['single', `only ${present[0]} traffic is present, which is what a correctly `
+ 'bound webhook looks like', present];
}
return ['none', `no marker matched any of the ${Number(unmatched)} messages read; `
+ 'widen the sample or correct the markers', present];
}
function arg(args, name, fallback = '') {
const i = args.indexOf(name);
return i === -1 ? fallback : args[i + 1];
}
async function read(method, token, params = {}) {
const qs = new URLSearchParams(params).toString();
const r = await fetch(`${API}${method}${qs ? `?${qs}` : ''}`,
{ headers: { Authorization: `Bearer ${token}` } });
try {
return await r.json();
} catch {
return { ok: false, error: 'unparseable_body' };
}
}
async function main() {
const args = process.argv.slice(2);
const sends = JSON.parse(await readFile(arg(args, '--sends'), 'utf8'));
const markers = JSON.parse(arg(args, '--markers', '{}'));
const boundChannel = arg(args, '--bound-channel');
const tokenEnv = arg(args, '--token-env', 'SLACK_BOT_TOKEN');
const limit = arg(args, '--limit', '200');
const token = process.env[tokenEnv];
let boundName = arg(args, '--bound-name');
let findings = 0;
if (token && boundChannel && !boundName) {
const info = await read('conversations.info', token, { channel: boundChannel });
if (info.ok === true) boundName = `#${info.channel?.name ?? ''}`;
else {
console.warn('bound unavailable conversations.info answered '
+ `${info.error}`);
}
}
console.log(`bound ${(boundChannel || 'unknown').padEnd(14)} `
+ `${boundName || 'name not resolved'}, from the install record`);
for (const send of sends) {
for (const [code, why] of payloadOverrides(send.payload ?? send)) {
console.warn(`override ${code.padEnd(16)} ${send.name ?? 'unnamed'}: ${why}`);
findings += 1;
}
}
const [state, detail, destinations] = fanoutVerdict(
sends.map((s) => s.payload ?? s), boundChannel, boundName);
const emit = (state === 'no-override' || state === 'aligned')
? console.log : console.warn;
emit(`fanout ${state.padEnd(14)} ${detail}`);
if (state === 'collapsed' || state === 'misdirected') {
findings += 1;
console.warn(` destinations ${destinations.join(', ')}`);
}
if (token && boundChannel && Object.keys(markers).length) {
const body = await read('conversations.history', token,
{ channel: boundChannel, limit });
if (body.ok === true) {
const [counts, unmatched] = arrivalMix(body.messages, markers);
const [evidence, why] = evidenceVerdict(counts, unmatched);
(evidence === 'mixed' ? console.warn : console.log)(
`arrival ${evidence.padEnd(14)} ${why}`);
for (const [dest, n] of Object.entries(counts)) {
console.log(` ${dest.padEnd(14)} ${n} message(s)`);
}
if (evidence === 'mixed') findings += 1;
} else {
console.warn('arrival unavailable conversations.history answered '
+ `${body.error}`);
}
} else {
console.log('arrival skipped pass --markers and a token to corroborate '
+ 'from the channel itself');
}
if (!findings) {
console.log('verdict clean no send tries to choose a channel a webhook '
+ 'cannot choose');
return;
}
console.warn(`verdict ${findings} finding(s)`);
console.warn(' repair: move to chat.postMessage with a bot token and an explicit '
+ 'channel id per message, which reaches any channel the bot is in');
console.warn(' repair: if webhooks must stay, create one per destination and route '
+ 'on your side, accepting one bearer secret per room');
console.warn(' repair: delete the channel field from the payloads either way; it '
+ 'reads as intent and carries none');
console.warn(' note: nothing was sent to any webhook URL to establish this');
process.exitCode = 1;
}
// Only run when invoked directly, so importing this module in the tests does
// not execute main().
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}
Add a test
No webhook URL appears in these fixtures at all, which is worth noticing on its own: the whole diagnosis runs on the destinations your code asks for and the history of one channel, and neither of those needs the credential. The assertions that carry the note are the counting ones — one inert channel field naming the bound channel is aligned and four distinct values are collapsed — and the pair that make a #name and a C… id comparable, including the case that catches a shouted channel name being mistaken for an id.
from slack_webhook_routing import (
arrival_mix, evidence_verdict, fanout_verdict, normalise_channel_reference,
payload_overrides, same_destination,
)
BOUND_ID = "C07AB12CD"
BOUND_NAME = "#alerts"
MARKERS = {"#db-oncall": ["pg-primary"], "#releases": ["deployed"],
"#sec-ops": ["cve-"]}
def msg(text):
return {"text": text}
def test_a_channel_field_is_named_as_inert_rather_than_as_an_error():
codes = [c for c, _w in payload_overrides({"text": "hi", "channel": "#db-oncall"})]
assert codes == ["channel-override"]
def test_the_other_custom_integration_fields_are_listed_beside_it():
codes = [c for c, _w in payload_overrides(
{"text": "hi", "username": "deploybot", "icon_emoji": ":rocket:"})]
assert codes == ["legacy-override", "legacy-override"]
def test_a_payload_with_no_overrides_has_nothing_to_say():
assert payload_overrides({"text": "hi"}) == []
def test_something_that_is_not_an_object_is_refused_before_it_is_read():
assert payload_overrides(["text"])[0][0] == "not-an-object"
def test_a_channel_id_is_recognised_by_shape():
assert normalise_channel_reference(BOUND_ID) == ("id", BOUND_ID)
def test_a_shouted_channel_name_is_not_mistaken_for_an_id():
kind, value = normalise_channel_reference("DEPLOYMENTS")
assert kind == "name"
assert value == "deployments"
def test_a_name_loses_its_hash_and_its_case():
assert normalise_channel_reference("#DB-Oncall") == ("name", "db-oncall")
def test_an_id_and_a_name_both_resolve_against_the_binding():
assert same_destination(BOUND_ID, BOUND_ID, BOUND_NAME) is True
assert same_destination("#Alerts", BOUND_ID, BOUND_NAME) is True
assert same_destination("#db-oncall", BOUND_ID, BOUND_NAME) is False
assert same_destination("", BOUND_ID, BOUND_NAME) is False
def test_code_that_sets_no_channel_field_is_the_correct_shape():
state, _detail, dests = fanout_verdict([{"text": "hi"}], BOUND_ID, BOUND_NAME)
assert state == "no-override"
assert dests == []
def test_one_field_naming_the_bound_channel_is_inert_but_harmless():
state, _detail, _dests = fanout_verdict([{"channel": "#alerts"}],
BOUND_ID, BOUND_NAME)
assert state == "aligned"
def test_one_field_naming_somewhere_else_is_misdirected_and_names_both():
state, detail, _dests = fanout_verdict([{"channel": "#db-oncall"}],
BOUND_ID, BOUND_NAME)
assert state == "misdirected"
assert "#db-oncall" in detail and "#alerts" in detail
def test_four_destinations_are_reported_as_collapsed_with_the_count():
sends = [{"channel": "#db-oncall"}, {"channel": "#releases"},
{"channel": "#sec-ops"}, {"channel": "#alerts"}]
state, detail, dests = fanout_verdict(sends, BOUND_ID, BOUND_NAME)
assert state == "collapsed"
assert "4" in detail
assert len(dests) == 4
def test_the_same_destination_twice_is_counted_once():
sends = [{"channel": "#db-oncall"}, {"channel": "#DB-Oncall"}]
state, _detail, dests = fanout_verdict(sends, BOUND_ID, BOUND_NAME)
assert dests == ["#db-oncall"]
assert state == "misdirected"
def test_the_first_matching_marker_wins_so_two_runs_agree():
counts, unmatched = arrival_mix(
[msg("pg-primary failover"), msg("deployed v2.1"), msg("cve-2026-1 patched"),
msg("good morning")], MARKERS)
assert counts == {"#db-oncall": 1, "#releases": 1, "#sec-ops": 1}
assert unmatched == 1
def test_matching_folds_case():
counts, _unmatched = arrival_mix([msg("PG-Primary failover")], MARKERS)
assert counts["#db-oncall"] == 1
def test_several_streams_in_one_channel_is_the_corroboration():
counts, _u = arrival_mix([msg("pg-primary down"), msg("deployed v3")], MARKERS)
state, detail, present = evidence_verdict(counts, 0)
assert state == "mixed"
assert present == ["#db-oncall", "#releases"]
assert "#db-oncall" in detail
def test_one_stream_is_what_a_correctly_bound_webhook_looks_like():
counts, _u = arrival_mix([msg("deployed v3"), msg("deployed v4")], MARKERS)
assert evidence_verdict(counts, 0)[0] == "single"
def test_no_marker_matching_is_a_statement_about_the_markers():
counts, unmatched = arrival_mix([msg("hello"), msg("world")], MARKERS)
state, detail, _present = evidence_verdict(counts, unmatched)
assert state == "none"
assert "2" in detail
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
arrivalMix, evidenceVerdict, fanoutVerdict, normaliseChannelReference,
payloadOverrides, sameDestination,
} from './slack-webhook-routing.mjs';
const BOUND_ID = 'C07AB12CD';
const BOUND_NAME = '#alerts';
const MARKERS = {
'#db-oncall': ['pg-primary'],
'#releases': ['deployed'],
'#sec-ops': ['cve-'],
};
const msg = (text) => ({ text });
const codes = (payload) => payloadOverrides(payload).map(([c]) => c);
test('a channel field is named as inert rather than as an error', () => {
assert.deepEqual(codes({ text: 'hi', channel: '#db-oncall' }), ['channel-override']);
});
test('the other custom integration fields are listed beside it', () => {
assert.deepEqual(codes({ text: 'hi', username: 'deploybot', icon_emoji: ':rocket:' }),
['legacy-override', 'legacy-override']);
});
test('a payload with no overrides has nothing to say', () => {
assert.deepEqual(payloadOverrides({ text: 'hi' }), []);
});
test('something that is not an object is refused before it is read', () => {
assert.equal(payloadOverrides(['text'])[0][0], 'not-an-object');
});
test('a channel id is recognised by shape', () => {
assert.deepEqual(normaliseChannelReference(BOUND_ID), ['id', BOUND_ID]);
});
test('a shouted channel name is not mistaken for an id', () => {
assert.deepEqual(normaliseChannelReference('DEPLOYMENTS'), ['name', 'deployments']);
});
test('a name loses its hash and its case', () => {
assert.deepEqual(normaliseChannelReference('#DB-Oncall'), ['name', 'db-oncall']);
});
test('an id and a name both resolve against the binding', () => {
assert.equal(sameDestination(BOUND_ID, BOUND_ID, BOUND_NAME), true);
assert.equal(sameDestination('#Alerts', BOUND_ID, BOUND_NAME), true);
assert.equal(sameDestination('#db-oncall', BOUND_ID, BOUND_NAME), false);
assert.equal(sameDestination('', BOUND_ID, BOUND_NAME), false);
});
test('code that sets no channel field is the correct shape', () => {
const [state, , dests] = fanoutVerdict([{ text: 'hi' }], BOUND_ID, BOUND_NAME);
assert.equal(state, 'no-override');
assert.deepEqual(dests, []);
});
test('one field naming the bound channel is inert but harmless', () => {
assert.equal(fanoutVerdict([{ channel: '#alerts' }], BOUND_ID, BOUND_NAME)[0],
'aligned');
});
test('one field naming somewhere else is misdirected and names both', () => {
const [state, detail] = fanoutVerdict([{ channel: '#db-oncall' }],
BOUND_ID, BOUND_NAME);
assert.equal(state, 'misdirected');
assert.equal(detail.includes('#db-oncall') && detail.includes('#alerts'), true);
});
test('four destinations are reported as collapsed with the count', () => {
const sends = [{ channel: '#db-oncall' }, { channel: '#releases' },
{ channel: '#sec-ops' }, { channel: '#alerts' }];
const [state, detail, dests] = fanoutVerdict(sends, BOUND_ID, BOUND_NAME);
assert.equal(state, 'collapsed');
assert.equal(detail.includes('4'), true);
assert.equal(dests.length, 4);
});
test('the same destination twice is counted once', () => {
const [state, , dests] = fanoutVerdict(
[{ channel: '#db-oncall' }, { channel: '#DB-Oncall' }], BOUND_ID, BOUND_NAME);
assert.deepEqual(dests, ['#db-oncall']);
assert.equal(state, 'misdirected');
});
test('the first matching marker wins so two runs agree', () => {
const [counts, unmatched] = arrivalMix([msg('pg-primary failover'),
msg('deployed v2.1'), msg('cve-2026-1 patched'), msg('good morning')], MARKERS);
assert.deepEqual(counts, { '#db-oncall': 1, '#releases': 1, '#sec-ops': 1 });
assert.equal(unmatched, 1);
});
test('matching folds case', () => {
const [counts] = arrivalMix([msg('PG-Primary failover')], MARKERS);
assert.equal(counts['#db-oncall'], 1);
});
test('several streams in one channel is the corroboration', () => {
const [counts] = arrivalMix([msg('pg-primary down'), msg('deployed v3')], MARKERS);
const [state, detail, present] = evidenceVerdict(counts, 0);
assert.equal(state, 'mixed');
assert.deepEqual(present, ['#db-oncall', '#releases']);
assert.equal(detail.includes('#db-oncall'), true);
});
test('one stream is what a correctly bound webhook looks like', () => {
const [counts] = arrivalMix([msg('deployed v3'), msg('deployed v4')], MARKERS);
assert.equal(evidenceVerdict(counts, 0)[0], 'single');
});
test('no marker matching is a statement about the markers', () => {
const [counts, unmatched] = arrivalMix([msg('hello'), msg('world')], MARKERS);
const [state, detail] = evidenceVerdict(counts, unmatched);
assert.equal(state, 'none');
assert.equal(detail.includes('2'), true);
});
FAQ
The channel override definitely used to work. What changed?
The integration model did. Custom integrations, configured per workspace before the app model existed, honoured a channel field in the webhook body, and that is what every tutorial written before 2016 demonstrates. An incoming webhook created through a Slack app is bound to the single channel chosen when it was created, and the field no longer chooses anything. Nothing about your code changed; the URL underneath it did, usually during a migration presented as a credential swap.
Can I create one webhook per channel and route on my side?
Yes, and it works. Be clear about what it costs. Each URL is a separate bearer credential that can post into your workspace as your app, with a separate lifetime, and Slack has no method that lists them for you. Each one dies independently when the person who authorised it is deactivated. Four destinations means four secrets to inventory and rotate, and the rotation has to reach every system holding each one. It is the right answer when the sending system genuinely cannot hold a token, and a poor answer for anything that can.
Why not just send one test message with a channel field and see where it lands?
Because that probe posts into a real channel, and to be conclusive it has to post twice: once addressed elsewhere and once as a control. Somebody then has to explain both messages. The two readings this script makes reach the same conclusion with no audience: the destinations your code asks for come from your code, and where the messages went comes from the bound channel's own history.
Our sends carry a channel field and everything looks fine. Is that a finding?
It is the aligned verdict, and it is worth fixing anyway. The field reads as intent to every future maintainer, so the first person who needs a second destination will add a second value to it and change nothing. Deleting it costs a line and makes the binding visible in the one place somebody will look.
Is this the same as posting to a channel the bot is not in?
No, and they are easy to tell apart because one produces an error and this one does not. not_in_channel comes back from chat.postMessage with a bot token and names the problem: the bot has to be invited. A webhook has no membership question at all, because the binding was made at creation and the app was joined to that channel then. If you are getting an error, you are on the bot token path and that is a different note. If you are getting ok and the messages are in the wrong room, you are here.
Related field notes
- the other way a channel reference goes wrong
- the same webhook, after its install went away
- when the one bound channel gets frozen
Sources
Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.
- Sending messages using incoming webhooks - Slack Docs
- Stack Overflow: the Slack webhook ignores the channel in the payload
- chat.postMessage method reference - Slack Docs
- conversations.history method reference - Slack Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.