Skip to content

Diagnostic Slack

invalid_payload: the webhook body stopped being JSON

The release notification has worked for two years. Then one release goes out with a commit message that reads fix: don't drop the "retry" header, and the notification does not arrive. The pipeline is green. The step that sends the message exited zero. Slack answered 400 with the plain text body invalid_payload, into a curl that was never given --fail, and the body of that response went to /dev/null along with the message.

The webhook is fine. The channel is fine. The app is fine. What left the machine was not JSON, because a double quote from a commit message was interpolated straight into a JSON string literal and closed it three characters early.

Read-only token Python and Node.js Tests included
Warehouse storage racks
Photo by Petr on Unsplash
The short answer

400 invalid_payload means the bytes you sent did not parse as JSON, and Slack will not tell you where. It is almost always string interpolation: a shell script, a CI YAML step or a template that builds the JSON as text and substitutes a value containing a double quote, a newline, a backslash or a control character. The close sibling is 400 no_text, which means the JSON parsed perfectly and carried no message.

This is one of the few places on Slack where the HTTP status is the truth. The Web API answers 200 OK for almost every failure and hides it in the body as ok: false; a webhook answers a real 400, 401, 403, 404 or 410 with a short plain text reason. Checking the status here actually works, which makes the usual cause of a silent failure not Slack's answer but a client that never reads it.

The check below is deliberately narrow and entirely local. It takes the bytes your sender produced, decodes them, tries to parse them, and if that fails it names which of the interpolation fingerprints is present. If it parses, it reads the top level object against the incoming webhook envelope, which is not the same contract as a Web API call and not the same contract as Block Kit. It makes no network call of any kind: nothing to Slack's API and, above all, nothing to the webhook, because sending the payload to find out whether it is valid delivers it into a channel if it happens to be valid.

The problem in plain words

Building JSON by hand works until it does not, and the day it stops is decided by somebody else's typing. A commit message, a test failure summary, a stack trace, a customer name, a filename with an apostrophe in it: any of these interpolated into a JSON literal will eventually contain a character that ends the string early. The code that built the payload has no bug in it that a review would catch, because the bug is a value that did not exist when the code was written.

The shell is where this concentrates, for two reasons. The first is that a shell has no JSON type, so the payload is a string with holes in it and there is no layer that could have escaped anything. The second is that curl -d sends application/x-www-form-urlencoded unless you tell it otherwise, so even a perfectly built body can arrive as the wrong kind of thing. Multi-line values make it worse: a raw newline inside a JSON string is not legal JSON, and a log fragment pasted into a payload is a stack of them.

Then there is the failure with a green tick. curl exits 0 on a 400 unless you pass --fail, so a CI step that sends a notification succeeds while the notification does not exist. The one class of message most likely to be built this way — a build alert, a deploy notice, a cron summary — is also the one nobody misses individually, so the fault is discovered weeks later by somebody asking a question about last month.

And there is the near miss, which is worse than the failure. A payload that ends up truncated at the first quote still parses, and still posts. So the message arrives, it is just wrong: cut off mid sentence, carrying a stray backslash, showing a raw ${VERSION} that never got expanded, or holding a fragment of JSON that leaked out of the value it was supposed to be in. Nothing anywhere reports a problem, because as far as every layer is concerned there was none.

A commitmessagewith a quote in itInterpolatedinto JSONby a shell heredocSent as awebhook bodyform encoded bydefault400invalid_payloada real status,plain textcurl exits 0the build goesgreen
String interpolation into a JSON literal works until the interpolated text contains a quote, a newline or a backslash. Then the body stops parsing, and the pipeline stays green.

Why it happens

This is the only script in this section that makes no network call at all, and that is the design. The natural way to test a payload is to send it, and for a webhook that means delivering it into a channel if it turns out to be valid, which is the outcome you were trying to avoid finding out about. Validating locally costs nothing, needs no credential, runs in a pre-commit hook, and cannot post anything to anybody.

It reads bytes rather than an object, because by the time you have an object the bug is gone. A payload built by a serializer is correct by construction and does not need this. The payload that needs it is a string built by a shell, and the only honest way to inspect it is as the bytes that left the machine: with the byte order mark a Windows editor added, the encoding, and the exact newline that ended the string early.

The string scanner is naive in exactly the way a parser is. An unescaped quote closes a string here as it does in a real parser, which is what makes the leftover text between the string literals a usable signal. Text sitting outside every string literal that is not JSON punctuation is the fingerprint of a quote that got in, and it is a much more direct statement than a parser offset, which points at where the damage was noticed rather than at where it began.

The envelope check is not a Block Kit check, and the line between them is the point of this note. Whether a section block has a text object with a legal type is Block Kit's contract and it is validated in its own note. What is checked here is the layer above: whether the top level is an object at all, whether it carries a message in text, blocks or attachments, and whether it is carrying keys that belong to a different surface entirely.

Keys from other surfaces are their own finding, because they are silent. A body carrying token, ts or as_user is a chat.postMessage call that somebody pointed at a webhook; a body carrying response_type or replace_original is a reply meant for a response_url. Both parse. Both post. Both do less than their author thinks, and no error is produced by either.

The status table is here because the statuses are real. Everywhere else in this section the advice is to stop trusting the HTTP status and read the body. On a webhook the status is meaningful and the body is four plain words, and the whole set fits in one function: what invalid_payload, no_text, invalid_token, no_service, channel_is_archived, action_prohibited and posting_to_general_channel_denied each mean, and which of them are a different note.

The fix, as a flow

The failure here happens before Slack is involved at all, which is why the chain starts inside a shell script. A commit message picks up a double quote, the quote is interpolated straight into a JSON literal, and the body that leaves the machine is no longer JSON. The red arrow is at the parser, and it is the earliest one in the batch that Slack itself draws: unusually for this section the answer is a real 400 with a real reason in it, and the reason nobody sees it is that shell scripts do not check exit codes. The fix branch sorts a byte string, not a message. Block structure comes later and belongs to another note.

The body as bytesdecode, parse, then envelopeA raw newline in a stringa log fragment pasted inText outside every stringa quote that was not escapedParses, but carries nothingno text, blocks or attachmentsWeb API keys in the bodycopied from chat.postMessageBuilt by a serializerand typed application/json
Read locally, from a file, with no network call of any kind. A payload that fails here would have failed at Slack, and finding out on your own machine costs nobody a message in a channel.

How to fix it

Capture the bytes, not the object

Have the sender write the exact body it is about to send to a file, before it sends it. In a shell that is a redirect; in CI it is an artefact. The bytes are the evidence, and reconstructing them afterwards from the code loses the one value that caused the problem.

Decode before you parse

