# Relay Rack4 webhooks to Slack and Discord with Node.js

> When the built-in Slack and Discord channels don't fit, run a small relay that verifies webhook signatures and posts events in your own format.

Source: https://rack4.io/tutorials/slack-and-discord-alerts-from-rack4-webhooks · Updated: 2026-10-29

Difficulty: intermediate · Time: about 25 minutes · Tested on: Debian 12 Oldstable, Ubuntu 24.04 LTS

Prerequisites:

- A server with a domain name and HTTPS in front of a local port
- Node.js 18 or later on that server
- A Slack incoming webhook or a Discord channel webhook

Rack4 can post notifications to Slack and Discord itself: add a [notification channel](https://rack4.io/help/notification-channels) in the panel, and that is the quickest way to get alerts there. This tutorial is for when you want something the channels don't do, such as your own message format, events filtered your way, or one relay feeding several chats. Rack4 sends signed webhook events to a small relay you run, and it checks each signature and posts a one-line message to a Slack incoming webhook, a Discord channel webhook, or both. Pointing a Rack4 webhook straight at Slack or Discord doesn't work, because they expect their own message format and can't check our signature.

What we tested: the relay below ran with Node.js 22 on Debian 12, with two stand-in servers playing Slack and Discord. We sent it deliveries signed with the Rack4 panel's own signing code: a server becoming ready, a CPU alert and a failed snapshot each produced one Slack and one Discord message; a repeat of the same event, a wrong secret, a changed body and an old timestamp produced none. We also ran it as the systemd service below on Ubuntu 24.04 (Node.js 18), where the test event posted and a chat webhook answering `404` was logged. Real Slack and Discord weren't part of the test; the message formats are the ones their documentation gives.

## Get a chat webhook URL

**Slack**: create an app at [api.slack.com/apps](https://api.slack.com/apps), turn on **Incoming Webhooks**, and add one for your channel. You get a URL starting `https://hooks.slack.com/services/`. Slack takes a JSON body with a `text` field ([Slack's guide](https://api.slack.com/messaging/webhooks)).

**Discord**: in the channel's settings, open **Integrations**, then **Webhooks**, and create one. Copy its URL, starting `https://discord.com/api/webhooks/`. Discord takes a JSON body with a `content` field of up to 2,000 characters ([Discord's reference](https://discord.com/developers/docs/resources/webhook#execute-webhook)).

Treat both URLs as secrets: anyone who has one can post to your channel.

## The relay

Save this as `relay.mjs`. It needs Node.js 18 or later and no packages:

```js
// relay.mjs: Rack4 webhooks to Slack and Discord. Node.js 18 or later, no dependencies.
import { createHmac, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";

const SECRETS = (process.env.VPS9_WEBHOOK_SECRET ?? "").split(",").filter(Boolean);
const SLACK = process.env.SLACK_WEBHOOK_URL; // https://hooks.slack.com/services/…
const DISCORD = process.env.DISCORD_WEBHOOK_URL; // https://discord.com/api/webhooks/…
const PANEL = "https://my.rack4.io";
if (SECRETS.length === 0 || (!SLACK && !DISCORD)) throw new Error("set VPS9_WEBHOOK_SECRET and SLACK_WEBHOOK_URL or DISCORD_WEBHOOK_URL");

function verify(header, raw) {
  const parts = header.split(",").map((p) => p.trim().split("="));
  const t = Number(parts.find(([k]) => k === "t")?.[1]);
  if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > 300) return false;
  const signed = Buffer.concat([Buffer.from(`${t}.`), raw]);
  const wanted = SECRETS.map((s) => createHmac("sha256", s).update(signed).digest());
  return parts.some(([k, v]) => k === "v1" && /^[0-9a-f]{64}$/.test(v) && wanted.some((w) => timingSafeEqual(Buffer.from(v, "hex"), w)));
}

/** One line of text per event, or null for events nobody needs in chat. */
function message(e) {
  const d = e.data ?? {};
  const host = d.server?.hostname ?? "a server";
  const page = d.server?.id ? `${PANEL}/servers/${d.server.id}` : PANEL;
  switch (e.type) {
    case "alert.triggered":
      return `ALERT ${host}: ${d.rule.metric}${d.rule.threshold ? ` over ${d.rule.threshold}%` : ""} (now ${d.value ?? "?"}) ${page}/alerts`;
    case "alert.resolved":
      return `RESOLVED ${host}: ${d.rule.metric} is back to normal`;
    case "server.ready":
      return `${host} is ready at ${d.server.ipv4} ${page}`;
    case "server.action_failed":
      return `FAILED on ${host}: ${d.action} (${d.reason}) ${page}`;
    case "snapshot.completed":
      return `Snapshot "${d.snapshot?.label ?? ""}" of ${host} is ready`;
    case "snapshot.failed":
      return `Snapshot of ${host} failed: ${d.reason}`;
    case "invoice.overdue":
      return `Invoice ${d.invoice.number} is overdue: $${(d.invoice.totalCents / 100).toFixed(2)} ${PANEL}/billing/invoices/${d.invoice.id}`;
    case "ping":
      return "Rack4 test event: the relay works.";
    default:
      return null;
  }
}

async function post(url, body) {
  const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), signal: AbortSignal.timeout(8000) });
  if (!res.ok) throw new Error(`${new URL(url).host} answered ${res.status}`);
}

async function relay(e) {
  const text = message(e);
  if (!text) return;
  const sends = [];
  if (SLACK) sends.push(post(SLACK, { text }));
  if (DISCORD) sends.push(post(DISCORD, { content: text.slice(0, 2000), allowed_mentions: { parse: [] } }));
  for (const r of await Promise.allSettled(sends)) if (r.status === "rejected") console.error(`event ${e.id}: ${r.reason.message}`);
}

const seen = new Set();
createServer((req, res) => {
  if (req.method !== "POST" || req.url !== "/vpsnine") return res.writeHead(404).end();
  const chunks = [];
  req.on("data", (c) => chunks.push(c));
  req.on("end", () => {
    const raw = Buffer.concat(chunks);
    if (raw.length > 1_000_000 || !verify(req.headers["rack4-signature"] ?? "", raw)) return res.writeHead(400).end();
    const event = JSON.parse(raw.toString("utf8"));
    res.writeHead(204).end();
    if (seen.has(event.id)) return;
    seen.add(event.id);
    if (seen.size > 10_000) seen.delete(seen.values().next().value);
    relay(event);
  });
}).listen(Number(process.env.PORT ?? 8080), "127.0.0.1");
```

How it works:

- `verify` is the same check as in [the receiver tutorial](https://rack4.io/tutorials/receive-rack4-webhooks-in-nodejs-and-python): HMAC-SHA256 of the timestamp, a dot and the raw body; timestamps more than 5 minutes off are refused; a comma-separated secret list covers a rotation.
- `message` turns each event into one line, and returns `null` for events you don't want in chat (invoices paid, tickets answered). Edit it to taste: the [event types](https://rack4.io/help/webhooks) and their `data` are listed in the webhooks guide.
- It answers `204` before posting, because Rack4 waits 10 seconds at most, and a slow chat service mustn't make the delivery count as failed.
- It remembers the last 10,000 event ids in memory, so a retry or a redelivery isn't posted twice. A restart forgets them, which at worst means one duplicate message.
- `allowed_mentions: { parse: [] }` stops a server name like `@everyone` from pinging a Discord channel.

## Run it

On the server, as in the receiver tutorial, give it its own user and keep the secrets in a root-only file:

```bash
sudo useradd --system --home /opt/vps9-relay --shell /usr/sbin/nologin relay
sudo install -d -o relay -g relay /opt/vps9-relay
sudo install -m 644 relay.mjs /opt/vps9-relay/relay.mjs
sudo install -m 600 /dev/null /etc/vps9-relay.env
sudoedit /etc/vps9-relay.env
```

```ini
VPS9_WEBHOOK_SECRET=whsec_…
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/…
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/…
PORT=8080
```

Leave out the Slack or Discord line if you only use one. Save this as `/etc/systemd/system/vps9-relay.service`:

```ini
[Unit]
Description=Rack4 webhooks to Slack and Discord
After=network-online.target
Wants=network-online.target

[Service]
User=relay
EnvironmentFile=/etc/vps9-relay.env
ExecStart=/usr/bin/node /opt/vps9-relay/relay.mjs
Restart=on-failure
NoNewPrivileges=true
ProtectSystem=strict
PrivateTmp=true

[Install]
WantedBy=multi-user.target
```

```bash
sudo systemctl daemon-reload
sudo systemctl enable --now vps9-relay
```

Put HTTPS in front of port 8080 for your domain, with Caddy or nginx, exactly as in [Put HTTPS in front](https://rack4.io/tutorials/receive-rack4-webhooks-in-nodejs-and-python#put-https-in-front). Rack4 only delivers to public `https://` addresses.

## Register it with Rack4

In the panel, open **Account**, then **webhooks**, add `https://hooks.example.com/vpsnine`, and tick the events the relay has messages for: `alert.triggered`, `alert.resolved`, `server.ready`, `server.action_failed`, `snapshot.completed`, `snapshot.failed` and `invoice.overdue`. Put the signing secret it shows into `/etc/vps9-relay.env` and restart with `sudo systemctl restart vps9-relay`.

Alerts only fire for rules you've set: add a CPU, memory, transfer or down rule on a server's **Alerts** tab ([how](https://rack4.io/help/notifications-and-alerts)).

## Check it worked

Choose **send test event** on the endpoint's page. The channel gets:

```text
Rack4 test event: the relay works.
```

and the delivery log shows status `204`. Real events look like these, from our test run:

```text
web-1 is ready at 203.0.113.10 https://my.rack4.io/servers/srv_k3j5m2abcdefgh2q
ALERT web-1: cpu over 90% (now 97) https://my.rack4.io/servers/srv_k3j5m2abcdefgh2q/alerts
Snapshot of web-1 failed: the host ran out of disk space
```

## Troubleshooting

**The test event shows `204` but nothing appears in the channel.** The relay answered, then the post to Slack or Discord failed. `journalctl -u vps9-relay -n 20` shows lines like `event evt_…: hooks.slack.com answered 404`: the chat webhook URL is wrong or was deleted.

**`discord.com answered 429`.** Discord limits how fast a webhook can post. A burst of alerts can hit it; the relay logs and drops those messages rather than delaying Rack4's deliveries.

**Every delivery gets `400`.** The signing secret in the env file isn't the endpoint's. Copy it again (rotate the secret if you no longer have it) and restart.

**The service won't start: `set VPS9_WEBHOOK_SECRET and SLACK_WEBHOOK_URL or DISCORD_WEBHOOK_URL`.** The env file is missing a line, or systemd can't read it. Check `EnvironmentFile` points at `/etc/vps9-relay.env`.

## Next steps

- [Receive Rack4 webhooks in Node.js and Python](https://rack4.io/tutorials/receive-rack4-webhooks-in-nodejs-and-python), for a receiver that does more than post messages.
- [Notifications and resource alerts](https://rack4.io/help/notifications-and-alerts), for the rules behind `alert.triggered`.
- [Monitor your server](https://rack4.io/help/monitor-your-server), for what to watch from inside the server.
