Deplyra Kesvara

Verify a Kesvara webhook signature

Every message Kesvara posts to your notification destination carries an HMAC-SHA256 of its own body in X-Kesvara-Signature and the time it was signed in X-Kesvara-Timestamp. Below are 4 complete receivers — Flask, Express, Go and PHP — that check both and refuse anything that does not add up.

A signature nobody checks is worth nothing. Slack, Discord and Mattermost ignore both headers entirely; for those destinations the secrecy of the URL is the only protection, and that is the model they are built on. This page is for an endpoint you wrote yourself. And checking is only half of it: a receiver that computes the signature and then handles the message anyway has performed a calculation, not a security check. It has to reject. Kesvara cannot do that part for you and cannot tell whether you have.

What these 4 programs have actually been through

They are not illustrations. Kesvara's test suite starts every one of them as a real server, sends them messages signed by the same code that signs yours, and then attacks them: a body with one field edited, a timestamp moved an hour, a message signed with the wrong key, a signature lifted from a different message, a missing header, a future version label, a re-spelt timestamp, and a genuinely signed message replayed two hours later. Each one has to be refused, in all 4 languages.

Four deliberately broken receivers are run through the same battery, because a test that only ever asserts acceptance would pass against a receiver you should not deploy. One hashes the re-serialised JSON instead of the raw body and must reject a genuine message. One skips the clock check and must accept the replay. One runs both checks in the wrong order and must blame the clock for a forgery. One signs the timestamp as a number rather than as the characters that arrived, and must accept a re-spelt copy of a message the real ones refuse. Those four are the reason the rules on this page are worth stating: each exists because a rule below had nothing proving it was load-bearing.

A language appears on this page only after it has been through all of that. That is a rule rather than a preference, and it is enforced by a test: the list this page is built from is the same list the suite reads, so an example added here without a program starting and attacking it turns the build red instead of publishing a receiver nobody has ever run.

The key

Each workspace has its own signing key. It appears on that workspace's settings page once a notification destination is set, and every example below reads it from the environment:

export KESVARA_SIGNING_KEY=...   # from your settings page

Treat it like a password. Anyone holding it can sign a message your receiver will believe. Replacing it from the settings page takes effect on the next message with no overlap period, so a receiver still holding the old key will reject everything until you update it — which is the receiver behaving correctly.

The rule that catches most people

The signature covers the exact bytes that arrived. A parsed object re-serialised is a different byte string — Python writes {"a": 1} where Node writes {"a":1}, and Python escapes a non-ASCII character that Kesvara sent as UTF-8. Verify against that and every message fails, including the real ones. The bad outcome is not the failure; it is that a check which rejects genuine traffic gets deleted rather than fixed.

Each language has its own spelling of the mistake, and the examples below carry the fix in a comment where you would make it: request.get_data() in Flask, express.raw rather than express.json, io.ReadAll before json.Unmarshal in Go — and in PHP, php://input, because $_POST is populated only for form content types and is simply empty for the JSON Kesvara sends.

All four fail the same way — every message rejected, including the genuine ones — but the PHP one misleads you about the cause. An empty $_POST reads as "Kesvara sent no body", so the hunt starts at the sending end, on a delivery log showing a body was sent. Nothing about it points at the line that is wrong.

The second rule is the clock. A captured message replayed tomorrow is byte-identical and its signature is genuine, so the signature cannot refuse it. The timestamp is inside the signed bytes precisely so that it cannot be edited to now, which is what makes rejecting an old one a real defence. Choose your own tolerance — the examples use five minutes. Kesvara cannot enforce a window at your end.

Python, with Flask

pip install flask · download flask_receiver.py

#!/usr/bin/env python3
"""A complete Kesvara webhook receiver, in Flask.

Copy this file, set KESVARA_SIGNING_KEY, run it, point your workspace's
notification destination at http://your-host/kesvara.

    export KESVARA_SIGNING_KEY=...   # from your Kesvara settings page
    pip install flask
    python flask_receiver.py

This file is not an illustration. Kesvara's own test suite starts this exact
program, sends it messages signed by the same code that signs yours, and then
sends it a battery of forgeries and replays and asserts it refuses every one.
If you change it, the part most worth re-testing is `verify` -- everything
below it is ordinary web plumbing.

The two mistakes this file exists to stop you making:

  1. Verifying the signature and then handling the message anyway. A receiver
     that computes an HMAC and does not REJECT on mismatch has performed a
     calculation, not a security check. The `return` on the refusal path is the
     entire feature.

  2. Signing a re-serialised copy of the body. `request.get_json()` gives you a
     dict; `json.dumps` of that dict is a DIFFERENT byte string from the one
     that arrived -- different spacing, and different escaping for any
     non-ASCII character, of which incident titles have plenty. The signature
     is over the bytes on the wire and nothing else, so this file reads
     `request.get_data()` and parses afterwards.
"""
from __future__ import annotations