decode_body reports empty, byte-order-mark and not-utf8 before JSON is attempted. A file saved by a Windows editor with a byte order mark fails to parse for a reason that has nothing to do with the content, and a JSON parser's error message will not mention it.

Parse, and if it fails, name the fingerprint

parse_probe gives you the line and column. interpolation_fingerprints gives you the cause: a raw newline inside a string, a control character, an unterminated string, a trailing comma, single quotes, Python literals, an unexpanded shell variable, an unrendered template, or text sitting outside every string literal, which is what an unescaped quote leaves behind.

Read the envelope, not the blocks

envelope_findings checks the layer this surface owns: is the top level an object, does it carry a message at all, are blocks and attachments the right types, and is it carrying keys from the Web API or from a response_url reply. Block structure is a different contract and a different note.

Check the Content-Type you actually sent

curl -d sends application/x-www-form-urlencoded. Pass -H 'Content-Type: application/json' and --data-binary @payload.json, and the last of those matters too: -d strips newlines from a file, which is fine for JSON and confusing when you are comparing what you wrote against what was sent.

Stop interpolating, and start checking the status

Build the body with jq -n --arg text "$MSG" '{text: $text}', json.dumps or JSON.stringify. Then give curl its --fail so that a 400 becomes a non zero exit, because on this surface, unlike the rest of Slack, the status code tells the truth.

How to check it worked

Run it over the captured body before your sender is allowed to send it. A clean run says the payload parses and the envelope carries a message.

python3 slack_webhook_payload.py --payload captured.json \
  --content-type application/x-www-form-urlencoded --status 400 --body invalid_payload
# decode     ok             247 byte(s), utf-8
# parse      invalid-json   line 1 column 34: Expecting ',' delimiter
# fingerprint stray-text-outside-string  14 character(s) sit outside every string
#                           literal, which is what an unescaped quote leaves behind
# fingerprint unexpanded-variable  a $NAME sequence survived into the body
# content    form-encoded   this is curl -d's default; the body is not read as JSON
# status     invalid_payload the body did not parse as JSON, and Slack does not say
#                            where
# verdict    3 finding(s)
#   repair: build the body with a serializer and send it with --data-binary
#   note:   nothing was sent anywhere; this file makes no network call at all

The full code

There is no HTTP client in this file, in either language, and that is the feature. decode_body reads the captured bytes and reports a byte order mark or a bad encoding before JSON is attempted; parse_probe tries the parse and returns the position when it fails; interpolation_fingerprints scans for the ten shapes a hand built body takes when it stops being JSON, using a string scanner that closes a string on an unescaped quote exactly as a parser does; envelope_findings reads the top level object against the incoming webhook contract, which is neither the Web API's nor Block Kit's; content_type_finding and explain_status cover the header and the four word answer. Every one of them is pure.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 108 Slack fixes, free and open source.
slack_webhook_payload.py
"""Sort a captured incoming webhook body before anything is sent to a webhook.

This file makes no network call of any kind. That is deliberate and it is the
whole point: the obvious way to find out whether a payload is valid is to send
it to the webhook, and if it turns out to be valid that delivers it into a real
channel in front of real people. There is no dry run mode on a webhook and no
validation endpoint. So the payload is read here, locally, from the bytes your
sender produced.

The scope is narrow on purpose. This covers the layer a webhook owns: whether
the bytes are JSON at all, and whether the top level object is a legal incoming
webhook envelope. It does not validate Block Kit structure, which is a
different contract with its own errors and its own note.
"""
import argparse
import codecs
import json
import logging
import sys

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("slack_webhook_payload")

# What may legally appear outside a JSON string literal, once the three bare
# words are taken out. Anything else out there is text that escaped from a
# string, which is what an unescaped quote leaves behind.
JSON_PUNCTUATION = set(" \t\r\n{}[]:,-+.eE0123456789")
BARE_WORDS = ("true", "false", "null")

# The incoming webhook envelope. Four separate vocabularies end up in these
# bodies and only the first one does anything.
ENVELOPE_KEYS = ("text", "blocks", "attachments", "thread_ts", "mrkdwn",
                 "unfurl_links", "unfurl_media")
LEGACY_OVERRIDE_KEYS = ("channel", "username", "icon_emoji", "icon_url")
RESPONSE_URL_KEYS = ("response_type", "replace_original", "delete_original")
WEB_API_KEYS = ("token", "ts", "as_user", "reply_broadcast", "link_names", "parse",
                "metadata")


def decode_body(raw):
    """Turn captured bytes into text, reporting what a parser will not. Pure.

    Returns (text, notes). A byte order mark and a bad encoding both make a
    perfectly sensible looking file fail to parse, and a JSON parser's message
    will mention neither.
    """
    data = raw if isinstance(raw, (bytes, bytearray)) else str(raw or "").encode("utf-8")
    notes = []
    if not data:
        return ("", ["empty"])
    if bytes(data[:3]) == codecs.BOM_UTF8:
        notes.append("byte-order-mark")
        data = data[3:]
    try:
        text = bytes(data).decode("utf-8")
    except UnicodeDecodeError:
        return ("", notes + ["not-utf8"])
    if not text.strip():
        notes.append("blank")
    return (text, notes)


def parse_probe(text):
    """Try the parse. Pure. Returns (state, detail, value).

    States are ok and invalid-json. The detail carries the position, which is
    where the damage was noticed rather than where it began - which is exactly
    why the fingerprints below exist.
    """
    if not str(text or "").strip():
        return ("invalid-json", "the body is empty, which is not a JSON document", None)
    try:
        return ("ok", "the body parses as JSON", json.loads(text))
    except ValueError as exc:
        return ("invalid-json", str(exc), None)


def _string_spans(text):
    """Where the JSON string literals are. Pure. Returns (spans, unterminated).

    Naive in one direction on purpose: an unescaped quote closes a string here
    exactly as it does in a real parser, which is what makes the text left over
    between the spans a usable signal rather than noise.
    """
    spans = []
    inside = False
    escaped = False
    start = 0
    for i, ch in enumerate(text):
        if inside:
            if escaped:
                escaped = False
            elif ch == "\\":
                escaped = True
            elif ch == '"':
                inside = False
                spans.append((start, i))
        elif ch == '"':
            inside = True
            start = i + 1
    if inside:
        spans.append((start, len(text)))
    return (spans, inside)


def _outside(text, spans):
    """Everything that is not inside a string literal, quotes excluded. Pure."""
    out = []
    prev = 0
    for a, b in spans:
        out.append(text[prev:max(prev, a - 1)])
        prev = b + 1
    out.append(text[prev:])
    return "".join(out)


