$npx -y skills add hookdeck/webhook-skills --skill discord-webhooksReceive and verify Discord webhook events. Use when setting up Discord webhook handlers, debugging Ed25519 signature verification, handling PING endpoint validation, or processing events like APPLICATION_AUTHORIZED, ENTITLEMENT_CREATE, or LOBBY_MESSAGE_CREATE.
| 1 | # Discord Webhooks |
| 2 | |
| 3 | ## When to Use This Skill |
| 4 | |
| 5 | - Setting up Discord webhook event handlers (outgoing webhooks) |
| 6 | - Verifying Discord Ed25519 signatures with `X-Signature-Ed25519` and `X-Signature-Timestamp` |
| 7 | - Handling the PING (type 0) endpoint validation request |
| 8 | - Handling events like `APPLICATION_AUTHORIZED`, `APPLICATION_DEAUTHORIZED`, `ENTITLEMENT_CREATE`, `LOBBY_MESSAGE_CREATE`, `GAME_DIRECT_MESSAGE_CREATE`, `QUEST_USER_ENROLLMENT` |
| 9 | - Debugging "invalid request signature" errors when registering your webhook endpoint |
| 10 | |
| 11 | > Note: This skill covers **outgoing webhooks** (Discord → your server) — the same Ed25519 signing scheme is shared with Interactions endpoints. Incoming webhooks (your server → Discord channel via webhook URL) are not signed and not covered here. |
| 12 | |
| 13 | ## Essential Code (USE THIS) |
| 14 | |
| 15 | Discord uses **Ed25519 asymmetric signatures** (not HMAC). The signed content is the raw concatenation `X-Signature-Timestamp + raw_body`. Verification uses your application's **public key** (hex-encoded), available in the Discord Developer Portal. |
| 16 | |
| 17 | ### Express Webhook Handler (Node.js) |
| 18 | |
| 19 | Use the official-style [`discord-interactions`](https://www.npmjs.com/package/discord-interactions) helper (built on `tweetnacl`). |
| 20 | |
| 21 | ```javascript |
| 22 | const express = require('express'); |
| 23 | const { verifyKey } = require('discord-interactions'); |
| 24 | |
| 25 | const app = express(); |
| 26 | |
| 27 | // CRITICAL: Use express.raw() - verification needs raw body bytes |
| 28 | // Note: discord-interactions v4 returns a Promise from verifyKey — await it. |
| 29 | app.post('/webhooks/discord', |
| 30 | express.raw({ type: 'application/json' }), |
| 31 | async (req, res) => { |
| 32 | const signature = req.headers['x-signature-ed25519']; |
| 33 | const timestamp = req.headers['x-signature-timestamp']; |
| 34 | const publicKey = process.env.DISCORD_PUBLIC_KEY; |
| 35 | |
| 36 | if (!signature || !timestamp) { |
| 37 | return res.status(401).send('Missing signature headers'); |
| 38 | } |
| 39 | |
| 40 | const isValid = await verifyKey(req.body, signature, timestamp, publicKey); |
| 41 | if (!isValid) { |
| 42 | return res.status(401).send('Invalid request signature'); |
| 43 | } |
| 44 | |
| 45 | const payload = JSON.parse(req.body.toString()); |
| 46 | |
| 47 | // type: 0 = PING (endpoint validation). Reply 204 empty body. |
| 48 | if (payload.type === 0) { |
| 49 | return res.status(204).send(); |
| 50 | } |
| 51 | |
| 52 | // type: 1 = event payload |
| 53 | if (payload.type === 1) { |
| 54 | const event = payload.event; |
| 55 | switch (event.type) { |
| 56 | case 'APPLICATION_AUTHORIZED': |
| 57 | console.log('App authorized for user:', event.data.user?.id); |
| 58 | break; |
| 59 | case 'APPLICATION_DEAUTHORIZED': |
| 60 | console.log('App deauthorized for user:', event.data.user?.id); |
| 61 | break; |
| 62 | case 'ENTITLEMENT_CREATE': |
| 63 | console.log('Entitlement created:', event.data.id); |
| 64 | break; |
| 65 | case 'LOBBY_MESSAGE_CREATE': |
| 66 | console.log('Lobby message:', event.data.content); |
| 67 | break; |
| 68 | case 'GAME_DIRECT_MESSAGE_CREATE': |
| 69 | console.log('Game DM:', event.data.content); |
| 70 | break; |
| 71 | default: |
| 72 | console.log('Unhandled event type:', event.type); |
| 73 | } |
| 74 | } |
| 75 | |
| 76 | res.status(204).send(); |
| 77 | } |
| 78 | ); |
| 79 | ``` |
| 80 | |
| 81 | ### Python (FastAPI) Webhook Handler |
| 82 | |
| 83 | Use [`PyNaCl`](https://pypi.org/project/PyNaCl/) for Ed25519 verification. |
| 84 | |
| 85 | ```python |
| 86 | import os |
| 87 | import json |
| 88 | from fastapi import FastAPI, Request, Response, HTTPException |
| 89 | from nacl.signing import VerifyKey |
| 90 | from nacl.exceptions import BadSignatureError |
| 91 | |
| 92 | app = FastAPI() |
| 93 | |
| 94 | PUBLIC_KEY = os.environ["DISCORD_PUBLIC_KEY"] |
| 95 | |
| 96 | def verify_discord_signature(body: bytes, signature: str, timestamp: str, public_key: str) -> bool: |
| 97 | try: |
| 98 | verify_key = VerifyKey(bytes.fromhex(public_key)) |
| 99 | verify_key.verify(timestamp.encode() + body, bytes.fromhex(signature)) |
| 100 | return True |
| 101 | except (BadSignatureError, ValueError): |
| 102 | return False |
| 103 | |
| 104 | @app.post("/webhooks/discord") |
| 105 | async def discord_webhook(request: Request): |
| 106 | signature = request.headers.get("x-signature-ed25519") |
| 107 | timestamp = request.headers.get("x-signature-timestamp") |
| 108 | if not signature or not timestamp: |
| 109 | raise HTTPException(status_code=401, detail="Missing signature headers") |
| 110 | |
| 111 | body = await request.body() |
| 112 | if not verify_discord_signature(body, signature, timestamp, PUBLIC_KEY): |
| 113 | raise HTTPException(status_code=401, detail="Invalid request signature") |
| 114 | |
| 115 | payload = json.loads(body) |
| 116 | |
| 117 | # type 0 = PING endpoint validation |
| 118 | if payload.get("type") == 0: |
| 119 | return Response(status_code=204) |
| 120 | |
| 121 | # type 1 = event |
| 122 | if payload.get("type") == 1: |
| 123 | event = payload.get("event", {}) |
| 124 | event_type = eve |