import hashlib
import hmac
import json
import os
import time

from flask import Flask, request

# Read from the environment, never pasted into the file. This is the one value
# that lets anyone forge a message your receiver will believe.
SECRET = os.environ["KESVARA_SIGNING_KEY"]

SIG_HEADER = "X-Kesvara-Signature"
TS_HEADER = "X-Kesvara-Timestamp"
VERSION = "v1"

# How much clock skew you will tolerate, in seconds. Kesvara cannot enforce
# this for you -- it is your end of the exchange. Five minutes is a normal
# choice: long enough to survive an unsynchronised clock, short enough that a
# captured message is not replayable tomorrow.
TOLERANCE_SECONDS = 300


def verify(secret: str, ts_header: str, sig_header: str, body: bytes,
           now: int | None = None) -> str | None:
    """Return None if the message is genuine, or a reason to refuse it.

    Returning a reason rather than a bool because the reason belongs in your
    log. "Refused a webhook" at 4am with no reason is a support ticket.
    """
    if not sig_header or not ts_header:
        return "missing signature headers"

    # An explicit version check. If Kesvara ever signs a different shape of
    # string it will say v2, and a receiver written against v1 must refuse it
    # rather than compute a v1 signature over it and quietly disagree.
    if not sig_header.startswith(VERSION + "="):
        return "unknown signature version"

    if not ts_header.lstrip("-").isdigit():
        return "malformed timestamp"

    # The timestamp is signed as the exact string that was sent, not as a
    # number that has been through int() and back. Those differ for " 123" and
    # for "0123", and a signature scheme with two spellings of the same input
    # is a signature scheme with a hole in it.
    signed = VERSION.encode() + b":" + ts_header.encode() + b":" + body
    expected = VERSION + "=" + hmac.new(
        secret.encode(), signed, hashlib.sha256).hexdigest()

    # compare_digest, not ==. A byte-by-byte comparison that returns early
    # leaks the correct signature to anyone who can time it.
    if not hmac.compare_digest(expected, sig_header):
        return "signature mismatch"

    # Freshness is checked AFTER the signature, deliberately. Before it, you
    # would be making a decision about a header that nothing has authenticated
    # yet. After it, the timestamp is known to be the one Kesvara signed --
    # which is what makes rejecting an old one a real defence against a
    # replayed message rather than a formality.
    age = (int(time.time()) if now is None else now) - int(ts_header)
    if age > TOLERANCE_SECONDS:
        return "timestamp too old"
    if age < -TOLERANCE_SECONDS:
        return "timestamp too far in the future"

    return None


app = Flask(__name__)


@app.post("/kesvara")
def kesvara():
    body = request.get_data()          # RAW bytes. Not request.get_json().

    why = verify(SECRET,
                 request.headers.get(TS_HEADER, ""),
                 request.headers.get(SIG_HEADER, ""),
                 body)
    if why is not None:
        # Log the reason, never the key and never the expected signature --
        # printing what you expected hands a forger the answer.
        app.logger.warning("refused a Kesvara webhook: %s", why)
        return {"refused": why}, 401

    message = json.loads(body)

    if message.get("event") == "incident.opened":
        incident = message["incident"]
        # Kesvara retries a failed delivery, and a retry re-sends the SAME
        # bytes with the same timestamp and the same signature. If this handler
        # does something you would not want done twice -- paging someone,
        # opening a ticket -- key it on incident["id"], which is stable across
        # retries. Do not key it on the signature, which is stable too and will
        # therefore also suppress a genuine second delivery after a rotation.
        handle_incident(incident)

    return {"ok": True}, 200


def handle_incident(incident: dict) -> None:
    """Yours to write. This is what the payload contains."""
    print(f"incident {incident['id']}: {incident['title']} "
          f"({incident['alert_count']} alerts)")

    similar = incident.get("similar")
    if similar:
        # Kesvara found an older incident of yours that looks like this one.
        # `action_item` is a sentence one of your team typed at the time.
        # It is not a diagnosis and Kesvara does not claim it is one.
        print(f"  similar to {similar['date']}: {similar['action_item']}")
    else:
        print("  no similar resolved incident in this workspace's history")


if __name__ == "__main__":
    app.run(host="127.0.0.1", port=int(os.environ.get("PORT", "8080")))

