Ir al contenido

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.

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

Ventana de terminal
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"]}'
Ventana de terminal
pluspanel webhooks create --url https://hooks.example.com/pluspanel \
--events 'site.*,backup.failed,job.failed' --description 'Ops chat' -o json

El 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 ejemplo site.*) o un tipo de evento exacto (GET /api/v1/webhooks/event-types los 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), *.localhost o *.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 error address_not_public).
  • El botón Enviar evento de prueba del panel (POST /api/v1/webhooks/{id}/ping) envía un webhook.ping solo a este endpoint; los patrones nunca coinciden con webhook.ping. Un endpoint desactivado responde 409 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.
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.

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"
}
}
  • id identifica el evento: es el mismo en cada intento y en cada reenvío, así que úsalo para descartar duplicados.
  • data contiene ids, tipos y un conjunto fijo de campos públicos (*.updated y dns.records_changed indican en data.change qué 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_at y 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)
PlusPanel-Signature: t=1792411200,v1=64204189634f66cf069ff698589d653aa27299d9c6a4f69854f3c5fa4455b23e

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

  1. 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).
  2. Divide la cabecera por ,; toma t y cada v1. Durante la rotación de un secreto la cabecera lleva un v1 por cada secreto válido; acepta la entrega si coincide cualquiera.
  3. Rechaza la entrega si t se aleja más de 5 minutos de tu reloj (protección frente a repeticiones).
  4. Calcula el HMAC y compáralo con cada v1 en tiempo constante.
  5. Responde 2xx rá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.

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']
  • Una entrega tiene éxito cuando tu endpoint responde 2xx en 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-Delivery nuevo, mismo id de evento.

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.