Webhooks

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.