Doku
Webhooks
Kaspar kann jeden Alarm als signierten JSON-Request an deinen eigenen Endpunkt schicken, für Uptime-Monitore, Cron-Heartbeats und Broken-Link-Scans.
Webhook einrichten
Webhooks sind Teil des Pro-Plans. Öffne in Kaspar Alarme, trag unter Webhook einen Namen und die URL deines Endpunkts ein und füge ihn hinzu. Wie jeder Alarmkanal kann ein Webhook Standardkanal des Workspaces sein oder pro Monitor ausgewählt werden.
- Die URL muss
https://verwenden. Adressen, die auf private, Loopback-, Link-Local- oder andere interne Netze zeigen, werden abgelehnt, beim Hinzufügen und noch einmal vor jedem Request. - Jeder Webhook bekommt ein eigenes Signing-Secret, das mit
whsec_beginnt. Du kannst es in Kaspar anzeigen und kopieren und jederzeit ein neues erzeugen. Ein neues Secret ersetzt das alte sofort. - Mit Test-Webhook senden schickst du ein
webhook.test-Event. Kaspar zeigt neben dem Webhook den Status der letzten Zustellung und den HTTP-Statuscode.
Der Request
Kaspar sendet einen POST-Request mit JSON-Body (Content-Type: application/json) und diesen Headern:
| Header | Inhalt |
|---|---|
Kaspar-Signature | t=<Unix-Zeitstempel>,v1=<Signatur>, siehe Prüfung |
Kaspar-Event | Das Event, z. B. monitor.down (wie event im Body) |
Kaspar-Delivery | Eine UUID pro Zustellung. Bei Wiederholungen bleibt sie gleich, damit kannst du Duplikate erkennen. |
User-Agent | Kaspar-Webhooks/1.0 |
Events
| Event | Wann |
|---|---|
monitor.down | Ein Uptime-Monitor ist down (auch bei fehlgeschlagener Keyword-Prüfung). |
monitor.still_down | Erinnerung, solange ein Uptime-Monitor down bleibt. |
monitor.up | Ein Uptime-Monitor ist wieder erreichbar. |
monitors.down | Mehrere Monitore sind gleichzeitig ausgefallen; kommt statt einzelner Alarme. monitor ist null, data.monitors enthält die Namen. |
ssl.expiring | Das SSL-Zertifikat läuft bald ab oder ist abgelaufen. |
cron.missed | Ein Cron-Job hat sich nicht innerhalb der erwarteten Zeit und Karenzzeit gemeldet. |
cron.failed | Ein Cron-Job hat einen Fehler gemeldet. |
cron.recovered | Ein Cron-Job ist wieder OK. |
links.broken | Ein Link-Scan hat defekte Links gefunden, die noch nicht gemeldet wurden. |
links.fixed | Alle zuvor defekten Links sind behoben. |
webhook.test | Kommt von Test-Webhook senden. |
Payload
Jeder Body hat dieselbe Struktur. version ist die Schema-Version (aktuell 1). Innerhalb einer Version kommen nur Felder hinzu, keine werden umbenannt oder entfernt. Ignoriere also Felder, die du nicht kennst.
{
"version": 1,
"id": "0b5c1e0e-6f1e-4c39-9d43-2f2a9e1c7a51",
"event": "monitor.down",
"created_at": "2026-10-14T16:35:12Z",
"workspace": { "id": "Wk7pQ2rX9aLm", "name": "Acme" },
"monitor": {
"id": "Mn3xV8sK1qTe",
"name": "Shop",
"type": "uptime",
"url": "https://shop.example.com"
},
"data": {
"incident": {
"type": "down",
"cause": "Unexpected status code 503",
"started_at": "2026-10-14T16:34:40Z",
"resolved_at": null,
"duration_seconds": null
}
},
"alert": {
"level": "down",
"title": "Shop is down",
"lines": ["…"]
},
"dashboard_url": "https://kaspar.app/acme/monitors/Mn3xV8sK1qTe"
}
| Feld | Beschreibung |
|---|---|
id | UUID der Zustellung, identisch mit dem Header Kaspar-Delivery. |
event | Name des Events, siehe oben. |
created_at | Zeitpunkt des Alarms, ISO 8601 in UTC. |
workspace | ID und Name des Workspaces. |
monitor | ID, Name, Typ (uptime, cron oder links) und geprüfte URL des Monitors (url ist bei Cron-Jobs null). null bei monitors.down und webhook.test. |
data | Details zum Event: incident (Typ, Ursache, Beginn, Ende, Dauer in Sekunden) bei Monitor- und Cron-Events, last_ping_at bei cron.missed, days_left und expires_at bei ssl.expiring, broken_count, new_count und bis zu 10 links bei links.broken, fixed_count bei links.fixed. |
alert | Der Alarm, wie ihn Menschen per E-Mail, Slack oder Telegram sehen: Level (down, up, warning, info), Titel und Zeilen, in der Sprache des Workspaces. Praktisch zum Weiterleiten; für deine Logik nimm event und data. |
dashboard_url | Link zum Monitor (oder zum Dashboard) in Kaspar. |
Signatur prüfen
Der Header Kaspar-Signature belegt, dass ein Request von Kaspar kommt und nicht verändert wurde. Er enthält einen Unix-Zeitstempel t und die Signatur v1: den hex-kodierten HMAC-SHA256 von "{t}.{body}", mit dem kompletten Signing-Secret (inklusive whsec_) als Schlüssel.
- Lies den rohen Request-Body, bevor du JSON parst.
- Nimm
tundv1aus dem Header. - Berechne HMAC-SHA256 aus
t, einem Punkt und dem rohen Body mit deinem Secret. - Vergleiche das Ergebnis mit
v1, und zwar in konstanter Zeit. - Lehne den Request ab, wenn
tmehr als fünf Minuten von deiner Uhrzeit abweicht. Das schützt vor Replays.
Jede Wiederholung wird mit neuem Zeitstempel neu signiert.
PHP
<?php
// Verify a Kaspar webhook (plain PHP; works the same in Laravel or Symfony).
$secret = getenv('KASPAR_WEBHOOK_SECRET'); // whsec_…
$body = file_get_contents('php://input'); // the raw body, before any JSON decoding
$header = $_SERVER['HTTP_KASPAR_SIGNATURE'] ?? '';
function kaspar_verify(string $body, string $header, string $secret, int $tolerance = 300): bool
{
$timestamp = null;
$signatures = [];
foreach (explode(',', $header) as $part) {
[$key, $value] = array_pad(explode('=', trim($part), 2), 2, '');
if ($key === 't' && ctype_digit($value)) {
$timestamp = (int) $value;
} elseif ($key === 'v1' && $value !== '') {
$signatures[] = $value;
}
}
if ($timestamp === null || abs(time() - $timestamp) > $tolerance) {
return false;
}
$expected = hash_hmac('sha256', $timestamp.'.'.$body, $secret);
foreach ($signatures as $signature) {
if (hash_equals($expected, $signature)) {
return true;
}
}
return false;
}
if (! kaspar_verify($body, $header, $secret)) {
http_response_code(400);
exit;
}
$event = json_decode($body, true);
// e.g. if ($event['event'] === 'monitor.down') { … }
http_response_code(204);
Node.js
// Verify a Kaspar webhook (Node.js 18+, Express).
import crypto from 'node:crypto';
import express from 'express';
const secret = process.env.KASPAR_WEBHOOK_SECRET; // whsec_…
function verifyKaspar(rawBody, header, secret, toleranceSeconds = 300) {
let timestamp = null;
const signatures = [];
for (const part of (header ?? '').split(',')) {
const [key, value = ''] = part.trim().split('=');
if (key === 't' && /^\d+$/.test(value)) timestamp = Number(value);
if (key === 'v1' && value) signatures.push(value);
}
if (timestamp === null || Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) {
return false;
}
const expected = crypto.createHmac('sha256', secret)
.update(`${timestamp}.`)
.update(rawBody) // Buffer with the raw request body
.digest('hex');
return signatures.some((signature) =>
signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)));
}
const app = express();
// express.raw keeps the body as a Buffer; parsing JSON first would change the bytes.
app.post('/kaspar-webhook', express.raw({ type: 'application/json' }), (req, res) => {
if (!verifyKaspar(req.body, req.get('Kaspar-Signature'), secret)) {
return res.sendStatus(400);
}
const event = JSON.parse(req.body.toString('utf8'));
// e.g. if (event.event === 'monitor.down') { … }
res.sendStatus(204);
});
app.listen(3000);
Zustellung und Wiederholungen
- Antworte innerhalb von 10 Sekunden mit einem
2xx-Statuscode. Kaspar liest den Response-Body nicht. - Jeder andere Statuscode, ein Timeout oder ein Verbindungsfehler gilt als fehlgeschlagen. Kaspar versucht es insgesamt bis zu dreimal: sofort, nach etwa 30 Sekunden und nach etwa zwei weiteren Minuten.
- Weiterleitungen werden nicht verfolgt; eine
3xx-Antwort gilt als fehlgeschlagen. - Zustellungen laufen über eine Warteschlange. Erledige langsame Arbeit also erst, nachdem du geantwortet hast.