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:

json
{
  "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:

text
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:

  1. Check the raw bytes. Parsing the JSON and serialising it again changes spacing and key order, and the signature no longer matches.
  2. Answer within 10 seconds. Anything slower counts as a failure and is retried. Answer first, then work.
  3. Expect repeats. A retry after a lost answer, or a redelivery you asked for, brings the same event id again.

Node.js receiver

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

js
// 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:

python
"""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:

bash
VPS9_WEBHOOK_SECRET=whsec_test PORT=8080 node receiver.mjs

In another terminal, sign a body the way VPSNine does and send it:

bash
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/vpsnine

It 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:

bash
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.env

Put two lines in it (the secret comes in the next section; use a placeholder for now):

ini
VPS9_WEBHOOK_SECRET=whsec_…
PORT=8080

Save this as /etc/systemd/system/vps9-hooks.service:

ini
[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.target

For the Python receiver, copy receiver.py instead and change ExecStart to /usr/bin/python3 /opt/vps9-hooks/receiver.py. Start it:

bash
sudo systemctl daemon-reload
sudo systemctl enable --now vps9-hooks
systemctl status vps9-hooks --no-pager

ProtectSystem=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:

text
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:

bash
sudoedit /etc/vps9-hooks.env
sudo systemctl restart vps9-hooks

Check 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:

bash
journalctl -u vps9-hooks --no-pager -n 5
text
Oct 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

  1. Choose rotate secret on the endpoint's page and copy the new secret.
  2. Set VPS9_WEBHOOK_SECRET=whsec_new,whsec_old in /etc/vps9-hooks.env and restart the service.
  3. 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

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