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:
- Sender authentication — only thump knows your channel's signing secret, so a valid signature is proof of origin.
- Tamper detection — the signature covers the exact bytes of the request body. Any rewrite of the JSON invalidates it.
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:
| Header | Value |
|---|---|
X-Thump-Signature | sha256=<hex> — HMAC-SHA256 of the raw request body using the channel's signing secret, hex-encoded |
X-Thump-Timestamp | Unix milliseconds at the time of signing |
X-Thump-Event | opened, 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:
- Open
/app/channels/:idfor the webhook channel. - Reveal the current secret if you need to confirm the old value.
- 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:
- Reject any request whose
X-Thump-Timestampis older than your tolerance window (e.g. 5 minutes). - 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.