Node, with Express

npm install express · download express_receiver.js

#!/usr/bin/env node
/**
 * A complete Kesvara webhook receiver, in Express.
 *
 * Copy this file, set KESVARA_SIGNING_KEY, run it, point your workspace's
 * notification destination at http://your-host/kesvara.
 *
 *     export KESVARA_SIGNING_KEY=...   # from your Kesvara settings page
 *     npm install express
 *     node express_receiver.js
 *
 * This file is not an illustration. Kesvara's own test suite starts this
 * exact program, sends it messages signed by the same code that signs yours,
 * and then sends it a battery of forgeries and replays and asserts it refuses
 * every one. If you change it, the part most worth re-testing is `verify` --
 * everything below it is ordinary plumbing.
 *
 * The mistake this file exists to stop you making is `express.json()`.
 *
 * The signature is over the bytes on the wire. `express.json()` consumes those
 * bytes and hands you a parsed object, and `JSON.stringify` of that object is
 * a DIFFERENT byte string: Node writes {"a":1} where the sender wrote
 * {"a": 1}. Every signature check against it fails, for every message, and the
 * obvious way out of a check that fails on genuine traffic is to stop
 * checking. So this route uses express.raw() and parses afterwards.
 *
 * If something else in your app has already installed express.json() globally,
 * the raw bytes are gone by the time your handler runs. Either mount this
 * route before that middleware, or capture the body as it arrives:
 *
 *     app.use(express.json({
 *       verify: (req, res, buf) => { req.rawBody = buf },
 *     }))
 */
const crypto = require("node:crypto");
const express = require("express");

// Read from the environment, never pasted into the file. This is the one value
// that lets anyone forge a message your receiver will believe.
const SECRET = process.env.KESVARA_SIGNING_KEY;
if (!SECRET) throw new Error("KESVARA_SIGNING_KEY is not set");

const SIG_HEADER = "x-kesvara-signature";
const TS_HEADER = "x-kesvara-timestamp";
const VERSION = "v1";

// How much clock skew you will tolerate, in seconds. Kesvara cannot enforce
// this for you -- it is your end of the exchange. Five minutes is a normal
// choice: long enough to survive an unsynchronised clock, short enough that a
// captured message is not replayable tomorrow.
const TOLERANCE_SECONDS = 300;

/**
 * Returns null if the message is genuine, or a reason to refuse it.
 * The reason is for your log; "refused a webhook" with no reason is a support
 * ticket at 4am.
 *
 * @param {string} secret
 * @param {string} tsHeader
 * @param {string} sigHeader
 * @param {Buffer} body   the RAW request body, not a re-serialised object
 * @param {number} [now]  unix seconds; defaults to the clock
 */
function verify(secret, tsHeader, sigHeader, body, now) {
  if (!sigHeader || !tsHeader) return "missing signature headers";

  // An explicit version check. If Kesvara ever signs a different shape of
  // string it will say v2, and a receiver written against v1 must refuse it
  // rather than compute a v1 signature over it and quietly disagree.
  if (!sigHeader.startsWith(VERSION + "=")) return "unknown signature version";

  if (!/^-?[0-9]+$/.test(tsHeader)) return "malformed timestamp";

  // The timestamp is signed as the exact string that was sent, not as a number
  // that has been through Number() and back. Those differ for "0123", and a
  // signature scheme with two spellings of the same input has a hole in it.
  const signed = Buffer.concat([
    Buffer.from(`${VERSION}:${tsHeader}:`, "utf8"),
    body,
  ]);
  const expected =
    VERSION + "=" +
    crypto.createHmac("sha256", secret).update(signed).digest("hex");

  // timingSafeEqual, not ===. A comparison that returns early leaks the
  // correct signature to anyone who can measure it. It throws on a length
  // mismatch, so the lengths are compared first -- that comparison reveals
  // only the length, which is fixed and public anyway.
  const a = Buffer.from(expected, "utf8");
  const b = Buffer.from(sigHeader, "utf8");
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return "signature mismatch";
  }

  // Freshness is checked AFTER the signature, deliberately. Before it, you
  // would be making a decision about a header nothing has authenticated yet.
  // After it, the timestamp is known to be the one Kesvara signed -- which is
  // what makes rejecting an old one a real defence against a replayed message
  // rather than a formality.
  const clock = now === undefined ? Math.floor(Date.now() / 1000) : now;
  const age = clock - Number(tsHeader);
  if (age > TOLERANCE_SECONDS) return "timestamp too old";
  if (age < -TOLERANCE_SECONDS) return "timestamp too far in the future";

  return null;
}

