# Get Slack and Discord alerts from VPSNine webhooks

> VPSNine has no Slack or Discord integration, so run a tiny relay that verifies webhook signatures and posts alerts and events to your channels.

Source: https://vpsnine.com/tutorials/slack-and-discord-alerts-from-vpsnine-webhooks · Updated: 2026-10-21

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

To get VPSNine alerts in Slack or Discord, run a small relay: VPSNine sends signed webhook events to it, and it checks each signature and posts a one-line message to a Slack incoming webhook, a Discord channel webhook, or both. VPSNine doesn't post to Slack or Discord itself. There's no built-in integration, and pointing a VPSNine 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 VPSNine 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: VPSNine 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.vpsnine.com";
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 "VPSNine 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["vpsnine-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://vpsnine.com/tutorials/receive-vpsnine-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://vpsnine.com/help/webhooks) and their `data` are listed in the webhooks guide.
- It answers `204` before posting, because VPSNine 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=VPSNine 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://vpsnine.com/tutorials/receive-vpsnine-webhooks-in-nodejs-and-python#put-https-in-front). VPSNine only delivers to public `https://` addresses.

## Register it with VPSNine

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://vpsnine.com/help/notifications-and-alerts)).

## Check it worked

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

```text
VPSNine 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.vpsnine.com/servers/srv_k3j5m2abcdefgh2q
ALERT web-1: cpu over 90% (now 97) https://my.vpsnine.com/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 VPSNine'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 VPSNine webhooks in Node.js and Python](https://vpsnine.com/tutorials/receive-vpsnine-webhooks-in-nodejs-and-python), for a receiver that does more than post messages.
- [Notifications and resource alerts](https://vpsnine.com/help/notifications-and-alerts), for the rules behind `alert.triggered`.
- [Monitor your server](https://vpsnine.com/help/monitor-your-server), for what to watch from inside the server.
