Skip to content
Kaspar

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.test event. 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:

HeaderContent
Kaspar-Signaturet=<unix timestamp>,v1=<signature>, see verification
Kaspar-EventThe event, e.g. monitor.down (same as event in the body)
Kaspar-DeliveryA UUID per delivery. It stays the same when a delivery is retried, so you can use it to ignore duplicates.
User-AgentKaspar-Webhooks/1.0

Events

EventWhen
monitor.downAn uptime monitor is down (including failed keyword checks).
monitor.still_downReminder while an uptime monitor stays down.
monitor.upAn uptime monitor is back up.
monitors.downSeveral monitors failed at the same time; sent instead of individual alerts. monitor is null, data.monitors lists the names.
ssl.expiringThe SSL certificate expires soon or has expired.
cron.missedA cron job did not ping within the expected time and grace period.
cron.failedA cron job reported a failure.
cron.recoveredA cron job is OK again.
links.brokenA link scan found broken links that were not reported before.
links.fixedAll previously broken links are fixed.
webhook.testSent 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"
}
FieldDescription
idDelivery UUID, identical to the Kaspar-Delivery header.
eventEvent name, see above.
created_atWhen the alert was created, ISO 8601 in UTC.
workspaceID and name of the workspace.
monitorID, 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.
dataEvent-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.
alertThe 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_urlLink 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.

  1. Read the raw request body, before any JSON parsing.
  2. Take t and v1 from the header.
  3. Compute HMAC-SHA256 of t, a dot and the raw body with your secret.
  4. Compare it to v1 with a constant-time comparison.
  5. Reject the request if t is 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 2xx status 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 3xx answer counts as failed.
  • Deliveries are queued, so do slow work after you have answered.