Capturing the PING
Create a URL above, then open the Discord Developer Portal → your application → Webhooks, paste the URL into Endpoint URL, and press save. Discord immediately POSTs:
{"version":1,"application_id":"000000000000000000","type":0}
type: 0 is the PING. It will show up here within a second — and Discord will then refuse to save the URL, because a capture bin returns its own acknowledgement instead of a bare 204. That rejection is expected and the capture is still worth having: it proves Discord can reach the URL and shows exactly what you have to answer.
Discord’s validation is stricter than most, in a way worth knowing about before you write the handler. It sends two requests: the PING, which must get 204, and a request with a deliberately corrupted signature, which must get 401. You cannot pass by acknowledging everything.
if (!verifyEd25519(publicKey, timestamp, rawBody, signature)) {
return new Response('invalid signature', { status: 401 });
}
if (payload.type === 0) return new Response(null, { status: 204 });
Both branches are required. Once your own endpoint has them, point Discord at it and use this page to read the event bodies.
Ed25519, not HMAC
Two headers arrive on every request:
X-Signature-Ed25519: 0000000000000000...
X-Signature-Timestamp: 1750000000
The signed data is the timestamp string followed immediately by the raw body, with nothing between them. The signature is a hex-encoded Ed25519 signature, and the key that verifies it is the public key on your application’s General Information page.
import nacl from 'tweetnacl';
const ok = nacl.sign.detached.verify(
Buffer.from(timestamp + rawBody),
Buffer.from(signature, 'hex'),
Buffer.from(publicKey, 'hex'),
);
There is no shared secret anywhere in this flow, which is the part that stalls people — they go looking for a signing secret in the portal, do not find one, and try to HMAC with the public key instead. It also means there is nothing to paste into an HMAC verifier, including the one on this site. The upside is real: a compromised web server leaks a key that cannot forge a single event.
Reading a webhook event
{
"version": 1,
"application_id": "000000000000000000",
"type": 1,
"event": {
"type": "APPLICATION_AUTHORIZED",
"timestamp": "2026-06-15T10:04:00.000Z",
"data": { "integration_type": 0, "scopes": ["applications.commands"], "user": { ... } }
}
}
The outer type distinguishes a PING (0) from an event (1). The thing you actually switch on is event.type.
integration_type is 0 for a guild install and 1 for a user install — the difference between your app being added to a server and being added to one person’s account, and usually the difference between two quite different onboarding paths.
Discord snowflake ids arrive as strings, and they must stay strings. They exceed 2^53, so parsing one as a JavaScript number silently changes it, and the id you store will not match the id Discord sends next time.
Acknowledge first, work later
Webhook events want a 204 and nothing else. Interactions want a response object within three seconds — and for anything slower than that, a deferred response:
return Response.json({ type: 5 }); // DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE
Then edit the original response through the follow-up endpoint when the work is done. This is the same shape as Slack’s three-second rule: acknowledge inside the deadline, produce the real answer afterwards.