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.
Subscribe
Section titled “Subscribe”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):
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"]}'pluspanel webhooks create --url https://hooks.example.com/pluspanel \ --events 'site.*,backup.failed,job.failed' --description 'Ops chat' -o jsonThe 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 examplesite.*) or an exact event type (GET /api/v1/webhooks/event-typeslists 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),*.localhostor*.localis 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 classaddress_not_public). - The panel’s Send test event (
POST /api/v1/webhooks/{id}/ping) sends awebhook.pingto this endpoint only; patterns never matchwebhook.ping. A disabled endpoint answers409 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.
Events
Section titled “Events”| 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.
Payload
Section titled “Payload”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" }}ididentifies the event: it is the same for every attempt and every re-delivery, so use it to ignore duplicates.datacarries ids, types and a fixed set of public fields (*.updatedanddns.records_changedname what changed indata.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_atand 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) |
Verify the signature
Section titled “Verify the signature”PlusPanel-Signature: t=1792411200,v1=64204189634f66cf069ff698589d653aa27299d9c6a4f69854f3c5fa4455b23ev1 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:
- Read the raw request body, byte for byte, before any JSON parsing (re-serialized JSON does not match).
- Split the header on
,; taketand everyv1. During a secret rotation the header carries onev1per valid secret; accept the delivery if any of them matches. - Refuse the delivery if
tis more than 5 minutes away from your clock (replay protection). - Compute the HMAC and compare it with each
v1in constant time. - Answer
2xxquickly, 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.
Node.js
Section titled “Node.js”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);Python
Section titled “Python”import hashlibimport hmacimport osimport 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 "", 204package 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)}<?phpfunction 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_decodeif (!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']Delivery and retries
Section titled “Delivery and retries”- A delivery succeeds when your endpoint answers
2xxwithin 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: newPlusPanel-Delivery, same eventid.
Rotating the secret
Section titled “Rotating the secret”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.