Only show guides for your app
A small service that receives GitHub webhook events, formats them into a tidy message, and forwards them to a Discord webhook from your .env. Two files, verified receive-and-forward path, deploy and extend.
GitHub can POST a JSON payload to a URL every time something happens in your repo — a push, a new issue, a release. This relay is that URL: it receives the event, turns it into a readable one-liner, and forwards it to a Discord webhook so your team channel lights up on every push. Two files on the Node.js application, and the whole receive-and-forward path is verified end to end.
| At a glance | |
|---|---|
| You're building | A service that relays GitHub events into a Discord channel |
| You need | A Node.js server, a repo you admin, and a Discord webhook URL |
| Plan | Any — free runs while your session timer has time, premium runs 24/7 |
| Time | About twenty-five minutes |
First time with webhooks? Post to a Discord webhook from any code explains what a webhook is and how to create one — you'll need that Discord URL here.
| Feature | How |
|---|---|
| Receive events | POST /github reads the X-GitHub-Event header and the JSON body |
| Format | Turns push / issue / ping events into a Discord-ready message |
| Forward | POSTs the message to your Discord webhook URL from .env |
| Skip the noise | Events you don't handle get a 202 and go no further |
| Secret stays out of code | The Discord URL lives in .env, never in the source |
package.json:
{
"name": "webhook-relay",
"version": "1.0.0",
"private": true,
"main": "index.js",
"dependencies": {
"dotenv": "^16.4.7",
"express": "^4.21.2"
}
}
.env — paste your real Discord webhook URL here:
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/YOUR_ID/YOUR_TOKEN
index.js:
require('dotenv').config();
const express = require('express');
const app = express();
app.use(express.json());
const DISCORD_WEBHOOK = process.env.DISCORD_WEBHOOK_URL;
app.get('/', (req, res) => {
res.type('text/plain').send('Webhook relay is running. Point a GitHub webhook at /github');
});
app.post('/github', async (req, res) => {
const event = req.get('X-GitHub-Event') || 'unknown';
const message = formatEvent(event, req.body);
// An event we don't relay — acknowledge it and stop.
if (!message) return res.status(202).json({ skipped: event });
if (!DISCORD_WEBHOOK) {
console.error('DISCORD_WEBHOOK_URL is not set');
return res.status(500).json({ error: 'relay not configured' });
}
try {
const r = await fetch(DISCORD_WEBHOOK, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: message }),
});
if (!r.ok) {
console.error(`Discord returned ${r.status}`);
return res.status(502).json({ error: `discord ${r.status}` });
}
res.status(200).json({ ok: true });
} catch (err) {
console.error(err);
res.status(502).json({ error: 'forward failed' });
}
});
// Turn a GitHub payload into a one-line Discord message. Return null to skip.
function formatEvent(event, p) {
if (event === 'ping') {
return `✅ Webhook connected to **${p.repository?.full_name || 'a repository'}**`;
}
if (event === 'push') {
const branch = (p.ref || '').replace('refs/heads/', '');
const commits = p.commits || [];
const who = p.pusher?.name || p.sender?.login || 'someone';
const lines = commits.slice(0, 5).map((c) => `• ${c.message.split('\n')[0]}`);
return `📦 **${who}** pushed ${commits.length} commit(s) to \`${branch}\` of `
+ `**${p.repository?.full_name}**\n${lines.join('\n')}`;
}
if (event === 'issues' && p.action === 'opened') {
return `🐛 Issue opened in **${p.repository?.full_name}**: `
+ `[#${p.issue.number} ${p.issue.title}](${p.issue.html_url})`;
}
return null;
}
const PORT = process.env.SERVER_PORT || 8080;
app.listen(PORT, '0.0.0.0', () => console.log(`Listening on port ${PORT}`));
X-GitHub-Event header (push, issues, ping, …) and the details in the JSON body. formatEvent switches on the header and reaches into the body for the bits worth announcing.if builds one Discord message. ?. (optional chaining) guards against missing fields so a slightly different payload never crashes the relay. Return null for anything you don't want to relay, and the handler answers 202 and stops.fetch. The message goes to your Discord webhook as { "content": "..." }. If Discord answers anything but 2xx, the relay reports 502 with the status so you can see what happened in the console..env. dotenv loads DISCORD_WEBHOOK_URL into process.env at startup — the URL never appears in your code or your repo. See Environment variables & secrets.🎯 Good to know: GitHub expects a fast answer. This relay replies as soon as the forward finishes, which is fine for a handful of events. If you ever relay heavy traffic, answer
200first and forward in the background so GitHub never waits on Discord.
package.json, index.js, and .env (put your real Discord URL in .env), or deploy from Git — but keep .env out of the repo (Keep secrets out of Git).npm install the console prints Listening on port … and the server goes online. Reading SERVER_PORT and binding 0.0.0.0 are already done for you.http://YOUR_ADDRESS:PORT/github, content type application/json, and save. GitHub immediately sends a ping — your Discord channel should show "Webhook connected".Test the receive-and-forward path yourself with curl before wiring up GitHub:
curl -X POST http://YOUR_ADDRESS:PORT/github \
-H "X-GitHub-Event: push" -H "Content-Type: application/json" \
-d '{"ref":"refs/heads/main","pusher":{"name":"you"},
"repository":{"full_name":"you/repo"},
"commits":[{"message":"first commit"}]}'
# -> {"ok":true} (and the message appears in Discord)
🎯 Good to know: If the relay returns
{"error":"discord 404"}, that's Discord saying Unknown Webhook — yourDISCORD_WEBHOOK_URLis wrong or was deleted, not a bug in the relay. A404from Discord means your request was well-formed and reached it; only the webhook identity was rejected. Copy a fresh URL from the channel's Integrations page. (Full detail in Post to a Discord webhook.)
formatEvent for release, pull_request, star, or workflow_run — the payload fields are in GitHub's docs.content for a Discord embeds array to get titled, colored cards per event type.X-Hub-Signature-256 header against a shared secret so only GitHub can post to you.The receive side is GitHub's contract: the GitHub webhook events reference lists every event and its payload. The forward side is standard Discord — the webhook guide covers embeds and limits.
{"error":"discord 404"} or discord 401 — the Discord URL is wrong or deleted (404) or has a bad token (401). Re-copy the full webhook URL into .env and restart.relay not configured (500) — DISCORD_WEBHOOK_URL isn't set. Check .env exists and the server was restarted after you edited it.https:// while your app only serves http://address:port. Use the exact address from the Network page, or put a real domain in front with Domains and HTTPS.