Skip to content

Webhooks

Webhooks push events to your systems as they happen: a site was created, a backup failed, an alert started firing. Each organization can subscribe one or more HTTPS endpoints.

In the panel, open Settings → Webhooks and add an endpoint: its URL, the events it wants and whether it is enabled. Or with the API (scope webhooks:write):

Terminal window
curl -fsS -X POST "$PLUSPANEL_URL/api/v1/webhooks" \
-H "Authorization: Bearer $PLUSPANEL_API_KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"url": "https://hooks.example.com/pluspanel", "events": ["site.*", "backup.failed", "job.failed"]}'
Terminal window
pluspanel webhooks create --url https://hooks.example.com/pluspanel \
--events 'site.*,backup.failed,job.failed' --description 'Ops chat' -o json

The body takes url and events (required), description (up to 200 characters) and enabled (default true). The answer is {"webhook": {…}, "secret": "whsec_…"}: the endpoint’s signing secret is shown once, at creation and at each rotation: store it with your receiver. An organization has at most 20 endpoints (409 webhook_limit_reached).

  • Patterns: 1 to 100 of * (everything), <resource>.* (for example site.*) or an exact event type (GET /api/v1/webhooks/event-types lists them).
  • HTTPS to public addresses only. A URL with credentials or a fragment, a private, loopback, link-local or shared (CGNAT) address, or a name without a dot (localhost), *.localhost or *.local is refused when it is saved (422). Names are resolved at every connection, and a connection to such an address, or to PlusPanel’s own networks, is refused then (the attempt fails with the error class address_not_public).
  • The panel’s Send test event (POST /api/v1/webhooks/{id}/ping) sends a webhook.ping to this endpoint only; patterns never match webhook.ping. A disabled endpoint answers 409 webhook_disabled, and its deliveries wait until it is enabled again.
  • Webhooks need the platform’s secrets vault; if it is not configured the API answers 503 webhooks_unavailable.
Event When
server.enrolled A server completed enrollment and received its identity.
server.revoked A server’s identity was revoked; it can no longer connect.
account.created A hosting account was created (its storage is being provisioned).
account.updated An account’s plan, overrides, quota or limits changed.
account.deleted The deletion of an account was confirmed and started.
site.created A site was created (its installation job is running).
site.updated A site’s domains, PHP version or security policy changed.
site.tls_updated A site’s certificate or TLS policy was applied on its server.
site.deleted A site was removed from its server.
backup.completed A backup run finished successfully.
backup.failed A backup run failed (data.will_retry says whether it is retried).
backup.restored A restore from a backup finished successfully.
dns.zone_created A DNS zone was created.
dns.zone_deleted The removal of a DNS zone started.
dns.records_changed Records of a DNS zone were created, changed, deleted or imported.
database.created A database was requested on a database service.
database.deleted The removal of a database was requested.
job.succeeded A job finished successfully on its server.
job.failed A job failed for good on its server.
alert.firing An alert started firing.
alert.resolved A firing alert resolved.
invoice.ready An invoice is ready. Reserved for billing: not sent yet.
webhook.ping A test event, sent on request to one endpoint.

New event types may be added at any time (versioning): subscribe with explicit types or patterns, and ignore types you do not handle.

Every delivery is a POST with a JSON body:

{
"id": "1d2c3b4a-0000-4000-8000-000000000001",
"type": "site.created",
"created_at": "2026-10-19T12:00:05.123456Z",
"tenant_id": "0b5e6f0c-6d0e-4c1e-9a43-3f0d2a2f1c11",
"data": {
"account_id": "5a5a5a5a-1111-4222-8333-444455556666",
"object_id": "6b6b6b6b-1111-4222-8333-444455556666",
"object_type": "site",
"primary_domain": "example.com"
}
}
  • id identifies the event: it is the same for every attempt and every re-delivery, so use it to ignore duplicates.
  • data carries ids, types and a fixed set of public fields (*.updated and dns.records_changed name what changed in data.change). It never contains personal data, secrets or audit details: fetch the object with the API when you need more.
  • Do not rely on delivery order; use created_at and fetch the current state when order matters.

Headers:

Header Value
Content-Type application/json
User-Agent PlusPanel-Webhooks/1
PlusPanel-Event the event type, e.g. site.created
PlusPanel-Delivery the id of this delivery (changes on a manual re-send)
PlusPanel-Timestamp Unix time of the signature, in seconds
PlusPanel-Signature t=<unix seconds>,v1=<hex signature> (see below)
PlusPanel-Signature: t=1792411200,v1=64204189634f66cf069ff698589d653aa27299d9c6a4f69854f3c5fa4455b23e

v1 is the lowercase hex HMAC-SHA256 of the string "<t>.<raw body>", keyed with the endpoint’s signing secret exactly as shown (the whole whsec_… string, as UTF-8 bytes). To verify a delivery:

  1. Read the raw request body, byte for byte, before any JSON parsing (re-serialized JSON does not match).
  2. Split the header on ,; take t and every v1. During a secret rotation the header carries one v1 per valid secret; accept the delivery if any of them matches.
  3. Refuse the delivery if t is more than 5 minutes away from your clock (replay protection).
  4. Compute the HMAC and compare it with each v1 in constant time.
  5. Answer 2xx quickly, then process the event (a queue is a good idea).

Test vector: with the secret whsec_AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8, t=1792411200 and the body

{"id":"8f14e45f-ceea-467f-a1b6-5a3c3f1b9a10","type":"webhook.ping","created_at":"2026-10-19T12:00:00Z","tenant_id":"0b5e6f0c-6d0e-4c1e-9a43-3f0d2a2f1c11","data":{"object_id":"3c1b2a4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d","object_type":"webhook"}}