const app = express();

app.post("/kesvara", express.raw({ type: "*/*" }), (req, res) => {
  const body = req.body; // a Buffer of the RAW bytes, because of express.raw

  const why = verify(
    SECRET,
    req.get(TS_HEADER) || "",
    req.get(SIG_HEADER) || "",
    body,
  );
  if (why !== null) {
    // Log the reason, never the key and never the expected signature --
    // printing what you expected hands a forger the answer.
    console.warn(`refused a Kesvara webhook: ${why}`);
    return res.status(401).json({ refused: why });
  }

  const message = JSON.parse(body.toString("utf8"));

  if (message.event === "incident.opened") {
    // Kesvara retries a failed delivery, and a retry re-sends the SAME bytes
    // with the same timestamp and the same signature. If this handler does
    // something you would not want done twice -- paging someone, opening a
    // ticket -- key it on incident.id, which is stable across retries. Do not
    // key it on the signature, which is stable too and will therefore also
    // suppress a genuine second delivery after a rotation.
    handleIncident(message.incident);
  }

  return res.status(200).json({ ok: true });
});

/** Yours to write. This is what the payload contains. */
function handleIncident(incident) {
  console.log(
    `incident ${incident.id}: ${incident.title} ` +
      `(${incident.alert_count} alerts)`,
  );

  if (incident.similar) {
    // Kesvara found an older incident of yours that looks like this one.
    // `action_item` is a sentence one of your team typed at the time. It is
    // not a diagnosis and Kesvara does not claim it is one.
    console.log(
      `  similar to ${incident.similar.date}: ${incident.similar.action_item}`,
    );
  } else {
    console.log("  no similar resolved incident in this workspace's history");
  }
}

// Exported so a test can call `verify` directly. The server only starts when
// this file is run, not when it is required.
module.exports = { verify, app };

if (require.main === module) {
  const port = Number(process.env.PORT || 8080);
  app.listen(port, "127.0.0.1", () => {
    console.log(`listening on 127.0.0.1:${port}`);
  });
}

Go, standard library only

no dependencies · download go_receiver.go

// A complete Kesvara webhook receiver, in Go.
//
// Copy this file, set KESVARA_SIGNING_KEY, run it, point your workspace's
// notification destination at http://your-host/kesvara.
//
//	export KESVARA_SIGNING_KEY=...   # from your Kesvara settings page
//	go run go_receiver.go             # no dependencies; standard library only
//
// This file is not an illustration. Kesvara's own test suite starts this exact
// program, sends it messages signed by the same code that signs yours, and then
// sends it a battery of forgeries and replays and asserts it refuses every one.
// If you change it, the part most worth re-testing is `verify` -- everything
// below it is ordinary plumbing.
//
// The two mistakes this file exists to stop you making:
//
//  1. json.NewDecoder(r.Body).Decode(&msg). It reads the body to parse it, and
//     the raw bytes are gone afterwards -- so the natural next step is to
//     re-encode the struct and sign that, which produces a DIFFERENT byte
//     string. Go's encoder escapes `<`, `>` and `&` as < and friends by
//     default and orders struct fields by declaration, so the MAC disagrees on
//     ordinary traffic. The obvious way out of a check that fails on genuine
//     messages is to stop checking. Read the bytes first, verify, parse after.
//
//  2. Reading r.Body without a limit. This handler runs BEFORE anything has
//     been authenticated -- that is unavoidable, since the signature covers the
//     body you have not read yet -- so io.ReadAll on a raw r.Body will read as
//     much as a stranger cares to send you, and the first thing anyone finds
//     when they discover this endpoint is that it accepts a 10GB POST. Go gives
//     you no default limit here; Express does (100kb) and Flask does not.
//     http.MaxBytesReader is the fix and it is one line.
package main

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

const (
	sigHeader = "X-Kesvara-Signature"
	tsHeader  = "X-Kesvara-Timestamp"
	version   = "v1"

	// How much clock skew you will tolerate, in seconds. Kesvara cannot
	// enforce this for you -- it is your end of the exchange. Five minutes is a
	// normal choice: long enough to survive an unsynchronised clock, short
	// enough that a captured message is not replayable tomorrow.
	toleranceSeconds = 300

	// See mistake 2 above. Kesvara's messages are a few kilobytes; this is
	// generous by two orders of magnitude and still refuses a flood.
	maxBodyBytes = 1 << 20
)

