Webhooks
Webhook-urile îți trimit evenimentele pe măsură ce se întâmplă: a fost creat un site, un backup a eșuat, o alertă a început să se declanșeze. Fiecare organizație poate abona unul sau mai multe endpoint-uri HTTPS.
În panou, deschide Setări → Webhooks și adaugă un endpoint: URL-ul lui, evenimentele pe care le vrea
și dacă este activ. Sau prin 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 jsonCorpul primește url și events (obligatorii), description (cel mult 200 de caractere) și enabled
(implicit true). Răspunsul este {"webhook": {…}, "secret": "whsec_…"}: secretul de semnare al
endpoint-ului este afișat o singură dată, la creare și la fiecare rotație: păstrează-l lângă
receptorul tău. O organizație are cel mult 20 de endpointuri (409 webhook_limit_reached).
- Tipare: între 1 și 100 dintre
*(tot),<resursă>.*(de exemplusite.*) sau un tip exact de eveniment (GET /api/v1/webhooks/event-typesle listează). - Doar HTTPS către adrese publice. Un URL cu credențiale sau fragment, o adresă privată, loopback,
link-local sau partajată (CGNAT) ori un nume fără punct (
localhost),*.localhostsau*.localsunt refuzate la salvare (422). Numele sunt rezolvate la fiecare conexiune, iar o conexiune către o astfel de adresă sau către rețelele proprii PlusPanel este refuzată atunci (încercarea eșuează cu clasa de eroareaddress_not_public). - Butonul Trimite eveniment de test din panou (
POST /api/v1/webhooks/{id}/ping) trimite unwebhook.pingdoar către acest endpoint; tiparele nu se potrivesc niciodată cuwebhook.ping. Un endpoint dezactivat răspunde409 webhook_disabled, iar livrările lui așteaptă până este reactivat. - Webhook-urile au nevoie de seiful de secrete al platformei; dacă nu este configurat, API-ul răspunde
503 webhooks_unavailable.
Evenimente
Secțiune intitulată „Evenimente”| Eveniment | Când |
|---|---|
server.enrolled |
Un server a terminat înrolarea și și-a primit identitatea. |
server.revoked |
Identitatea unui server a fost revocată; nu se mai poate conecta. |
account.created |
A fost creat un cont de găzduire (spațiul lui de stocare se pregătește). |
account.updated |
S-au schimbat planul, excepțiile, cota sau limitele unui cont. |
account.deleted |
Ștergerea unui cont a fost confirmată și a început. |
site.created |
A fost creat un site (jobul de instalare rulează). |
site.updated |
S-au schimbat domeniile, versiunea PHP sau politica de securitate a unui site. |
site.tls_updated |
Certificatul sau politica TLS a unui site a fost aplicată pe server. |
site.deleted |
Un site a fost eliminat de pe server. |
backup.completed |
Un backup s-a încheiat cu succes. |
backup.failed |
Un backup a eșuat (data.will_retry spune dacă se reîncearcă). |
backup.restored |
O restaurare dintr-un backup s-a încheiat cu succes. |
dns.zone_created |
A fost creată o zonă DNS. |
dns.zone_deleted |
A început eliminarea unei zone DNS. |
dns.records_changed |
Înregistrări ale unei zone DNS au fost create, modificate, șterse sau importate. |
database.created |
A fost cerută o bază de date pe un serviciu de baze de date. |
database.deleted |
A fost cerută eliminarea unei baze de date. |
job.succeeded |
Un job s-a încheiat cu succes pe serverul lui. |
job.failed |
Un job a eșuat definitiv pe serverul lui. |
alert.firing |
O alertă a început să se declanșeze. |
alert.resolved |
O alertă activă s-a rezolvat. |
invoice.ready |
O factură este gata. Rezervat pentru facturare: nu se trimite încă. |
webhook.ping |
Un eveniment de test, trimis la cerere către un endpoint. |
Tipuri noi de evenimente pot apărea oricând (versionare): abonează-te cu tipuri sau tipare explicite și ignoră tipurile pe care nu le tratezi.
Fiecare livrare este un POST cu un corp JSON:
{ "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" }}ididentifică evenimentul: este același la fiecare încercare și la fiecare re-livrare, deci folosește-l ca să ignori duplicatele.dataconține id-uri, tipuri și un set fix de câmpuri publice (*.updatedșidns.records_changednumesc îndata.changece s-a schimbat). Nu conține niciodată date personale, secrete sau detalii de audit: citește obiectul prin API când ai nevoie de mai mult.- Nu te baza pe ordinea livrărilor; folosește
created_atși citește starea curentă când ordinea contează.
Antete:
| Antet | Valoare |
|---|---|
Content-Type |
application/json |
User-Agent |
PlusPanel-Webhooks/1 |
PlusPanel-Event |
tipul evenimentului, de exemplu site.created |
PlusPanel-Delivery |
id-ul acestei livrări (se schimbă la o retrimitere manuală) |
PlusPanel-Timestamp |
momentul semnăturii, timp Unix în secunde |
PlusPanel-Signature |
t=<secunde unix>,v1=<semnătură hex> (vezi mai jos) |
Verificarea semnăturii
Secțiune intitulată „Verificarea semnăturii”PlusPanel-Signature: t=1792411200,v1=64204189634f66cf069ff698589d653aa27299d9c6a4f69854f3c5fa4455b23ev1 este HMAC-SHA256 în hex (litere mici) al șirului "<t>.<corpul brut>", cu cheia egală cu secretul
de semnare al endpoint-ului exact cum a fost afișat (tot șirul whsec_…, ca octeți UTF-8). Ca să
verifici o livrare:
- Citește corpul brut al cererii, octet cu octet, înainte de orice parsare JSON (un JSON re-serializat nu se potrivește).
- Împarte antetul după
,; iatși fiecarev1. În timpul rotației unui secret antetul are câte unv1pentru fiecare secret valid; acceptă livrarea dacă oricare se potrivește. - Refuză livrarea dacă
teste la mai mult de 5 minute de ceasul tău (protecție la replay). - Calculează HMAC-ul și compară-l cu fiecare
v1în timp constant. - Răspunde repede cu
2xx, apoi procesează evenimentul (o coadă este o idee bună).
Vector de test: cu secretul whsec_AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8, t=1792411200 și
corpul
{"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"}}semnătura este 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 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']Livrare și reîncercări
Secțiune intitulată „Livrare și reîncercări”- O livrare reușește când endpoint-ul tău răspunde
2xxîn cel mult 10 secunde. Redirecționările nu sunt urmate. - Orice altceva este reîncercat după 1 min, 5 min, 30 min, 2 h, 6 h și 12 h (7 încercări în aproximativ 21 de ore). După ultimul eșec livrarea devine dead.
- Fiecare încercare este păstrată 30 de zile, cu codul de stare, durata și clasa erorii (niciodată corpul răspunsului tău). Istoricul pe endpoint îl vezi în Setări → Webhooks și prin API.
- O livrare (inclusiv una dead) poate fi retrimisă manual din panou sau prin API
(
POST /api/v1/webhook-deliveries/{id}/redeliver,pluspanel webhooks redeliver <delivery-id>). O retrimitere este o livrare nouă a aceluiași eveniment, cu propriile reîncercări:PlusPanel-Deliverynou, acelașiidde eveniment.
Rotația secretului
Secțiune intitulată „Rotația secretului”Rotește secretul unui endpoint din panou sau prin API (POST /api/v1/webhooks/{id}/rotate-secret cu
grace_seconds, cel mult 86400; 0 retrage imediat secretul vechi). Secretul nou este afișat o singură
dată. În perioada de grație ambele secrete sunt valide și fiecare livrare are două semnături v1, ca să poți
instala secretul nou la receptor fără să pierzi evenimente.