Skip to content

Notification webhook contract

Changing a field, header or the signature scheme here is a contract change (see CONTRIBUTING.md). How to set notifications up is in notifications.md.

A webhook channel receives an HTTPS POST with a JSON body for every event its rules match and for the weekly digest. Slack and Microsoft Teams channels receive a chat message instead (a Block Kit message, an Adaptive Card); only the webhook channel's body is a contract.

Versioning

Every body has "version": 1. Within a version Kerf only adds fields, so a receiver must ignore fields it does not know. A breaking change gets version: 2, and the hub then sends version 1 to existing channels until they are changed (there is no change planned).

Request

POST <your URL>
Content-Type: application/json
User-Agent: kerf-hub/<hub version>
X-Kerf-Event: rolled_back
X-Kerf-Delivery: 6f1c2a8e-0d7b-4c55-a0b1-8f9e2f0d1c3a
X-Kerf-Timestamp: 1793955438
X-Kerf-Signature: sha256=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
Header Meaning
X-Kerf-Event The event type (applied, rolled_back, failed, blocked, readiness_degraded, automation_paused, automation_resumed), or digest, or test.
X-Kerf-Delivery The message id (id in the body). The same on every retry of a message: use it to drop duplicates. For an event it is the id of the event in the audit log (GET /v1/audit).
X-Kerf-Timestamp When this attempt was sent, in Unix seconds. It is also in the body as timestamp, so the signature covers it. A retry has a new timestamp.
X-Kerf-Signature sha256= and the lower-case hex HMAC-SHA256 of the raw request body under the channel's signing secret. Only present when the channel has a secret.

Answer with any 2xx status within 15 seconds. Anything else is a failed attempt: Kerf retries 5xx, 429 (it honors Retry-After) and network errors with exponential backoff (30 s, 1 min, 2 min, … at most 15 min apart) for one hour, then marks the delivery failed (Admin → Notifications shows it; the metric kerf_notification_deliveries_total{result="failed"} counts it). Other 4xx answers are not retried: a revoked URL does not come back. Redirects are not followed. Messages to one channel are rate limited (default 20 per minute); the surplus waits.

Body

{
  "version": 1,
  "timestamp": 1793955438,
  "id": "6f1c2a8e-0d7b-4c55-a0b1-8f9e2f0d1c3a",
  "kind": "event",
  "time": "2026-11-04T10:15:00Z",
  "event": {
    "type": "rolled_back",
    "reason": "OOMKills",
    "cluster": "prod-eu",
    "namespace": "payments",
    "workload": {"kind": "Deployment", "name": "checkout"},
    "strategy": "in-place",
    "resources": ["memory"],
    "changes": [
      {"container": "app", "resource": "memory", "field": "requests", "from": "384Mi", "to": "1Gi"}
    ],
    "message": "2 OOM kills within 30m after change (threshold 1)",
    "details": {"oomKills": "2", "watchDuration": "30m"},
    "link": "https://kerf.example.com/workloads/prod-eu/payments/Deployment/checkout"
  }
}
Field Notes
version 1.
timestamp Unix seconds of this attempt; equals X-Kerf-Timestamp.
id The message id; equals X-Kerf-Delivery.
kind event, digest or test. Exactly one of event, digest, test is present, matching kind.
time When it happened (events), the end of the digest's week, or when the test was made. RFC 3339, UTC.

event

Field Notes
type applied, rolled_back, failed, blocked (the audit event types of events.md), readiness_degraded (a cluster's readiness state went down), automation_paused, automation_resumed.
reason The CamelCase reason of the audit event (OOMKills, FreezeWindow, …); absent for readiness_degraded and the automation types.
cluster The cluster; absent for fleet-wide automation events.
namespace, workload workload is {kind, name}. Absent for cluster and fleet events.
strategy, resources, changes For workload events: the strategy, cpu and/or memory, and each changed value (field is requests or limits; from/to are Kubernetes quantities, one of them is absent when the value was added or removed).
message The event's free text, with credentials removed and shortened to 500 characters. For automation events the reason the operator gave.
details Only the keys oomKills, restarts, throttlingRatio, watchDuration, resizeStatus, previousMode, newMode, pauseScope, approvedBy.
actor Who paused or resumed automation (a user name, or system for an automatic resume).
scope cluster or fleet for the automation types.
fromState, toState The readiness states of readiness_degraded.
link The workload page (or the cluster page, or the overview) in the web UI. Absent unless the hub has notifications.baseURL.

digest

{
  "version": 1, "timestamp": 1793955438, "id": "…", "kind": "digest", "time": "2026-11-09T08:00:00Z",
  "digest": {
    "periodStart": "2026-11-02T08:00:00Z",
    "periodEnd": "2026-11-09T08:00:00Z",
    "estimatedSavings": {"currentUSD": 1234, "previousUSD": 1000, "changeUSD": 234},
    "realizedSavings": {"periodUSD": 310, "previousPeriodUSD": 280, "since": "2026-10-03T14:00:00Z"},
    "topOpportunities": [
      {"cluster": "prod-eu", "namespace": "payments", "kind": "Deployment", "name": "checkout", "monthlySavingsUSD": 410, "link": "https://…"}
    ],
    "changes": {"applied": 12, "rolledBack": 1, "failed": 0},
    "attention": [{"cluster": "dev-1", "reasons": ["automation is paused"], "link": "https://…"}],
    "teams": [
      {"team": "payments", "displayName": "Payments", "workloads": 14, "monthlySavingsUSD": 620, "link": "https://…"},
      {"team": "unassigned", "displayName": "Unassigned", "workloads": 3, "monthlySavingsUSD": 40}
    ],
    "link": "https://kerf.example.com/"
  }
}

estimatedSavings are USD per month for the whole fleet now and a week ago (previousUSD and changeUSD are absent without history). realizedSavings is what the applied changes actually saved during the week (see realized-savings.md); since is when the first of them started to save, and the chat messages label the figure "Realized since \<date>". It is a different quantity than estimatedSavings and is never added to it. previousPeriodUSD is absent when the savings began inside the week. The whole field is absent until some change has released requests. topOpportunities has at most five workloads. attention lists the clusters whose agent is silent, whose readiness is below Automation ready, or whose automation is paused; it is empty when all is well. teams is the estimated monthly savings per team, largest first, with unassigned for the workloads in no team; the field is absent while no team is defined. Each link opens Opportunities filtered to the team.

test

{"test": {"channel": "<channel name>"}}, sent by Send test.

Verifying the signature

Compute the HMAC-SHA256 of the body exactly as received (do not parse and re-encode it), compare in constant time, and reject a request whose timestamp is more than five minutes old.

Go:

package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "encoding/json"
    "io"
    "net/http"
    "os"
    "strconv"
    "time"
)

