Sari la conținut

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

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

Corpul 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 exemplu site.*) sau un tip exact de eveniment (GET /api/v1/webhooks/event-types le 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), *.localhost sau *.local sunt 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 eroare address_not_public).
  • Butonul Trimite eveniment de test din panou (POST /api/v1/webhooks/{id}/ping) trimite un webhook.ping doar către acest endpoint; tiparele nu se potrivesc niciodată cu webhook.ping. Un endpoint dezactivat răspunde 409 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.
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"
}
}
  • id identifică evenimentul: este același la fiecare încercare și la fiecare re-livrare, deci folosește-l ca să ignori duplicatele.
  • data conține id-uri, tipuri și un set fix de câmpuri publice (*.updated și dns.records_changed numesc în data.change ce 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)
PlusPanel-Signature: t=1792411200,v1=64204189634f66cf069ff698589d653aa27299d9c6a4f69854f3c5fa4455b23e

v1 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:

  1. Citește corpul brut al cererii, octet cu octet, înainte de orice parsare JSON (un JSON re-serializat nu se potrivește).
  2. Împarte antetul după ,; ia t și fiecare v1. În timpul rotației unui secret antetul are câte un v1 pentru fiecare secret valid; acceptă livrarea dacă oricare se potrivește.
  3. Refuză livrarea dacă t este la mai mult de 5 minute de ceasul tău (protecție la replay).
  4. Calculează HMAC-ul și compară-l cu fiecare v1 în timp constant.
  5. 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 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']
  • 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-Delivery nou, același id de eveniment.

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.