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.

Tested on Debian 12 Oldstable, Ubuntu 24.04 LTS

Before you start

  • 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, 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).

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).

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: 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 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. 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).

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

Be first when orders open

Join the waitlist and we'll send you one email when VPSNine launches. Pick a plan if you already know which one you want, and we'll size the first servers around it.

  • One email, on launch day
  • Monthly or yearly, no lock-in
  • 14-day money-back guarantee