Skip to main content

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/…" }
}
}
PartWhat it is
webhook-idThe event's id, the same on every attempt and redelivery.
webhook-timestampThe time of this attempt, in seconds since 1970.
webhook-signatureOne or more signatures, space-separated. Two during a secret rotation.
data.objectThe thing the event is about, as it was at the time of the event.
data.summaryThe 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.selfWhere 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 first, work later

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:

  1. Refuse a timestamp more than 5 minutes from your clock.
  2. Compute the signature from the raw body.
  3. Compare it with each v1, value in webhook-signature, in constant time.
  4. Use webhook-id to ignore an event you already received.
Use the raw body

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
Only the Node.js relay answers before it posts

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.