You paste your URL into the Developer Portal, hit Save, and Discord throws "interactions_endpoint_url: The specified interactions endpoint url could not be verified." — with zero detail about what went wrong. Here's exactly what Discord does when you press Save, why "just return 200" endpoints always fail, and the specific bugs that cause 90% of these rejections.
Saving the URL triggers an immediate, two-part test of your endpoint:
PING interaction — a POST with body
{"type": 1, …}, correctly signed. Your endpoint must respond
200 with a JSON body of {"type": 1} (the ACK).401. An endpoint that returns 200 to everything fails this half of the
test — that's the most common reason for the error.Both halves must pass or the URL won't save. And it doesn't stop there: Discord keeps running these security checks routinely after you've saved. If your endpoint later regresses (say, a refactor drops the verification middleware), Discord removes your interactions URL and alerts you by email and system DM.
Honest note: you cannot point Discord at a Hookden bin (or webhook.site, or any inspector) and pass verification. A capture URL returns a configured, static response — it can't verify Ed25519 signatures in real time, so it answers the invalid-signature probes the same way as the real PING, and Discord rejects it. The same goes for our CLI relay: it re-delivers requests to localhost asynchronously, but Discord needs the verified response to come back on the original connection, synchronously. No capture tool can pass this check — any that claims to is skipping the signature test.
What a bin is good for: seeing the test traffic with your own eyes. Create a
bin (curl https://hookden.pages.dev/new), paste it as the endpoint URL, press Save —
verification will fail, but the probes land in your dashboard: the exact
PING body, the X-Signature-Ed25519 and
X-Signature-Timestamp headers, the Discord-Interactions/1.0
user agent, and what the bad-signature probes look like next to the good one. Once
you've seen the real traffic, the verification recipe below stops being abstract.
Every interaction request carries two headers:
X-Signature-Ed25519 — hex-encoded Ed25519 signatureX-Signature-Timestamp — a timestamp stringThe signed message is timestamp + raw request body, concatenated as strings, verified against your app's Public Key — the hex string on the General Information page of the Developer Portal. Node example (tweetnacl):
const nacl = require('tweetnacl');
const PUBLIC_KEY = 'your app public key (hex, from the Dev Portal)';
const sig = req.get('X-Signature-Ed25519');
const ts = req.get('X-Signature-Timestamp');
const ok = nacl.sign.detached.verify(
Buffer.from(ts + rawBody), // rawBody = exact bytes, as a string
Buffer.from(sig, 'hex'),
Buffer.from(PUBLIC_KEY, 'hex'),
);
if (!ok) return res.status(401).end('invalid request signature');
const body = JSON.parse(rawBody);
if (body.type === 1) return res.json({ type: 1 }); // PING → ACK
Python (PyNaCl):
from nacl.signing import VerifyKey
from nacl.exceptions import BadSignatureError
verify_key = VerifyKey(bytes.fromhex(PUBLIC_KEY))
try:
verify_key.verify(f'{timestamp}{raw_body}'.encode(),
bytes.fromhex(signature))
except BadSignatureError:
abort(401, 'invalid request signature')
Or skip the hand-rolling: the official discord-interactions packages
(JS/Python) ship a verifyKey helper that does exactly this.
express.json() parses the body; JSON.stringify(req.body) does
not reproduce the original bytes (key order, whitespace, unicode escapes), so the
signature fails on perfectly valid requests. Use express.raw() on the
interactions route, or a verify callback that stashes the raw buffer.
Same trap in every framework — verify before anything touches the body.Because the verified response must come from your code, synchronously, local
development needs a real tunnel — this is one case where a relay genuinely can't
substitute. cloudflared tunnel --url http://localhost:3000 gives you a
free public HTTPS URL; ngrok works too if its free-plan limits fit you (see our honest
tunnel comparison). Paste the tunnel URL into
the portal, and keep in mind the URL must stay reachable — if the tunnel dies, Discord's
routine re-checks will eventually unset your endpoint.
For everything around the interactions endpoint — outgoing Discord webhooks you POST to, third-party webhooks your bot consumes, or replaying a captured interaction payload at your handler while unit-testing the parsing logic (verification stubbed) — a capture URL still earns its keep: testing webhooks, webhooks to localhost.
No signup needed. Or from your terminal: curl https://hookden.pages.dev/new