24observe
checking… Sign in Start free
Docs · webhooks

Verify the webhook came from us.

When we POST to a URL you configured, we sign it. Your receiver verifies the signature to be certain the request originated here and wasn't tampered with in transit. Same signing scheme covers both kinds of outbound webhook below.

Two kinds of outbound webhook

We ship two distinct webhook surfaces. They use the same signing scheme, so the verification code below works for both — but they're configured in different places.

Both ship from User-Agent: 24observe-webhooks/1.0. Event subscriptions also send X-24Observe-Event and X-24Observe-Delivery-Id headers so your handler can branch on event type and dedupe retries.

Headers we send

X-24Observe-Timestamp: 1777200000
X-24Observe-Signature: t=1777200000,v1=<64-char-hex>

Verification recipe

  1. Read the t= value from the signature header.
  2. Reject if it's older than your tolerance window (we recommend 5 minutes — replay protection).
  3. Compute HMAC-SHA256(secret, "{t}.{rawBody}").hex()
  4. Compare with the v1= value using a constant-time comparison.

The secret is the per-organization webhook signing secret you can read and rotate from Settings → Webhook signing secret in your dashboard.

Node.js (Express)

import { createHmac, timingSafeEqual } from 'crypto';
import express from 'express';

const SECRET = process.env.OBSERVE24_WEBHOOK_SECRET; // from your dashboard
const app = express();

// IMPORTANT: use the raw body, not parsed JSON.
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const sigHeader = req.header('x-24observe-signature') || '';
  const parts = Object.fromEntries(sigHeader.split(',').map(p => p.split('=')));
  const t = Number(parts.t);
  const v1 = parts.v1;

  // Replay defense: reject if timestamp is more than 5 minutes off.
  if (!t || Math.abs(Date.now() / 1000 - t) > 300) return res.sendStatus(400);

  const expected = createHmac('sha256', SECRET)
    .update(`${t}.${req.body.toString('utf8')}`)
    .digest();
  const actual = Buffer.from(v1, 'hex');
  if (actual.length !== expected.length) return res.sendStatus(400);
  if (!timingSafeEqual(expected, actual)) return res.sendStatus(401);

  // Trusted! Parse and process.
  const payload = JSON.parse(req.body.toString('utf8'));
  console.log('alert:', payload);
  res.sendStatus(204);
});

Python (FastAPI)

import hmac, hashlib, time, os
from fastapi import FastAPI, Request, HTTPException

SECRET = os.environ['OBSERVE24_WEBHOOK_SECRET'].encode()
app = FastAPI()

@app.post('/webhook')
async def webhook(request: Request):
    raw = await request.body()
    sig_header = request.headers.get('x-24observe-signature', '')
    parts = dict(p.split('=', 1) for p in sig_header.split(','))
    t = int(parts.get('t', 0))
    v1 = parts.get('v1', '')

    if abs(time.time() - t) > 300:
        raise HTTPException(400, 'timestamp out of tolerance')

    expected = hmac.new(SECRET, f'{t}.{raw.decode()}'.encode(), hashlib.sha256).digest()
    actual = bytes.fromhex(v1)
    if not hmac.compare_digest(expected, actual):
        raise HTTPException(401, 'bad signature')

    return {'ok': True}

Go

package main

import (
  "crypto/hmac"
  "crypto/sha256"
  "encoding/hex"
  "fmt"
  "io"
  "net/http"
  "os"
  "strconv"
  "strings"
  "time"
)

var secret = []byte(os.Getenv("OBSERVE24_WEBHOOK_SECRET"))

func handler(w http.ResponseWriter, r *http.Request) {
  body, _ := io.ReadAll(r.Body)
  sigHeader := r.Header.Get("X-24Observe-Signature")
  parts := map[string]string{}
  for _, p := range strings.Split(sigHeader, ",") {
    kv := strings.SplitN(p, "=", 2)
    if len(kv) == 2 { parts[kv[0]] = kv[1] }
  }
  t, _ := strconv.ParseInt(parts["t"], 10, 64)
  if abs(time.Now().Unix() - t) > 300 { http.Error(w, "stale", 400); return }

  mac := hmac.New(sha256.New, secret)
  fmt.Fprintf(mac, "%d.%s", t, body)
  expected := mac.Sum(nil)
  actual, _ := hex.DecodeString(parts["v1"])
  if !hmac.Equal(expected, actual) { http.Error(w, "bad sig", 401); return }
  w.WriteHeader(204)
}

func abs(x int64) int64 { if x < 0 { return -x }; return x }

Common mistakes

Want to test before going live?

Open any monitor → "Send test alert to all configured channels" — we POST a sample payload to your webhook URL with a real signature. Verify locally; you've got the recipe right when your endpoint returns 2xx.