webhooks
How to Test Discord and Slack Webhooks (Without Writing Code)
The exact JSON payloads both platforms expect, what each status code means, why 204 looks like a failure, and how to debug rate limits, invalid_payload, and no_service errors.
You’ve got a webhook URL and a build pipeline that’s supposed to post to it. Something isn’t arriving, and the loop of edit script → deploy → wait → check channel is a miserable way to find out why. Send one request by hand first. Ten seconds, and it tells you whether the problem is the URL, the payload, or your code — which is three quarters of the debugging.
Here’s what both platforms expect, what they send back, and how to read the failures.
Discord: the payload
Discord’s webhook endpoint takes a JSON POST. The minimum viable message:
{
"content": "Deploy finished ✅"
}
That’s it. Content-Type: application/json, POST to the webhook URL, done.
The full field set:
| Field | Type | Notes |
|---|---|---|
content | string | Up to 2000 characters. Supports Discord markdown |
username | string | Overrides the webhook’s configured name per-message |
avatar_url | string | Overrides the avatar per-message |
embeds | array | Up to 10 rich embed objects |
tts | boolean | Text-to-speech |
allowed_mentions | object | Controls whether @everyone actually pings |
thread_name | string | Creates a thread in forum/media channels |
poll | object | Attaches a poll |
You must provide at least one of content, embeds, components, a file, or poll. An empty object is rejected.
A rich embed — the bordered card format most CI notifications use:
{
"username": "Deploy Bot",
"embeds": [
{
"title": "Build #482 succeeded",
"description": "Deployed `main` to production in 94s",
"url": "https://example.com/builds/482",
"color": 3066993,
"fields": [
{ "name": "Branch", "value": "main", "inline": true },
{ "name": "Commit", "value": "a3f9c21", "inline": true }
],
"footer": { "text": "example.com CI" },
"timestamp": "2026-07-25T14:22:00.000Z"
}
]
}
Two things bite people here. color is a decimal integer, not a hex string — 3066993 is #2ECC71. Convert with the Color Converter, or in your head: 0x2ECC71 → decimal. And timestamp must be ISO 8601; anything else is silently dropped rather than erroring. The Timestamp Converter handles the conversion if your source is a Unix epoch.
Discord’s response codes
| Status | Meaning |
|---|---|
| 204 No Content | Success. Default response — empty body, message posted |
| 200 OK | Success with ?wait=true — returns the created message object |
| 400 Bad Request | Malformed JSON or invalid field (Invalid Form Body, with which field) |
| 401 / 403 | Bad or revoked token in the URL |
| 404 Not Found | Webhook deleted, or the URL is wrong |
| 429 Too Many Requests | Rate limited — see below |
204 is the one that confuses everyone. An empty response body with no message text reads like a failure in most HTTP tools, and it’s the correct success response. If you want a body back, append ?wait=true to the webhook URL and Discord returns the full message object with a 200 instead — genuinely useful when testing, because you get the message ID and confirmation of exactly what was rendered.
Slack: the payload
Slack incoming webhooks are simpler on the surface:
{
"text": "Deploy finished :white_check_mark:"
}
For anything richer, Slack uses Block Kit rather than Discord-style embeds:
{
"text": "Build #482 succeeded",
"blocks": [
{
"type": "header",
"text": { "type": "plain_text", "text": "Build #482 succeeded" }
},
{
"type": "section",
"fields": [
{ "type": "mrkdwn", "text": "*Branch:*\nmain" },
{ "type": "mrkdwn", "text": "*Commit:*\n`a3f9c21`" }
]
},
{
"type": "context",
"elements": [{ "type": "mrkdwn", "text": "Deployed in 94s" }]
}
]
}
Keep text populated even when you use blocks — it’s what appears in notifications and on devices that can’t render blocks. Leaving it out gives you push notifications that say “This content can’t be displayed.”
Also note Slack’s markdown is mrkdwn, not standard Markdown: *bold* (single asterisks), _italic_, ~strike~, and links are <https://example.com|link text>. Pasting GitHub-flavored Markdown produces literal asterisks in your channel.
Slack’s response codes
| Status | Body | Meaning |
|---|---|---|
| 200 | ok (plain text) | Success |
| 400 | invalid_payload | Malformed JSON or bad escaping |
| 400 | no_text | No text and no usable blocks |
| 403 | action_prohibited | Workspace admin restriction |
| 404 | no_service | Webhook was disabled or deleted |
| 404 | channel_not_found | Target channel archived or gone |
| 410 | channel_is_archived | Channel archived after the hook was created |
| 429 | rate limited | See below |
Slack’s errors are refreshingly specific — the body tells you exactly what’s wrong in plain text. invalid_payload is nearly always a JSON escaping problem: an unescaped quote or a raw newline inside a string, usually from string-concatenating a message in a shell script.
Send a test request in the browser
Rather than crafting a curl command with the escaping hazards that implies, paste the URL and body into the free Webhook Tester. It sends a real POST with your headers and shows the status code and response body — which is precisely the information you need to tell “my payload is wrong” from “my URL is dead.”
The equivalent curl for the record:
# Discord
curl -X POST -H "Content-Type: application/json" \
-d '{"content":"test from curl"}' \
"https://discord.com/api/webhooks/ID/TOKEN"
# Discord, with a response body
curl -X POST -H "Content-Type: application/json" \
-d '{"content":"test"}' \
"https://discord.com/api/webhooks/ID/TOKEN?wait=true"
# Slack
curl -X POST -H "Content-Type: application/json" \
-d '{"text":"test from curl"}' \
"https://hooks.slack.com/services/T00/B00/XXXX"
If you need to build a more elaborate request — custom headers, other methods, non-JSON bodies — the HTTP Request Builder covers the general case, and the JSON Formatter will validate a payload before you send it. Malformed JSON is the most common cause of a 400 from either platform, and it’s the easiest to rule out.
The failures you’ll actually hit
Missing or wrong Content-Type. Both platforms want application/json. Send text/plain and Discord returns a 400 that reads like a JSON error even though your JSON is fine.
Unescaped content in shell scripts. Building JSON with string interpolation breaks the moment a commit message contains a quote, a backslash, or a newline. Use jq -n --arg msg "$MESSAGE" '{content: $msg}' instead of hand-rolling the string. This single change fixes most flaky CI notifications.
Discord’s 2000-character limit. Dump a stack trace or a git log into content and you get a 400. Truncate, or move the bulk into an embed description (which has its own limits — 4096 characters, and 6000 total across an entire embed).
Rate limits. Discord allows roughly 30 requests per 60 seconds per webhook, with a tighter per-channel limit of about 5 per 5 seconds shared across all webhooks in that channel. Exceed it and you get a 429 with a JSON body containing retry_after (a float, in seconds) — wait that long and retry, don’t hammer. If the body has "global": true, back off everything.
Slack’s limit is about one message per second per channel, with short bursts tolerated. Sustained abuse gets you a 429 with a Retry-After header, and Slack explicitly warns that ignoring it can get an app permanently disabled. If you’re posting per-item in a loop, batch into one message instead.
A webhook URL that has quietly died. Discord returns 404 if the webhook was deleted; Slack returns no_service. Both happen when someone removes an integration or the channel is archived. A test request distinguishes this from a code bug in seconds.
Working locally, failing in CI. Almost always the secret: an unset environment variable produces a POST to an empty or literal $WEBHOOK_URL string. Log the URL’s length, never the URL itself, to confirm it’s populated.
Treat webhook URLs as secrets
Both platforms’ webhook URLs contain the credential inline. Anyone with the URL can post to your channel as your bot, forever, with no other authentication. (This is the opposite of how GitHub and Stripe secure outgoing webhooks — they sign each payload with an HMAC so the URL itself can stay boring; see how HMAC works.)
- Never commit them. Environment variables or your CI’s secret store, always. A leaked Discord webhook in a public repo gets found by scrapers within hours and used for spam.
- Rotate on exposure. Delete the webhook and create a new one — there’s no way to invalidate a URL while keeping it.
- Don’t put them in client-side code. Anything in your frontend bundle is public, so a “contact form that posts to Slack” needs a server-side proxy.
- Restrict what can be mentioned. Set
allowed_mentions: { "parse": [] }on Discord so a user-supplied string containing@everyonecan’t ping your whole server.
The debugging order
- Send a minimal payload by hand —
{"content":"test"}or{"text":"test"}. - Read the status code. 204 or 200 = your URL is good, the problem is in your payload or your code. 404 = dead URL. 400 = payload.
- If it’s a 400, validate the JSON, then check the specific limits — length, embed count, required fields.
- If it works by hand but not in production, it’s the secret, the
Content-Type, or string escaping. In that order.
The Webhook Tester covers step 1 and 2 in one shot; the HTTP Status Code Reference has the full list if something unexpected comes back. Webhooks are the push-a-message half of realtime; if what you’re debugging is a persistent socket instead, why your WebSocket keeps disconnecting gives the WebSocket Tester the same status-code-by-status-code treatment.