NewLocalCan 3.0 with CLI, Multi-region and more ⟶

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.

Start Free Trial →DownloadFree trial. No credit card required.

30-second setup

From a stuck handler to a live Discord delivery you can read and replay.

  1. 1Start LocalCan and point it at your local server: localcan http 3000. It opens the tunnel and prints your .local domains plus a public HTTPS URL.
  2. 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.
  3. 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).
  4. 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_COMMAND through the same URL.
  5. 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 body
  • APPLICATION_COMMANDType 2, a slash, user or message command was invoked
  • MESSAGE_COMPONENTType 3, a button click or a select-menu choice
  • APPLICATION_COMMAND_AUTOCOMPLETEType 4, fires on keystrokes, so the 3-second budget bites hardest here
  • MODAL_SUBMITType 5, a modal form was submitted
  • APPLICATION_AUTHORIZEDWebhook Event, sent when a user installs your app
  • ENTITLEMENT_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, alongside X-Signature-Timestamp
Algorithm
Ed25519 public-key signature, hex-encoded, not an HMAC
What's signed
Discord signs the X-Signature-Timestamp header value immediately followed by the exact bytes of the request body, with no separator between them. You verify that message against the X-Signature-Ed25519 value 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.
Node.js
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.

LocalCanhosted testers and relays
Pass the endpoint PING checkYour app’s own signed PONG travels backCannot: the tester answers Discord, not your app
Stable URL to pastePersistent …localcan.dev, identical after restartssmee channel and webhook.site URLs also persist
Cost and setupDesktop app, paid after the trialFree; npx smee-client installs nothing
See a payload with no code writtenNeeds a local server actually listeningPaste the URL and watch requests arrive
Read headers and raw bodyGUI inspector, headers + untouched bytesAlso exactly what they are built for
See your app’s responseThe status and body your handler returnedYour handler is not in the loop
The 3-second budgetPer-request timing in the inspectorTimes the tester, not your code
Re-fire a captured PINGOne-click Replay of the same bytesRe-save the field in the portal

Discord webhook FAQ

Stop guessing why the webhook 401'd. Get a stable Discord endpoint, read every delivery, and replay it until your handler is right.

Free trial. No credit card required.