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.
What it does
| 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 |
The files
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}`));
How it works
- The event type is a header, not the body. GitHub sends the kind of event in the
X-GitHub-Eventheader (push,issues,ping, …) and the details in the JSON body.formatEventswitches on the header and reaches into the body for the bits worth announcing. - Formatting is where you make it yours. Each
ifbuilds one Discord message.?.(optional chaining) guards against missing fields so a slightly different payload never crashes the relay. Returnnullfor anything you don't want to relay, and the handler answers202and stops. - Forwarding is one
fetch. The message goes to your Discord webhook as{ "content": "..." }. If Discord answers anything but2xx, the relay reports502with the status so you can see what happened in the console. - The secret is read from
.env.dotenvloadsDISCORD_WEBHOOK_URLintoprocess.envat 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.
Run it on Falix
- Upload
package.json,index.js, and.env(put your real Discord URL in.env), or deploy from Git — but keep.envout of the repo (Keep secrets out of Git). - Press Start. After
npm installthe console printsListening on port …and the server goes online. ReadingSERVER_PORTand binding0.0.0.0are already done for you. - Find your server's public address on the Network page. In your repo on GitHub, go to Settings → Webhooks → Add webhook, set the Payload URL to
http://YOUR_ADDRESS:PORT/github, content type application/json, and save. GitHub immediately sends aping— 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.)
Make it yours
- More events. Add branches to
formatEventforrelease,pull_request,star, orworkflow_run— the payload fields are in GitHub's docs. - Colored embeds. Swap the plain
contentfor a Discordembedsarray to get titled, colored cards per event type. - Verify the signature. For a public relay, check GitHub's
X-Hub-Signature-256header against a shared secret so only GitHub can post to you. - Relay to other places. The forward is just an HTTP POST — send to Slack, a database, or your own log instead.
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.
Troubleshooting
{"error":"discord 404"}ordiscord 401— the Discord URL is wrong or deleted (404) or has a bad token (401). Re-copy the full webhook URL into.envand restart.relay not configured(500) —DISCORD_WEBHOOK_URLisn't set. Check.envexists and the server was restarted after you edited it.- GitHub shows the delivery as failed — open the webhook's Recent Deliveries on GitHub to see the response your relay sent. A timeout there usually means the app isn't reachable: I can't reach my app.
- Nothing arrives in Discord but curl works — GitHub's payload URL is wrong, or it's pointed at
https://while your app only serveshttp://address:port. Use the exact address from the Network page, or put a real domain in front with Domains and HTTPS.