Skip to the content
WellKept

// Using WellKept

Alerts and webhooks

Email, Slack and signed webhooks: who hears what, when, and how to check a signature.

Channels#

An alert channel is somewhere WellKept sends messages. There are three types, all on every plan:

  • Email goes to organisation members you tick. The addresses are read from the membership when a message is sent, so removing a member stops their alerts. Email never goes to an address outside the organisation. For a client or a pager, use Slack or a webhook.
  • Slack posts to an incoming webhook. The address must be a https://hooks.slack.com/services/… one.
  • Webhook sends a signed JSON request to your own address. The format is below.

Add them under Settings, then Alerts. WellKept sends a test message when you save a channel, or change where it goes, and only starts sending real alerts once the test gets through. If the test fails, the channel stays unverified and the toast shows the sender's own words.

Each channel has a rule: which events it hears (findings opening or clearing, incidents, an agent going offline), the lowest severity it wants and, optionally, quiet hours.

What gets announced#

What happened Alert
A finding opens The finding's own severity.
A finding becomes more serious Announced again, as "Now high: …".
A finding becomes less serious Nothing.
A finding clears by itself Only if it was high severity.
A person resolves a finding Nothing.
An incident opens or resolves Always high.
An agent has been silent for three heartbeats A high finding.

When it is sent#

  • High severity and every incident go out at the channel's next delivery, whatever the hour.
  • Medium goes out at once, unless the rule has quiet hours on and it falls inside them. Then it is held.
  • Low is never sent at once. It is held for the digest.
  • A burst is one message. When a new server first reports, a dozen findings can open together. Alerts for a channel wait about 20 seconds, then go as one email or Slack message listing up to 15, followed by "and N more". A webhook gets one request per alert, so your code parses one shape.
  • The daily digest goes out once it is past 08:00 in your organisation's time zone, as one email or Slack message, and never empty. A webhook channel has no summary, so its held alerts go out as individual events at that point. Something that cleared while it was held is listed as resolved.
  • Repeats are ignored. The same event for the same channel and subject within 15 minutes is dropped, but a rise in severity is always announced.
  • Failures are retried. A failed delivery is tried three times, a minute and then five minutes apart, and then shown as failed on the Alerts page. A channel whose last three deliveries failed is flagged there. Nothing switches a channel off.

Set the organisation's time zone under Settings, then Organisation. The digest and the default quiet hours follow it.

Webhooks#

A webhook channel sends one HTTPS POST to your address for every alert, signed so you can check it came from WellKept.

POST /your/path HTTP/1.1
Content-Type: application/json
User-Agent: WellKept-Monitor/1.0 (+https://wellkept.io)
X-WellKept-Event: incident_opened
X-WellKept-Delivery: 5f0c7a8e-3c1d-4b8a-9c4e-0d6f2a1b7e33
X-WellKept-Timestamp: 1782312000
X-WellKept-Signature: sha256=3f6c…
{
    "id": "5f0c7a8e-3c1d-4b8a-9c4e-0d6f2a1b7e33",
    "event": "incident_opened",
    "severity": "high",
    "occurred_at": "2026-10-24T19:56:04+01:00",
    "organisation": { "id": 1, "name": "FullWerks" },
    "title": "Shop is down",
    "summary": "HTTP 503 Service Unavailable",
    "url": "https://app.wellkept.io/monitors/3",
    "server": null,
    "monitor": "Shop",
    "site": null,
    "escalated_from": null,
    "test": false
}
Field Meaning
id The delivery id, also in X-WellKept-Delivery. A retry of the same alert carries the same id, so use it to ignore a repeat.
event finding_opened, finding_resolved, incident_opened, incident_resolved or agent_offline. A finding that becomes more serious is announced again with escalated_from set.
severity low, medium or high. Incidents are always high.
title, summary The words a person would read. The title begins "Resolved:" for a resolution and "Now high:" for an escalation.
url The finding or monitor in WellKept.
server, monitor, site Where it happened. Any of them can be null.
test true for the message sent when a channel is saved or tested. Answer it with a 2xx and ignore it.

Answering#

Reply with any 2xx status within 10 seconds. Anything else, a timeout or a connection error counts as a failure and is retried twice more. Redirects are not followed.

Checking the signature#

X-WellKept-Signature is sha256= followed by the hex HMAC-SHA256 of the string "<timestamp>.<raw body>", using the channel's signing secret, which is shown on its edit form. Compute it over the raw bytes you received, before any JSON parsing. Compare in constant time. Reject a timestamp older than five minutes, so a captured request cannot be replayed.

PHP:

$body = file_get_contents('php://input');
$timestamp = (int) ($_SERVER['HTTP_X_WELLKEPT_TIMESTAMP'] ?? 0);
$expected = 'sha256='.hash_hmac('sha256', $timestamp.'.'.$body, $secret);

if (abs(time() - $timestamp) > 300 || ! hash_equals($expected, $_SERVER['HTTP_X_WELLKEPT_SIGNATURE'] ?? '')) {
    http_response_code(401);
    exit;
}

Node:

import { createHmac, timingSafeEqual } from 'node:crypto';

function verify(rawBody, headers, secret) {
    const timestamp = Number(headers['x-wellkept-timestamp']);
    const expected =
        'sha256=' +
        createHmac('sha256', secret)
            .update(`${timestamp}.${rawBody}`)
            .digest('hex');
    const given = headers['x-wellkept-signature'] ?? '';

    return (
        Math.abs(Date.now() / 1000 - timestamp) <= 300 &&
        given.length === expected.length &&
        timingSafeEqual(Buffer.from(given), Buffer.from(expected))
    );
}

Shell, to check a captured request:

printf '%s' "$TIMESTAMP.$BODY" | openssl dgst -sha256 -hmac "$SECRET"

What the address may be#

https only, and a public address. A name that resolves to a private, loopback or link-local address, or is localhost or ends in .local, .internal or .test, is refused when the channel is saved and again on every send. If your service sits on a private network, put a public endpoint in front of it.