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

Source: https://vpsnine.com/help/webhooks · Updated: 2026-10-21

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](https://my.vpsnine.com/account/webhooks), or with the [API](https://vpsnine.com/help/api-quickstart) (`/api/v1/webhooks`).

## Add an endpoint

1. Open **Account**, then **webhooks**.
2. 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.
3. Tick the **Events to send**.
4. 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](https://vpsnine.com/help/verify-your-email) 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.

[Screenshot: The Endpoints table: the deploy bot endpoint, on, with 4 event types and a recent delivery, and a status page endpoint that is off and never delivered] Open an endpoint to test it, read its deliveries, rotate its secret or turn it off.

## What a delivery looks like

Each event is a `POST` with a JSON body:

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

```js
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):

```python
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 "", 204
```

PHP:

```php
<?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**.

[Screenshot: An endpoint's page: status on, its four events, last delivered and last failure times, and the send test event, turn off, rotate secret and delete buttons, with send test event outlined] Send a test event after any change to your receiver.

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

[Screenshot: The delivery log of an endpoint: a pending alert.triggered delivery, then delivered ones with HTTP 200 and 204, response times and attempt counts] A delivery that failed once and then succeeded shows 2 attempts.

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.

## Next steps

- [Choose which notifications and emails you get](https://vpsnine.com/help/notification-settings)
- [Notifications and resource alerts](https://vpsnine.com/help/notifications-and-alerts)
- [Create and use API tokens](https://vpsnine.com/help/api-tokens)
- [Share an account with your team](https://vpsnine.com/help/teams)
