Receive VPSNine webhooks in Node.js and Python
A dependency-free webhook receiver in Node.js or Python that checks every VPSNine signature, refuses replays, skips duplicates and runs under systemd.
Tested on Ubuntu 24.04 LTS, Debian 12 Oldstable
Before you start
- A server with a domain name pointing at it
- Node.js 18 or later or Python 3.10 or later on the server
- A VPSNine account where you can add a webhook endpoint
To receive VPSNine webhooks, run a small HTTP service that reads the raw request body, checks the VPSNine-Signature header (an HMAC-SHA256 of the timestamp and the body, keyed with your endpoint's signing secret), refuses timestamps more than 5 minutes old, answers 204 straight away and then handles the event once. This tutorial builds that receiver twice, in Node.js and in Python, with nothing but each language's standard library, and runs it as a systemd service behind HTTPS.
What we tested: we signed test deliveries with the VPSNine panel's own signing code and sent them to both receivers on Ubuntu 24.04 (Node.js 18, Python 3.12) and Debian 12 (Node.js 22): a valid event, the same event again, an event signed during a secret rotation, a wrong secret, a changed body, a 10-minute-old timestamp and no header. The valid ones got 204 and ran once each; the rest got 400. Both also ran as the systemd service below. The HTTPS proxy step wasn't part of the test.
How a delivery looks
Each event is a POST with a JSON body:
{
"id": "evt_a2b3c4d5e6f7g2h3",
"type": "server.ready",
"createdAt": "2026-10-21T09:14:00.000Z",
"data": { "server": { "id": "srv_k3j5m2abcdefgh2q", "hostname": "web-1", "ipv4": "203.0.113.10", "ipv6": null } }
}and three headers: VPSNine-Event (the type), VPSNine-Delivery (this delivery's id) and VPSNine-Signature:
VPSNine-Signature: t=1792624734,v1=92f1290065485b33698dc29fbf36ba8c7a0c4dbd6d11adc49e…v1 is the hex HMAC-SHA256 of t, a dot, and the raw body, keyed with the endpoint's whsec_… secret. For 24 hours after you rotate the secret there are two v1 values, one per secret. Webhooks and verifying their signatures lists every event type and the retry schedule.
Three rules follow, and both receivers keep them:
- Check the raw bytes. Parsing the JSON and serialising it again changes spacing and key order, and the signature no longer matches.
- Answer within 10 seconds. Anything slower counts as a failure and is retried. Answer first, then work.
- Expect repeats. A retry after a lost answer, or a redelivery you asked for, brings the same event
idagain.
Node.js receiver
Save this as receiver.mjs. It needs Node.js 18 or later and no packages:
// receiver.mjs: VPSNine webhooks with nothing but Node.js (18 or later).
import { createHmac, timingSafeEqual } from "node:crypto";
import { appendFileSync, existsSync, readFileSync } from "node:fs";
import { createServer } from "node:http";
const SECRETS = (process.env.VPS9_WEBHOOK_SECRET ?? "").split(",").filter(Boolean);
const PORT = Number(process.env.PORT ?? 8080);
const SEEN_FILE = process.env.SEEN_FILE ?? "seen-events.txt";
if (SECRETS.length === 0) throw new Error("set VPS9_WEBHOOK_SECRET");
const seen = new Set(existsSync(SEEN_FILE) ? readFileSync(SEEN_FILE, "utf8").split("\n").filter(Boolean) : []);
/** True when the header has a fresh timestamp and a v1 that matches one of our secrets. */
export function verify(header, rawBody, secrets = SECRETS, now = Date.now()) {
const parts = header.split(",").map((p) => p.trim().split("="));
const t = Number(parts.find(([k]) => k === "t")?.[1]);
if (!Number.isInteger(t) || Math.abs(now / 1000 - t) > 300) return false;
const signed = Buffer.concat([Buffer.from(`${t}.`), rawBody]);
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)));
}
async function handle(event) {
// The slow work goes here: deploy, page someone, update a status page.
console.log(`${event.type} ${event.id}`, JSON.stringify(event.data));
}
createServer((req, res) => {
if (req.method !== "POST" || req.url !== "/vpsnine") return res.writeHead(404).end();
const chunks = [];
let size = 0;
req.on("data", (c) => {
size += c.length;
if (size > 1_000_000) req.destroy();
else chunks.push(c);
});
req.on("end", () => {
const raw = Buffer.concat(chunks);
if (!verify(req.headers["vpsnine-signature"] ?? "", raw)) return res.writeHead(400).end("bad signature");
const event = JSON.parse(raw.toString("utf8"));
res.writeHead(204).end(); // answer first: we wait 10 seconds at most
if (seen.has(event.id)) return; // a retry or a redelivery we've handled
seen.add(event.id);
appendFileSync(SEEN_FILE, event.id + "\n");
handle(event).catch((err) => console.error(`event ${event.id} failed:`, err));
});
}).listen(PORT, "127.0.0.1", () => console.log(`listening on 127.0.0.1:${PORT}`));The secret can be a comma-separated list: put the new secret first and the old one second while a rotation is in progress, and either signature is accepted. timingSafeEqual compares in constant time, so the check doesn't leak how much of a guess was right. Seen event ids go to seen-events.txt, so a restart doesn't forget them.
Python receiver
The same in Python 3.10 or later, standard library only. Save it as receiver.py:
"""receiver.py: VPSNine webhooks with nothing but Python 3.10 or later."""
import hashlib, hmac, json, os, sqlite3, threading, time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
SECRETS = [s.encode() for s in os.environ.get("VPS9_WEBHOOK_SECRET", "").split(",") if s]
PORT = int(os.environ.get("PORT", "8080"))
if not SECRETS:
raise SystemExit("set VPS9_WEBHOOK_SECRET")
db = sqlite3.connect(os.environ.get("SEEN_DB", "seen-events.db"), check_same_thread=False)
db.execute("CREATE TABLE IF NOT EXISTS seen (id TEXT PRIMARY KEY, at INTEGER)")
lock = threading.Lock()
def verify(header: str, body: bytes, now: float | None = None) -> bool:
fields = [p.strip().split("=", 1) for p in header.split(",") if "=" in p]
ts = next((v for k, v in fields if k == "t"), "")
if not ts.isdigit() or abs((now or time.time()) - int(ts)) > 300:
return False
signed = ts.encode() + b"." + body
wanted = [hmac.new(s, signed, hashlib.sha256).hexdigest() for s in SECRETS]
return any(k == "v1" and any(hmac.compare_digest(v, w) for w in wanted) for k, v in fields)
def first_time(event_id: str) -> bool:
with lock, db:
return db.execute("INSERT OR IGNORE INTO seen VALUES (?, ?)", (event_id, int(time.time()))).rowcount == 1
def handle(event: dict) -> None:
# The slow work goes here: deploy, page someone, update a status page.
print(event["type"], event["id"], json.dumps(event["data"]), flush=True)
class Hook(BaseHTTPRequestHandler):
def do_POST(self):
if self.path != "/vpsnine":
return self.send_error(404)
length = int(self.headers.get("Content-Length", "0"))
if length > 1_000_000:
return self.send_error(413)
body = self.rfile.read(length)
if not verify(self.headers.get("VPSNine-Signature", ""), body):
return self.send_error(400, "bad signature")
event = json.loads(body)
self.send_response(204) # answer first: we wait 10 seconds at most
self.end_headers()
if first_time(event["id"]):
threading.Thread(target=handle, args=(event,), daemon=True).start()
ThreadingHTTPServer(("127.0.0.1", PORT), Hook).serve_forever()Here the seen ids live in a small SQLite table, and INSERT OR IGNORE decides in one step whether an event is new, even with two deliveries arriving at once. hmac.compare_digest is the constant-time comparison.
Try it locally
Start either receiver with a made-up secret:
VPS9_WEBHOOK_SECRET=whsec_test PORT=8080 node receiver.mjsIn another terminal, sign a body the way VPSNine does and send it:
body='{"id":"evt_test1","type":"ping","createdAt":"2026-10-21T09:20:00.000Z","data":{"message":"hello"}}'
t=$(date +%s)
sig=$(printf '%s.%s' "$t" "$body" | openssl dgst -sha256 -hmac whsec_test -hex | sed 's/^.* //')
curl -s -o /dev/null -w '%{http_code}\n' -X POST -H "VPSNine-Signature: t=$t,v1=$sig" \
-H 'Content-Type: application/json' --data-binary "$body" http://127.0.0.1:8080/vpsnineIt prints 204, and the receiver logs ping evt_test1. Change one character of $body after signing, and it prints 400.
Run it as a service
On the server, make a user with no login for the receiver, a folder for it, and a file for the secret that only root can read:
sudo useradd --system --home /opt/vps9-hooks --shell /usr/sbin/nologin hooks
sudo install -d -o hooks -g hooks /opt/vps9-hooks
sudo install -m 644 receiver.mjs /opt/vps9-hooks/receiver.mjs
sudo install -m 600 /dev/null /etc/vps9-hooks.env
sudoedit /etc/vps9-hooks.envPut two lines in it (the secret comes in the next section; use a placeholder for now):
VPS9_WEBHOOK_SECRET=whsec_…
PORT=8080Save this as /etc/systemd/system/vps9-hooks.service:
[Unit]
Description=VPSNine webhook receiver
After=network-online.target
Wants=network-online.target
[Service]
User=hooks
WorkingDirectory=/opt/vps9-hooks
EnvironmentFile=/etc/vps9-hooks.env
ExecStart=/usr/bin/node /opt/vps9-hooks/receiver.mjs
Restart=on-failure
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=/opt/vps9-hooks
PrivateTmp=true
[Install]
WantedBy=multi-user.targetFor the Python receiver, copy receiver.py instead and change ExecStart to /usr/bin/python3 /opt/vps9-hooks/receiver.py. Start it:
sudo systemctl daemon-reload
sudo systemctl enable --now vps9-hooks
systemctl status vps9-hooks --no-pagerProtectSystem=strict makes the whole filesystem read-only to the service except /opt/vps9-hooks, where it keeps its list of seen events, and NoNewPrivileges stops it gaining more rights than the hooks user has.
Put HTTPS in front
VPSNine only delivers to https:// addresses on public IPs. The receiver listens on 127.0.0.1 only, so put a reverse proxy in front that gets a certificate for your domain. With Caddy (the Caddy setup script installs it), the whole site block is:
hooks.example.com {
reverse_proxy 127.0.0.1:8080
}With nginx, add location /vpsnine { proxy_pass http://127.0.0.1:8080; } to a server block with a certificate (nginx and HTTPS). Open port 443 in the panel firewall if its policy is deny.
Register the endpoint
In the panel, open Account, then webhooks, and add https://hooks.example.com/vpsnine with the events you want. The page shows the signing secret once. Put it in /etc/vps9-hooks.env and restart:
sudoedit /etc/vps9-hooks.env
sudo systemctl restart vps9-hooksCheck it worked
Choose send test event on the endpoint's page. The delivery log shows the attempt with status 204, and the receiver logs it:
journalctl -u vps9-hooks --no-pager -n 5Oct 22 01:26:46 hooks systemd[1]: Started vps9-hooks.service - VPSNine webhook receiver.
Oct 22 01:26:46 hooks node[4205]: listening on 127.0.0.1:8080
Oct 22 01:26:47 hooks node[4205]: ping evt_t4k2r7abcdefgh3e {"message":"A test event from VPSNine. Your endpoint is set up.","endpoint":{"id":"whk_x7m2q4abcdefgh3c"}}Rotate the secret without dropping events
- Choose rotate secret on the endpoint's page and copy the new secret.
- Set
VPS9_WEBHOOK_SECRET=whsec_new,whsec_oldin/etc/vps9-hooks.envand restart the service. - After 24 hours, deliveries stop carrying the old signature. Remove the old secret and restart again.
Troubleshooting
Every delivery gets 400. The secret in /etc/vps9-hooks.env isn't the endpoint's, or something in front of the receiver changes the body (a proxy that re-encodes JSON). Check that the proxy passes the body through untouched, and copy the secret again after a rotation.
Deliveries pass locally but fail from VPSNine with a timeout. The proxy isn't reachable on 443, or the firewall drops it. curl -sI https://hooks.example.com/vpsnine from another machine should get an answer from the receiver: 404 from the Node.js one or 501 from the Python one, since neither takes a HEAD. A timeout or a certificate error means the problem is in front of it.
400 only some of the time. The server's clock is off by more than 5 minutes. Check timedatectl; systemd-timesyncd keeps it right.
The same event is handled twice after a restart. The service can't write its seen-events file. Make sure /opt/vps9-hooks is owned by hooks and listed in ReadWritePaths.
Next steps
- Get Slack and Discord alerts from VPSNine webhooks turns this receiver into a chat relay.
- Notifications and resource alerts, to set up the alerts that fire
alert.triggered. - Webhooks and verifying their signatures for every event type, retries and redelivery.