// verify returns "" if the message is genuine, or a reason to refuse it.
//
// A reason rather than a bool because the reason belongs in your log. "Refused
// a webhook" at 4am with no reason is a support ticket.
//
// `now` is unix seconds, taken as a parameter so a test can move the clock
// without waiting for it.
func verify(secret, ts, sig string, body []byte, now int64) string {
	if sig == "" || ts == "" {
		return "missing signature headers"
	}

	// An explicit version check. If Kesvara ever signs a different shape of
	// string it will say v2, and a receiver written against v1 must refuse it
	// rather than compute a v1 signature over it and quietly disagree.
	if !strings.HasPrefix(sig, version+"=") {
		return "unknown signature version"
	}

	if !isInteger(ts) {
		return "malformed timestamp"
	}

	// The timestamp is signed as the exact string that arrived, not as a number
	// that has been through strconv and back. Those differ for "0123", and a
	// signature scheme with two spellings of one input has a hole in it. This
	// is why the []byte below is built from `ts` and not from a parsed int --
	// see tests/bad_receivers/normalised_timestamp_php.php for the receiver
	// that gets this wrong and what it then accepts.
	signed := append([]byte(version+":"+ts+":"), body...)
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write(signed)
	expected := version + "=" + hex.EncodeToString(mac.Sum(nil))

	// hmac.Equal, not ==. A byte-by-byte comparison that returns early leaks
	// the correct signature to anyone who can time it.
	if !hmac.Equal([]byte(expected), []byte(sig)) {
		return "signature mismatch"
	}

	// Freshness is checked AFTER the signature, deliberately. Before it, you
	// would be making a decision about a header that nothing has authenticated
	// yet. After it, the timestamp is known to be the one Kesvara signed --
	// which is what makes rejecting an old one a real defence against a
	// replayed message rather than a formality. It also keeps the refusal
	// reason from telling a forger which of the two checks they got past.
	stamp, err := strconv.ParseInt(ts, 10, 64)
	if err != nil {
		// Unreachable: isInteger already passed. Handled rather than ignored
		// because a 20-digit timestamp overflows int64, and the alternative
		// spelling of this line is a panic in a handler.
		return "malformed timestamp"
	}
	age := now - stamp
	if age > toleranceSeconds {
		return "timestamp too old"
	}
	if age < -toleranceSeconds {
		return "timestamp too far in the future"
	}

	return ""
}

// isInteger reports whether s is an optionally-negative run of ASCII digits.
//
// Written out rather than reached for via strconv, because ParseInt accepts
// things this must not: "+5", "0x1f", and underscores in a Go source literal
// spelling like "1_600_000_000". A timestamp with several spellings is the
// same hole the raw-string signing above exists to close.
func isInteger(s string) bool {
	s = strings.TrimPrefix(s, "-")
	if s == "" {
		return false
	}
	for _, c := range s {
		if c < '0' || c > '9' {
			return false
		}
	}
	return true
}

// Message is the payload Kesvara sends. Only the fields a receiver acts on are
// declared; the rest are ignored by the decoder, which is what you want -- a
// new field added by Kesvara must not stop your receiver working.
type Message struct {
	Event    string    `json:"event"`
	Text     string    `json:"text"`
	Incident *Incident `json:"incident"`
}

type Incident struct {
	ID         int64    `json:"id"`
	Title      string   `json:"title"`
	Service    string   `json:"service"`
	AlertCount int      `json:"alert_count"`
	URL        string   `json:"url"`
	Similar    *Similar `json:"similar"`
}

// Similar is null when Kesvara has nothing in your history close enough, which
// is the common case on a new workspace and is said in `text` too. A pointer
// rather than a value so that "no citation" is distinguishable from "a citation
// whose fields happen to be empty".
type Similar struct {
	IncidentID int64  `json:"incident_id"`
	Date       string `json:"date"`
	Title      string `json:"title"`
	ActionItem string `json:"action_item"`
	URL        string `json:"url"`
	// Kesvara watched this one happen, or your team imported it as history.
	// Worth carrying into whatever you build: "as written at the time" is a
	// claim Kesvara cannot make about a row somebody pasted in.
	ObservedByKesvara bool `json:"observed_by_kesvara"`
	// False when the link came from a vocabulary entry somebody on your team
	// wrote, rather than from wording the two incidents share.
	MatchedBySharedWording bool `json:"matched_by_shared_wording"`
}

