Diagnostic Slack
The Request URL never echoed back the challenge
The app is installed. The scopes are right. The bot is in the channel and people are talking to it. Not one line has appeared in the handler's log, ever, because the handler has never been called: on the Event Subscriptions page there is a red line under the Request URL that says the URL did not respond with the value of the challenge parameter.
That sentence is the whole failure, and it lives in a browser tab rather than in your logs, which is why apps sit in this state for days.
Before Slack will deliver any event, it sends your candidate Request URL a single request containing {"type": "url_verification", "challenge": "<random>"} and requires the challenge value handed straight back, within three seconds, either as a plain body or as {"challenge": "..."} JSON. It does not follow redirects. It does not carry your session cookie. It is the only request your endpoint will ever receive while it is still an unverified stranger, so every piece of middleware you have is standing in front of it.
Two things are therefore worth reading, and neither of them involves sending anything to your own URL. The first is the manifest: whether a Request URL is configured at all, whether Socket Mode is on and makes the question moot, and whether the URL's shape can survive the exchange. The second is the exchange itself, replayed offline from what your endpoint actually returned — a status, a content type, a body — which your access log already has. The script below does both and names which of the seven usual endings you got.
The problem in plain words
The handshake is a one-time event with a permanent consequence, and it does not resemble anything else Slack does to you. Every other note in this section is about a running app that is failing at something; this is about an app that has never run. There is no ok: false to find, no error string to grep, no gap in a message history to measure, because the app has never been given the chance to produce any of those. The single artefact is a red line in the app configuration UI.
What makes it stick is the ordering. The verification request arrives before Slack knows anything about your endpoint, so it arrives at whatever your endpoint presents to strangers. If your framework requires an authenticated session on every route, the handshake gets a redirect to a login page. If an API gateway authoriser sits in front, it gets a 403. If a load balancer normalises the path and issues a 301 to add a trailing slash, Slack does not follow it and records a failure. If a body parser is registered only for application/x-www-form-urlencoded because that is what slash commands send, the JSON body arrives as an empty object and your handler reads undefined for the challenge and answers cheerfully with a 200 and nothing in it.
All five of those produce the same red line, and four of the five never reach your application code at all, which is why "I added the challenge handler and it still fails" is the most common second message in every thread on this subject. The handler is fine. It is not being reached.
Why it happens
The challenge is random per attempt, so it cannot be memorised. People occasionally get the URL verified by hard-coding the string they saw in a log, and it works exactly once. Every subsequent verification — and Slack re-verifies when you change the URL — sends a different value. A handler that echoes a constant is a handler that passed once and will fail silently the next time somebody touches the configuration.
Redirects are not followed here, and this is unusual enough to catch people twice. Most HTTP clients follow a 301 without mentioning it, so the URL you tested with curl works and the one Slack tested does not. The two most common sources are the trailing slash and the http-to-https upgrade, and both are invisible from the outside unless you look at the status code rather than the final body.
Socket Mode removes the question entirely. An app using Socket Mode has no Request URL, cannot fail a handshake, and does not want one configured. If you are staring at a verification error on an app that is meant to be socket-based, the finding is that the configuration is trying to do both.
Verification is a different failure from having nothing subscribed. A manifest with a verified URL and an empty bot event list is a fully working delivery path with nothing routed down it, and no amount of work on the URL will fix it. The script separates the two before it says anything about the URL, because they present identically: a bot that answers nobody.
The three second budget applies to the handshake too. A cold-starting function that takes four seconds to return the challenge fails verification for a reason that has nothing to do with the challenge, and the message Slack shows you will still be about the challenge parameter. That is the point where this note hands over to the deadline note.
The fix, as a flow
The only chain in this batch that ends before the app has run once. Everything in it happens at configuration time, and the box that fails is not code anybody wrote for Slack: it is the authenticator that answers strangers, doing exactly its job. The fix branch is the only one here whose good row is a hand-off, because a verified URL proves one exchange succeeded and nothing else.
How to fix it
Establish whether a Request URL is even the right question
Socket Mode on and no URL configured means there is no handshake to fail. An empty bot event list means the delivery path is fine and nothing is routed down it. The script sorts those two out first and stops, because both of them look like a silent bot and neither is repaired by touching the URL.
Read the configured URL out of the manifest rather than out of memory
apps.manifest.export returns settings.event_subscriptions.request_url. That needs an app configuration token, which is a different credential class from your bot token — if you do not have one, the script says so and moves on to the half that does not need it.
Look at the shape of the URL before the behaviour
A plain http:// scheme, an ngrok or trycloudflare host, credentials in the userinfo, a query string a gateway will drop: each of these is decidable from the string alone, without sending anything. A tunnel host in particular is a URL that verified perfectly on Tuesday and points at nothing today.
Take the recorded exchange from your own access log
Your endpoint answered the verification request, and whatever it answered is in your log with a status code, a content type and a length. Copy that into a small JSON file along with the challenge string Slack sent. This is deliberately not a live probe: firing requests at a production Request URL to see what it says is how an audit becomes an incident.
Let the replay name the ending rather than guessing at it
Seven endings, and they have five different repairs. A redirect is a routing fix, an auth wall is a middleware ordering fix, an empty 200 is a body parser fix, a correct echo that took four seconds is a latency fix, and a correct echo inside the budget means the handshake is not your problem.
Move the challenge handler in front of everything
The printed repair is an ordering instruction, not a code change: the branch that answers url_verification has to run before authentication, before redirects, and before anything that inspects the body for a shape it expects. Both Bolt receivers already do this, which is why the usual real repair is to mount the receiver properly rather than to hand-roll the route.
How to check it worked
Re-run after the ordering change with a fresh recorded exchange, taken from the log line produced when you press Retry on the Event Subscriptions page.
python3 slack_request_url_handshake.py --app-id A04HANDSHK --record exchange.json
# config configured a request url is set and 3 bot event(s) are subscribed
# url clean https://hooks.example.com/slack/events
# replay echoed the challenge came back as a plain body in 180ms
# verdict ok nothing here explains a silent app; look at delivery next
The full code
Three pure functions and one GET. handshake_config decides whether the handshake is the question at all, which for a Socket Mode app or an app with nothing subscribed it is not. url_shape reads the four things that are decidable from the URL string alone. replay_verdict is the note itself: it takes the status, headers and body your endpoint returned and the challenge it was sent, and sorts that into one of seven endings, of which exactly two are success.
"""Diagnose a Slack Request URL that never passed the challenge handshake.
Read only, and deliberately not a probe. The verification exchange is replayed
from a record the caller supplies out of their own access log; this script
never sends anything to the Request URL, because an audit that fires traffic at
a production endpoint to see what it says is an incident with a report attached.
The one network call it makes is a GET to apps.manifest.export.
"""
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_request_url_handshake")
API = "https://slack.com/api/"
# Slack requires the challenge back within the same three seconds it gives
# every other delivery. A correct echo that arrives late is still a failure,
# and it is worth calling that out separately because the repair is different.
DEADLINE_MS = 3000
# Hosts that hand out ephemeral URLs. A tunnel verified fine on the day it was
# configured and points at nothing now, which is the single most common reason
# a URL that "used to work" stops without anybody changing it.
TUNNEL_HOSTS = ("ngrok.io", "ngrok-free.app", "ngrok.app", "trycloudflare.com",
"loca.lt", "serveo.net", "localhost", "127.0.0.1", "0.0.0.0")
def handshake_config(manifest):
"""Decide whether the challenge handshake is the question here at all. Pure.
Returns (verdict, detail). Two of the verdicts are hand-offs: a Socket Mode
app has no Request URL to verify, and an app with nothing subscribed has a
delivery path with nothing routed down it. Both present as a bot that
answers nobody, and neither is repaired by touching a URL.
"""
if not isinstance(manifest, dict) or not manifest:
return ("not-assessed",
"no manifest was read, so the configuration half of this check "
"did not run. That is a missing credential, not a finding.")
settings = manifest.get("settings") or {}
subs = settings.get("event_subscriptions") or {}
url = (subs.get("request_url") or "").strip()
events = list(subs.get("bot_events") or []) + list(subs.get("user_events") or [])
socket = bool(settings.get("socket_mode_enabled"))
if socket and not url:
return ("socket-mode",
"Socket Mode is enabled and no request url is set, so there is "
"no handshake to fail. Delivery problems on this app are "
"connection problems, not verification ones.")
if socket and url:
return ("both-configured",
"Socket Mode is enabled and a request url is also set (%s). The "
"configuration is describing two delivery paths; verify which "
"one this app is actually meant to use before reading anything "
"into a verification error." % url)
if not events:
return ("nothing-subscribed",
"no bot or user events are subscribed, so the Request URL is "
"not yet the question: a verified URL with an empty event list "
"delivers nothing and looks exactly the same from a channel.")
if not url:
return ("no-request-url",
"%d event(s) are subscribed and there is no request url and no "
"Socket Mode. Nothing can be delivered, and no handshake has "
"ever been attempted." % len(events))
return ("configured",
"a request url is set and %d event(s) are subscribed" % len(events))
def url_shape(url):
"""Read what is decidable from the URL string alone. Pure.
Returns a list of (code, detail). None of these need a request, which is
the point: four of the five ways this handshake fails are visible in the
configured string before anybody sends anything anywhere.
"""
out = []
raw = (url or "").strip()
if not raw:
return out
scheme, _, rest = raw.partition("://")
if not rest:
scheme, rest = "", raw
if scheme.lower() != "https":
out.append(("not-https",
"the scheme is %r. Slack requires https and will not follow "
"an upgrade redirect, so an http url fails the handshake "
"even when the host answers correctly on 443."
% (scheme or "missing")))
authority = rest.split("/", 1)[0]
if "@" in authority:
out.append(("credentials-in-url",
"the authority carries userinfo before the @. Slack sends "
"no basic auth, and the credentials are configuration "
"anybody with the app page can read."))
authority = authority.rsplit("@", 1)[1]
host = authority.split(":", 1)[0].lower()
port = authority.split(":", 1)[1] if ":" in authority else ""
if any(host == h or host.endswith("." + h) for h in TUNNEL_HOSTS):
out.append(("tunnel-host",
"%s is an ephemeral tunnel or a loopback address. It "
"verified on the day it was set and there is no reason to "
"expect it resolves to your service now." % host))
if port and port not in ("443", ""):
out.append(("non-standard-port",
"port %s. Legal, and one more thing between Slack and the "
"handler that a firewall rule can quietly close." % port))
if "?" in raw:
out.append(("query-string",
"the url carries a query string. Some gateways strip or "
"rewrite it before your route sees it, and the handshake "
"then lands on a path you never wrote."))
return out
def replay_verdict(record, challenge):
"""Sort a recorded verification exchange into one of seven endings. Pure.
``record`` is what the caller's own access log holds: a status, response
headers, the body that was returned, and optionally how long it took.
Nothing here sends a request. Returns (verdict, detail); only "echoed" and
"echoed-json" mean the handshake passed.
"""
rec = record or {}
want = str(challenge or "").strip()
status = int(rec.get("status") or 0)
headers = {str(k).lower(): str(v) for k, v in (rec.get("headers") or {}).items()}
body = rec.get("body")
body = "" if body is None else str(body)
ctype = headers.get("content-type", "").lower()
elapsed = rec.get("elapsed_ms")
if 300 <= status < 400:
return ("redirected",
"status %d to %s. Slack does not follow redirects during "
"verification, so this is recorded as a failure however "
"correct the destination is. A trailing slash and an "
"http-to-https upgrade are the two usual sources."
% (status, headers.get("location", "an unnamed location")))
if status in (401, 403):
return ("auth-wall",
"status %d. Something in front of the handler demanded "
"credentials. The verification request is the only one Slack "
"sends while your endpoint is still a stranger, so every "
"authenticator you have is standing in its way." % status)
if status == 404:
return ("not-found",
"status 404. The configured path is not the path the handler "
"is mounted at, which is usually a router prefix or a stage "
"name the gateway adds.")
if status >= 500:
return ("server-error",
"status %d. The handler was reached and threw. This is the one "
"ending where your own stack trace is the next thing to read."
% status)
if status < 200 or status >= 300:
return ("unexpected-status",
"status %d, which is neither a success nor one of the failures "
"this replay knows how to name." % status)
if not body.strip():
return ("empty-200",
"status 200 with an empty body. The route answered and echoed "
"nothing, which is what a handler reading an unparsed body "
"does: the challenge was never in the object it looked at.")
echoed = None
if body.strip() == want and want:
echoed = "echoed"
else:
try:
parsed = json.loads(body)
except ValueError:
parsed = None
if isinstance(parsed, dict) and want and str(parsed.get("challenge", "")) == want:
echoed = "echoed-json"
if echoed:
if isinstance(elapsed, (int, float)) and elapsed > DEADLINE_MS:
return ("too-slow",
"the challenge came back correctly and took %dms, over the "
"%dms Slack allows. The body is right and the handshake "
"still failed, which is why the error message about the "
"challenge parameter is misleading here."
% (int(elapsed), DEADLINE_MS))
took = ("" if not isinstance(elapsed, (int, float))
else " in %dms" % int(elapsed))
kind = "a plain body" if echoed == "echoed" else "a json challenge field"
return (echoed, "the challenge came back as %s%s" % (kind, took))
if want and want in body:
return ("buried",
"status 200 and the challenge is somewhere in the body but is "
"not the body and is not a top-level challenge field. An error "
"page that happens to include the request payload does this.")
if "text/html" in ctype:
return ("html-page",
"status 200 with %s. A framework error page or a single page "
"app index answered on this path; the handler is not on it."
% ctype)
return ("wrong-body",
"status 200 and %d byte(s) that are neither the challenge nor a "
"json object carrying it." % len(body))
def export_manifest(session, token, app_id):
"""One GET, with an app configuration token. Returns the parsed body."""
r = session.get(API + "apps.manifest.export",
headers={"Authorization": "Bearer " + token},
params={"app_id": app_id}, timeout=30)
try:
return r.json()
except ValueError:
return {"ok": False, "error": "unparseable_body"}
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--app-id", default="", help="the app id to export, A...")
ap.add_argument("--config-token-env", default="SLACK_CONFIG_ACCESS_TOKEN",
help="environment variable holding an app configuration token")
ap.add_argument("--record", default="",
help="json file holding the recorded verification exchange")
ap.add_argument("--challenge", default="",
help="the challenge string Slack sent, if not in the record")
args = ap.parse_args()
findings = 0
manifest = {}
token = os.environ.get(args.config_token_env)
if args.app_id and token:
body = export_manifest(requests.Session(), token, args.app_id)
if body.get("ok") is not True:
log.warning("manifest unavailable apps.manifest.export answered "
"ok: false, error=%s", body.get("error"))
else:
manifest = body
elif args.app_id:
log.info("manifest skipped set %s to read the configured url; "
"a bot token cannot", args.config_token_env)
verdict, detail = handshake_config(manifest)
(log.info if verdict in ("configured", "socket-mode") else log.warning)(
"config %-14s %s", verdict, detail)
if verdict in ("no-request-url", "both-configured"):
findings += 1
url = ((manifest.get("settings") or {}).get("event_subscriptions") or {}).get(
"request_url") or ""
concerns = url_shape(url)
if url and not concerns:
log.info("url clean %s", url)
for code, why in concerns:
findings += 1
log.warning("url %-14s %s", code, why)
if args.record:
rec = json.loads(open(args.record, encoding="utf-8").read())
challenge = args.challenge or rec.get("challenge") or ""
state, why = replay_verdict(rec, challenge)
ok = state in ("echoed", "echoed-json")
(log.info if ok else log.warning)("replay %-14s %s", state, why)
if not ok:
findings += 1
log.warning(" repair: answer url_verification before any "
"authentication, redirect or body-shape check runs, and "
"return body.challenge exactly as it arrived")
else:
log.info("replay skipped pass --record with the status, "
"headers and body your endpoint returned")
if findings:
log.warning("verdict %-14s %d finding(s); press Retry on the Event "
"Subscriptions page once they are addressed", "action", findings)
return 1
log.info("verdict %-14s nothing here explains a silent app; look at "
"delivery next", "ok")
return 0
if __name__ == "__main__":
sys.exit(main())
/**
* Diagnose a Slack Request URL that never passed the challenge handshake.
*
* Read only, and deliberately not a probe. The verification exchange is
* replayed from a record the caller supplies out of their own access log; this
* script never sends anything to the Request URL. The one network call it
* makes is a GET to apps.manifest.export.
*/
import { readFileSync } from 'node:fs';
const API = 'https://slack.com/api/';
// A correct echo that arrives late is still a failed handshake, and it is
// worth naming separately because the repair is a different one.
const DEADLINE_MS = 3000;
// Ephemeral tunnels and loopback: verified on the day they were configured,
// pointing at nothing now.
const TUNNEL_HOSTS = ['ngrok.io', 'ngrok-free.app', 'ngrok.app',
'trycloudflare.com', 'loca.lt', 'serveo.net', 'localhost', '127.0.0.1',
'0.0.0.0'];
/**
* Decide whether the challenge handshake is the question here at all. Pure.
* Two verdicts are hand-offs: Socket Mode has no URL to verify, and an empty
* event list is a working delivery path with nothing routed down it.
*/
export function handshakeConfig(manifest) {
if (!manifest || typeof manifest !== 'object' || Object.keys(manifest).length === 0) {
return ['not-assessed',
'no manifest was read, so the configuration half of this check did not ' +
'run. That is a missing credential, not a finding.'];
}
const settings = manifest.settings ?? {};
const subs = settings.event_subscriptions ?? {};
const url = String(subs.request_url ?? '').trim();
const events = [...(subs.bot_events ?? []), ...(subs.user_events ?? [])];
const socket = Boolean(settings.socket_mode_enabled);
if (socket && !url) {
return ['socket-mode',
'Socket Mode is enabled and no request url is set, so there is no ' +
'handshake to fail. Delivery problems on this app are connection ' +
'problems, not verification ones.'];
}
if (socket && url) {
return ['both-configured',
`Socket Mode is enabled and a request url is also set (${url}). The ` +
'configuration is describing two delivery paths; verify which one this ' +
'app is actually meant to use before reading anything into a ' +
'verification error.'];
}
if (events.length === 0) {
return ['nothing-subscribed',
'no bot or user events are subscribed, so the Request URL is not yet ' +
'the question: a verified URL with an empty event list delivers nothing ' +
'and looks exactly the same from a channel.'];
}
if (!url) {
return ['no-request-url',
`${events.length} event(s) are subscribed and there is no request url ` +
'and no Socket Mode. Nothing can be delivered, and no handshake has ' +
'ever been attempted.'];
}
return ['configured', `a request url is set and ${events.length} event(s) are subscribed`];
}
/**
* Read what is decidable from the URL string alone. Pure.
* Four of the five ways this handshake fails are visible in the configured
* string before anybody sends anything anywhere.
*/
export function urlShape(url) {
const out = [];
const raw = String(url ?? '').trim();
if (!raw) return out;
const idx = raw.indexOf('://');
const scheme = idx === -1 ? '' : raw.slice(0, idx);
const rest = idx === -1 ? raw : raw.slice(idx + 3);
if (scheme.toLowerCase() !== 'https') {
out.push(['not-https',
`the scheme is ${scheme || 'missing'}. Slack requires https and will not ` +
'follow an upgrade redirect, so an http url fails the handshake even ' +
'when the host answers correctly on 443.']);
}
let authority = rest.split('/')[0];
if (authority.includes('@')) {
out.push(['credentials-in-url',
'the authority carries userinfo before the @. Slack sends no basic ' +
'auth, and the credentials are configuration anybody with the app page ' +
'can read.']);
authority = authority.slice(authority.lastIndexOf('@') + 1);
}
const host = authority.split(':')[0].toLowerCase();
const port = authority.includes(':') ? authority.split(':')[1] : '';
if (TUNNEL_HOSTS.some((h) => host === h || host.endsWith(`.${h}`))) {
out.push(['tunnel-host',
`${host} is an ephemeral tunnel or a loopback address. It verified on ` +
'the day it was set and there is no reason to expect it resolves to ' +
'your service now.']);
}
if (port && port !== '443') {
out.push(['non-standard-port',
`port ${port}. Legal, and one more thing between Slack and the handler ` +
'that a firewall rule can quietly close.']);
}
if (raw.includes('?')) {
out.push(['query-string',
'the url carries a query string. Some gateways strip or rewrite it ' +
'before your route sees it, and the handshake then lands on a path you ' +
'never wrote.']);
}
return out;
}
/**
* Sort a recorded verification exchange into one of seven endings. Pure.
* The record is what the caller's own access log holds. Nothing here sends a
* request. Only 'echoed' and 'echoed-json' mean the handshake passed.
*/
export function replayVerdict(record, challenge) {
const rec = record ?? {};
const want = String(challenge ?? '').trim();
const status = Number(rec.status ?? 0);
const headers = {};
for (const [k, v] of Object.entries(rec.headers ?? {})) {
headers[String(k).toLowerCase()] = String(v);
}
const body = rec.body === null || rec.body === undefined ? '' : String(rec.body);
const ctype = (headers['content-type'] ?? '').toLowerCase();
const elapsed = rec.elapsed_ms;
if (status >= 300 && status < 400) {
return ['redirected',
`status ${status} to ${headers.location ?? 'an unnamed location'}. Slack ` +
'does not follow redirects during verification, so this is recorded as ' +
'a failure however correct the destination is. A trailing slash and an ' +
'http-to-https upgrade are the two usual sources.'];
}
if (status === 401 || status === 403) {
return ['auth-wall',
`status ${status}. Something in front of the handler demanded ` +
'credentials. The verification request is the only one Slack sends ' +
'while your endpoint is still a stranger, so every authenticator you ' +
'have is standing in its way.'];
}
if (status === 404) {
return ['not-found',
'status 404. The configured path is not the path the handler is mounted ' +
'at, which is usually a router prefix or a stage name the gateway adds.'];
}
if (status >= 500) {
return ['server-error',
`status ${status}. The handler was reached and threw. This is the one ` +
'ending where your own stack trace is the next thing to read.'];
}
if (status < 200 || status >= 300) {
return ['unexpected-status',
`status ${status}, which is neither a success nor one of the failures ` +
'this replay knows how to name.'];
}
if (!body.trim()) {
return ['empty-200',
'status 200 with an empty body. The route answered and echoed nothing, ' +
'which is what a handler reading an unparsed body does: the challenge ' +
'was never in the object it looked at.'];
}
let echoed = null;
if (want && body.trim() === want) {
echoed = 'echoed';
} else {
let parsed = null;
try { parsed = JSON.parse(body); } catch { parsed = null; }
if (parsed && typeof parsed === 'object' && want
&& String(parsed.challenge ?? '') === want) echoed = 'echoed-json';
}
if (echoed) {
if (typeof elapsed === 'number' && elapsed > DEADLINE_MS) {
return ['too-slow',
`the challenge came back correctly and took ${Math.trunc(elapsed)}ms, ` +
`over the ${DEADLINE_MS}ms Slack allows. The body is right and the ` +
'handshake still failed, which is why the error message about the ' +
'challenge parameter is misleading here.'];
}
const took = typeof elapsed === 'number' ? ` in ${Math.trunc(elapsed)}ms` : '';
const kind = echoed === 'echoed' ? 'a plain body' : 'a json challenge field';
return [echoed, `the challenge came back as ${kind}${took}`];
}
if (want && body.includes(want)) {
return ['buried',
'status 200 and the challenge is somewhere in the body but is not the ' +
'body and is not a top-level challenge field. An error page that ' +
'happens to include the request payload does this.'];
}
if (ctype.includes('text/html')) {
return ['html-page',
`status 200 with ${ctype}. A framework error page or a single page app ` +
'index answered on this path; the handler is not on it.'];
}
return ['wrong-body',
`status 200 and ${body.length} byte(s) that are neither the challenge nor ` +
'a json object carrying it.'];
}
async function exportManifest(token, appId) {
const res = await fetch(`${API}apps.manifest.export?app_id=${encodeURIComponent(appId)}`, {
headers: { Authorization: `Bearer ${token}` },
});
try { return await res.json(); } catch { return { ok: false, error: 'unparseable_body' }; }
}
function arg(args, name, fallback = '') {
const i = args.indexOf(name);
return i === -1 ? fallback : args[i + 1];
}
async function main() {
const args = process.argv.slice(2);
const appId = arg(args, '--app-id', '');
const tokenEnv = arg(args, '--config-token-env', 'SLACK_CONFIG_ACCESS_TOKEN');
const token = process.env[tokenEnv];
let findings = 0;
let manifest = {};
if (appId && token) {
const body = await exportManifest(token, appId);
if (body.ok !== true) {
console.warn('manifest unavailable apps.manifest.export answered ' +
`ok: false, error=${body.error}`);
} else {
manifest = body;
}
} else if (appId) {
console.log(`manifest skipped set ${tokenEnv} to read the configured ` +
'url; a bot token cannot');
}
const [verdict, detail] = handshakeConfig(manifest);
const good = verdict === 'configured' || verdict === 'socket-mode';
(good ? console.log : console.warn)(`config ${verdict.padEnd(14)} ${detail}`);
if (verdict === 'no-request-url' || verdict === 'both-configured') findings += 1;
const url = manifest.settings?.event_subscriptions?.request_url ?? '';
const concerns = urlShape(url);
if (url && concerns.length === 0) console.log(`url clean ${url}`);
for (const [code, why] of concerns) {
findings += 1;
console.warn(`url ${code.padEnd(14)} ${why}`);
}
const recordPath = arg(args, '--record', '');
if (recordPath) {
const rec = JSON.parse(readFileSync(recordPath, 'utf8'));
const challenge = arg(args, '--challenge', '') || rec.challenge || '';
const [state, why] = replayVerdict(rec, challenge);
const ok = state === 'echoed' || state === 'echoed-json';
(ok ? console.log : console.warn)(`replay ${state.padEnd(14)} ${why}`);
if (!ok) {
findings += 1;
console.warn(' repair: answer url_verification before any authentication, ' +
'redirect or body-shape check runs, and return body.challenge exactly ' +
'as it arrived');
}
} else {
console.log('replay skipped pass --record with the status, headers ' +
'and body your endpoint returned');
}
if (findings) {
console.warn(`verdict action ${findings} finding(s); press Retry on ` +
'the Event Subscriptions page once they are addressed');
process.exitCode = 1;
} else {
console.log('verdict ok nothing here explains a silent app; ' +
'look at delivery next');
}
}
// Only run when invoked directly, so importing this module in the tests does
// not execute main() and fail the file on a missing token.
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}
Add a test
The replay classifier is the part worth pinning, because the whole value of this note is that seven different endings do not all mean “fix the challenge handler”. A 301 to the same URL with a trailing slash is asserted to come back as redirected rather than as a success, a correct echo that took four seconds is asserted to come back as too-slow rather than as an echo, and an empty 200 is kept apart from a 200 that carries the wrong thing. The configuration half is pinned on its two hand-offs, which are the cases where the honest answer is that this is somebody else's note.
import json
from slack_request_url_handshake import handshake_config, replay_verdict, url_shape
CH = "3eZbrw1aB1LmzHi8"
def rec(status=200, body="", headers=None, elapsed_ms=None):
out = {"status": status, "body": body, "headers": headers or {}}
if elapsed_ms is not None:
out["elapsed_ms"] = elapsed_ms
return out
def test_a_plain_echo_and_a_json_echo_both_pass():
assert replay_verdict(rec(body=CH), CH)[0] == "echoed"
assert replay_verdict(rec(body=json.dumps({"challenge": CH})), CH)[0] == "echoed-json"
def test_a_redirect_is_a_failure_however_correct_the_destination():
state, detail = replay_verdict(
rec(status=301, headers={"Location": "https://x.example.com/slack/events/"}), CH)
assert state == "redirected"
assert "does not follow redirects" in detail
def test_an_authenticator_in_front_of_the_handler_is_named_as_such():
assert replay_verdict(rec(status=403), CH)[0] == "auth-wall"
assert replay_verdict(rec(status=401), CH)[0] == "auth-wall"
def test_a_correct_echo_that_missed_the_budget_is_not_reported_as_success():
state, detail = replay_verdict(rec(body=CH, elapsed_ms=4200), CH)
assert state == "too-slow"
assert "4200ms" in detail
assert replay_verdict(rec(body=CH, elapsed_ms=180), CH)[0] == "echoed"
def test_an_empty_200_is_kept_apart_from_a_wrong_200():
assert replay_verdict(rec(body=" "), CH)[0] == "empty-200"
assert replay_verdict(rec(body="thanks"), CH)[0] == "wrong-body"
def test_a_challenge_buried_in_an_error_page_is_not_an_echo():
assert replay_verdict(rec(body="<h1>500</h1> payload was " + CH), CH)[0] == "buried"
assert replay_verdict(
rec(body="<html>ok</html>", headers={"Content-Type": "text/html"}), CH
)[0] == "html-page"
def test_socket_mode_and_an_empty_event_list_are_both_hand_offs():
assert handshake_config({"settings": {"socket_mode_enabled": True}})[0] == "socket-mode"
assert handshake_config(
{"settings": {"event_subscriptions": {"request_url": "https://a.example/x",
"bot_events": []}}}
)[0] == "nothing-subscribed"
def test_subscribed_events_with_nowhere_to_go_is_the_finding():
state, detail = handshake_config(
{"settings": {"event_subscriptions": {"bot_events": ["app_mention"]}}})
assert state == "no-request-url"
assert "1 event(s)" in detail
def test_two_delivery_paths_configured_at_once_is_its_own_verdict():
state, _ = handshake_config({"settings": {
"socket_mode_enabled": True,
"event_subscriptions": {"request_url": "https://a.example/x",
"bot_events": ["app_mention"]}}})
assert state == "both-configured"
def test_a_missing_manifest_is_never_reported_as_a_clean_configuration():
assert handshake_config({})[0] == "not-assessed"
assert handshake_config(None)[0] == "not-assessed"
def test_the_url_string_gives_up_four_things_without_a_request():
codes = [c for c, _ in url_shape("http://bot.ngrok.io:8443/slack/events?stage=prod")]
assert codes == ["not-https", "tunnel-host", "non-standard-port", "query-string"]
assert url_shape("https://hooks.example.com/slack/events") == []
def test_userinfo_is_reported_and_then_stripped_before_the_host_is_read():
codes = [c for c, _ in url_shape("https://user:pw@hooks.example.com/slack/events")]
assert codes == ["credentials-in-url"]
assert url_shape("") == []
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { handshakeConfig, replayVerdict, urlShape } from './slack-request-url-handshake.mjs';
const CH = '3eZbrw1aB1LmzHi8';
function rec(status = 200, body = '', headers = {}, elapsedMs = undefined) {
const out = { status, body, headers };
if (elapsedMs !== undefined) out.elapsed_ms = elapsedMs;
return out;
}
test('a plain echo and a json echo both pass', () => {
assert.equal(replayVerdict(rec(200, CH), CH)[0], 'echoed');
assert.equal(replayVerdict(rec(200, JSON.stringify({ challenge: CH })), CH)[0],
'echoed-json');
});
test('a redirect is a failure however correct the destination', () => {
const [state, detail] = replayVerdict(
rec(301, '', { Location: 'https://x.example.com/slack/events/' }), CH);
assert.equal(state, 'redirected');
assert.match(detail, /does not follow redirects/);
});
test('an authenticator in front of the handler is named as such', () => {
assert.equal(replayVerdict(rec(403), CH)[0], 'auth-wall');
assert.equal(replayVerdict(rec(401), CH)[0], 'auth-wall');
});
test('a correct echo that missed the budget is not reported as success', () => {
const [state, detail] = replayVerdict(rec(200, CH, {}, 4200), CH);
assert.equal(state, 'too-slow');
assert.match(detail, /4200ms/);
assert.equal(replayVerdict(rec(200, CH, {}, 180), CH)[0], 'echoed');
});
test('an empty 200 is kept apart from a wrong 200', () => {
assert.equal(replayVerdict(rec(200, ' '), CH)[0], 'empty-200');
assert.equal(replayVerdict(rec(200, 'thanks'), CH)[0], 'wrong-body');
});
test('a challenge buried in an error page is not an echo', () => {
assert.equal(replayVerdict(rec(200, `<h1>500</h1> payload was ${CH}`), CH)[0], 'buried');
assert.equal(
replayVerdict(rec(200, '<html>ok</html>', { 'Content-Type': 'text/html' }), CH)[0],
'html-page');
});
test('socket mode and an empty event list are both hand-offs', () => {
assert.equal(handshakeConfig({ settings: { socket_mode_enabled: true } })[0],
'socket-mode');
assert.equal(handshakeConfig({
settings: {
event_subscriptions: { request_url: 'https://a.example/x', bot_events: [] },
},
})[0], 'nothing-subscribed');
});
test('subscribed events with nowhere to go is the finding', () => {
const [state, detail] = handshakeConfig({
settings: { event_subscriptions: { bot_events: ['app_mention'] } },
});
assert.equal(state, 'no-request-url');
assert.match(detail, /1 event\(s\)/);
});
test('two delivery paths configured at once is its own verdict', () => {
const [state] = handshakeConfig({
settings: {
socket_mode_enabled: true,
event_subscriptions: {
request_url: 'https://a.example/x', bot_events: ['app_mention'],
},
},
});
assert.equal(state, 'both-configured');
});
test('a missing manifest is never reported as a clean configuration', () => {
assert.equal(handshakeConfig({})[0], 'not-assessed');
assert.equal(handshakeConfig(null)[0], 'not-assessed');
});
test('the url string gives up four things without a request', () => {
const codes = urlShape('http://bot.ngrok.io:8443/slack/events?stage=prod')
.map((r) => r[0]);
assert.deepEqual(codes,
['not-https', 'tunnel-host', 'non-standard-port', 'query-string']);
assert.deepEqual(urlShape('https://hooks.example.com/slack/events'), []);
});
test('userinfo is reported and then stripped before the host is read', () => {
const codes = urlShape('https://user:pw@hooks.example.com/slack/events').map((r) => r[0]);
assert.deepEqual(codes, ['credentials-in-url']);
assert.deepEqual(urlShape(''), []);
});
FAQ
Does the challenge request carry a Slack signature?
Yes. The verification request is signed like every other delivery, so a correctly ordered signature check does not break the handshake. What breaks it is your own authentication: session middleware, an API gateway authoriser, an IP allowlist that has not been told about Slack. Those run before your route and answer the stranger with a redirect or a 403, and the app configuration page reports that as a challenge failure.
Can I return the challenge as JSON, or does it have to be plain text?
Either works. A body that is exactly the challenge string with content type text/plain is accepted, and so is a JSON object with a top-level challenge field holding the same value. What is not accepted is the challenge nested inside a wrapper, or an object with the challenge alongside other fields your framework added, or the string with quotation marks around it from a serialiser that JSON-encoded a bare string.
Why does the script refuse to send a test request to my Request URL?
Because it holds a token for a live workspace and is auditing a production endpoint that it did not write. A probe against an unknown handler can create records, page somebody, or trip a WAF rule that blocks Slack's own egress next. Everything this note needs is already written down: the manifest holds the configuration, and your access log holds what your endpoint said when Slack last asked.
The URL verified fine and the app is still silent. What now?
Then the handshake is not your problem and the script says so in as many words. Verification proves that one exchange succeeded; it says nothing about whether any events are subscribed, whether the scopes behind them were granted, or whether Slack has since switched delivery off after a run of failures. Those are three different notes, and the last of them is the one to read if the app used to work.
We use Socket Mode. Does any of this apply?
No, and that is worth confirming rather than assuming. A Socket Mode app has no Request URL, so there is no handshake and no way to fail one. If the manifest shows Socket Mode enabled and a Request URL configured at the same time, the configuration is describing two delivery paths, and somebody should decide which one this app uses before reading anything into an error about either.
Related field notes
- the same three seconds, applied to every event after the handshake
- a URL that verified once and was switched off later
- the credential that reads the manifest, and its twelve hours
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.
- url_verification event reference - Slack Docs
- Events API - Slack Docs
- apps.manifest.export method reference - Slack Docs
- Socket Mode - Slack Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.