def interpolation_fingerprints(text):
    """Name the ways a hand built body stops being JSON. Pure.

    Returns [(code, why), ...] in a fixed order, so two runs over the same body
    produce the same report.
    """
    out = []
    spans, unterminated = _string_spans(text)
    inside = [text[a:b] for a, b in spans]
    if any(chr(10) in s or chr(13) in s for s in inside):
        out.append(("raw-newline-in-string",
                    "a literal newline sits inside a string; JSON has no multi line "
                    "strings, and a log fragment pasted into a payload is a stack of "
                    "them"))
    if any(any(ord(c) < 32 and c not in (chr(10), chr(13)) for c in s) for s in inside):
        out.append(("control-character",
                    "a control character sits inside a string, usually a tab or a "
                    "terminal escape that came in with captured output"))
    if unterminated:
        out.append(("unterminated-string",
                    "the body ends inside a string literal, which is what truncation "
                    "and a missing closing quote both look like"))
    outside = _outside(text, spans)
    if any(word in outside for word in ("True", "False", "None")):
        out.append(("python-literals",
                    "True, False or None appear outside a string; JSON spells those "
                    "true, false and null, and a Python repr is not JSON"))
    if "'" in outside:
        out.append(("single-quoted",
                    "an apostrophe sits outside every string; JSON has no single "
                    "quoted strings or keys"))
    scrub = outside.lower()
    for word in BARE_WORDS:
        scrub = scrub.replace(word, "")
    stray = "".join(ch for ch in scrub if ch not in JSON_PUNCTUATION and ch != "'")
    if stray:
        out.append(("stray-text-outside-string",
                    "%d character(s) sit outside every string literal, which is what "
                    "an unescaped quote leaves behind: the value closed its string "
                    "early and the rest spilled out" % len(stray)))
    j = 0
    while j < len(outside):
        if outside[j] == ",":
            k = j + 1
            while k < len(outside) and outside[k].isspace():
                k += 1
            if k < len(outside) and outside[k] in "}]":
                out.append(("trailing-comma",
                            "a comma is followed by a closing brace or bracket; a "
                            "loop that appends an item and a separator every time "
                            "leaves one behind"))
                break
        j += 1
    for i, ch in enumerate(text):
        if ch == "$" and i + 1 < len(text) and (text[i + 1].isalpha()
                                                or text[i + 1] == "_"):
            out.append(("unexpanded-variable",
                        "a dollar sign followed by a name survived into the body, so "
                        "a shell or template variable was never substituted"))
            break
    if "{{" in text or "${" in text:
        out.append(("unrendered-template",
                    "a template delimiter survived into the body, so the templating "
                    "layer either did not run or did not know about this value"))
    return out


def envelope_findings(value):
    """Read the top level object against the incoming webhook contract. Pure.

    Not a Block Kit check. Whether a section block has a legal text object is a
    different contract with its own error and its own note; what is read here
    is the envelope those blocks travel in.
    """
    if not isinstance(value, dict):
        return [("not-an-object",
                 "the body parses and the top level is a %s; an incoming webhook takes "
                 "a JSON object" % type(value).__name__)]
    keys = list(value.keys())
    out = []
    if keys == ["payload"]:
        out.append(("payload-wrapper",
                    "the body is a single payload key, which is the form encoded shape "
                    "where the JSON is the value of a payload field; sent as JSON it is "
                    "an envelope with no message in it"))
    text = value.get("text")
    if "text" in keys and not isinstance(text, str):
        out.append(("text-not-a-string",
                    "text is a %s; a number or an object here is a serializer that was "
                    "handed the wrong thing" % type(text).__name__))
    if "blocks" in keys and not isinstance(value.get("blocks"), list):
        out.append(("blocks-not-a-list", "blocks has to be an array of blocks"))
    if "attachments" in keys and not isinstance(value.get("attachments"), list):
        out.append(("attachments-not-a-list",
                    "attachments has to be an array of attachments"))
    carries = (bool(str(text or "").strip()) or bool(value.get("blocks"))
               or bool(value.get("attachments")))
    if not carries:
        out.append(("no-text",
                    "nothing in text, blocks or attachments carries a message, which "
                    "is the 400 no_text answer rather than the invalid_payload one"))
    if value.get("blocks") and not str(text or "").strip():
        out.append(("no-fallback-text",
                    "blocks with no top level text leaves the push notification, the "
                    "sidebar preview and the screen reader with nothing to read"))
    for key in LEGACY_OVERRIDE_KEYS:
        if key in keys:
            out.append(("legacy-override",
                        "%s is a custom integration field; on an app based webhook it "
                        "does not choose anything" % key))
    for key in RESPONSE_URL_KEYS:
        if key in keys:
            out.append(("response-url-key",
                        "%s belongs to a reply sent to a response_url, not to an "
                        "incoming webhook, and is ignored here" % key))
    for key in WEB_API_KEYS:
        if key in keys:
            out.append(("web-api-key",
                        "%s is a chat.postMessage argument; a webhook body is not a "
                        "Web API request and this key does nothing" % key))
    known = (set(ENVELOPE_KEYS) | set(LEGACY_OVERRIDE_KEYS) | set(RESPONSE_URL_KEYS)
             | set(WEB_API_KEYS) | {"payload"})
    for key in keys:
        if key not in known:
            out.append(("unknown-key",
                        "%s is not part of the incoming webhook envelope and is "
                        "ignored" % key))
    return out


def content_type_finding(header):
    """What the header you actually sent does to the body. Pure."""
    value = str(header or "").split(";")[0].strip().lower()
    if not value:
        return ("no-content-type",
                "no Content-Type was recorded; curl -d sends "
                "application/x-www-form-urlencoded unless you say otherwise")
    if value == "application/json":
        return ("ok", "application/json is what a JSON body should be sent as")
    if value == "application/x-www-form-urlencoded":
        return ("form-encoded",
                "this is curl -d's default; the body is then looked for in a payload "
                "field rather than read as JSON")
    return ("wrong-type", "%s is not application/json" % value)