func handler(secret string) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		if r.Method != http.MethodPost {
			w.Header().Set("Allow", "POST")
			refuse(w, http.StatusMethodNotAllowed, "POST only")
			return
		}

		// The limit goes on BEFORE the read, not as a length check after it.
		r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
		body, err := io.ReadAll(r.Body) // RAW bytes. Not a JSON decoder.
		if err != nil {
			refuse(w, http.StatusRequestEntityTooLarge, "body too large or unreadable")
			return
		}

		if why := verify(secret, r.Header.Get(tsHeader), r.Header.Get(sigHeader),
			body, time.Now().Unix()); why != "" {
			// Log the reason, never the key and never the expected signature --
			// printing what you expected hands a forger the answer, and the
			// same string usually ends up in a log a whole team can read.
			log.Printf("refused a Kesvara webhook: %s", why)
			refuse(w, http.StatusUnauthorized, why)
			return
		}

		var msg Message
		if err := json.Unmarshal(body, &msg); err != nil {
			// Only reachable from something holding your signing key, so this
			// is a bug report rather than an attack.
			refuse(w, http.StatusBadRequest, "signed body was not JSON")
			return
		}

		if msg.Event == "incident.opened" && msg.Incident != nil {
			// Kesvara retries a failed delivery, and a retry re-sends the SAME
			// bytes with the same timestamp and the same signature. If this
			// does something you would not want done twice -- paging someone,
			// opening a ticket -- key it on Incident.ID, which is stable across
			// retries. Do not key it on the signature, which is stable too and
			// will therefore also suppress a genuine second delivery after a
			// key rotation.
			handleIncident(msg.Incident)
		}

		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusOK)
		json.NewEncoder(w).Encode(map[string]bool{"ok": true})
	}
}

func refuse(w http.ResponseWriter, status int, why string) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(map[string]string{"refused": why})
}

// handleIncident is yours to write. This is what the payload contains.
func handleIncident(inc *Incident) {
	fmt.Printf("incident %d: %s (%d alerts)\n", inc.ID, inc.Title, inc.AlertCount)

	if inc.Similar == nil {
		fmt.Println("  no similar resolved incident in this workspace's history")
		return
	}
	// Kesvara found an older incident of yours that looks like this one.
	// ActionItem is a sentence one of your team typed at the time. It is not a
	// diagnosis and Kesvara does not claim it is one.
	fmt.Printf("  similar to %s: %s\n", inc.Similar.Date, inc.Similar.ActionItem)
	if !inc.Similar.ObservedByKesvara {
		fmt.Println("  (from history your team imported, not one Kesvara saw)")
	}
	if !inc.Similar.MatchedBySharedWording {
		fmt.Println("  (linked by a vocabulary entry somebody on your team wrote)")
	}
}

func main() {
	// Read from the environment, never pasted into the file. This is the one
	// value that lets anyone forge a message your receiver will believe.
	secret := os.Getenv("KESVARA_SIGNING_KEY")
	if secret == "" {
		log.Fatal("KESVARA_SIGNING_KEY is not set")
	}

	port := os.Getenv("PORT")
	if port == "" {
		port = "8080"
	}

	mux := http.NewServeMux()
	mux.HandleFunc("/kesvara", handler(secret))

	// Timeouts set explicitly. http.ListenAndServe has none, so one client that
	// opens a connection and never finishes the request holds a goroutine
	// forever -- and this endpoint is reachable by anyone who learns the URL.
	srv := &http.Server{
		Addr:              "127.0.0.1:" + port,
		Handler:           mux,
		ReadHeaderTimeout: 10 * time.Second,
		ReadTimeout:       30 * time.Second,
		WriteTimeout:      30 * time.Second,
	}
	log.Printf("listening on %s", srv.Addr)
	log.Fatal(srv.ListenAndServe())
}

PHP, no framework

no dependencies · download php_receiver.php

