Webhooks
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.
Developer → Webhookshttps://YOUR-SERVER-IP:8443/developer/webhooks (ROOT only)| Name | Destination | Events | Status | Actions |
|---|---|---|---|---|
| Ops alerts | telegram | ssl.expiring_soon +3 | active | test · logs · key · delete |
| Billing sync | http | user.created +2 | active | test · 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.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.
Verifying the signature (the important part)
Every HTTP delivery includes these headers:
X-Panelica-Signature, the HMAC, in the formsha256=<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:
- Compute the HMAC over the raw bytes you received, do not re-serialize the parsed JSON first (that changes whitespace and breaks the match).
- 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.
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
SECRETto 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.