def explain_status(status, body):
    """What a webhook's real HTTP answer means. Pure. Returns (code, repair).

    Worth stating plainly because it is the exception in this section: the Web
    API answers 200 with ok false for almost every failure, and a webhook
    answers a real status with a short plain text reason. Checking the status
    works here.
    """
    code = str(status or "").strip()
    reason = str(body or "").strip().lower()
    if code == "200" and reason in ("ok", ""):
        return ("delivered", "the message was accepted and posted into the bound "
                             "channel")
    if reason == "invalid_payload":
        return ("invalid_payload", "the body did not parse as JSON, and Slack does not "
                                   "say where; build it with a serializer and send it "
                                   "with --data-binary")
    if reason in ("no_text", "missing_text_or_fallback_or_attachments"):
        return ("no_text", "the body parsed and carried no message; guard the empty "
                           "case before sending rather than after")
    if reason == "invalid_token":
        return ("invalid_token", "the URL is not one Slack recognises, which usually "
                                 "means it was truncated, re-wrapped or copied with a "
                                 "trailing character")
    if reason in ("no_service", "no_active_hooks"):
        return ("no_service", "the webhook itself is gone: uninstalled, revoked, "
                              "deleted, or authorised by somebody who has left")
    if reason == "channel_is_archived":
        return ("channel_is_archived", "the bound channel was archived, and a webhook "
                                       "cannot be pointed at a different one")
    if reason == "action_prohibited":
        return ("action_prohibited", "an administrator has restricted what this app is "
                                     "allowed to do in that channel")
    if reason == "posting_to_general_channel_denied":
        return ("posting_to_general_channel_denied",
                "the webhook is bound to the workspace default channel, where posting "
                "is restricted")
    if code == "429":
        return ("rate_limited", "one message per second per webhook is the shape to "
                                "aim for; slow down rather than retrying immediately")
    if code.startswith("5"):
        return ("server_error", "a Slack side failure; retry with backoff and check "
                                "the status page before changing anything")
    return ("unrecognised", "no entry for status %s with body %s"
            % (code or "none", body or "none"))


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--payload", required=True,
                    help="file holding the exact bytes your sender produced")
    ap.add_argument("--content-type", default="",
                    help="the Content-Type header you actually sent, if you have it")
    ap.add_argument("--status", default="",
                    help="the HTTP status the webhook answered, if you captured it")
    ap.add_argument("--body", default="",
                    help="the plain text response body, if you captured it")
    args = ap.parse_args()

    with open(args.payload, "rb") as fh:
        raw = fh.read()
    findings = 0

    text, notes = decode_body(raw)
    for note in notes:
        log.warning("decode     %-14s %s", note, {
            "empty": "the captured file is empty; the sender wrote nothing",
            "blank": "the body is whitespace only",
            "byte-order-mark": "a byte order mark leads the file, which no JSON parser "
                               "accepts and no parser error mentions",
            "not-utf8": "the bytes are not valid UTF-8, so the body cannot be read as "
                        "JSON at all",
        }.get(note, note))
        findings += 1
    if not notes:
        log.info("decode     ok             %d byte(s), utf-8", len(raw))

    state, detail, value = parse_probe(text)
    if state == "ok":
        log.info("parse      ok             %s", detail)
    else:
        log.warning("parse      invalid-json   %s", detail)
        findings += 1

    for code, why in interpolation_fingerprints(text):
        log.warning("fingerprint %-14s %s", code, why)
        findings += 1

    if state == "ok":
        for code, why in envelope_findings(value):
            log.warning("envelope   %-14s %s", code, why)
            findings += 1

    code, why = content_type_finding(args.content_type)
    (log.info if code == "ok" else log.warning)("content    %-14s %s", code, why)
    if code != "ok":
        findings += 1

    if args.status or args.body:
        code, why = explain_status(args.status, args.body)
        (log.info if code == "delivered" else log.warning)(
            "status     %-14s %s", code, why)

    if not findings:
        log.info("verdict    clean          the body parses and the envelope carries a "
                 "message")
        return 0
    log.warning("verdict    %d finding(s)", findings)
    log.warning("  repair: build the body with a serializer - jq -n --arg, json.dumps "
                "or JSON.stringify - and never by interpolating into a JSON literal")
    log.warning("  repair: send it with --data-binary and an explicit "
                "Content-Type: application/json header")
    log.warning("  repair: give curl --fail so a 400 becomes a non zero exit; on this "
                "surface, unlike the rest of Slack, the status code tells the truth")
    log.warning("  note:   nothing was sent anywhere to establish this; this file "
                "makes no network call at all")
    return 1


if __name__ == "__main__":
    sys.exit(main())
slack-webhook-payload.mjs
/**
 * Sort a captured incoming webhook body before anything is sent to a webhook.
 *
 * This file makes no network call of any kind, and there is no HTTP client in
 * it. That is deliberate: the obvious way to find out whether a payload is
 * valid is to send it to the webhook, and if it turns out to be valid that
 * delivers it into a real channel in front of real people. There is no dry run
 * mode on a webhook and no validation endpoint.
 *
 * The scope is narrow on purpose. This covers the layer a webhook owns:
 * whether the bytes are JSON at all, and whether the top level object is a
 * legal incoming webhook envelope. Block Kit structure is a different contract
 * with its own errors and its own note.
 */
import { readFile } from 'node:fs/promises';

// What may legally appear outside a JSON string literal, once the three bare
// words are taken out. Anything else out there escaped from a string.
export const JSON_PUNCTUATION = new Set([...' \t\r\n{}[]:,-+.eE0123456789']);
export const BARE_WORDS = ['true', 'false', 'null'];

// The incoming webhook envelope. Four vocabularies end up in these bodies and
// only the first one does anything.
export const ENVELOPE_KEYS = ['text', 'blocks', 'attachments', 'thread_ts', 'mrkdwn',
  'unfurl_links', 'unfurl_media'];
export const LEGACY_OVERRIDE_KEYS = ['channel', 'username', 'icon_emoji', 'icon_url'];
export const RESPONSE_URL_KEYS = ['response_type', 'replace_original',
  'delete_original'];
export const WEB_API_KEYS = ['token', 'ts', 'as_user', 'reply_broadcast', 'link_names',
  'parse', 'metadata'];

/**
 * Turn captured bytes into text, reporting what a parser will not. Pure.
 * Returns [text, notes].
 */
export function decodeBody(raw) {
  const data = (raw instanceof Uint8Array) ? raw
    : new TextEncoder().encode(String(raw ?? ''));
  const notes = [];
  if (!data.length) return ['', ['empty']];
  let body = data;
  if (data[0] === 0xef && data[1] === 0xbb && data[2] === 0xbf) {
    notes.push('byte-order-mark');
    body = data.subarray(3);
  }
  let text;
  try {
    text = new TextDecoder('utf-8', { fatal: true }).decode(body);
  } catch {
    return ['', notes.concat(['not-utf8'])];
  }
  if (!text.trim()) notes.push('blank');
  return [text, notes];
}

/** Try the parse. Pure. Returns [state, detail, value]; ok or invalid-json. */
export function parseProbe(text) {
  if (!String(text ?? '').trim()) {
    return ['invalid-json', 'the body is empty, which is not a JSON document', null];
  }
  try {
    return ['ok', 'the body parses as JSON', JSON.parse(text)];
  } catch (err) {
    return ['invalid-json', err.message, null];
  }
}

/**
 * Where the JSON string literals are. Pure. Returns [spans, unterminated].
 * Naive in one direction on purpose: an unescaped quote closes a string here
 * exactly as it does in a real parser.
 */
