Webhooks and verifying their signatures
Send VPSNine events to your own HTTPS endpoint as signed JSON, check the signature in Node.js, Python or PHP, and handle retries and test events.
A webhook sends events from your account to a URL you run, as they happen: a server is ready, an invoice is paid, an alert fires. Your code gets a small JSON body and can deploy, page someone or update a status page without polling the API.
You manage them in the panel at my.vpsnine.com/account/webhooks, or with the API (/api/v1/webhooks).
Add an endpoint
- Open Account, then webhooks.
- Enter the URL. It must be
https://on a public address: we refuse private, loopback, link-local and cloud metadata addresses, and check again before every delivery. - Tick the Events to send.
- Type Your password and choose add endpoint.
The page then shows the signing secret once, starting with whsec_. Copy it into your receiver's configuration. We keep it encrypted and can't show it again; if you lose it, rotate it.
An account can have 5 endpoints, and needs a confirmed email address to add one.
Check it worked
The endpoint is listed under Endpoints, with how many event types it gets, whether it's on, and when we last delivered to it.

What a delivery looks like
Each event is a POST with a JSON body:
{
"id": "evt_t4k2r7abcdefgh3e",
"type": "alert.triggered",
"createdAt": "2026-10-21T09:14:00.000Z",
"data": {
"rule": { "id": "alr_m4q2x7abcdefgh3b", "metric": "cpu", "threshold": 90, "durationMin": 10 },
"server": { "id": "srv_k3j5m2abcdefgh2q", "hostname": "web-1" },
"value": 97,
"state": "firing"
}
}and these headers:
VPSNine-Event: the event type.VPSNine-Delivery: this delivery's id (whd_…).VPSNine-Signature:t=<unix seconds>,v1=<hex>.
Event types: server.ready, server.suspended, server.unsuspended, server.action_failed, snapshot.completed, snapshot.failed, invoice.issued, invoice.paid, invoice.overdue, payment.received, credit.added, ticket.replied, alert.triggered, alert.resolved, and ping for a test event. Security notices (a new login, 2FA changes) are never sent by webhook.
Verify the signature
v1 is an HMAC-SHA256, in hex, of the timestamp t, a dot, and the raw request body, keyed with your signing secret. Check it before you trust anything in the body, and refuse a timestamp more than 5 minutes from your clock so an old delivery can't be replayed. Use the raw body exactly as it arrived: parsing and re-serialising the JSON changes the bytes.
After you rotate a secret, deliveries carry two v1 values for 24 hours (new secret first, then the old one). Accept the request if any of them matches.
Node.js (Express):
const crypto = require("node:crypto");
const express = require("express");
const SECRET = process.env.VPS9_WEBHOOK_SECRET;
function verified(header, body) {
const fields = header.split(",").map((p) => p.trim().split("="));
const t = Number(fields.find(([k]) => k === "t")?.[1]);
if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > 300) return false;
const want = crypto.createHmac("sha256", SECRET).update(`${t}.${body}`).digest();
return fields.some(([k, v]) => k === "v1" && /^[0-9a-f]{64}$/.test(v) && crypto.timingSafeEqual(Buffer.from(v, "hex"), want));
}
const app = express();
app.post("/vpsnine", express.raw({ type: "application/json" }), (req, res) => {
if (!verified(req.get("VPSNine-Signature") ?? "", req.body.toString("utf8"))) return res.status(400).end();
const event = JSON.parse(req.body);
res.status(204).end(); // answer quickly, then do the work
console.log(event.type, event.id);
});
app.listen(8080);Python (Flask):
import hashlib, hmac, os, time
from flask import Flask, request, abort
SECRET = os.environ["VPS9_WEBHOOK_SECRET"].encode()
app = Flask(__name__)
def verified(header: str, body: bytes) -> 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(time.time() - int(ts)) > 300:
return False
want = hmac.new(SECRET, ts.encode() + b"." + body, hashlib.sha256).hexdigest()
return any(k == "v1" and hmac.compare_digest(v, want) for k, v in fields)
@app.post("/vpsnine")
def vpsnine():
if not verified(request.headers.get("VPSNine-Signature", ""), request.get_data()):
abort(400)
event = request.get_json()
print(event["type"], event["id"])
return "", 204PHP:
<?php
$secret = getenv('VPS9_WEBHOOK_SECRET');
$body = file_get_contents('php://input');
$fields = [];
foreach (explode(',', $_SERVER['HTTP_VPSNINE_SIGNATURE'] ?? '') as $part) {
[$k, $v] = array_pad(explode('=', trim($part), 2), 2, '');
$fields[] = [$k, $v];
}
$t = null;
foreach ($fields as [$k, $v]) if ($k === 't') $t = $v;
$want = hash_hmac('sha256', $t . '.' . $body, $secret);
$ok = ctype_digit((string) $t) && abs(time() - (int) $t) <= 300;
$ok = $ok && array_filter($fields, fn($f) => $f[0] === 'v1' && hash_equals($want, $f[1]));
if (!$ok) { http_response_code(400); exit; }
$event = json_decode($body, true);
http_response_code(204);Answer quickly
Answer with any 2xx status within 10 seconds. Anything else counts as a failure: another status, a timeout, a refused connection, a TLS error, or a redirect (we don't follow redirects). Do slow work after you answer, in a queue or a background job.
Retries, duplicates and turning off
A failed delivery is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 12 hours, then marked failed. Waiting deliveries are kept in our database, so a restart on our side loses nothing.
The same event can arrive more than once: a retry after your answer was lost, or a redelivery you asked for. Each event has one id; store the ids you've handled and skip repeats.
If an endpoint keeps failing for more than a day (20 failed attempts in a row, the first over 24 hours ago), we turn it off, mark what was waiting as failed, and tell you in the panel and by email. Fix the receiver, send a test event, then turn it back on.
Test, inspect and redeliver
Open an endpoint from the list. Its page shows its status, its events, when it last delivered and last failed, and the buttons send test event, turn off (or turn on), rotate secret and delete.

send test event sends a ping event once, whatever the endpoint subscribes to. Below it, the delivery log shows the last 50 deliveries: status, attempts, response code, time taken, the exact body we sent, and the first 1 KiB of your answer. redeliver sends the same event (same id, same body) again as a new delivery.

The Edit box under the log changes the URL, the description or the events. Changing the URL asks for your password again.
Rotate the secret
Choose rotate secret on the endpoint's page (or POST /api/v1/webhooks/:id/rotate-secret). The new secret is shown once. For the next 24 hours each delivery is signed with both, so you can update your receiver without dropping anything.