<?php
/**
 * A complete Kesvara webhook receiver, in PHP.
 *
 * Copy this file, set KESVARA_SIGNING_KEY, serve it, point your workspace's
 * notification destination at http://your-host/kesvara.
 *
 * To try it out, PHP's built-in server will do -- it is a development server
 * and the PHP manual says so, so do not put it in front of the internet:
 *
 *     export KESVARA_SIGNING_KEY=...   # from your Kesvara settings page
 *     php -S 127.0.0.1:8080 php_receiver.php
 *
 * In production this is an ordinary script under php-fpm, Apache or whatever
 * you already run. It reads nothing framework-specific and has no dependencies,
 * so it drops into a Laravel or Symfony app as a controller body unchanged --
 * with the one caveat in mistake 1 below.
 *
 * This file is not an illustration. Kesvara's own test suite starts this exact
 * program, sends it messages signed by the same code that signs yours, and then
 * sends it a battery of forgeries and replays and asserts it refuses every one.
 * If you change it, the part most worth re-testing is verify() -- everything
 * below it is ordinary plumbing.
 *
 * The three mistakes this file exists to stop you making:
 *
 *   1. Reaching for $_POST. PHP fills $_POST from the body only for form
 *      content types, and Kesvara sends application/json -- so $_POST is
 *      EMPTY and a receiver built on it verifies a signature over nothing.
 *      The raw bytes are at php://input, which is what this file reads. In a
 *      framework, ask for the raw body specifically ($request->getContent() in
 *      Symfony, $request->getContent() in Laravel) -- NOT the parsed array.
 *
 *   2. Re-encoding what you parsed. json_encode(json_decode($raw)) is a
 *      DIFFERENT byte string from $raw: PHP escapes every "/" as "\/" and
 *      every non-ASCII character as \uXXXX unless you remember two flags, and
 *      incident titles have plenty of both. Sign the bytes that arrived.
 *
 *   3. Comparing with == or ===. Use hash_equals. A comparison that returns on
 *      the first differing byte leaks the correct signature to anyone who can
 *      time it -- and in PHP, == on two strings that both look numeric has
 *      historically had a second, louder problem on top of the timing one.
 */
declare(strict_types=1);

// Read from the environment, never pasted into the file. This is the one value
// that lets anyone forge a message your receiver will believe.
$secret = getenv('KESVARA_SIGNING_KEY');
if ($secret === false || $secret === '') {
    // error_log, not fwrite(STDERR, ...): the STDERR constant exists only in
    // the CLI SAPI, so the tidy-looking version of this line is itself a fatal
    // error under php-fpm -- while you are trying to report a different one.
    error_log('KESVARA_SIGNING_KEY is not set');
    http_response_code(500);
    echo json_encode(['refused' => 'receiver is misconfigured']);
    exit(1);
}

const SIG_HEADER = 'HTTP_X_KESVARA_SIGNATURE';   // X-Kesvara-Signature
const TS_HEADER  = 'HTTP_X_KESVARA_TIMESTAMP';   // X-Kesvara-Timestamp
const VERSION    = 'v1';

// How much clock skew you will tolerate, in seconds. Kesvara cannot enforce
// this for you -- it is your end of the exchange. Five minutes is a normal
// choice: long enough to survive an unsynchronised clock, short enough that a
// captured message is not replayable tomorrow.
const TOLERANCE_SECONDS = 300;

/**
 * Returns null if the message is genuine, or a reason to refuse it.
 *
 * A reason rather than a bool because the reason belongs in your log. "Refused
 * a webhook" at 4am with no reason is a support ticket.
 *
 * $now is unix seconds, a parameter so a test can move the clock without
 * waiting for it.
 */
function verify(string $secret, string $ts, string $sig, string $body, int $now): ?string
{
    if ($sig === '' || $ts === '') {
        return 'missing signature headers';
    }

    // An explicit version check. If Kesvara ever signs a different shape of
    // string it will say v2, and a receiver written against v1 must refuse it
    // rather than compute a v1 signature over it and quietly disagree.
    if (!str_starts_with($sig, VERSION . '=')) {
        return 'unknown signature version';
    }

    // preg_match rather than is_numeric or ctype_digit. is_numeric accepts
    // "1e9", " 12" and "0x1A"; ctype_digit rejects a leading "-". Both would
    // hand the line below a string the signature step and the clock step read
    // differently, which is how one input acquires two spellings.
    if (!preg_match('/^-?[0-9]+$/', $ts)) {
        return 'malformed timestamp';
    }

    // THE TIMESTAMP IS SIGNED AS THE EXACT CHARACTERS THAT ARRIVED, not as a
    // number that has been through (int) and back. Those differ for "0173" and
    // "173", and a receiver that rebuilds this string from the parsed integer
    // will accept both spellings of one message. There is a control receiver
    // making exactly that mistake in tests/bad_receivers/, put through this
    // same battery, so the rule is proven rather than asserted.
    $signed = VERSION . ':' . $ts . ':' . $body;
    $expected = VERSION . '=' . hash_hmac('sha256', $signed, $secret);

    // hash_equals, not ===. See mistake 3 at the top of the file.
    if (!hash_equals($expected, $sig)) {
        return 'signature mismatch';
    }

    // Freshness is checked AFTER the signature, deliberately. Before it, you
    // would be making a decision about a header that nothing has authenticated
    // yet. After it, the timestamp is known to be the one Kesvara signed --
    // which is what makes rejecting an old one a real defence against a
    // replayed message rather than a formality. It also keeps the refusal
    // reason from telling a forger which of the two checks they got past.
    $age = $now - (int) $ts;
    if ($age > TOLERANCE_SECONDS) {
        return 'timestamp too old';
    }
    if ($age < -TOLERANCE_SECONDS) {
        return 'timestamp too far in the future';
    }

    return null;
}

