Test Discord Webhooks Locally
The specified interactions endpoint url could not be verified. Discord refuses to save an Interactions Endpoint URL until a correctly signed PONG comes back from your machine within three seconds. This page is about that URL (the one Discord POSTs to), not the channel incoming webhook you POST messages to, which needs no tunnel at all, and not a Gateway bot, which opens an outbound WebSocket and never needs a public URL. Get a stable address Discord can reach, read the exact X-Signature-Ed25519 and raw body it sent, and replay the PING until verification passes.
30-second setup
From a stuck handler to a live Discord delivery you can read and replay.
- 1Start LocalCan and point it at your local server:
localcan http 3000. It opens the tunnel and prints your.localdomains plus a public HTTPS URL. - 2Grab your persistent URL, something like
your-app-12.localcan.dev. It is saved to your project file and comes back identical after every restart, so you wire it up once. - 3Open your app in the Developer Portal, go to the General Information page (
discord.com/developers/applications/select/information) and copy the Public Key. Your handler needs it before any of this succeeds. Paste your LocalCan URL plus your route into the Interactions Endpoint URL field, e.g.https://your-bot-12.localcan.dev/interactions, then click Save Changes. Discord’s docs render that label three ways (Interactions, Interaction, Interactive). It is the only URL field on the page. Webhook Events are separate, on the Webhooks page (discord.com/developers/applications/select/webhooks). - 4There is no test-event button: Save Changes is the test, and Discord fires the PING the moment you click it. If the save is refused, leave LocalCan capturing and click it again. The failed attempt is already in Inspect Traffic. Once the endpoint verifies, run a slash command to send a real
APPLICATION_COMMANDthrough the same URL. - 5Open Inspect Traffic in LocalCan. The delivery lands with full headers and body. Expand it, fix your handler, and hit Replay to send the exact same request again. No new test event needed.
Why localhost can't receive Discord webhooks
Discord sends interactions from its own servers to a public URL. localhost:3000 resolves only on your machine, so there is nothing for Discord to connect to, and here that failure arrives earlier than elsewhere. A Stripe or GitHub endpoint can be registered now and debugged later. An Interactions Endpoint URL cannot be saved at all until the PING reaches your handler and a signed PONG comes back.
Discord’s own quick start solves this with ngrok http 3000, and the official example app hardcodes the same step, so a tunnel is the right shape of answer. Which tunnel matters, because every change of address means sitting through the verification handshake again.
Register the endpoint (and pass Discord’s PING check)
Discord saysValidation errors: interactions_endpoint_url: The specified interactions endpoint url could not be verified.
Saving the field is the test. Discord POSTs a PING to the URL you typed and waits three seconds for an answer, and if anything in that round trip is wrong the value is refused: the same message for an unreachable host, a signature computed over a re-serialised body, the wrong Public Key, a missing Content-Type, or an npm run dev that was still compiling.
The two surfaces answer differently, and swapping them produces that identical refusal. An interactions PING arrives as {"type": 1} and wants HTTP 200 with {"type": 1} in the body. A Webhook Events PING arrives as {"type": 0} and wants HTTP 204 with no body at all. Both are synchronous: Discord holds the connection open for your app’s own answer.
What makes the Discord loop fast
One URL, verified once
Discord does not simply store the Interactions Endpoint URL, it re-runs the whole PING check every time the value changes. The cost of a new address is not a paste, it is another three-second handshake that has to succeed while your dev server happens to be warm, and when it does not, the field refuses to save.
A …localcan.dev URL is written into your project file and comes back byte-identical after a reboot or a closed lid, so you clear that check on day one and never again.
See both halves of the handshake
Ed25519 is asymmetric, so there is no shared secret you could have mistyped: when verification fails, the only suspects are the raw bytes and the public key. The inspector shows X-Signature-Ed25519, X-Signature-Timestamp and the untouched body exactly as Discord sent them, which is how you catch middleware that re-serialised the JSON before your verify step ran.
It also shows what your app answered, so you can tell whether Discord got {"type": 1}, a 401, or your framework’s HTML error page.
Re-fire the PING without the portal
Discord has no resend button for interactions. The only way to make it send another PING is to open General Information (discord.com/developers/applications/select/information) and save the field again, and a real command interaction cannot be summoned to order at all.
Replay re-sends a captured request from the inspector, same bytes and same signature, as often as your handler needs. A portal round trip becomes a keystroke.
Inspect & replay Discord events
Common events
PINGInteraction type 1, the verification handshake: answer 200 with a type 1 bodyAPPLICATION_COMMANDType 2, a slash, user or message command was invokedMESSAGE_COMPONENTType 3, a button click or a select-menu choiceAPPLICATION_COMMAND_AUTOCOMPLETEType 4, fires on keystrokes, so the 3-second budget bites hardest hereMODAL_SUBMITType 5, a modal form was submittedAPPLICATION_AUTHORIZEDWebhook Event, sent when a user installs your appENTITLEMENT_CREATEWebhook Event, a user was granted one of your SKUs
The first five are interaction types, arriving at your Interactions Endpoint URL. The last two are Webhook Events, a separate subscription on the Webhooks page (discord.com/developers/applications/select/webhooks) with its own PING and its own 204 answer. Both land in Inspect Traffic with full headers and body, so you can expand a delivery and read its type, its data and the token it carried.
The column worth watching here is response time: Discord allows three seconds from arrival, and a cold compile or a forgotten breakpoint eats that quietly. When the handler throws, fix it and hit Replay. The same request goes through again, signature intact, with no trip back to the portal.
- Timeout
- Three seconds. An interaction needs its initial response within 3 seconds of arriving, and a Webhook Event needs its 204 in the same window.
- Retries
- Interactions are never retried: miss the 3 seconds and the token is invalidated, though tokens otherwise live 15 minutes for followups. Webhook Events retry with exponential backoff for up to 10 minutes; Discord does not publish that schedule.
- Auto-disable
- Fail too often and Discord stops sending webhook events and emails you. It also sends deliberately invalid signatures as a routine check: answer one with 200 instead of 401 and it removes your endpoint URL, with an email and a System DM.
Verify the Discord request
- Header
X-Signature-Ed25519, alongsideX-Signature-Timestamp- Algorithm
- Ed25519 public-key signature, hex-encoded, not an HMAC
- What's signed
- Discord signs the
X-Signature-Timestampheader value immediately followed by the exact bytes of the request body, with no separator between them. You verify that message against theX-Signature-Ed25519value using your app’s public key. Both the signature and the key travel as hex. Because the raw body is part of the signed message, middleware that parses the JSON and hands your verifier a re-serialised object breaks the check while the payload still looks correct. - Secret
- There is no shared secret. The Public Key on your app’s General Information page (
discord.com/developers/applications/select/information) is public by design and only ever verifies.
import express from 'express'
import crypto from 'crypto'
// The Public Key from General Information is a raw 32-byte Ed25519 key in hex.
// Node wants a KeyObject, so wrap it in the fixed DER/SPKI prefix once at boot.
const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex')
const publicKey = crypto.createPublicKey({
key: Buffer.concat([ED25519_SPKI_PREFIX, Buffer.from(process.env.DISCORD_PUBLIC_KEY, 'hex')]),
format: 'der',
type: 'spki',
})
const app = express()
// RAW body: the signature covers the exact bytes, so no JSON parser first.
app.post('/interactions', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.get('X-Signature-Ed25519')
const timestamp = req.get('X-Signature-Timestamp')
if (!signature || !timestamp) return res.status(401).send('missing signature headers')
// Signed message is timestamp + raw body, concatenated with no separator.
const message = Buffer.concat([Buffer.from(timestamp, 'utf8'), req.body])
const ok = crypto.verify(null, message, publicKey, Buffer.from(signature, 'hex'))
// 401 is mandatory: Discord sends invalid signatures as a routine security
// check and removes the endpoint of any app that answers one with a 200.
if (!ok) return res.status(401).send('invalid request signature')
const interaction = JSON.parse(req.body)
// PING (type 1): the check that must pass before the URL can be saved.
if (interaction.type === 1) return res.json({ type: 1 })
// Anything else: answer inside 3 seconds or the token is invalidated.
res.json({ type: 4, data: { content: 'hello from localhost' } })
})
app.listen(3000)LocalCan does not verify the signature for you: verifyKey from discord-interactions, or the snippet above, does that. It puts every input of the check in front of you: the X-Signature-Ed25519 and X-Signature-Timestamp Discord sent, the body as bytes, and the response your app returned.
That matters more here than on an HMAC provider, because Discord reports one message for every possible cause: request and response side by side is what separates a re-serialised body from the wrong Public Key from a handler that answered too late.
A replayed capture still verifies, and it does not expire: Discord documents no timestamp tolerance for the Ed25519 check, so a PING captured this morning verifies this afternoon. One thing does expire: a replayed APPLICATION_COMMAND passes verification, but its interaction token was invalidated three seconds after the original arrived, so followup calls to Discord’s API with it fail. Replay exercises your handler, it does not produce a reply in Discord.
LocalCan vs hosted testers and relays
Discord ships no CLI that forwards interactions to localhost and no button that resends one, so the real choice is between a tunnel and the hosted testers filling this search result. Those tools are alive and well (smee-client shipped 5.0.0 in late 2025), and they are good at showing what a payload contains. What they cannot do is register a Discord endpoint: they answer Discord themselves, so your app’s signed PONG never arrives. Any bidirectional tunnel clears that bar. Discord’s quick start reaches for ngrok, and Cloudflare Tunnel is free if you will move a domain onto Cloudflare DNS.
| LocalCan | hosted testers and relays | |
|---|---|---|
| Pass the endpoint PING check | Your app’s own signed PONG travels back | Cannot: the tester answers Discord, not your app |
| Stable URL to paste | Persistent …localcan.dev, identical after restarts | smee channel and webhook.site URLs also persist |
| Cost and setup | Desktop app, paid after the trial | Free; npx smee-client installs nothing |
| See a payload with no code written | Needs a local server actually listening | Paste the URL and watch requests arrive |
| Read headers and raw body | GUI inspector, headers + untouched bytes | Also exactly what they are built for |
| See your app’s response | The status and body your handler returned | Your handler is not in the loop |
| The 3-second budget | Per-request timing in the inspector | Times the tester, not your code |
| Re-fire a captured PING | One-click Replay of the same bytes | Re-save the field in the portal |
Discord webhook FAQ
Test webhooks for other providers
- Test Stripe Webhooks Locally →
- Test GitHub Webhooks Locally →
- Test Shopify Webhooks Locally →
- Test Twilio Webhooks Locally →
- Test Slack Webhooks Locally →
- Test Clerk Webhooks Locally →
- Test Supabase Webhooks Locally →
- Test GitLab Webhooks Locally →
- Test Square Webhooks Locally →
- Test OpenAI Webhooks Locally →
- Test Resend Webhooks Locally →