Webhook events

Receive Discord's HTTP webhook events (installs, entitlements) with the athena/webhooks subpath: signature verification, PING handling, and a ready-to-listen server.

Discord can deliver a small set of events over HTTP instead of the gateway: the Webhook Events transport, configured as the Webhook Events URL on your application's developer portal page. Athena ships a zero-dependency receiver for it at the athena/webhooks subpath export.

What arrives over this transport

  • APPLICATION_AUTHORIZED: a user or guild installed your app. This event has no gateway equivalent, so the webhook transport is the only way to track installs.
  • APPLICATION_DEAUTHORIZED: a user deauthorized your app.
  • ENTITLEMENT_CREATE / ENTITLEMENT_UPDATE / ENTITLEMENT_DELETE: monetization entitlements (these also exist as gateway events).

No gateway connection is required; an HTTP-only app can run entirely on webhook events.

The exports

ExportWhat it does
verifyWebhookSignature(publicKey, signature, timestamp, rawBody)Verifies a request's Ed25519 signature via node:crypto. Returns boolean. Works for HTTP interactions too.
handleWebhookEventRequest({ publicKey, signature, timestamp, rawBody })Framework-agnostic core: verifies and parses one request, returns { status: 401 } (bad signature) or { status: 204, event? }. Respond with status and an empty body, then handle event if present.
createWebhookEventsListener({ publicKey, path?, onEvent, onError? })Returns a ready-to-listen node:http server that does all of the above; call .listen(port) yourself.

publicKey is the hex public key from your application's portal page. signature and timestamp are the X-Signature-Ed25519 and X-Signature-Timestamp headers; rawBody must be the raw, unparsed request body.

What Discord requires of your endpoint

  • Verify the Ed25519 signature on every request and respond 401 on bad ones. Discord deliberately sends requests with invalid signatures as health checks and removes endpoints that accept them.
  • Acknowledge with an empty 204 within 3 seconds, for PING requests (type: 0) and real events (type: 1) alike. Do slow work after responding.

Both handleWebhookEventRequest and the listener implement this contract for you; the listener additionally dispatches onEvent only after the response has been written, so a slow handler can never trip the 3-second deadline.

Complete example

import { createWebhookEventsListener } from 'athena/webhooks';
 
const server = createWebhookEventsListener({
  publicKey: process.env.DISCORD_PUBLIC_KEY!,
  path: '/discord/events',          // optional; default handles every path
  onEvent: async (event) => {
    switch (event.type) {
      case 'APPLICATION_AUTHORIZED':
        await trackInstall(event.data);
        break;
      case 'ENTITLEMENT_CREATE':
        await grantPremium(event.data);
        break;
    }
  },
  onError: (err) => console.error('webhook event error', err),
});
 
server.listen(8080);

Point the Webhook Events URL in the developer portal at the public HTTPS address of this endpoint and enable the event types you want delivered.

Using your own framework

handleWebhookEventRequest plugs into any HTTP framework. With Express:

import { handleWebhookEventRequest } from 'athena/webhooks';
 
app.post('/discord/events', express.raw({ type: '*/*' }), (req, res) => {
  const result = handleWebhookEventRequest({
    publicKey: process.env.DISCORD_PUBLIC_KEY!,
    signature: req.header('X-Signature-Ed25519'),
    timestamp: req.header('X-Signature-Timestamp'),
    rawBody: req.body,
  });
  res.status(result.status).end();
  if (result.event) void handleEvent(result.event);
});

The signature is computed over the raw bytes, so the body must reach the handler unparsed (express.raw, not express.json).

Tips

  • Respond first, work second. Never await your business logic before sending the 204; queue it.
  • Expect health checks. Requests that fail verification are normal; return 401 and move on. Do not log them as errors.
  • PINGs carry no event. A type: 0 payload just needs the 204; result.event is undefined for it.
  • The transport is portal-configured. Nothing in ClientOptions enables it; the Webhook Events URL and per-event toggles live on the developer portal.