function refuse(int $status, string $why): void
{
    // Log the reason, never the key and never the expected signature --
    // printing what you expected hands a forger the answer, and the same string
    // usually ends up in a log a whole team can read.
    error_log("refused a Kesvara webhook: {$why}");
    http_response_code($status);
    header('Content-Type: application/json');
    echo json_encode(['refused' => $why]);
}

/**
 * Yours to write. This is what the payload contains.
 *
 * NOTE THE error_log. This is the one place the PHP example has to differ from
 * the Python and Node ones, and it is the difference most likely to bite you.
 * `print` in Python and `console.log` in Node go to your console; `echo` and
 * `printf` in PHP go INTO THE RESPONSE BODY, under every SAPI including the
 * built-in server. So an incident printed the obvious way is prepended to the
 * JSON reply, and Kesvara reads the delivery as failed and retries it -- which
 * looks like a network problem and is a print statement.
 */
function handle_incident(array $incident): void
{
    error_log(sprintf('incident %d: %s (%d alerts)',
        $incident['id'], $incident['title'], $incident['alert_count']));

    $similar = $incident['similar'] ?? null;
    if ($similar === null) {
        error_log("  no similar resolved incident in this workspace's history");
        return;
    }
    // Kesvara found an older incident of yours that looks like this one.
    // action_item is a sentence one of your team typed at the time. It is not
    // a diagnosis and Kesvara does not claim it is one.
    error_log(sprintf('  similar to %s: %s',
        $similar['date'], $similar['action_item']));
    if (!($similar['observed_by_kesvara'] ?? true)) {
        error_log('  (from history your team imported, not one Kesvara saw)');
    }
    if (!($similar['matched_by_shared_wording'] ?? true)) {
        error_log('  (linked by a vocabulary entry somebody on your team wrote)');
    }
}

// ---------------------------------------------------------------------------
// The request
// ---------------------------------------------------------------------------

if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
    header('Allow: POST');
    refuse(405, 'POST only');
    exit;
}

// RAW bytes. Not $_POST, and not json_decode'd first -- see mistakes 1 and 2.
$body = file_get_contents('php://input');
if ($body === false) {
    refuse(400, 'unreadable body');
    exit;
}

$why = verify(
    $secret,
    $_SERVER[TS_HEADER] ?? '',
    $_SERVER[SIG_HEADER] ?? '',
    $body,
    time()
);
if ($why !== null) {
    refuse(401, $why);
    exit;
}

$message = json_decode($body, true);
if (!is_array($message)) {
    // Only reachable from something holding your signing key, so this is a bug
    // report rather than an attack.
    refuse(400, 'signed body was not JSON');
    exit;
}

if (($message['event'] ?? '') === 'incident.opened' && isset($message['incident'])) {
    // Kesvara retries a failed delivery, and a retry re-sends the SAME bytes
    // with the same timestamp and the same signature. If this does something
    // you would not want done twice -- paging someone, opening a ticket -- key
    // it on incident['id'], which is stable across retries. Do not key it on
    // the signature, which is stable too and will therefore also suppress a
    // genuine second delivery after a key rotation.
    handle_incident($message['incident']);
}

http_response_code(200);
header('Content-Type: application/json');
echo json_encode(['ok' => true]);

Questions

Do I have to check the signature?

No. If your destination is a Slack, Discord or Mattermost incoming webhook, those services ignore both headers and there is nothing you can do about it — the secrecy of the destination URL is the only thing protecting it, which is the model those products are built on. The signature is for an endpoint you wrote yourself.

What happens if I verify the parsed JSON instead of the raw body?

Every message fails, including genuine ones. A re-serialised object differs from the bytes on the wire in spacing, and in the escaping of any non-ASCII character — and incident titles contain plenty. The danger is that a check which fails on real traffic gets removed rather than fixed.

Does checking the signature stop a replay?

Not on its own. A captured message is byte-identical to the original and its signature is genuine, so no amount of signature checking will refuse it. The timestamp window is the part that does, which is why the timestamp is inside the signed bytes rather than merely sent alongside them.

Will a retry arrive twice with the same signature?

Yes. Kesvara retries a failed delivery by re-sending the identical bytes, with the same timestamp and the same signature. If your handler does something that must not happen twice, key it on the incident id rather than on the signature.

The signature format is versioned. Today it is v1, and every example refuses a label it does not recognise rather than checking it as v1 anyway.