function stringSpans(text) {
  const spans = [];
  let inside = false;
  let escaped = false;
  let start = 0;
  for (let i = 0; i < text.length; i += 1) {
    const ch = text[i];
    if (inside) {
      if (escaped) escaped = false;
      else if (ch === '\\') escaped = true;
      else if (ch === '"') { inside = false; spans.push([start, i]); }
    } else if (ch === '"') {
      inside = true;
      start = i + 1;
    }
  }
  if (inside) spans.push([start, text.length]);
  return [spans, inside];
}

/** Everything that is not inside a string literal, quotes excluded. Pure. */
function outsideText(text, spans) {
  const out = [];
  let prev = 0;
  for (const [a, b] of spans) {
    out.push(text.slice(prev, Math.max(prev, a - 1)));
    prev = b + 1;
  }
  out.push(text.slice(prev));
  return out.join('');
}

/** Name the ways a hand built body stops being JSON. Pure, in a fixed order. */
export function interpolationFingerprints(text) {
  const out = [];
  const [spans, unterminated] = stringSpans(text);
  const inside = spans.map(([a, b]) => text.slice(a, b));
  const NL = String.fromCharCode(10);
  const CR = String.fromCharCode(13);
  if (inside.some((s) => s.includes(NL) || s.includes(CR))) {
    out.push(['raw-newline-in-string', 'a literal newline sits inside a string; JSON '
      + 'has no multi line strings, and a log fragment pasted into a payload is a '
      + 'stack of them']);
  }
  if (inside.some((s) => [...s].some((c) => c.charCodeAt(0) < 32
    && c !== NL && c !== CR))) {
    out.push(['control-character', 'a control character sits inside a string, usually '
      + 'a tab or a terminal escape that came in with captured output']);
  }
  if (unterminated) {
    out.push(['unterminated-string', 'the body ends inside a string literal, which is '
      + 'what truncation and a missing closing quote both look like']);
  }
  const outside = outsideText(text, spans);
  if (['True', 'False', 'None'].some((w) => outside.includes(w))) {
    out.push(['python-literals', 'True, False or None appear outside a string; JSON '
      + 'spells those true, false and null, and a Python repr is not JSON']);
  }
  if (outside.includes("'")) {
    out.push(['single-quoted', 'an apostrophe sits outside every string; JSON has no '
      + 'single quoted strings or keys']);
  }
  let scrub = outside.toLowerCase();
  for (const word of BARE_WORDS) scrub = scrub.split(word).join('');
  const stray = [...scrub].filter((c) => !JSON_PUNCTUATION.has(c) && c !== "'").length;
  if (stray) {
    out.push(['stray-text-outside-string', `${stray} character(s) sit outside every `
      + 'string literal, which is what an unescaped quote leaves behind: the value '
      + 'closed its string early and the rest spilled out']);
  }
  for (let j = 0; j < outside.length; j += 1) {
    if (outside[j] !== ',') continue;
    let k = j + 1;
    while (k < outside.length && /\s/.test(outside[k])) k += 1;
    if (k < outside.length && (outside[k] === '}' || outside[k] === ']')) {
      out.push(['trailing-comma', 'a comma is followed by a closing brace or bracket; '
        + 'a loop that appends an item and a separator every time leaves one behind']);
      break;
    }
  }
  for (let i = 0; i < text.length; i += 1) {
    if (text[i] === '$' && i + 1 < text.length && /[A-Za-z_]/.test(text[i + 1])) {
      out.push(['unexpanded-variable', 'a dollar sign followed by a name survived into '
        + 'the body, so a shell or template variable was never substituted']);
      break;
    }
  }
  if (text.includes('{{') || text.includes('${')) {
    out.push(['unrendered-template', 'a template delimiter survived into the body, so '
      + 'the templating layer either did not run or did not know about this value']);
  }
  return out;
}

/**
 * Read the top level object against the incoming webhook contract. Pure.
 * Not a Block Kit check: this is the envelope those blocks travel in.
 */
export function envelopeFindings(value) {
  if (!value || typeof value !== 'object' || Array.isArray(value)) {
    const kind = Array.isArray(value) ? 'array' : typeof value;
    return [['not-an-object', `the body parses and the top level is a ${kind}; an `
      + 'incoming webhook takes a JSON object']];
  }
  const keys = Object.keys(value);
  const out = [];
  if (keys.length === 1 && keys[0] === 'payload') {
    out.push(['payload-wrapper', 'the body is a single payload key, which is the form '
      + 'encoded shape where the JSON is the value of a payload field; sent as JSON it '
      + 'is an envelope with no message in it']);
  }
  const text = value.text;
  if (keys.includes('text') && typeof text !== 'string') {
    out.push(['text-not-a-string', `text is a ${typeof text}; a number or an object `
      + 'here is a serializer that was handed the wrong thing']);
  }
  if (keys.includes('blocks') && !Array.isArray(value.blocks)) {
    out.push(['blocks-not-a-list', 'blocks has to be an array of blocks']);
  }
  if (keys.includes('attachments') && !Array.isArray(value.attachments)) {
    out.push(['attachments-not-a-list', 'attachments has to be an array of attachments']);
  }
  const carries = Boolean(String(text ?? '').trim())
    || Boolean(value.blocks?.length) || Boolean(value.attachments?.length);
  if (!carries) {
    out.push(['no-text', 'nothing in text, blocks or attachments carries a message, '
      + 'which is the 400 no_text answer rather than the invalid_payload one']);
  }
  if (value.blocks?.length && !String(text ?? '').trim()) {
    out.push(['no-fallback-text', 'blocks with no top level text leaves the push '
      + 'notification, the sidebar preview and the screen reader with nothing to read']);
  }
  for (const key of LEGACY_OVERRIDE_KEYS) {
    if (keys.includes(key)) {
      out.push(['legacy-override', `${key} is a custom integration field; on an app `
        + 'based webhook it does not choose anything']);
    }
  }
  for (const key of RESPONSE_URL_KEYS) {
    if (keys.includes(key)) {
      out.push(['response-url-key', `${key} belongs to a reply sent to a response_url, `
        + 'not to an incoming webhook, and is ignored here']);
    }
  }
  for (const key of WEB_API_KEYS) {
    if (keys.includes(key)) {
      out.push(['web-api-key', `${key} is a chat.postMessage argument; a webhook body `
        + 'is not a Web API request and this key does nothing']);
    }
  }
  const known = new Set([...ENVELOPE_KEYS, ...LEGACY_OVERRIDE_KEYS,
    ...RESPONSE_URL_KEYS, ...WEB_API_KEYS, 'payload']);
  for (const key of keys) {
    if (!known.has(key)) {
      out.push(['unknown-key', `${key} is not part of the incoming webhook envelope `
        + 'and is ignored']);
    }
  }
  return out;
}

