Zum Hauptinhalt springenZenit-Hosting Logo

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 https auf Port 443 nutzen. Zugangsdaten in der Adresse, IP-Adressen, interne Namen wie localhost oder .local und Adressen der Domain zenit-hosting.de lehnt 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.

TypWannAboInhalt von data
server.activityAuf einem Server mit installiertem Bot gibt es einen neuen Eintrag im Aktivitätsverlauf.abonnierbarDevActivityDto
installation.createdDer Besitzer eines Servers hat den Bot installiert.ohne Abo, bei aktivem WebhookDevInstallationDto
installation.deletedDie Installation des Bots auf einem Server ist entfernt worden.ohne Abo, bei aktivem WebhookDevInstallationDto
pingDu hast in den Webhook-Einstellungen des Bots ein Testereignis ausgelöst.ohne Abo, bei aktivem WebhookbotId 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.

Payload von server.activity
{
  "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"
  }
}
Payload von installation.created
{
  "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"
  }
}
Payload von installation.deleted
{
  "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"
  }
}
Payload von ping
{
  "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"
  }
}
HeaderInhalt
Zenit-SignatureZeitstempel und Signatur im Format t=<Unix-Sekunden>,v1=<Hex>. Damit prüfst du, dass die Anfrage von Zenit kommt.
Zenit-Event-IdId des Ereignisses, gleich dem Feld id im Körper. Damit erkennst du Wiederholungen.
Zenit-Event-TypeTyp des Ereignisses, gleich dem Feld type im Körper.
Zenit-Delivery-IdId der Zustellung, wie im Zustellprotokoll des Bots.
Content-TypeImmer application/json.
Beispiel einer Zustellung mit Headern
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:

  1. Lies den Körper unverändert als Rohtext, bevor ein Framework ihn als JSON parst. Schon ein anderes Leerzeichen ändert die Signatur.
  2. Lies t und v1 aus dem Header Zenit-Signature.
  3. Lehne die Anfrage ab, wenn t mehr als 300 Sekunden von deiner Uhrzeit abweicht. Das stoppt abgefangene Anfragen, die jemand später erneut sendet.
  4. Berechne HMAC-SHA256 mit dem kompletten Secret einschließlich zhs_ als UTF-8-Bytes über den Text t + "." + Rohkörper, also den Zeitstempel, einen Punkt und den Rohkörper, und schreibe das Ergebnis als Hex.
  5. Vergleiche dein Ergebnis mit v1 in konstanter Zeit, nicht mit ==. Nur bei Gleichheit vertraust du der Anfrage.

Node.js

Node.js: Signatur prüfen
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);
}
Node.js: Beispiel mit Express
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

Python: Signatur prüfen
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())
Python: Beispiel mit Flask
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 "", 204

Zustellung 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-Id oder id im 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.
VersuchWartezeit nach dem Fehlschlag
1. Wiederholung1 min
2. Wiederholung5 min
3. Wiederholung30 min
4. Wiederholung2 h
5. Wiederholung6 h
6. Wiederholung12 h
7. Wiederholung24 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.