the signature is 64204189634f66cf069ff698589d653aa27299d9c6a4f69854f3c5fa4455b23e.

import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";
function verifyPlusPanel(rawBody, header, secret, toleranceSeconds = 300) {
let timestamp;
const signatures = [];
for (const part of String(header ?? "").split(",")) {
const [key, value] = part.trim().split("=");
if (key === "t") timestamp = value;
else if (key === "v1" && value) signatures.push(value);
}
if (!timestamp || !/^\d+$/.test(timestamp) || signatures.length === 0) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest();
return signatures.some((sig) => {
const got = Buffer.from(sig, "hex");
return got.length === expected.length && timingSafeEqual(got, expected);
});
}
const app = express();
// express.raw keeps the body as a Buffer: verify before parsing.
app.post("/pluspanel", express.raw({ type: "application/json" }), (req, res) => {
const header = req.get("PlusPanel-Signature");
if (!verifyPlusPanel(req.body, header, process.env.PLUSPANEL_WEBHOOK_SECRET)) {
return res.sendStatus(400);
}
const event = JSON.parse(req.body.toString("utf8"));
res.sendStatus(204);
// … handle event.type / event.data asynchronously, de-duplicated on event.id
});
app.listen(3000);
import hashlib
import hmac
import os
import time
from flask import Flask, abort, request
def verify_pluspanel(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
timestamp, signatures = None, []
for part in (header or "").split(","):
key, _, value = part.strip().partition("=")
if key == "t":
timestamp = value
elif key == "v1" and value:
signatures.append(value.encode())
if not timestamp or not (timestamp.isascii() and timestamp.isdigit()) or not signatures:
return False
if abs(time.time() - int(timestamp)) > tolerance:
return False
expected = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256)
return any(hmac.compare_digest(expected.hexdigest().encode(), s) for s in signatures)
app = Flask(__name__)
@app.post("/pluspanel")
def pluspanel_webhook():
raw = request.get_data() # the raw bytes, before any JSON parsing
if not verify_pluspanel(raw, request.headers.get("PlusPanel-Signature", ""),
os.environ["PLUSPANEL_WEBHOOK_SECRET"]):
abort(400)
event = request.get_json()
# … handle event["type"] / event["data"], de-duplicated on event["id"]
return "", 204
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
// VerifyPlusPanel reports whether header signs body with secret, at most tolerance away from now.
func VerifyPlusPanel(body []byte, header, secret string, now time.Time, tolerance time.Duration) bool {
var ts string
var sigs []string
for _, part := range strings.Split(header, ",") {
key, value, _ := strings.Cut(strings.TrimSpace(part), "=")
switch {
case key == "t":
ts = value
case key == "v1" && value != "":
sigs = append(sigs, value)
}
}
unix, err := strconv.ParseInt(ts, 10, 64)
if err != nil || len(sigs) == 0 {
return false
}
if age := now.Sub(time.Unix(unix, 0)); age > tolerance || age < -tolerance {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(ts + "."))
mac.Write(body)
want := mac.Sum(nil)
for _, s := range sigs {
if got, err := hex.DecodeString(s); err == nil && hmac.Equal(got, want) {
return true
}
}
return false
}
func main() {
secret := os.Getenv("PLUSPANEL_WEBHOOK_SECRET")
http.HandleFunc("POST /pluspanel", func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
if err != nil || !VerifyPlusPanel(body, r.Header.Get("PlusPanel-Signature"), secret, time.Now(), 5*time.Minute) {
http.Error(w, "invalid signature", http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusNoContent)
// … json.Unmarshal(body, &event) and handle it, de-duplicated on event.ID
})
_ = http.ListenAndServe(":3000", nil)
}
<?php
function pluspanel_verify(string $rawBody, string $header, string $secret, int $tolerance = 300): bool
{
$timestamp = null;
$signatures = [];
foreach (explode(',', $header) as $part) {
[$key, $value] = array_pad(explode('=', trim($part), 2), 2, '');
if ($key === 't') {
$timestamp = $value;
} elseif ($key === 'v1' && $value !== '') {
$signatures[] = $value;
}
}
if ($timestamp === null || !ctype_digit($timestamp) || $signatures === []) {
return false;
}
if (abs(time() - (int) $timestamp) > $tolerance) {
return false;
}
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
foreach ($signatures as $signature) {
if (hash_equals($expected, $signature)) {
return true;
}
}
return false;
}
$raw = file_get_contents('php://input'); // the raw body, before json_decode
if (!pluspanel_verify($raw, $_SERVER['HTTP_PLUSPANEL_SIGNATURE'] ?? '', getenv('PLUSPANEL_WEBHOOK_SECRET'))) {
http_response_code(400);
exit;
}
$event = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
http_response_code(204);
// … handle $event['type'] / $event['data'], de-duplicated on $event['id']
  • A delivery succeeds when your endpoint answers 2xx within 10 seconds. Redirects are not followed.
  • Anything else is retried after 1 min, 5 min, 30 min, 2 h, 6 h and 12 h (7 attempts over about 21 hours). After the last failure the delivery is dead.
  • Every attempt is kept for 30 days with its status code, duration and error class (never your response body). You can see the history per endpoint in Settings → Webhooks and through the API.
  • A delivery (including a dead one) can be re-sent by hand from the panel or the API (POST /api/v1/webhook-deliveries/{id}/redeliver, pluspanel webhooks redeliver <delivery-id>). A re-send is a new delivery of the same event, with its own retries: new PlusPanel-Delivery, same event id.

Rotate an endpoint’s secret from the panel or the API (POST /api/v1/webhooks/{id}/rotate-secret with grace_seconds, at most 86400; 0 retires the old secret at once). The new secret is shown once. During the grace period both secrets are valid and each delivery carries two v1 signatures, so you can deploy the new secret to your receiver without dropping events.