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.
TL;DR
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.
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.
- Per-monitor alert webhook — one URL stored on the monitor row. Fires when that
specific monitor goes down. Payload is flat:
{monitor, url, status, error, timestamp}. Configure via the monitor edit form's "Webhook URL" field, or PATCH/api/v1/monitors/:id {"alertWebhookUrl": "..."}. - Event subscriptions — register a URL for one or more event types
(
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.
Headers we send
X-24Observe-Timestamp: 1777200000 X-24Observe-Signature: t=1777200000,v1=<64-char-hex>
Verification recipe
- Read the
t=value from the signature header. - Reject if it's older than your tolerance window (we recommend 5 minutes — replay protection).
- Compute
HMAC-SHA256(secret, "{t}.{rawBody}").hex() - 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
- Parsing the body before verifying. JSON.parse may reorder keys; you must hash the exact bytes we sent.
- Non-constant-time comparison. Use
timingSafeEqual/hmac.compare_digest/hmac.Equal— never==. - Missing replay defense. Without the 5-minute timestamp check, a captured request can be replayed indefinitely.
- Confusing the signing secret with your PAT. The signing secret is per-org (Settings → Webhook signing secret); your PAT is per-user (Settings → API tokens). Different things.
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.