Discord webhooks & interactions: testing guide
Discord has two flows: incoming webhooks (you POST messages to a discord.com/api/webhooks URL, no bot needed) and interactions (Discord POSTs slash commands to your endpoint, which must answer PING and verify the Ed25519 signature).
"Discord webhooks" means two completely different things, and mixing them up wastes hours. Incoming webhooks are URLs you POST to when you want to drop a message into a channel. Interactions are HTTP requests Discord POSTs to your endpoint when someone runs your slash command or clicks your button. Different direction, different security model, different testing strategy. Here's both.
Incoming webhooks: you POST to Discord
Create one in a channel's settings (Edit Channel → Integrations → Webhooks) and you get a URL shaped like https://discord.com/api/webhooks/{id}/{token}. Anyone holding that URL can post to the channel — no bot, no OAuth. A minimal call:
curl -X POST "https://discord.com/api/webhooks/1234…/abc…?wait=true" \
-H "Content-Type: application/json" \
-d '{"content": "deploy finished", "username": "ci-bot"}'
usernameandavatar_urlin the JSON body override the webhook's default name and avatar per message — handy for distinguishing environments (ci-bot [staging]).wait=truechanges the response contract: default (false) returns204 No Contentand fire-and-forget delivery;truewaits for the message to be created and returns the full message object — which you need if you want to edit or delete it later via/webhooks/{id}/{token}/messages/{message.id}.- You must send at least one of
content,embeds,components, a file, or apoll— an empty payload is a 400.contentcaps at 2000 characters. - Rate limits are per webhook. Exceed them and you get a
429with aretry_afterfield and aRetry-Afterheader — read it and back off; don't hardcode guesses, limits vary. Bursty CI notifications are the usual offender. - Compatibility endpoints exist: append
/slackto the URL and Discord accepts Slack-formatted payloads, or/githuband it renders GitHub events directly — you can point a GitHub repo webhook at it as-is. See the Slack and GitHub playbooks for the source formats.
Debugging tip: before pointing your notifier at the real Discord URL, point it at a bin at view.webhooks.page and inspect the exact JSON your code emits — quoting bugs and wrong Content-Types are much easier to see there than in Discord's terse 400 responses.
Interactions: Discord POSTs to you
Slash commands, buttons, selects, and modals arrive as HTTP POSTs to your app's Interactions Endpoint URL (set in the Developer Portal, on your app's General Information page). This flow has a hard gate most platforms don't: Discord validates your endpoint before it will save the URL at all.
Ed25519, not HMAC
Every interaction request carries two headers:
X-Signature-Ed25519— a hex-encoded Ed25519 signatureX-Signature-Timestamp— the timestamp that was signed
The signed message is timestamp + raw request body (the body as a string, unparsed), verified against your application's public key from the Developer Portal. No shared secret, no HMAC — asymmetric crypto. Fail validation and you must return 401. Node with tweetnacl (the library Discord's own docs use):
import express from "express";
import nacl from "tweetnacl";
const app = express();
app.post("/interactions", express.json({
// We need the raw body string for verification; JSON parsing alone loses it.
verify: (req, res, buf) => { (req as any).rawBody = buf.toString("utf8"); },
}), (req, res) => {
const signature = req.get("X-Signature-Ed25519") ?? "";
const timestamp = req.get("X-Signature-Timestamp") ?? "";
const valid = nacl.sign.detached.verify(
Buffer.from(timestamp + (req as any).rawBody),
Buffer.from(signature, "hex"),
Buffer.from(process.env.DISCORD_PUBLIC_KEY!, "hex"),
);
if (!valid) return res.status(401).send("invalid request signature");
if (req.body.type === 1) return res.json({ type: 1 }); // PING → PONG
if (req.body.type === 2) {
// Slash command — answer inline…
return res.json({ type: 4, data: { content: "pong" } });
}
res.sendStatus(400);
});
Discord's official discord-interactions package wraps this as verifyKey (and ships an Express middleware) if you'd rather not touch tweetnacl directly. Either way, take this seriously: Discord runs automated routine security checks against your endpoint, including deliberately sending invalid signatures. Accept one, and Discord removes your endpoint URL and notifies you by email and System DM. Your production bot just stops working.
Answer the PING
When you paste your endpoint URL into the Developer Portal, Discord immediately POSTs a PING payload — a body of just {"type": 1}, signed like everything else. Your endpoint must verify the signature and respond 200 with a JSON body of {"type": 1} (a PONG, with a valid Content-Type). If either check fails, the portal refuses to save the URL. There is no "save now, fix later."
The 3-second clock
Once saved, every real interaction must get its initial response within 3 seconds or the interaction token is invalidated and the user sees "The application did not respond." Your options:
- Respond inline with
{"type": 4, "data": {…}}if you're fast enough. - Defer with
{"type": 5}(user sees a loading state), then do the slow work and send the real answer as a followup:POST /webhooks/{application.id}/{interaction.token}. The token lives for 15 minutes and can send multiple followups — it's a webhook, same shape as incoming webhooks, which is why the two flows converge here.
What a slash command looks like on the wire
An APPLICATION_COMMAND (type 2) interaction, trimmed:
{
"id": "1244…",
"application_id": "1040…",
"type": 2,
"data": {
"id": "1244…",
"name": "ping",
"type": 1,
"options": [{ "name": "target", "type": 3, "value": "api" }]
},
"guild_id": "8927…",
"channel_id": "1001…",
"member": { "user": { "id": "6012…", "username": "bart" } },
"token": "aW9tZXdhcmR0b2tlbg…",
"version": 1
}
Other types you'll meet: 3 (message component — button/select, with a custom_id you assigned), 4 (autocomplete), 5 (modal submit).
The testing order that actually works
Because Discord validates before saving, you cannot start with a dumb request bin — a bin can't verify signatures or answer PING, so the portal would reject it outright. The working sequence:
- Write the handler first: Ed25519 verification + PING ACK. That's ~20 lines, above.
- Expose it publicly. A tunnel is the fast path — see testing webhooks locally and tunnel options without ngrok.
- Save the URL in the Developer Portal. Discord's signed PING now doubles as your first real test: if it saves, your verification works against a genuine Discord signature.
- Register a slash command and run it from a client. Log the raw body at your handler — that's your ground truth for payload shapes.
- Exercise the failure path: craft a request with a garbage signature (curl, no valid key needed) and confirm your handler returns 401. Discord's routine checks will do this to you in production; better to know now.
Where the bin still earns its keep: for outgoing traffic. Point your followup/notification code at a bin instead of discord.com while you iterate on payload shape, then flip the host back when it's right. Ground truth first, Discord second.
Last updated
Try it in 10 seconds.
Create a free bin at view.webhooks.page — no signup, no install. Point your sender at the URL and watch requests land live. Bins expire after 48 hours.