Guides / Webhook signing

Webhook signing

Verify X-Thump-Signature HMAC signatures on alert webhooks, with copy-paste examples for Node, Python, and Go.

On this page

Webhook Signing

Every outbound webhook from a thump alert channel is signed with HMAC-SHA256. Verify the signature on every request and you can be confident the body really came from thump and was not tampered with in flight.

Why sign

Webhooks are public POSTs to a URL you publish. Without authentication, anyone who guesses or scrapes that URL can forge incident events into your pager. Signing closes two gaps:

The secret never leaves thump's API + your verifier. It is generated when the webhook channel is created and rotatable from the channel detail page.

What we send

Every signed webhook attaches three headers in addition to Content-Type: application/json:

HeaderValue
X-Thump-Signaturesha256=<hex> — HMAC-SHA256 of the raw request body using the channel's signing secret, hex-encoded
X-Thump-TimestampUnix milliseconds at the time of signing
X-Thump-Eventopened, resolved, or test

The sha256= prefix is part of the value — strip it before comparing, or fold it into the expected string when you compute your own. The body is signed before any framework re-serializes it, so verifiers must hash the raw request body, not a re-stringified parsed object. (Re-stringifying changes whitespace and key order — the hash will not match.)

The body itself is the canonical IncidentEvent JSON shape:

{
  "kind": "opened",
  "monitor": { "id": "…", "name": "Marketing site", "url": "https://example.com" },
  "incident": {
    "id": "…",
    "startedAt": 1746800000000,
    "resolvedAt": null,
    "cause": "5xx from 3/5 regions",
    "affectedRegions": ["us-east", "eu-west", "ap-south"]
  }
}

For test sends, testEvent: true is set in the body and the X-Thump-Event header is test. Real incidents never set testEvent.

How to verify

The pattern is the same in every language: read the raw body, recompute the HMAC, compare in constant time.

Node.js

import crypto from 'node:crypto';

export function verifyThumpSignature(rawBody, headerValue, secret) {
  if (!headerValue?.startsWith('sha256=')) return false;
  const expected = crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex');
  const got = headerValue.slice('sha256='.length);
  const a = Buffer.from(expected, 'hex');
  const b = Buffer.from(got, 'hex');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

In Express, capture the raw body before JSON parsing — for example, app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } })).

Python

import hmac, hashlib

def verify_thump_signature(raw_body: bytes, header_value: str, secret: str) -> bool:
    if not header_value or not header_value.startswith("sha256="):
        return False
    expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    got = header_value[len("sha256="):]
    return hmac.compare_digest(expected, got)

In FastAPI/Flask, use request.body() / request.get_data(cache=True) to read the bytes before any JSON parsing.

Go

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "strings"
)

func VerifyThumpSignature(rawBody []byte, header, secret string) bool {
    if !strings.HasPrefix(header, "sha256=") {
        return false
    }
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write(rawBody)
    expected := mac.Sum(nil)
    got, err := hex.DecodeString(strings.TrimPrefix(header, "sha256="))
    if err != nil {
        return false
    }
    return hmac.Equal(expected, got)
}

If your handler reads the body into io.ReadAll(r.Body) then unmarshals from those bytes you already have the raw body available for hashing.

Secret rotation

Every webhook channel gets a unique signing secret on creation. Rotate it from the alert-channel detail page in the dashboard:

  1. Open /app/channels/:id for the webhook channel.
  2. Reveal the current secret if you need to confirm the old value.
  3. Click Rotate, confirm, and copy the new secret.

Rotation is immediate. The previous secret is invalidated the moment you confirm — there is no grace period. Update your verifier(s) before clicking confirm to avoid dropped notifications.

If you operate multiple receivers from the same channel, deploy the new secret to every receiver before rotating.

Timestamps and replay

We send X-Thump-Timestamp with every signed request — the unix-ms instant the signature was generated. We currently include the timestamp for visibility only; the signature is computed over the body alone, not over timestamp + body, so you cannot use it to bind freshness to the signature.

If you need replay protection right now, the practical option is to dedupe on incident.id + kind server-side: every event for a given incident transition is unique, so re-deliveries are detectable.

A future iteration may move to a Stripe-style signed payload (timestamp.body is what gets HMAC-ed). When that ships, the recommended verifier pattern will be:

  1. Reject any request whose X-Thump-Timestamp is older than your tolerance window (e.g. 5 minutes).
  2. Recompute the signature over ${timestamp}.${rawBody} and constant-time-compare.

This page will be updated when that change lands; the current body-only signing will continue to work in parallel during any deprecation window.

Troubleshooting

Signature never matches. You are almost certainly hashing a re-serialized body. Capture the raw bytes before any JSON parsing and hash those.

Signature occasionally matches. Multiple receivers behind a load balancer with different secrets — rotate so every node has the same one.

X-Thump-Signature is missing. The channel does not have a signing secret set, or the header was stripped by a proxy. Check the channel detail page; if the secret shows (none), rotate to generate one.