Receive a webhook
For the program that gets the requests from your webhook endpoints. Each request follows Standard Webhooks, so a Standard Webhooks library for your language works instead of the code on this page.
The request
The panel sends a POST with a JSON body:
POST /hooks/panel HTTP/1.1
Content-Type: application/json
User-Agent: Elpan-Webhook/1
webhook-id: 01a18855-a79e-7a3d-97f5-3509f0a3ed52
webhook-timestamp: 1761752681
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
elpan-event-type: backup.succeeded
{
"type": "backup.succeeded",
"timestamp": "2026-10-29T15:44:41Z",
"data": {
"account": { "id": "01a1529c-…", "username": "client1" },
"object": {
"type": "backup-tasks",
"id": "01a18855-…",
"attributes": { "kind": "backup", "state": "succeeded", "snapshot": "dbead7cc", "…": "…" }
},
"summary": {
"title": "Backup finished: Home Directory (client1)",
"text": "Home Directory (client1) was backed up.",
"severity": "success",
"url": "https://control.example.net/backup/01a1529c-…"
},
"links": { "self": "https://control.example.net/api/v1/…" }
}
}
| Part | What it is |
|---|---|
webhook-id | The event's id, the same on every attempt and redelivery. |
webhook-timestamp | The time of this attempt, in seconds since 1970. |
webhook-signature | One or more signatures, space-separated. Two during a secret rotation. |
data.object | The thing the event is about, as it was at the time of the event. |
data.summary | The event in words, in your account's language: a title, a text, a severity (info, success, warning, or failure), and a link to the page in the panel. |
data.links.self | Where the REST API shows the current state, if it has it. |
The API reference describes data.object for each event type in its
webhooks section.
Answer 2xx as soon as the signature is correct, then do the work. The
panel waits 10 seconds at most before it tries again later.
Check the signature
The signature is HMAC-SHA256 of {webhook-id}.{webhook-timestamp}.{body},
keyed with the part of the signing secret after whsec_, Base64-decoded.
Your receiver must:
- Refuse a timestamp more than 5 minutes from your clock.
- Compute the signature from the raw body.
- Compare it with each
v1,value inwebhook-signature, in constant time. - Use
webhook-idto ignore an event you already received.
Compute the signature from the bytes you received. If your framework decodes the JSON and you encode it again, the bytes change and the signature doesn't match.
PHP
<?php
function verify_webhook(string $secret, array $headers, string $body): array
{
$id = $headers['webhook-id'] ?? '';
$timestamp = $headers['webhook-timestamp'] ?? '';
if (abs(time() - (int) $timestamp) > 300) {
throw new RuntimeException('The timestamp is too old or too new.');
}
$key = base64_decode(substr($secret, strlen('whsec_')), true);
$expected = base64_encode(hash_hmac('sha256', "$id.$timestamp.$body", $key, true));
foreach (explode(' ', $headers['webhook-signature'] ?? '') as $signature) {
[$version, $value] = array_pad(explode(',', $signature, 2), 2, '');
if ($version === 'v1' && hash_equals($expected, $value)) {
return json_decode($body, true, flags: JSON_THROW_ON_ERROR);
}
}
throw new RuntimeException('No signature matches.');
}
$body = file_get_contents('php://input');
$headers = array_change_key_case(getallheaders(), CASE_LOWER);
$event = verify_webhook(getenv('WEBHOOK_SECRET'), $headers, $body);
http_response_code(204);
Python
import base64, hashlib, hmac, json, time
def verify_webhook(secret, headers, body):
msg_id = headers["webhook-id"]
timestamp = headers["webhook-timestamp"]
if abs(time.time() - int(timestamp)) > 300:
raise ValueError("The timestamp is too old or too new.")
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{msg_id}.{timestamp}.".encode() + body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
for signature in headers["webhook-signature"].split(" "):
version, _, value = signature.partition(",")
if version == "v1" and hmac.compare_digest(expected, value):
return json.loads(body)
raise ValueError("No signature matches.")
With Flask, pass it request.headers and request.get_data().
Node.js
import crypto from 'node:crypto';
export function verifyWebhook(secret, headers, body) {
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
throw new Error('The timestamp is too old or too new.');
}
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
const expected = crypto.createHmac('sha256', key)
.update(`${id}.${timestamp}.`).update(body).digest();
for (const signature of String(headers['webhook-signature']).split(' ')) {
const [version, value = ''] = signature.split(',', 2);
const given = Buffer.from(value, 'base64');
if (version === 'v1' && given.length === expected.length
&& crypto.timingSafeEqual(given, expected)) {
return JSON.parse(body.toString('utf8'));
}
}
throw new Error('No signature matches.');
}
body is a Buffer. With Express, get it with
express.raw({ type: 'application/json' }).
Relay to another format
A system that doesn't read Standard Webhooks needs a relay: it receives the panel's request, checks the signature, and sends the other system a request it understands.
For a chat channel, data.summary has everything you need, and it's the
same for every event type, so one relay handles every event.
Slack, in Node.js
Posts each event to a Slack incoming webhook, using verifyWebhook
from the Node.js example.
import express from 'express';
import { verifyWebhook } from './verify.js';
const app = express();
app.post('/panel', express.raw({ type: 'application/json' }), async (req, res) => {
let event;
try {
event = verifyWebhook(process.env.WEBHOOK_SECRET, req.headers, req.body);
} catch {
return res.sendStatus(401);
}
res.sendStatus(204);
const { title, text, url } = event.data.summary;
await fetch(process.env.SLACK_WEBHOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: `*${title}*\n${text}${url ? `\n<${url}|Open in the panel>` : ''}` }),
});
});
app.listen(8080);
Discord, in Python
Posts each event to a Discord channel webhook, using verify_webhook
from the Python example.
import os, requests
from flask import Flask, request
from verify import verify_webhook
app = Flask(__name__)
@app.post("/panel")
def panel():
try:
event = verify_webhook(os.environ["WEBHOOK_SECRET"], request.headers, request.get_data())
except (KeyError, ValueError):
return "", 401
summary = event["data"]["summary"]
content = f"**{summary['title']}**\n{summary['text']}"
if summary["url"]:
content += f"\n{summary['url']}"
requests.post(os.environ["DISCORD_WEBHOOK_URL"], json={"content": content}, timeout=5)
return "", 204
The Python relay answers the panel after Discord answers. If Discord takes more than 10 seconds, the panel tries again and the channel gets the event twice. A queue in the relay prevents this.
Automation tools
n8n, Make, Zapier, and Pipedream can receive a webhook and post to a chat
tool, but most can't check an HMAC signature. For them, set a
secret header on the endpoint, e.g.
Authorization: Bearer <a long random value>, and have the tool refuse a
request without it. The panel still sends the signature, so a receiver
that can check it still can.