Orbtrace

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:

HeaderMeaning
X-Orbtrace-Signaturet=<unix-seconds>,v1=<hex> — the timestamp and the HMAC.
X-Orbtrace-Idempotency-KeyStable per alert instance — de-dupe retries on this.
X-Orbtrace-Delivery-AttemptDelivery 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 hex

Your receiver MUST, in order:

  1. Parse t and reject if it's more than 5 minutes from your clock — this is what kills replay of a captured body.
  2. Recompute HMAC-SHA256(secret, t + "." + raw_body) and compare with v1 in constant time.
  3. De-dupe on X-Orbtrace-Idempotency-Key so 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).