Skip to content

Verify Discord webhooks

Verify that a webhook really came from Discord before acting on it.

Identifier 'discord'
Import import { verifyDiscord } from 'verihook/discord'
Headers x-signature-ed25519
x-signature-timestamp
Signature Ed25519 signature of <timestamp><raw body>.
Replay window 300 seconds (options.tolerance)
result.eventType the webhook event’s event.type, or the interaction type (PING, …)

The application’s Public Key (hex): in the Discord Developer Portal, open your application → General Information. It’s a public key, so there is no shared secret to protect.

Store it in an environment variable (DISCORD_PUBLIC_KEY below) and never commit it.

import { verifyDiscord } from 'verihook/discord';
export async function POST(request: Request) {
const result = await verifyDiscord(request, process.env.DISCORD_PUBLIC_KEY!);
if (!result.valid) {
console.warn(result.code, result.reason, result.hint);
return new Response('Invalid signature', { status: 401 });
}
console.log(result.eventType, result.event);
return Response.json({ received: true });
}

request can be a Fetch Request or { headers, body, url? } with the raw body. verifyWebhook('discord', request, secret) from verihook does the same. Read why the raw body matters if verification fails behind a body parser.

app/api/webhooks/discord/route.ts
import { createWebhookHandler } from 'verihook/next';
export const POST = createWebhookHandler('discord', process.env.DISCORD_PUBLIC_KEY!, async (payload, result) => {
// Runs only for a valid signature
console.log(result.eventType, result.event);
});

Each adapter responds 401 to an invalid signature, 413 to a body over maxBodySize (2 MB by default) and 200 to a duplicate when you pass a dedupeStore.

Interactions start with a PING (type 1) that must be answered with { "type": 1 }:

import { createWebhookHandler } from 'verihook/next';
export const POST = createWebhookHandler('discord', process.env.DISCORD_PUBLIC_KEY!, async (payload) => {
if ((payload as { type?: number }).type === 1) {
return Response.json({ type: 1 });
}
// handle the interaction
});
  • Discord sends invalid signatures on purpose when you save the endpoint URL, and refuses the URL unless you reject them with 401. The adapters already do that.

signWebhook from verihook/testing builds a correctly signed Discord request for your tests:

import { signWebhook } from 'verihook/testing';
import { verifyDiscord } from 'verihook/discord';
const hook = await signWebhook('discord', {
payload: { id: 'evt_test' },
});
const result = await verifyDiscord(
{ headers: hook.headers, body: hook.body, url: hook.url },
hook.secret,
);
// result.valid === true

Discord uses a key pair. signWebhook generates one and returns the public key as hook.secret, so verify with that.

From the command line, npx verihook simulate discord --url http://localhost:3000/webhooks/discord sends one to your local server.

See Testing your handlers for supertest and Fetch Request examples.

Failed results carry a code and often a hint with the likely cause. See Troubleshooting for each error code.

Discord’s webhook documentation