Webhooks prüfen
Ein Webhook schickt dir Ereignisse als HTTP-POST an eine Adresse deines Bots. Prüfe jede Anfrage anhand ihrer Signatur, bevor du ihr traust.
Webhook einrichten
- Trage die Adresse in den Webhook-Einstellungen deines Bots unter Entwickler ein und wähle die Ereignisse, die du abonnieren willst.
- Die Adresse muss
httpsauf Port443nutzen. Zugangsdaten in der Adresse, IP-Adressen, interne Namen wielocalhostoder.localund Adressen der Domainzenit-hosting.delehnt Zenit ab. - Beim ersten Speichern der Adresse zeigt Zenit das Webhook-Secret (Präfix
zhs_) genau einmal. Kopiere es sofort. Ein neues Secret erzeugst du später mit einer Erneuerung, die deinen zweiten Faktor verlangt. - Mit dem Testereignis schickt Zenit dir ein
ping, damit du Adresse und Signaturprüfung ausprobierst.
Ereignisse
Der Körper jeder Zustellung hat denselben Aufbau: id, type, serverId (bei pingnull), createdAt und data. Was in data steht, hängt vom Typ ab. server.activity abonnierst du. installation.created, installation.deleted und ping sind an deinen Bot gerichtet: Du bekommst sie ohne Abo, aber nur, solange dein Webhook aktiv ist.
| Typ | Wann | Abo | Inhalt von data |
|---|---|---|---|
| server.activity | Auf einem Server mit installiertem Bot gibt es einen neuen Eintrag im Aktivitätsverlauf. | abonnierbar | DevActivityDto |
| installation.created | Der Besitzer eines Servers hat den Bot installiert. | ohne Abo, bei aktivem Webhook | DevInstallationDto |
| installation.deleted | Die Installation des Bots auf einem Server ist entfernt worden. | ohne Abo, bei aktivem Webhook | DevInstallationDto |
| ping | Du hast in den Webhook-Einstellungen des Bots ein Testereignis ausgelöst. | ohne Abo, bei aktivem Webhook | botId und message |
Die Werte in den Beispielen sind Platzhalter. Welche Werte category, type und severity einer Aktivität annehmen, hängt vom Ereignis ab. Dein Bot sollte unbekannte Werte und Felder ignorieren.
{
"id": "7d2e5f90-3b1a-4c68-a4d7-9e0b6c1f8a23",
"type": "server.activity",
"serverId": "2e7f4a91-6c3d-4b08-9a15-8d0c3e6b7f42",
"createdAt": "2026-10-05T12:00:00Z",
"data": {
"id": "f4a60b2d-8c15-4e97-b3d0-5a1e7c9f2b68",
"serverId": "2e7f4a91-6c3d-4b08-9a15-8d0c3e6b7f42",
"category": "power",
"type": "server.start",
"severity": "info",
"source": "api",
"title": "Server gestartet",
"createdAt": "2026-10-05T12:00:00Z"
}
}{
"id": "a81c5d3e-2f07-4b94-96e1-7d4a0c8b3f25",
"type": "installation.created",
"serverId": "2e7f4a91-6c3d-4b08-9a15-8d0c3e6b7f42",
"createdAt": "2026-10-05T12:00:00Z",
"data": {
"id": "d93b1c7e-4a20-4e5f-8b16-0c7a5e2f9d34",
"serverId": "2e7f4a91-6c3d-4b08-9a15-8d0c3e6b7f42",
"grantedScopes": ["servers:read"],
"eventTypes": ["server.activity"],
"status": "active",
"createdAt": "2026-10-05T12:00:00Z"
}
}{
"id": "5c9e2a07-1d48-4f63-8b0a-e3d7f1a96c24",
"type": "installation.deleted",
"serverId": "2e7f4a91-6c3d-4b08-9a15-8d0c3e6b7f42",
"createdAt": "2026-10-05T12:30:00Z",
"data": {
"id": "d93b1c7e-4a20-4e5f-8b16-0c7a5e2f9d34",
"serverId": "2e7f4a91-6c3d-4b08-9a15-8d0c3e6b7f42",
"grantedScopes": ["servers:read"],
"eventTypes": ["server.activity"],
"status": "active",
"createdAt": "2026-10-05T12:00:00Z"
}
}{
"id": "b3d07f61-9a2c-4e58-a1f4-6c0e8d5b7a39",
"type": "ping",
"serverId": null,
"createdAt": "2026-10-05T12:00:00Z",
"data": {
"botId": "9e1a4c70-5b28-4d93-8f06-a2c7e3d1b584",
"message": "Testereignis"
}
}Header einer Zustellung
| Header | Inhalt |
|---|---|
| Zenit-Signature | Zeitstempel und Signatur im Format t=<Unix-Sekunden>,v1=<Hex>. Damit prüfst du, dass die Anfrage von Zenit kommt. |
| Zenit-Event-Id | Id des Ereignisses, gleich dem Feld id im Körper. Damit erkennst du Wiederholungen. |
| Zenit-Event-Type | Typ des Ereignisses, gleich dem Feld type im Körper. |
| Zenit-Delivery-Id | Id der Zustellung, wie im Zustellprotokoll des Bots. |
| Content-Type | Immer application/json. |
POST /zenit/webhook HTTP/1.1 Content-Type: application/json Zenit-Signature: t=1791200000,v1=5f0c3b8e2d4a4c6e9a1b7e3d2c1f0a915f0c3b8e2d4a4c6e9a1b7e3d2c1f0a91 Zenit-Event-Id: 7d2e5f90-3b1a-4c68-a4d7-9e0b6c1f8a23 Zenit-Event-Type: server.activity Zenit-Delivery-Id: 0a9c4e7b-5d31-4f82-b6a0-2c8e1d7f3b59
Signatur prüfen
So prüfst du eine Anfrage in fünf Schritten:
- Lies den Körper unverändert als Rohtext, bevor ein Framework ihn als JSON parst. Schon ein anderes Leerzeichen ändert die Signatur.
- Lies
tundv1aus dem HeaderZenit-Signature. - Lehne die Anfrage ab, wenn
tmehr als 300 Sekunden von deiner Uhrzeit abweicht. Das stoppt abgefangene Anfragen, die jemand später erneut sendet. - Berechne HMAC-SHA256 mit dem kompletten Secret einschließlich
zhs_als UTF-8-Bytes über den Textt + "." + Rohkörper, also den Zeitstempel, einen Punkt und den Rohkörper, und schreibe das Ergebnis als Hex. - Vergleiche dein Ergebnis mit
v1in konstanter Zeit, nicht mit==. Nur bei Gleichheit vertraust du der Anfrage.
Node.js
import { createHmac, timingSafeEqual } from 'node:crypto';
const TOLERANCE_SECONDS = 300;
// rawBody: der Körper der Anfrage unverändert (Buffer oder String), nicht das geparste JSON.
// signatureHeader: der Wert von Zenit-Signature, secret: das komplette Webhook-Secret einschließlich zhs_.
export function verifyZenitSignature(
rawBody,
signatureHeader,
secret,
nowSeconds = Math.floor(Date.now() / 1000),
) {
const parts = {};
for (const part of String(signatureHeader ?? '').split(',')) {
const index = part.indexOf('=');
if (index > 0) parts[part.slice(0, index).trim()] = part.slice(index + 1).trim();
}
const timestamp = Number(parts.t);
if (!Number.isInteger(timestamp) || !parts.v1) return false;
if (Math.abs(nowSeconds - timestamp) > TOLERANCE_SECONDS) return false;
const expected = createHmac('sha256', secret)
.update(parts.t + '.')
.update(rawBody)
.digest();
const received = Buffer.from(parts.v1, 'hex');
return received.length === expected.length && timingSafeEqual(received, expected);
}import express from 'express';
const app = express();
// express.raw liefert den Körper als Buffer, bevor ihn etwas als JSON liest.
app.post('/zenit/webhook', express.raw({ type: 'application/json' }), (req, res) => {
// Kein Buffer heißt: Der Körper wurde schon anders gelesen oder hatte einen anderen Content-Type.
if (!Buffer.isBuffer(req.body)) return res.sendStatus(400);
const valid = verifyZenitSignature(
req.body,
req.get('Zenit-Signature'),
process.env.ZENIT_WEBHOOK_SECRET,
);
if (!valid) return res.sendStatus(400);
const event = JSON.parse(req.body.toString('utf8'));
// Zuerst prüfen, ob event.id schon verarbeitet ist, dann verarbeiten.
res.sendStatus(204);
});Python
import hashlib
import hmac
import time
TOLERANCE_SECONDS = 300
def verify_zenit_signature(raw_body: bytes, signature_header: str, secret: str, now=None) -> bool:
# raw_body: der Körper der Anfrage unverändert, nicht das geparste JSON.
# signature_header: der Wert von Zenit-Signature, secret: das komplette Webhook-Secret einschließlich zhs_.
parts = {}
for part in (signature_header or "").split(","):
key, separator, value = part.partition("=")
if separator:
parts[key.strip()] = value.strip()
try:
timestamp = int(parts["t"])
received = parts["v1"]
except (KeyError, ValueError):
return False
current = time.time() if now is None else now
if abs(current - timestamp) > TOLERANCE_SECONDS:
return False
signed = parts["t"].encode() + b"." + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected.encode(), received.encode())import os
from flask import Flask, request
app = Flask(__name__)
@app.post("/zenit/webhook")
def zenit_webhook():
# get_data liefert den Körper unverändert als bytes.
valid = verify_zenit_signature(
request.get_data(),
request.headers.get("Zenit-Signature", ""),
os.environ["ZENIT_WEBHOOK_SECRET"],
)
if not valid:
return "", 400
event = request.get_json()
# Zuerst prüfen, ob event["id"] schon verarbeitet ist, dann verarbeiten.
return "", 204Zustellung und Wiederholungen
- Zenit stellt mindestens einmal zu. Dasselbe Ereignis kann also mehrfach ankommen, und die Reihenfolge ist nicht garantiert. Dedupliziere über die Id des Ereignisses (
Zenit-Event-Idoderidim Körper). - Als Erfolg zählt nur eine Antwort mit 2xx. Antworte innerhalb von 10 Sekunden, am besten sofort, und verarbeite das Ereignis danach. Zenit folgt keinen Weiterleitungen.
- Schlägt eine Zustellung fehl, wiederholt Zenit sie nach dem Plan unten. Schlägt auch die letzte Wiederholung fehl, steht die Zustellung auf
dead. - Den Stand jeder Zustellung siehst du im Zustellprotokoll des Bots und kannst sie dort erneut senden. Zenit bewahrt Zustellungen 30 Tage auf, Ereignisse 14 Tage.
| Versuch | Wartezeit nach dem Fehlschlag |
|---|---|
| 1. Wiederholung | 1 min |
| 2. Wiederholung | 5 min |
| 3. Wiederholung | 30 min |
| 4. Wiederholung | 2 h |
| 5. Wiederholung | 6 h |
| 6. Wiederholung | 12 h |
| 7. Wiederholung | 24 h |
Abschaltung nach 20 Fehlschlägen
Schlagen 20 Zustellungen in Folge fehl, setzt Zenit den Webhook auf disabled und schickt dem Besitzer des Bots eine E-Mail. Behebe die Ursache, öffne danach die Seite „Webhook“ des Bots und speichere die Adresse erneut. Das schaltet den Webhook wieder ein. Das Testereignis zeigt dir, ob die Adresse wieder antwortet.
Mit dem Token des Bots kannst du Ereignisse auch abrufen, statt sie zu empfangen, siehe Bots und Manifest.
