Webhook verification
Verify that an alert webhook really came from your Orbtrace instance — HMAC-SHA256 signature, timestamp-skew check, and idempotency-key de-dup. Receiver snippets for Node, Python, Go, and Java.
When you add a Webhook alert channel, Orbtrace signs every delivery so your receiver can prove it came from your instance (and not a forged request to your public endpoint). The scheme is the industry-standard Stripe-style HMAC — your receiver verifies it with the standard library, no Orbtrace SDK required.
The signature
Each request carries three headers:
| Header | Meaning |
|---|---|
X-Orbtrace-Signature | t=<unix-seconds>,v1=<hex> — the timestamp and the HMAC. |
X-Orbtrace-Idempotency-Key | Stable per alert instance — de-dupe retries on this. |
X-Orbtrace-Delivery-Attempt | Delivery attempt number (1 = first try). |
The signed string is the timestamp, a literal ., then the raw request body:
signed = "<unix-seconds>" + "." + <raw_body>
v1 = HMAC_SHA256(signing_secret, signed) // lowercase hexYour receiver MUST, in order:
- Parse
tand reject if it's more than 5 minutes from your clock — this is what kills replay of a captured body. - Recompute
HMAC-SHA256(secret, t + "." + raw_body)and compare withv1in constant time. - De-dupe on
X-Orbtrace-Idempotency-Keyso a retried delivery isn't processed twice.
Verify against the raw bytes of the body, before any JSON parse/re-serialize — re-encoding changes whitespace and breaks the HMAC.
Receiver snippets
Node.js
import crypto from 'node:crypto';
function verify(rawBody, headers, secret) {
const [tPart, v1Part] = headers['x-orbtrace-signature'].split(',');
const t = tPart.slice(2); // strip "t="
const sig = v1Part.slice(3); // strip "v1="
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) throw new Error('stale');
const expected = crypto.createHmac('sha256', secret)
.update(`${t}.${rawBody}`).digest('hex');
const ok = crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
if (!ok) throw new Error('bad signature');
}Python
import hashlib, hmac, time
def verify(raw_body: bytes, headers, secret: str):
t, v1 = (p.split("=", 1)[1] for p in headers["X-Orbtrace-Signature"].split(","))
if abs(time.time() - int(t)) > 300:
raise ValueError("stale")
signed = f"{t}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
if not hmac.compare_digest(v1, expected):
raise ValueError("bad signature")Go
func Verify(rawBody []byte, header, secret string) error {
var t, v1 string
for _, p := range strings.Split(header, ",") {
kv := strings.SplitN(p, "=", 2)
switch kv[0] {
case "t":
t = kv[1]
case "v1":
v1 = kv[1]
}
}
ts, _ := strconv.ParseInt(t, 10, 64)
if math.Abs(float64(time.Now().Unix()-ts)) > 300 {
return errors.New("stale")
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(t + "."))
mac.Write(rawBody)
expected := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(v1), []byte(expected)) {
return errors.New("bad signature")
}
return nil
}Java
String[] parts = header.split(",");
String t = parts[0].substring(2); // "t="
String v1 = parts[1].substring(3); // "v1="
if (Math.abs(Instant.now().getEpochSecond() - Long.parseLong(t)) > 300)
throw new SecurityException("stale");
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(UTF_8), "HmacSHA256"));
mac.update((t + ".").getBytes(UTF_8));
byte[] expected = mac.doFinal(rawBody); // rawBody = raw bytes
if (!MessageDigest.isEqual(fromHex(v1), expected))
throw new SecurityException("bad signature");Idempotency
Orbtrace retries failed deliveries, so the same alert can arrive more than once. The X-Orbtrace-Idempotency-Key is stable for one alert instance across retries — store the keys you've processed and drop duplicates. The X-Orbtrace-Delivery-Attempt header is informational (handy for logging which retry finally landed).