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

Source: https://vpsnine.com/tutorials/receive-vpsnine-webhooks-in-nodejs-and-python · Updated: 2026-10-21

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

Prerequisites:

- 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](https://vpsnine.com/help/webhooks) 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](https://vpsnine.com/help/caddy-reverse-proxy-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](https://vpsnine.com/help/nginx-and-https)). Open port 443 in the [panel firewall](https://vpsnine.com/help/server-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

- [Get Slack and Discord alerts from VPSNine webhooks](https://vpsnine.com/tutorials/slack-and-discord-alerts-from-vpsnine-webhooks) turns this receiver into a chat relay.
- [Notifications and resource alerts](https://vpsnine.com/help/notifications-and-alerts), to set up the alerts that fire `alert.triggered`.
- [Webhooks and verifying their signatures](https://vpsnine.com/help/webhooks) for every event type, retries and redelivery.
