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.
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.
{monitor, url, status, error, timestamp}.
Configure via the monitor edit form's "Webhook URL" field, or PATCH
/api/v1/monitors/:id {"alertWebhookUrl": "..."}.
incident.opened, incident.acknowledged, incident.resolved,
monitor.status_changed, log_alert.fired). Payload is a structured
envelope: {id, type, created, data: {...}}. Configure under Settings →
Event webhooks, or POST /api/v1/webhook-subscriptions. Full walkthrough at
/docs/webhook-subscriptions/ — covers CRUD,
retry behaviour, auto-disable threshold, and per-subscription delivery log.
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.
X-24Observe-Timestamp: 1777200000 X-24Observe-Signature: t=1777200000,v1=<64-char-hex>
t= value from the signature header.HMAC-SHA256(secret, "{t}.{rawBody}").hex()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.
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);
}); 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} 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 } timingSafeEqual / hmac.compare_digest / hmac.Equal — never ==.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.