/** What the header you actually sent does to the body. Pure. */
export function contentTypeFinding(header) {
  const value = String(header ?? '').split(';')[0].trim().toLowerCase();
  if (!value) {
    return ['no-content-type', 'no Content-Type was recorded; curl -d sends '
      + 'application/x-www-form-urlencoded unless you say otherwise'];
  }
  if (value === 'application/json') {
    return ['ok', 'application/json is what a JSON body should be sent as'];
  }
  if (value === 'application/x-www-form-urlencoded') {
    return ['form-encoded', "this is curl -d's default; the body is then looked for in "
      + 'a payload field rather than read as JSON'];
  }
  return ['wrong-type', `${value} is not application/json`];
}

/**
 * What a webhook's real HTTP answer means. Pure. Returns [code, repair].
 * The exception in this section: here the status code tells the truth.
 */
export function explainStatus(status, body) {
  const code = String(status ?? '').trim();
  const reason = String(body ?? '').trim().toLowerCase();
  if (code === '200' && (reason === 'ok' || reason === '')) {
    return ['delivered', 'the message was accepted and posted into the bound channel'];
  }
  if (reason === 'invalid_payload') {
    return ['invalid_payload', 'the body did not parse as JSON, and Slack does not say '
      + 'where; build it with a serializer and send it with --data-binary'];
  }
  if (reason === 'no_text' || reason === 'missing_text_or_fallback_or_attachments') {
    return ['no_text', 'the body parsed and carried no message; guard the empty case '
      + 'before sending rather than after'];
  }
  if (reason === 'invalid_token') {
    return ['invalid_token', 'the URL is not one Slack recognises, which usually means '
      + 'it was truncated, re-wrapped or copied with a trailing character'];
  }
  if (reason === 'no_service' || reason === 'no_active_hooks') {
    return ['no_service', 'the webhook itself is gone: uninstalled, revoked, deleted, '
      + 'or authorised by somebody who has left'];
  }
  if (reason === 'channel_is_archived') {
    return ['channel_is_archived', 'the bound channel was archived, and a webhook '
      + 'cannot be pointed at a different one'];
  }
  if (reason === 'action_prohibited') {
    return ['action_prohibited', 'an administrator has restricted what this app is '
      + 'allowed to do in that channel'];
  }
  if (reason === 'posting_to_general_channel_denied') {
    return ['posting_to_general_channel_denied', 'the webhook is bound to the '
      + 'workspace default channel, where posting is restricted'];
  }
  if (code === '429') {
    return ['rate_limited', 'one message per second per webhook is the shape to aim '
      + 'for; slow down rather than retrying immediately'];
  }
  if (code.startsWith('5')) {
    return ['server_error', 'a Slack side failure; retry with backoff and check the '
      + 'status page before changing anything'];
  }
  return ['unrecognised', `no entry for status ${code || 'none'} with body `
    + `${body || 'none'}`];
}

const DECODE_WHY = {
  empty: 'the captured file is empty; the sender wrote nothing',
  blank: 'the body is whitespace only',
  'byte-order-mark': 'a byte order mark leads the file, which no JSON parser accepts '
    + 'and no parser error mentions',
  'not-utf8': 'the bytes are not valid UTF-8, so the body cannot be read as JSON at all',
};

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 raw = new Uint8Array(await readFile(arg(args, '--payload')));
  let findings = 0;

  const [text, notes] = decodeBody(raw);
  for (const note of notes) {
    console.warn(`decode     ${note.padEnd(14)} ${DECODE_WHY[note] ?? note}`);
    findings += 1;
  }
  if (!notes.length) {
    console.log(`decode     ok             ${raw.length} byte(s), utf-8`);
  }

  const [state, detail, value] = parseProbe(text);
  if (state === 'ok') console.log(`parse      ok             ${detail}`);
  else { console.warn(`parse      invalid-json   ${detail}`); findings += 1; }

  for (const [code, why] of interpolationFingerprints(text)) {
    console.warn(`fingerprint ${code.padEnd(14)} ${why}`);
    findings += 1;
  }

  if (state === 'ok') {
    for (const [code, why] of envelopeFindings(value)) {
      console.warn(`envelope   ${code.padEnd(14)} ${why}`);
      findings += 1;
    }
  }

  const [ctCode, ctWhy] = contentTypeFinding(arg(args, '--content-type'));
  (ctCode === 'ok' ? console.log : console.warn)(
    `content    ${ctCode.padEnd(14)} ${ctWhy}`);
  if (ctCode !== 'ok') findings += 1;

  const status = arg(args, '--status');
  const body = arg(args, '--body');
  if (status || body) {
    const [code, why] = explainStatus(status, body);
    (code === 'delivered' ? console.log : console.warn)(
      `status     ${code.padEnd(14)} ${why}`);
  }

  if (!findings) {
    console.log('verdict    clean          the body parses and the envelope carries a '
      + 'message');
    return;
  }
  console.warn(`verdict    ${findings} finding(s)`);
  console.warn('  repair: build the body with a serializer - jq -n --arg, json.dumps '
    + 'or JSON.stringify - and never by interpolating into a JSON literal');
  console.warn('  repair: send it with --data-binary and an explicit '
    + 'Content-Type: application/json header');
  console.warn('  repair: give curl --fail so a 400 becomes a non zero exit; on this '
    + 'surface, unlike the rest of Slack, the status code tells the truth');
  console.warn('  note:   nothing was sent anywhere to establish this; this file makes '
    + 'no network call at all');
  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

The fixtures are built with chr(10) and chr(7) rather than written out, because a raw newline and a control character inside a JSON string are the two faults this check exists for and a source file that contained them literally would not survive being edited. The assertion that carries the note is stray-text-outside-string: an unescaped quote closes the string early, the rest of the value spills into the space between the literals, and counting the characters out there names the cause where a parser offset only names the symptom. The envelope tests pin the line this note will not cross — blocks has to be an array and nothing here looks inside it.

test_slack_webhook_payload.py
import codecs

from slack_webhook_payload import (
    content_type_finding, decode_body, envelope_findings, explain_status,
    interpolation_fingerprints, parse_probe,
)

GOOD = '{"text": "deployed v2.1"}'
UNESCAPED = '{"text": "he said "hi" there"}'
RAW_NEWLINE = '{"text": "line one' + chr(10) + 'line two"}'
CONTROL = '{"text": "a' + chr(7) + 'b"}'


def codes(text):
    return [c for c, _w in interpolation_fingerprints(text)]


def envelope(value):
    return [c for c, _w in envelope_findings(value)]


