Build a GitHub-to-Discord webhook relay

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.

Skip the setup This guide has a one-click starter template that installs everything below onto your server.

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-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.
  • Formatting is where you make it yours. Each 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.
  • Forwarding is one 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.
  • The secret is read from .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 200 first and forward in the background so GitHub never waits on Discord.

Run it on Falix

  1. Upload 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).
  2. Press Start. After 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.
  3. 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 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 — your DISCORD_WEBHOOK_URL is wrong or was deleted, not a bug in the relay. A 404 from 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 formatEvent for release, pull_request, star, or workflow_run — the payload fields are in GitHub's docs.
  • Colored embeds. Swap the plain content for a Discord embeds array to get titled, colored cards per event type.
  • Verify the signature. For a public relay, check GitHub's X-Hub-Signature-256 header 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"} 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.
  • 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 serves http://address:port. Use the exact address from the Network page, or put a real domain in front with Domains and HTTPS.

Next steps

Was this guide helpful?