How to Test Discord Webhooks for Bots and Integrations
Discord webhooks come in two distinct forms, and most guides treat them as the same thing. They are not. Incoming Webhooks let your application post messages into a Discord channel by making an HTTP request to a Discord-hosted URL. Interactions webhooks work the other way: Discord calls your server when a user triggers a slash command or clicks a button in your app. Each has a different testing strategy, and confusing the two leads to a lot of wasted time.
Incoming Webhooks: posting messages to Discord
An Incoming Webhook is created inside Discord itself. Go to your server's channel settings, open the "Integrations" tab, and click "Create Webhook." Discord gives you a URL that looks like this:
https://discord.com/api/webhooks/{webhook.id}/{webhook.token}
To post a message, send an HTTP POST request to that URL with a JSON body. The simplest possible payload uses the content key:
curl -X POST "https://discord.com/api/webhooks/YOUR_ID/YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"content": "Deploy complete. Build 1042 passed all checks."}'
You can override the display name and avatar URL using the optional username and avatar_url fields.
Discord rate-limits Incoming Webhooks to 30 requests per minute per webhook. If you exceed this, the API returns a 429 Too Many Requests response.
The embed format
Stop reading raw JSON
Payloader shows you what your Discord webhook actually did — in plain English. See the event, amount, status, and more at a glance.
Start free trial →
Plain text messages work, but most integrations use embeds to send richly formatted content. An embed is a structured card with a colored sidebar, title, description, named fields, and a footer. You pass it in the embeds array:
{
"username": "Deploy Bot",
"embeds": [
{
"title": "Build #1042 Passed",
"description": "All 214 tests passed. Ready to promote to production.",
"color": 3066993,
"fields": [
{ "name": "Branch", "value": "main", "inline": true },
{ "name": "Duration", "value": "1m 42s", "inline": true },
{ "name": "Triggered by", "value": "alice", "inline": true }
],
"footer": {
"text": "GitHub Actions"
},
"timestamp": "2026-03-01T14:00:00.000Z"
}
]
}
The color field is a decimal integer representing an RGB color (not a hex string). Convert your hex color to decimal before setting it: #2ECC71 becomes 3066993. You can send up to ten embeds per request, and each embed's combined text length must stay under 6000 characters.
Discord Interactions API webhooks: Discord calling your server
When you build a Discord bot that uses slash commands, buttons, or select menus, Discord does not use a persistent WebSocket to dispatch interactions. Instead, it sends an HTTP POST request to an Interactions Endpoint URL that you configure in the Discord Developer Portal.
Your server must respond within three seconds. If it does not, Discord will display a failure message to the user and drop the interaction. This three-second window is a hard constraint, not a soft recommendation.
For slash command interactions, the payload looks like this:
{
"application_id": "1234567890123456789",
"token": "A_UNIQUE_TOKEN_FOR_THIS_INTERACTION",
"type": 2,
"data": {
"id": "9876543210987654321",
"name": "deploy",
"type": 1,
"options": [
{ "name": "environment", "type": 3, "value": "staging" }
]
},
"guild_id": "1111111111111111111",
"channel_id": "2222222222222222222",
"member": {
"user": {
"id": "3333333333333333333",
"username": "alice"
}
}
}
The type field tells you what kind of interaction it is: 1 is a Ping (used for endpoint verification), 2 is an Application Command (slash command), and 3 is a Message Component (button or select menu).
Testing Incoming Webhooks with Payloader
Incoming Webhooks send traffic outbound from your infrastructure, so the usual challenge is verifying that your application is producing the correct JSON before it reaches Discord. You can use Payloader as the target URL instead of your real Discord webhook while you build.
- Create a new endpoint in Payloader. Copy the URL.
- Replace your Discord webhook URL with the Payloader URL in your code or CI configuration.
- Trigger the action (deploy, alert, form submission) that sends the webhook.
- Open Payloader to inspect the exact JSON your application sent, including all headers.
This approach lets you catch malformed embed structures, missing fields, or incorrect content types before they cause silent failures against the real Discord API. Once the payload looks exactly right, swap the URL back to Discord.
You can also use automation tools like Zapier or Make.com with a Payloader URL to inspect the exact payload format those platforms generate before you build a custom receiver for them.
Testing Interactions webhooks with Payloader
To inspect what Discord sends for a slash command or button interaction, you can temporarily set your Discord application's Interactions Endpoint URL to a Payloader endpoint.
- Create a new endpoint in Payloader. Copy the URL.
- Go to the Discord Developer Portal, open your application, and paste the Payloader URL into the "Interactions Endpoint URL" field.
- Discord will immediately send a Ping request (
"type": 1). Payloader captures it, but Discord will show a verification failure because no server responded with{"type": 1}. This is expected. - To get past verification, your local server must handle the Ping response. Use Payloader's forwarding feature to route the Discord request to your local development server, let your server reply, and still see the full payload captured in Payloader.
- Once verification passes, trigger a slash command from Discord. Check Payloader to see the full interaction payload.
Payloader shows you the X-Signature-Ed25519 and X-Signature-Timestamp headers alongside the body. These are what you need to verify the request's authenticity.
Signature verification: Ed25519, not HMAC
Discord does not use HMAC-SHA256 like GitHub or Stripe. Discord signs all Interactions using Ed25519 public-key signatures. Every request from Discord includes two headers:
X-Signature-Ed25519: the hex-encoded Ed25519 signatureX-Signature-Timestamp: the Unix timestamp as a string
To verify the request, concatenate the timestamp and the raw request body (in that order, with no separator), then verify that concatenated string against the signature using your application's public key, which you find in the Discord Developer Portal under "General Information."
In Python, using the PyNaCl library:
from nacl.signing import VerifyKey
from nacl.exceptions import BadSignatureError
PUBLIC_KEY = "your_application_public_key_here"
def verify_discord_request(raw_body: bytes, signature: str, timestamp: str) -> bool:
verify_key = VerifyKey(bytes.fromhex(PUBLIC_KEY))
message = timestamp.encode() + raw_body
try:
verify_key.verify(message, bytes.fromhex(signature))
return True
except BadSignatureError:
return False
In Node.js, using the built-in crypto module (Node 15+):
import { createVerify } from "crypto";
const PUBLIC_KEY = "your_application_public_key_here";
function verifyDiscordRequest(
rawBody: string,
signature: string,
timestamp: string
): boolean {
const message = timestamp + rawBody;
const verifier = createVerify("sha512");
verifier.update(message);
return verifier.verify(
Buffer.from(PUBLIC_KEY, "hex"),
Buffer.from(signature, "hex")
);
}
If verification fails, return an HTTP 401 immediately and do not process the payload. Discord will retry failed requests, but a consistent 401 will cause Discord to disable your Interactions Endpoint URL. Always verify against the raw, unparsed request body. If your framework parses and re-serializes JSON before your verification code runs, the byte sequence will change and the signature will not match.
Capturing requests in Payloader gives you the raw body bytes and headers in isolation, which makes it much easier to debug a failing verification step without the noise of your application's request processing pipeline.