def test_a_clean_body_decodes_parses_and_says_nothing():
    text, notes = decode_body(GOOD.encode("utf-8"))
    assert notes == []
    assert parse_probe(text)[0] == "ok"
    assert codes(text) == []


def test_a_byte_order_mark_is_named_before_the_parser_is_asked():
    text, notes = decode_body(codecs.BOM_UTF8 + GOOD.encode("utf-8"))
    assert notes == ["byte-order-mark"]
    assert parse_probe(text)[0] == "ok"


def test_bytes_that_are_not_utf8_never_reach_the_parser():
    text, notes = decode_body(bytes([0xff, 0xfe, 0x00]))
    assert notes == ["not-utf8"]
    assert text == ""


def test_an_empty_capture_is_empty_rather_than_invalid():
    assert decode_body(b"")[1] == ["empty"]


def test_an_unescaped_quote_leaves_text_outside_every_string():
    assert parse_probe(UNESCAPED)[0] == "invalid-json"
    assert "stray-text-outside-string" in codes(UNESCAPED)


def test_a_raw_newline_inside_a_string_is_named_as_itself():
    assert parse_probe(RAW_NEWLINE)[0] == "invalid-json"
    assert "raw-newline-in-string" in codes(RAW_NEWLINE)


def test_a_control_character_that_came_in_with_captured_output():
    assert "control-character" in codes(CONTROL)


def test_a_body_that_ends_mid_string_is_reported_as_truncation():
    assert "unterminated-string" in codes('{"text": "truncated')


def test_a_trailing_comma_from_a_loop_that_appends_a_separator():
    assert "trailing-comma" in codes('{"text": "a", }')


def test_single_quotes_are_not_json_and_are_named_before_the_stray_text():
    found = codes("{'text': 'hi'}")
    assert found.index("single-quoted") < found.index("stray-text-outside-string")


def test_a_python_repr_is_recognised_rather_than_called_stray_text():
    assert "python-literals" in codes('{"ok": True}')


def test_a_shell_variable_that_was_never_substituted():
    assert "unexpanded-variable" in codes('{"text": "deploy $VERSION"}')


def test_a_template_delimiter_that_survived_into_the_body():
    found = codes('{"text": "${{ github.sha }}"}')
    assert "unrendered-template" in found
    assert "unexpanded-variable" not in found


def test_an_envelope_carrying_a_message_has_nothing_to_report():
    assert envelope({"text": "deployed"}) == []


def test_a_body_that_parses_and_carries_nothing_is_the_no_text_case():
    assert envelope({}) == ["no-text"]
    assert envelope({"text": "   "}) == ["no-text"]


def test_blocks_with_no_top_level_text_leaves_the_notification_blank():
    assert envelope({"blocks": [{"type": "divider"}]}) == ["no-fallback-text"]


def test_the_block_array_is_type_checked_and_never_looked_inside():
    assert "blocks-not-a-list" in envelope({"text": "hi", "blocks": "divider"})
    assert envelope({"text": "hi", "blocks": [{"type": "nonsense"}]}) == []


def test_text_that_is_not_a_string_is_a_serializer_handed_the_wrong_thing():
    assert envelope({"text": 42}) == ["text-not-a-string"]


def test_a_top_level_array_is_refused_before_anything_else_is_read():
    assert envelope(["text"]) == ["not-an-object"]


def test_the_form_encoded_wrapper_sent_as_json_is_an_empty_envelope():
    assert envelope({"payload": '{"text": "hi"}'}) == ["payload-wrapper", "no-text"]


def test_keys_from_the_other_three_surfaces_are_each_named():
    assert envelope({"text": "hi", "channel": "#ops"}) == ["legacy-override"]
    assert envelope({"text": "hi", "response_type": "in_channel"}) == ["response-url-key"]
    assert envelope({"text": "hi", "token": "not-a-token"}) == ["web-api-key"]
    assert envelope({"text": "hi", "colour": "red"}) == ["unknown-key"]


def test_the_default_curl_content_type_is_the_finding_people_miss():
    assert content_type_finding("application/x-www-form-urlencoded")[0] == "form-encoded"
    assert content_type_finding("application/json; charset=utf-8")[0] == "ok"
    assert content_type_finding("")[0] == "no-content-type"
    assert content_type_finding("text/plain")[0] == "wrong-type"


def test_the_status_table_separates_the_body_from_the_webhook():
    assert explain_status(400, "invalid_payload")[0] == "invalid_payload"
    assert explain_status(400, "no_text")[0] == "no_text"
    assert explain_status(404, "no_active_hooks")[0] == "no_service"
    assert explain_status(410, "channel_is_archived")[0] == "channel_is_archived"
    assert explain_status(401, "invalid_token")[0] == "invalid_token"
    assert explain_status(200, "ok")[0] == "delivered"


def test_an_answer_with_no_entry_says_so_rather_than_guessing():
    assert explain_status(418, "teapot")[0] == "unrecognised"
slack-webhook-payload.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
  contentTypeFinding, decodeBody, envelopeFindings, explainStatus,
  interpolationFingerprints, parseProbe,
} from './slack-webhook-payload.mjs';

const GOOD = '{"text": "deployed v2.1"}';
const UNESCAPED = '{"text": "he said "hi" there"}';
const RAW_NEWLINE = `{"text": "line one${String.fromCharCode(10)}line two"}`;
const CONTROL = `{"text": "a${String.fromCharCode(7)}b"}`;

const bytes = (s) => new TextEncoder().encode(s);
const codes = (text) => interpolationFingerprints(text).map(([c]) => c);
const envelope = (value) => envelopeFindings(value).map(([c]) => c);

test('a clean body decodes, parses and says nothing', () => {
  const [text, notes] = decodeBody(bytes(GOOD));
  assert.deepEqual(notes, []);
  assert.equal(parseProbe(text)[0], 'ok');
  assert.deepEqual(codes(text), []);
});

test('a byte order mark is named before the parser is asked', () => {
  const withBom = new Uint8Array([0xef, 0xbb, 0xbf, ...bytes(GOOD)]);
  const [text, notes] = decodeBody(withBom);
  assert.deepEqual(notes, ['byte-order-mark']);
  assert.equal(parseProbe(text)[0], 'ok');
});

test('bytes that are not utf8 never reach the parser', () => {
  const [text, notes] = decodeBody(new Uint8Array([0xff, 0xfe, 0x00]));
  assert.deepEqual(notes, ['not-utf8']);
  assert.equal(text, '');
});

test('an empty capture is empty rather than invalid', () => {
  assert.deepEqual(decodeBody(new Uint8Array([]))[1], ['empty']);
});