var secret = []byte(os.Getenv("KERF_WEBHOOK_SECRET"))

func handler(w http.ResponseWriter, r *http.Request) {
    body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
    if err != nil {
        http.Error(w, "bad body", http.StatusBadRequest)
        return
    }
    mac := hmac.New(sha256.New, secret)
    mac.Write(body)
    want := "sha256=" + hex.EncodeToString(mac.Sum(nil))
    if !hmac.Equal([]byte(want), []byte(r.Header.Get("X-Kerf-Signature"))) {
        http.Error(w, "bad signature", http.StatusUnauthorized)
        return
    }
    var m struct {
        Version   int   `json:"version"`
        Timestamp int64 `json:"timestamp"`
    }
    if json.Unmarshal(body, &m) != nil || m.Timestamp != mustInt(r.Header.Get("X-Kerf-Timestamp")) ||
        time.Since(time.Unix(m.Timestamp, 0)).Abs() > 5*time.Minute {
        http.Error(w, "stale or inconsistent timestamp", http.StatusUnauthorized)
        return
    }
    // Handle the message; X-Kerf-Delivery is the id to de-duplicate retries on.
    w.WriteHeader(http.StatusNoContent)
}

func mustInt(s string) int64 { n, _ := strconv.ParseInt(s, 10, 64); return n }

func main() { http.HandleFunc("/kerf", handler); _ = http.ListenAndServe(":8080", nil) }

Python (standard library only):

import hmac, hashlib, json, os, time
from http.server import BaseHTTPRequestHandler, HTTPServer

SECRET = os.environ["KERF_WEBHOOK_SECRET"].encode()


class Handler(BaseHTTPRequestHandler):
    def do_POST(self):
        body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
        want = "sha256=" + hmac.new(SECRET, body, hashlib.sha256).hexdigest()
        if not hmac.compare_digest(want, self.headers.get("X-Kerf-Signature", "")):
            return self.send_error(401, "bad signature")
        msg = json.loads(body)
        if str(msg["timestamp"]) != self.headers.get("X-Kerf-Timestamp") or abs(time.time() - msg["timestamp"]) > 300:
            return self.send_error(401, "stale or inconsistent timestamp")
        # Handle the message; X-Kerf-Delivery is the id to de-duplicate retries on.
        self.send_response(204)
        self.end_headers()


HTTPServer(("", 8080), Handler).serve_forever()

With curl, to try a receiver by hand:

body='{"version":1,"timestamp":'"$(date +%s)"',"id":"test","kind":"test","time":"2026-11-04T10:15:00Z","test":{"channel":"try"}}'
sig="sha256=$(printf '%s' "$body" | openssl dgst -sha256 -hmac "$KERF_WEBHOOK_SECRET" -hex | sed 's/^.* //')"
curl -i -X POST http://localhost:8080/kerf -H 'Content-Type: application/json' -H "X-Kerf-Signature: $sig" \
  -H "X-Kerf-Timestamp: $(echo "$body" | sed 's/.*"timestamp":\([0-9]*\).*/\1/')" --data "$body"

What is never sent

URLs, signing secrets, tokens and passwords never appear in a message, in the delivery history or in the API: the channel URL and secret are encrypted at rest and shown masked. Free text of events passes through a filter that removes webhook URLs, URL credentials, bearer and basic tokens, token=/password= values, JWTs, cloud access keys and long opaque strings; of an event's details only the keys above are kept.