Webhooks
Los webhooks te envían los eventos en cuanto ocurren: se creó un sitio, falló una copia de seguridad, una alerta empezó a dispararse. Cada organización puede suscribir uno o varios endpoints HTTPS.
Suscripción
Sección titulada «Suscripción»En el panel, abre Ajustes → Webhooks y añade un endpoint: su URL, los eventos que quiere recibir y si
está activo. O con la 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 jsonEl cuerpo acepta url y events (obligatorios), description (hasta 200 caracteres) y enabled
(por defecto true). La respuesta es {"webhook": {…}, "secret": "whsec_…"}: el secreto de firma
del endpoint se muestra una sola vez, al crearlo y en cada rotación: guárdalo junto a tu receptor.
Una organización tiene como máximo 20 endpoints (409 webhook_limit_reached).
- Patrones: de 1 a 100 entre
*(todo),<recurso>.*(por ejemplosite.*) o un tipo de evento exacto (GET /api/v1/webhooks/event-typeslos lista). - Solo HTTPS hacia direcciones públicas. Una URL con credenciales o fragmento, una dirección privada,
de loopback, link-local o compartida (CGNAT), o un nombre sin punto (
localhost),*.localhosto*.local, se rechaza al guardarla (422). Los nombres se resuelven en cada conexión, y una conexión a una dirección así o a las redes propias de PlusPanel se rechaza en ese momento (el intento falla con la clase de erroraddress_not_public). - El botón Enviar evento de prueba del panel (
POST /api/v1/webhooks/{id}/ping) envía unwebhook.pingsolo a este endpoint; los patrones nunca coinciden conwebhook.ping. Un endpoint desactivado responde409 webhook_disabled, y sus entregas esperan hasta que se vuelva a activar. - Los webhooks necesitan el almacén de secretos de la plataforma; si no está configurado, la API
responde
503 webhooks_unavailable.
Eventos
Sección titulada «Eventos»| Evento | Cuándo |
|---|---|
server.enrolled |
Un servidor completó su registro y recibió su identidad. |
server.revoked |
Se revocó la identidad de un servidor; ya no puede conectarse. |
account.created |
Se creó una cuenta de alojamiento (se está preparando su almacenamiento). |
account.updated |
Cambiaron el plan, las excepciones, la cuota o los límites de una cuenta. |
account.deleted |
Se confirmó y comenzó la eliminación de una cuenta. |
site.created |
Se creó un sitio (su job de instalación está en curso). |
site.updated |
Cambiaron los dominios, la versión de PHP o la política de seguridad de un sitio. |
site.tls_updated |
Se aplicó en su servidor el certificado o la política TLS de un sitio. |
site.deleted |
Se eliminó un sitio de su servidor. |
backup.completed |
Una copia de seguridad terminó correctamente. |
backup.failed |
Una copia de seguridad falló (data.will_retry indica si se reintenta). |
backup.restored |
Una restauración desde una copia de seguridad terminó correctamente. |
dns.zone_created |
Se creó una zona DNS. |
dns.zone_deleted |
Comenzó la eliminación de una zona DNS. |
dns.records_changed |
Se crearon, cambiaron, eliminaron o importaron registros de una zona DNS. |
database.created |
Se solicitó una base de datos en un servicio de bases de datos. |
database.deleted |
Se solicitó la eliminación de una base de datos. |
job.succeeded |
Un job terminó correctamente en su servidor. |
job.failed |
Un job falló definitivamente en su servidor. |
alert.firing |
Una alerta empezó a dispararse. |
alert.resolved |
Una alerta activa se resolvió. |
invoice.ready |
Una factura está lista. Reservado para la facturación: aún no se envía. |
webhook.ping |
Un evento de prueba, enviado a petición a un endpoint. |
Pueden añadirse tipos de evento nuevos en cualquier momento (versionado): suscríbete con tipos o patrones explícitos e ignora los tipos que no gestiones.
Payload
Sección titulada «Payload»Cada entrega es un POST con un cuerpo 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" }}ididentifica el evento: es el mismo en cada intento y en cada reenvío, así que úsalo para descartar duplicados.datacontiene ids, tipos y un conjunto fijo de campos públicos (*.updatedydns.records_changedindican endata.changequé cambió). Nunca contiene datos personales, secretos ni detalles de auditoría: consulta el objeto con la API cuando necesites más.- No dependas del orden de entrega; usa
created_aty consulta el estado actual cuando el orden importe.
Cabeceras:
| Cabecera | Valor |
|---|---|
Content-Type |
application/json |
User-Agent |
PlusPanel-Webhooks/1 |
PlusPanel-Event |
el tipo de evento, por ejemplo site.created |
PlusPanel-Delivery |
el id de esta entrega (cambia en un reenvío manual) |
PlusPanel-Timestamp |
momento de la firma, tiempo Unix en segundos |
PlusPanel-Signature |
t=<segundos unix>,v1=<firma hex> (ver más abajo) |
Verificar la firma
Sección titulada «Verificar la firma»PlusPanel-Signature: t=1792411200,v1=64204189634f66cf069ff698589d653aa27299d9c6a4f69854f3c5fa4455b23ev1 es el HMAC-SHA256 en hexadecimal (minúsculas) de la cadena "<t>.<cuerpo en bruto>", con el
secreto de firma del endpoint como clave, exactamente como se mostró (toda la cadena whsec_…, como bytes
UTF-8). Para verificar una entrega:
- Lee el cuerpo en bruto de la petición, byte a byte, antes de cualquier análisis JSON (un JSON vuelto a serializar no coincide).
- Divide la cabecera por
,; tomaty cadav1. Durante la rotación de un secreto la cabecera lleva unv1por cada secreto válido; acepta la entrega si coincide cualquiera. - Rechaza la entrega si
tse aleja más de 5 minutos de tu reloj (protección frente a repeticiones). - Calcula el HMAC y compáralo con cada
v1en tiempo constante. - Responde
2xxrápido y procesa después el evento (una cola es buena idea).
Vector de prueba: con el secreto whsec_AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8, t=1792411200 y
el cuerpo
{"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"}}la firma es 64204189634f66cf069ff698589d653aa27299d9c6a4f69854f3c5fa4455b23e.
Node.js
Sección titulada «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);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']Entrega y reintentos
Sección titulada «Entrega y reintentos»- Una entrega tiene éxito cuando tu endpoint responde
2xxen 10 segundos como máximo. No se siguen redirecciones. - Cualquier otra respuesta se reintenta tras 1 min, 5 min, 30 min, 2 h, 6 h y 12 h (7 intentos en unas 21 horas). Tras el último fallo la entrega queda dead.
- Cada intento se conserva 30 días, con su código de estado, su duración y la clase de error (nunca el cuerpo de tu respuesta). Puedes ver el historial de cada endpoint en Ajustes → Webhooks y con la API.
- Una entrega (también una dead) se puede reenviar a mano desde el panel o con la API
(
POST /api/v1/webhook-deliveries/{id}/redeliver,pluspanel webhooks redeliver <delivery-id>). Un reenvío es una entrega nueva del mismo evento, con sus propios reintentos:PlusPanel-Deliverynuevo, mismoidde evento.
Rotación del secreto
Sección titulada «Rotación del secreto»Rota el secreto de un endpoint desde el panel o con la API
(POST /api/v1/webhooks/{id}/rotate-secret con grace_seconds, como máximo 86400; 0 retira el secreto
anterior de inmediato). El secreto nuevo se muestra una sola vez. Durante el periodo de gracia los dos secretos son válidos y cada entrega lleva dos firmas v1, así que
puedes desplegar el secreto nuevo en tu receptor sin perder eventos.