test('an unescaped quote leaves text outside every string', () => {
  assert.equal(parseProbe(UNESCAPED)[0], 'invalid-json');
  assert.equal(codes(UNESCAPED).includes('stray-text-outside-string'), true);
});

test('a raw newline inside a string is named as itself', () => {
  assert.equal(parseProbe(RAW_NEWLINE)[0], 'invalid-json');
  assert.equal(codes(RAW_NEWLINE).includes('raw-newline-in-string'), true);
});

test('a control character that came in with captured output', () => {
  assert.equal(codes(CONTROL).includes('control-character'), true);
});

test('a body that ends mid string is reported as truncation', () => {
  assert.equal(codes('{"text": "truncated').includes('unterminated-string'), true);
});

test('a trailing comma from a loop that appends a separator', () => {
  assert.equal(codes('{"text": "a", }').includes('trailing-comma'), true);
});

test('single quotes are not json and are named before the stray text', () => {
  const found = codes("{'text': 'hi'}");
  assert.equal(found.indexOf('single-quoted')
    < found.indexOf('stray-text-outside-string'), true);
});

test('a python repr is recognised rather than called stray text', () => {
  assert.equal(codes('{"ok": True}').includes('python-literals'), true);
});

test('a shell variable that was never substituted', () => {
  assert.equal(codes('{"text": "deploy $VERSION"}').includes('unexpanded-variable'),
    true);
});

test('a template delimiter that survived into the body', () => {
  const found = codes('{"text": "${{ github.sha }}"}');
  assert.equal(found.includes('unrendered-template'), true);
  assert.equal(found.includes('unexpanded-variable'), false);
});

test('an envelope carrying a message has nothing to report', () => {
  assert.deepEqual(envelope({ text: 'deployed' }), []);
});

test('a body that parses and carries nothing is the no_text case', () => {
  assert.deepEqual(envelope({}), ['no-text']);
  assert.deepEqual(envelope({ text: '   ' }), ['no-text']);
});

test('blocks with no top level text leaves the notification blank', () => {
  assert.deepEqual(envelope({ blocks: [{ type: 'divider' }] }), ['no-fallback-text']);
});

test('the block array is type checked and never looked inside', () => {
  assert.equal(envelope({ text: 'hi', blocks: 'divider' })
    .includes('blocks-not-a-list'), true);
  assert.deepEqual(envelope({ text: 'hi', blocks: [{ type: 'nonsense' }] }), []);
});

test('text that is not a string is a serializer handed the wrong thing', () => {
  assert.deepEqual(envelope({ text: 42 }), ['text-not-a-string']);
});

test('a top level array is refused before anything else is read', () => {
  assert.deepEqual(envelope(['text']), ['not-an-object']);
});

test('the form encoded wrapper sent as json is an empty envelope', () => {
  assert.deepEqual(envelope({ payload: '{"text": "hi"}' }),
    ['payload-wrapper', 'no-text']);
});

test('keys from the other three surfaces are each named', () => {
  assert.deepEqual(envelope({ text: 'hi', channel: '#ops' }), ['legacy-override']);
  assert.deepEqual(envelope({ text: 'hi', response_type: 'in_channel' }),
    ['response-url-key']);
  assert.deepEqual(envelope({ text: 'hi', token: 'not-a-token' }), ['web-api-key']);
  assert.deepEqual(envelope({ text: 'hi', colour: 'red' }), ['unknown-key']);
});

test('the default curl content type is the finding people miss', () => {
  assert.equal(contentTypeFinding('application/x-www-form-urlencoded')[0],
    'form-encoded');
  assert.equal(contentTypeFinding('application/json; charset=utf-8')[0], 'ok');
  assert.equal(contentTypeFinding('')[0], 'no-content-type');
  assert.equal(contentTypeFinding('text/plain')[0], 'wrong-type');
});

test('the status table separates the body from the webhook', () => {
  assert.equal(explainStatus(400, 'invalid_payload')[0], 'invalid_payload');
  assert.equal(explainStatus(400, 'no_text')[0], 'no_text');
  assert.equal(explainStatus(404, 'no_active_hooks')[0], 'no_service');
  assert.equal(explainStatus(410, 'channel_is_archived')[0], 'channel_is_archived');
  assert.equal(explainStatus(401, 'invalid_token')[0], 'invalid_token');
  assert.equal(explainStatus(200, 'ok')[0], 'delivered');
});

test('an answer with no entry says so rather than guessing', () => {
  assert.equal(explainStatus(418, 'teapot')[0], 'unrecognised');
});

FAQ

Is this not just the same as validating Block Kit before sending?

No, and the boundary is the reason this note exists separately. Block Kit validation asks whether a section block has a legal text object, whether the array is under fifty, whether a field is over its ceiling. All of that assumes you already have a valid JSON document containing an array. This check is the layer underneath: whether the bytes are JSON at all, and whether the top level object is a legal incoming webhook envelope, which is not the same contract as chat.postMessage and not the same contract as a response_url reply. A payload can fail here while every block in it is perfect, because the failure happened in a shell before any block was parsed.

Why not send the payload to the webhook and read the error?

Because if the payload turns out to be valid, that sends it. A webhook has no dry run mode and no validation endpoint; the only outcomes of the experiment are a 400 that tells you what this script already told you, or a message delivered into a channel in front of people. Running the check locally costs nothing, needs no credential, and can sit in a pre-commit hook or a CI step before the sender is ever reached.

Slack says invalid_payload but does not say where. Can you do better?

Slightly, and the fingerprints are the better part. A parser offset points at where the damage was noticed, which for an unescaped quote is several characters after where it began. The fingerprints name the cause instead: a raw newline inside a string, a control character, an unterminated string, a trailing comma, single quotes, a Python repr, a variable that was never substituted, a template delimiter that survived, and text sitting outside every string literal, which is the signature of a quote that got in. The position is still printed, because sometimes it is enough on its own.

Our message arrives but it is truncated at the first quote. Nothing errored.

That is the near miss, and it is worse than the failure because no layer anywhere reports it. The interpolated value closed its string early, the rest of it happened to land somewhere the parser tolerated, and the message posted. Run this over a body you know was accepted: the stray text and unterminated string fingerprints both fire on payloads that parse, precisely so that the ones which nearly broke are visible before the one that does.

Should we check the HTTP status? Everywhere else this section says not to.

Here you should, and that is the one place in this section where that advice reverses. The Web API answers 200 with ok: false for almost every failure, so the status is meaningless. Incoming webhooks answer real statuses with a short plain text reason: 400 invalid_payload, 400 no_text, 401 invalid_token, 403 action_prohibited, 404 no_service, 410 channel_is_archived. Give curl its --fail flag and the failure becomes a non zero exit instead of a green build with no message in it.

Related field notes

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.

Stuck on a tricky one?

If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.