Docs
Webhooks
Kaspar can send every alert as a signed JSON request to your own endpoint, for uptime monitors, cron heartbeats and broken-link scans.
Set up a webhook
Webhooks are part of the Pro plan. In Kaspar, open Alerts, enter a name and your endpoint URL under Webhook and add it. Like every alert channel, a webhook can be a default channel for the workspace or be selected per monitor.
- The URL must use
https://. Addresses that resolve to private, loopback, link-local or other internal networks are rejected, both when you add the webhook and again before every request. - Each webhook gets its own signing secret that starts with
whsec_. You can show and copy it in Kaspar and create a new one at any time. A new secret replaces the old one immediately. - Use Send test webhook to send a
webhook.testevent. Kaspar shows the status of the last delivery and the HTTP status code next to the webhook.
The request
Kaspar sends a POST request with a JSON body (Content-Type: application/json) and these headers:
| Header | Content |
|---|---|
Kaspar-Signature | t=<unix timestamp>,v1=<signature>, see verification |
Kaspar-Event | The event, e.g. monitor.down (same as event in the body) |
Kaspar-Delivery | A UUID per delivery. It stays the same when a delivery is retried, so you can use it to ignore duplicates. |
User-Agent | Kaspar-Webhooks/1.0 |
Events
| Event | When |
|---|---|
monitor.down | An uptime monitor is down (including failed keyword checks). |
monitor.still_down | Reminder while an uptime monitor stays down. |
monitor.up | An uptime monitor is back up. |
monitors.down | Several monitors failed at the same time; sent instead of individual alerts. monitor is null, data.monitors lists the names. |
ssl.expiring | The SSL certificate expires soon or has expired. |
cron.missed | A cron job did not ping within the expected time and grace period. |
cron.failed | A cron job reported a failure. |
cron.recovered | A cron job is OK again. |
links.broken | A link scan found broken links that were not reported before. |
links.fixed | All previously broken links are fixed. |
webhook.test | Sent by Send test webhook. |
Payload
Every body has the same structure. version is the schema version (currently 1). Within a version, fields are only added, never renamed or removed, so ignore fields you don't know.
{
"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"
}
| Field | Description |
|---|---|
id | Delivery UUID, identical to the Kaspar-Delivery header. |
event | Event name, see above. |
created_at | When the alert was created, ISO 8601 in UTC. |
workspace | ID and name of the workspace. |
monitor | ID, name, type (uptime, cron or links) and checked URL of the monitor (url is null for cron jobs). null for monitors.down and webhook.test. |
data | Event-specific details: incident (type, cause, start, end, duration in seconds) for monitor and cron events, last_ping_at for cron.missed, days_left and expires_at for ssl.expiring, broken_count, new_count and up to 10 links for links.broken, fixed_count for links.fixed. |
alert | The alert as people see it in email, Slack or Telegram: level (down, up, warning, info), title and lines, in the workspace's language. Good for forwarding, but use event and data for your logic. |
dashboard_url | Link to the monitor (or the dashboard) in Kaspar. |
Verify the signature
The Kaspar-Signature header proves that a request comes from Kaspar and was not changed. It contains a Unix timestamp t and the signature v1: the hex-encoded HMAC-SHA256 of "{t}.{body}", with the complete signing secret (including whsec_) as key.
- Read the raw request body, before any JSON parsing.
- Take
tandv1from the header. - Compute HMAC-SHA256 of
t, a dot and the raw body with your secret. - Compare it to
v1with a constant-time comparison. - Reject the request if
tis more than five minutes away from your clock, to protect against replays.
Every retry is signed again with a new timestamp.
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);
Delivery and retries
- Answer with a
2xxstatus code within 10 seconds. Kaspar does not read the response body. - Any other status code, a timeout or a connection error counts as failed. Kaspar tries up to three times in total: right away, after about 30 seconds and after about two more minutes.
- Redirects are not followed; a
3xxanswer counts as failed. - Deliveries are queued, so do slow work after you have answered.