Webhooks
Webhooks are signed POST requests the panel sends to your URL when something happens, so billing and other systems don’t have to poll.
| Who | Where | Gets |
|---|---|---|
| Staff | /admin/api (needs the “API & webhooks” permission) |
Every event |
| Resellers | Reseller → Webhooks | Events about their own customers, never transfers |
| Customers | Account → Webhooks | Events about their own servers |
Each webhook has its events, a signing secret shown once, Send test, pause and delete, and a delivery log showing each attempt’s status, timing, and full request and response, with Redeliver.
- Up to 10 webhooks per account.
- The URL must be
https://, and private or loopback addresses are refused.
Events
| Event | When | Staff | Resellers | Customers |
|---|---|---|---|---|
server.created |
An order is accepted | ✓ | ✓ | |
server.provisioned |
Built and booted | ✓ | ✓ | ✓ |
server.suspended |
Suspended, from anywhere | ✓ | ✓ | ✓ |
server.unsuspended |
Unsuspended | ✓ | ✓ | ✓ |
server.terminated |
Terminated | ✓ | ✓ | ✓ |
server.reinstalled |
Reinstalled | ✓ | ✓ | ✓ |
server.resized |
Changed package | ✓ | ✓ | ✓ |
server.addresses_changed |
An address added or removed | ✓ | ✓ | ✓ |
server.speed_changed |
Speed limits overridden | ✓ | ✓ | |
server.owner_changed |
Moved to another customer | ✓ | ||
floating_ip.created |
A floating IP reserved | ✓ | ✓ | |
floating_ip.released |
A floating IP released | ✓ | ✓ | |
alert.fired |
A server alert fires | ✓ | ✓ | ✓ |
alert.recovered |
It recovers | ✓ | ✓ | ✓ |
backup.failed |
A backup fails | ✓ | ✓ | ✓ |
customer.created |
A new account | ✓ | ✓ | |
gpu_instance.created |
A GPU instance was created and is being set up | ✓ | ✓ | |
gpu_instance.ready |
A new GPU instance finished setting up and runs | ✓ | ✓ | ✓ |
gpu_instance.started |
A GPU instance started running (started, restarted or unsuspended) | ✓ | ✓ | ✓ |
gpu_instance.stopped |
A GPU instance stopped, when asked or on its own | ✓ | ✓ | ✓ |
gpu_instance.failed |
Setting up, starting or changing a GPU instance failed | ✓ | ✓ | ✓ |
gpu_instance.image_changed |
A GPU instance was made again from another image | ✓ | ✓ | ✓ |
gpu_instance.suspended |
A GPU instance was suspended, with the reason | ✓ | ✓ | ✓ |
gpu_instance.unsuspended |
A GPU instance was unsuspended | ✓ | ✓ | ✓ |
gpu_instance.deleted |
A GPU instance and its /workspace were deleted | ✓ | ✓ | ✓ |
quota.near_limit |
A reseller passes 90% of a quota | ✓ | ✓ | |
transfer.started |
A move between nodes starts | ✓ | ||
transfer.finished |
It finishes | ✓ | ||
transfer.failed |
It fails | ✓ |
The request
POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: NordvikPanel-Webhooks/1.0
X-Panel-Event: server.suspended
X-Panel-Delivery: dlv_…
X-Panel-Timestamp: 1790879428
X-Panel-Signature: v1=5d41402abc4b2a76b9719d911017c592…
{
"id": "evt_…",
"type": "server.suspended",
"created_at": "2026-10-01T14:30:28.000Z",
"data": {
"server": {
"id": "srv_abc123",
"hostname": "web-01.example.com",
"status": "suspended",
"package": "VPS-2",
"location": "ams1",
"ipv4": "203.0.113.10"
},
"customer": { "id": "usr_…", "email": "priya@example.com", "externalId": "1042" },
"reason": "Invoice #1042 overdue"
}
}
data by event
| Events | data |
|---|---|
server.* |
server and customer (with your billing externalId), plus: reason (suspended), image (created, reinstalled), releasedAddresses (terminated), from and to (resized), added and removed (addresses_changed), speed and overridden (speed_changed), previousOwner (owner_changed) |
alert.* |
server, customer, and alert with id, kind, threshold, description |
backup.failed |
server, customer, and backup with id, kind, error |
transfer.* |
transfer with id, mode, from, to; server with id, hostname; plus downtimeMs (finished) or error (failed) |
customer.created |
customer with id, email, name, externalId |
floating_ip.* |
floatingIp with id, address, location |
gpu_instance.* |
gpuInstance with id, name, status, offerId, billingProductId, location, address, createdAt; customer as for servers. stopped and started add reason; a crash adds detail, exitCode and oomKilled; failed adds action and error; suspended adds reason |
quota.near_limit |
resource, used, limit |
Send test delivers { "test": true, "message": "Sent from the panel to check this endpoint. Ignore it." } as data.
Verifying the signature
X-Panel-Signature is v1= followed by the hex HMAC-SHA256 of timestamp.body, made with your webhook’s secret (whsec_…):
- Read the raw request body, before parsing it.
- Compute
HMAC-SHA256(secret, X-Panel-Timestamp + "." + body). - Compare it, in constant time, with each
v1=value in the header. - Reject requests whose timestamp is more than 5 minutes old.
- Use the event
idto ignore duplicates.
After you rotate the secret, the old one keeps working for 24 hours: the header carries two signatures, new first, v1=<new>,v1=<old>. Accept either, so you can update your side without missing events.
$body = file_get_contents('php://input');
$ts = $_SERVER['HTTP_X_PANEL_TIMESTAMP'];
$want = hash_hmac('sha256', $ts . '.' . $body, $secret);
$ok = false;
foreach (explode(',', $_SERVER['HTTP_X_PANEL_SIGNATURE']) as $sig) {
$ok = $ok || hash_equals($want, substr(trim($sig), 3));
}
if (!$ok || abs(time() - (int) $ts) > 300) { http_response_code(401); exit; }import { createHmac, timingSafeEqual } from "node:crypto";
function verify(rawBody, headers, secret) {
const ts = headers["x-panel-timestamp"];
const want = createHmac("sha256", secret).update(`${ts}.${rawBody}`).digest();
const ok = headers["x-panel-signature"].split(",").some((sig) => {
const got = Buffer.from(sig.trim().slice(3), "hex");
return got.length === want.length && timingSafeEqual(got, want);
});
return ok && Math.abs(Date.now() / 1000 - Number(ts)) <= 300;
}import hashlib, hmac, time
def verify(raw_body: bytes, headers, secret: str) -> bool:
ts = headers["X-Panel-Timestamp"]
want = hmac.new(secret.encode(), ts.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
ok = any(hmac.compare_digest(want, sig.strip()[3:]) for sig in headers["X-Panel-Signature"].split(","))
return ok and abs(time.time() - int(ts)) <= 300Delivery and retries
- Anything but a
2xxanswer is a failure, and so is a receiver that goes quiet for 10 seconds or hasn’t finished its whole answer within 15 seconds. Redirects aren’t followed. - Failures are retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 8 hours, then given up: six attempts in all.
- Waiting retries are saved with everything else, so a controller restart doesn’t drop them.
- Send test and Redeliver are one-off: they aren’t retried. Each person can use them 10 times a minute.
- A customer’s or reseller’s webhooks send at most 2 deliveries at once; the rest wait their turn. A slow receiver only holds up its own deliveries, never the platform’s or anyone else’s.
- The delivery log keeps the last 200 deliveries for each customer or reseller, and the last 2,000 for the platform’s webhooks.
- At most 500 deliveries wait to be sent (or retried) for each customer or reseller, and 5,000 for the platform. Past that, the oldest waiting one is dropped for each new one, and the owner gets a notification (at most once a day).
- A webhook that has failed 50 times in a row, for more than a day, is paused: nothing more is queued for it, and its owner gets a notification and email. Fix the receiver, then turn the webhook back on from its page.
- Webhooks only go to public addresses. When a customer’s or reseller’s address leads somewhere else, their log says That address isn’t reachable from here. Staff see the address it resolved to.
Answer quickly and do slow work afterwards, so deliveries don’t time out.