Verify webhook signatures
Prove a webhook delivery actually came from Obzerv using HMAC-SHA256.
Every webhook delivery includes an X-Obzerv-Signature header — an HMAC-SHA256 of the raw request body keyed with your channel's signing secret. Verifying this is the only reliable way to know the request isn't forged.
Get your signing secret
The plaintext secret is shown once, in the response of POST /notification-channels:
{
"id": "ch_8f4a...",
"type": "webhook",
"name": "Internal pager",
"config": { "webhook_url": "https://hooks.internal.example/obzerv" },
"is_enabled": true,
"monitor_ids": [],
"created_at": "2026-04-25T14:00:00Z",
"signing_secret": "whsec_a1b2c3..."
}Store signing_secret in your secrets manager — Obzerv only persists a copy server-side for its own signing; we don't expose it on subsequent reads. To rotate, delete the channel and create a new one.
Verify on your endpoint
The signature is computed from the raw request body. Most frameworks parse JSON eagerly — capture the raw bytes first.
Node.js (Express)
import crypto from 'node:crypto';
import express from 'express';
const app = express();
app.post(
'/obzerv-webhook',
// Capture the raw body — required for HMAC.
express.raw({ type: 'application/json' }),
(req, res) => {
const header = req.headers['x-obzerv-signature'] ?? '';
const provided = header.replace(/^sha256=/, '');
const expected = crypto
.createHmac('sha256', process.env.OBZERV_WEBHOOK_SECRET)
.update(req.body)
.digest('hex');
const ok =
provided.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(provided), Buffer.from(expected));
if (!ok) return res.status(401).send('bad signature');
const event = JSON.parse(req.body.toString('utf8'));
// ...handle event
res.sendStatus(204);
},
);Python (Flask)
import hmac, hashlib, os
from flask import Flask, request, abort
app = Flask(__name__)
@app.post("/obzerv-webhook")
def webhook():
header = request.headers.get("X-Obzerv-Signature", "")
provided = header.removeprefix("sha256=")
expected = hmac.new(
os.environ["OBZERV_WEBHOOK_SECRET"].encode(),
request.get_data(), # raw bytes
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(provided, expected):
abort(401)
event = request.get_json()
# ...handle event
return ("", 204)Next.js (App Router)
import crypto from 'node:crypto';
import { NextResponse } from 'next/server';
export async function POST(req: Request) {
const raw = await req.text(); // raw body
const header = req.headers.get('x-obzerv-signature') ?? '';
const provided = header.replace(/^sha256=/, '');
const expected = crypto
.createHmac('sha256', process.env.OBZERV_WEBHOOK_SECRET!)
.update(raw)
.digest('hex');
const ok =
provided.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(provided), Buffer.from(expected));
if (!ok) return new NextResponse('bad signature', { status: 401 });
const event = JSON.parse(raw);
// ...
return new NextResponse(null, { status: 204 });
}Idempotency
Build defensively even though Obzerv delivers each status change once: dedupe on (monitor_id, timestamp) from the body, store seen pairs in Redis with a 1-hour TTL, drop duplicates with a 200.
Rotation
To rotate a secret today: delete the channel and create a new one (Obzerv mints a fresh whsec_... on every creation). Have your handler temporarily accept both old and new secrets during the cutover window.