PanelicaDocs
panelica.com
Docs / Developer / Webhooks

Webhooks

Panelica 1.0.375 Verified 2026-07-24 Developer

Webhooks push panel events to the outside world in real time: a new domain is created, an SSL certificate is issued, a backup finishes, and Panelica immediately notifies a destination you choose. Point them at a Telegram chat, a Slack or Discord channel, or your own HTTPS endpoint to drive automation. This page covers the destinations, event subscription, signature verification (with working receiver code), delivery and retries.

Panel locationDeveloper → Webhookshttps://YOUR-SERVER-IP:8443/developer/webhooks (ROOT only)
 Webhooks
NameDestinationEventsStatusActions
Ops alertstelegramssl.expiring_soon +3activetest · logs · key · delete
Billing synchttpuser.created +2activetest · logs · key · delete

The four destination types

When you create a webhook you pick one destination and fill in just its fields:

  • Telegram, a bot token and chat ID (and message format, HTML or Markdown). Panelica formats the event as a readable message and calls the Bot API.
  • Slack, an incoming-webhook URL, with optional channel and username. The event is posted as a formatted message.
  • Discord, an incoming-webhook URL (and username). Delivered as a colored embed.
  • HTTP, your own URL and method (POST/PUT/PATCH). This is the one that carries the raw JSON payload and the signature, the destination for real integrations.

Telegram, Slack and Discord are convenience destinations, Panelica does the formatting and they use their own platform auth, so signature verification below applies to the HTTP type.

Subscribing to events

A webhook fires only for the events you subscribe it to. The create dialog groups events by subsystem, with a search box and per-category and global select-all, so you can pick precisely (say ssl.expiring_soon + ssl.expired) or broadly. Events are named subsystem.action, and the catalog spans the panel's major subsystems:

Subsystem Example events
User user.created, user.deleted, user.suspended
Domain domain.created, domain.deleted, domain.php_updated
Subdomain / DNS subdomain.created, dns.record_created, dns.zone_deleted
SSL ssl.issued, ssl.renewed, ssl.expired, ssl.expiring_soon
Database database.created, database.deleted, database.backup_created
Email email.created, email.deleted, email.quota_exceeded
FTP ftp.created, ftp.deleted, ftp.password_changed
Backup backup.created, backup.failed, backup.restored
Security security.ip_blocked, security.malware_detected, security.api_key_created
Cron / Docker / Migration cron.executed, docker.container_started, migration.completed

Each event carries a JSON payload with the fields relevant to it (an ssl.issued payload includes the domain, issuer and expiry, for example). The Available Events tab shows the full catalog with a sample payload for each.

Pick events you know the panel emits. The catalog is broad and forward-looking; the events that fire today are the ones tied to real panel actions (creating a domain, issuing a certificate, running a backup, blocking an IP, and the like). Subscribe to what you actually need and confirm with a real action or the Test button, don't assume every entry in the catalog is wired up on your version.

Verifying the signature (the important part)

Every HTTP delivery includes these headers:

  • X-Panelica-Signature, the HMAC, in the form sha256=<hex>.
  • X-Panelica-Event, the event type (e.g. ssl.issued).
  • X-Panelica-Delivery, the delivery/log ID.

The signature is HMAC-SHA256 of the raw request body, keyed with the webhook's secret (shown once when you create the webhook, regenerable). Two things you must get right:

  1. Compute the HMAC over the raw bytes you received, do not re-serialize the parsed JSON first (that changes whitespace and breaks the match).
  2. The header value has a sha256= prefix, compare against "sha256=" + your_hex, or strip the prefix before comparing.

PHP receiver:

<?php
$secret  = 'whsec_your_webhook_secret';
$payload = file_get_contents('php://input');              // RAW body
$header  = $_SERVER['HTTP_X_PANELICA_SIGNATURE'] ?? '';   // "sha256=..."
$expected = 'sha256=' . hash_hmac('sha256', $payload, $secret);

if (!hash_equals($expected, $header)) {
    http_response_code(401);
    exit('bad signature');
}

$event = json_decode($payload, true);
// ... handle $event ...
http_response_code(200);

Python (Flask):

import hmac, hashlib
from flask import Flask, request, abort

SECRET = b"whsec_your_webhook_secret"
app = Flask(__name__)

@app.post("/webhook")
def webhook():
    payload = request.get_data()                       # RAW bytes
    header  = request.headers.get("X-Panelica-Signature", "")
    expected = "sha256=" + hmac.new(SECRET, payload, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, header):
        abort(401)
    event = request.get_json()
    # ... handle event ...
    return "", 200

Node.js (Express):

import express from "express";
import crypto from "node:crypto";

const SECRET = "whsec_your_webhook_secret";
const app = express();

// capture the RAW body for signature verification
app.use(express.raw({ type: "application/json" }));

app.post("/webhook", (req, res) => {
  const expected =
    "sha256=" + crypto.createHmac("sha256", SECRET).update(req.body).digest("hex");
  const header = req.get("X-Panelica-Signature") || "";
  const ok =
    expected.length === header.length &&
    crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(header));
  if (!ok) return res.status(401).send("bad signature");

  const event = JSON.parse(req.body.toString());
  // ... handle event ...
  res.sendStatus(200);
});

Go:

func verify(body []byte, header, secret string) bool {
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write(body)
	expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
	return hmac.Equal([]byte(expected), []byte(header))
}

func handler(w http.ResponseWriter, r *http.Request) {
	body, _ := io.ReadAll(r.Body)
	if !verify(body, r.Header.Get("X-Panelica-Signature"), "whsec_your_webhook_secret") {
		http.Error(w, "bad signature", http.StatusUnauthorized)
		return
	}
	// ... handle event ...
	w.WriteHeader(http.StatusOK)
}

Respond with a 2xx to acknowledge; any other status (or a timeout) marks the delivery failed and schedules a retry.

Delivery, retries and timeouts

Panelica delivers asynchronously through a queue of worker goroutines, so firing an event never blocks the panel action that triggered it. Each delivery has a 30-second timeout. If the destination doesn't return a 2xx, the delivery is retried on a fixed back-off:

Attempt When
1st (initial) immediately
2nd ~1 minute later
3rd ~5 minutes later, then it stops

So a flaky receiver gets up to two retries over about six minutes. Build your endpoint to be idempotent, the same event may arrive more than once, and use the X-Panelica-Delivery ID to de-duplicate.

Only public HTTPS/HTTP destinations are allowed. To prevent server-side request forgery, HTTP webhook URLs are validated, private, loopback, link-local and reserved IP ranges are refused. Point webhooks at a real, externally reachable endpoint.

Testing and managing

Each webhook row has five actions:

  • Test, injects a test event so you can confirm your receiver is reachable (watch the result in the logs).
  • Logs, opens the delivery history: per attempt you see the status (pending/success/failed), HTTP status code, response time, the request payload and the response body, and when the next retry is due.
  • Toggle, enable or disable the webhook without deleting it.
  • Regenerate Secret, issue a new signing secret (shown once), update your receiver's SECRET to match.
  • Delete, remove it.

The page also keeps a global All Logs view across every webhook, with a per-delivery detail modal for debugging a failing integration.

Access

Webhooks are a licensed feature (api_access, PRO and up) and ROOT-only at the route. Management respects the role chain, ROOT and ADMIN see all webhooks, other roles only their own.

Panelica Documentation · Written and verified against the live panel. · Last verified 2026-07-24 on Panelica 1.0.375