← Back to the dashboard

Verifying SkyQon signed webhooks

When a signing secret is configured for your outbound webhook (Alerts → Outbound webhook, or via PUT /v1/integrations/webhook/{tenant_id}), every delivery SkyQon sends is signed. Verifying the signature proves the request came from SkyQon and that the payload was not tampered with or replayed.

Headers on every signed delivery

HeaderMeaning
X-SkyQon-SignatureThe signature, prefixed with its scheme version: v1=<hex hmac-sha256>
X-SkyQon-Signature-VersionCurrently always v1
X-SkyQon-TimestampUnix seconds when the delivery was signed
X-SkyQon-DeliveryOpaque delivery id (use it for idempotency / dedup on retries)

The v1 scheme

The signature is HMAC-SHA256 over the string "{timestamp}.{raw_body}", keyed with your webhook secret and hex-encoded. The timestamp is inside the MAC (Stripe-style), so a captured body cannot be replayed later with a fresh timestamp.

  1. Read the exact raw request bytes — before any JSON parsing or re-serialisation.
  2. Reject the delivery if |now − X-SkyQon-Timestamp| exceeds your tolerance window (we recommend 300 seconds).
  3. Compute hex(hmac_sha256(secret, timestamp + "." + raw_body)) and prefix v1=.
  4. Compare against X-SkyQon-Signature using a constant-time comparison.
Sign-what-you-receive: always MAC the raw bytes. Parsing the JSON and re-serialising it will change key order or whitespace and the signature will not match.

Python

import hashlib, hmac, time

TOLERANCE = 300  # seconds

def verify_skyqon_signature(secret: str, raw_body: bytes,
                            signature: str, timestamp: str) -> bool:
    try:
        ts = int(timestamp)
    except (TypeError, ValueError):
        return False
    if abs(int(time.time()) - ts) > TOLERANCE:
        return False
    mac = hmac.new(secret.encode(), f"{ts}.".encode() + raw_body,
                   hashlib.sha256).hexdigest()
    return hmac.compare_digest(f"v1={mac}", signature)

# Flask example:
#   ok = verify_skyqon_signature(
#       SECRET, request.get_data(),
#       request.headers.get("X-SkyQon-Signature", ""),
#       request.headers.get("X-SkyQon-Timestamp", ""))

Node.js

const crypto = require('crypto');

const TOLERANCE = 300; // seconds

function verifySkyqonSignature(secret, rawBody, signature, timestamp) {
  const ts = parseInt(timestamp, 10);
  if (!Number.isFinite(ts)) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - ts) > TOLERANCE) return false;
  const mac = crypto.createHmac('sha256', secret)
    .update(`${ts}.`).update(rawBody).digest('hex');
  const expected = Buffer.from(`v1=${mac}`);
  const got = Buffer.from(String(signature || ''));
  return expected.length === got.length &&
    crypto.timingSafeEqual(expected, got);
}

// Express: use express.raw({ type: '*/*' }) on the webhook route so
// req.body is the raw Buffer, then:
//   const ok = verifySkyqonSignature(SECRET, req.body,
//     req.get('X-SkyQon-Signature'), req.get('X-SkyQon-Timestamp'));

Rotating the secret

  1. Rotate in the dashboard (Alerts → Outbound webhook → Rotate secret) or via PUT /v1/integrations/webhook/{tenant_id} with {"rotate_secret": true}.
  2. The new secret is shown exactly once in the response — it can never be read back.
  3. Update your receiver with the new value. Deliveries are signed with the new secret immediately, so update the receiver promptly (or accept both during the switch).

Unsigned mode

If no secret is configured, deliveries are sent unsigned (no X-SkyQon-Signature header) — legacy behaviour. We recommend configuring a secret for any receiver that acts on alert data.