Delivery events & webhooks verify webhook signature

Verifying Signed Webhooks: Why and How

An unverified webhook endpoint is an open door — anyone who finds the URL can forge events. How webhook signatures work, why raw-body and replay checks matter, and how to verify.

A webhook request being checked against an HMAC signature at the door, with a forged event rejected
A webhook request being checked against an HMAC signature at the door, with a forged event rejected

Your webhook endpoint is, by design, a public URL that anyone on the internet can POST to — and it changes your data when they do. It suppresses addresses, flips delivery statuses, updates dashboards. If you act on whatever arrives without checking who sent it, you've built a control panel for your data and left the door unlocked. Signature verification is the lock. It's a small amount of code, and it's not optional.

An unverified webhook handler will do whatever a stranger tells it to. The signature is the difference between "an event from Notifiva" and "an HTTP request from anyone."

What a webhook signature proves#

Notifiva signs the events it sends you. A signature is a keyed hash — an HMAC — computed over the event payload using a signing secret that only you and Notifiva know. When an event arrives, you recompute the same HMAC over what you received and compare. If they match, two things are true:

  1. Authenticity — it was produced by someone holding the secret (Notifiva), not a stranger who found your URL.
  2. Integrity — the payload wasn't altered in transit; a single changed byte breaks the hash.

That's the whole idea. The details are where people get it subtly wrong, so let's get them right.

The three mistakes that break verification#

1. Verifying a parsed body instead of the raw bytes. The signature is computed over the exact bytes Notifiva sent. If your web framework parses the JSON and you re-serialise it to verify, key order, whitespace, and number formatting can differ — and your recomputed hash won't match even for a genuine event. Capture the raw body before any JSON parsing and verify against that.

2. Comparing with ==. A normal string comparison returns as soon as it finds a differing character, so how long it takes leaks how much of the signature was correct — a timing side-channel an attacker can exploit to forge a signature byte by byte. Use a constant-time comparison (crypto.timingSafeEqual, hmac.compare_digest, and equivalents).

3. No replay protection. A valid signed request captured once can be replayed forever unless you bound it in time. Notifiva includes a timestamp; check it's within a small tolerance (a few minutes) and reject anything older, so a captured event can't be re-fired at you next week.

Verification flow: capture raw body, recompute HMAC with signing secret, constant-time compare, check timestamp tolerance, then process or reject
Raw body in, HMAC recomputed, constant-time compared, timestamp checked — then and only then, act.

The verification checklist#

Before your handler acts on any event:

  • [ ] Read the raw request body (no framework JSON parsing first).
  • [ ] Recompute the HMAC with your signing secret over the exact bytes (and the timestamp, if the scheme prefixes it — check your webhook settings for the exact construction).
  • [ ] Compare signatures with a constant-time function.
  • [ ] Check the timestamp is within tolerance; reject stale requests.
  • [ ] Only then parse the JSON and process.
  • [ ] Keep the signing secret in a secret store, out of source control; support rotation.
  • [ ] Return 200 fast; do heavy work async (see webhooks vs polling).

Hands-on: verify before you parse#

The exact header names and signature construction come from your Notifiva webhook settings — treat the values below as placeholders and drop in the real ones. The shape is the same across correct implementations.

Node/Express — raw body, HMAC, constant-time compare, timestamp tolerance:

JavaScript
const crypto = require("crypto");
const SIGNING_SECRET = process.env.NOTIFIVA_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300;

// IMPORTANT: capture the raw body — do not JSON-parse before verifying
app.post("/webhooks/notifiva",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const signature = req.get(""); // e.g. a hex HMAC
    const timestamp = Number(req.get(""));

    // 1) replay guard
    if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) {
      return res.sendStatus(400);
    }

    // 2) recompute over the exact bytes (prefix with timestamp if your scheme does)
    const signed = `${timestamp}.` + req.body.toString("utf8");
    const expected = crypto.createHmac("sha256", SIGNING_SECRET)
                           .update(signed).digest("hex");

    // 3) constant-time compare
    const ok = signature &&
      signature.length === expected.length &&
      crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
    if (!ok) return res.sendStatus(401);

    // 4) safe to parse and process now
    const event = JSON.parse(req.body.toString("utf8"));
    queue.add("delivery-event", event);
    res.sendStatus(200);
  }
);

Python (Flask) — same discipline:

Python
import hmac, hashlib, time
from flask import request, abort

SIGNING_SECRET = os.environ["NOTIFIVA_WEBHOOK_SECRET"].encode()
TOLERANCE = 300

@app.post("/webhooks/notifiva")
def notifiva_webhook():
    raw = request.get_data()  # raw bytes BEFORE any parsing
    ts = int(request.headers.get("", "0"))
    if not ts or abs(time.time() - ts) > TOLERANCE:
        abort(400)
    signed = f"{ts}.".encode() + raw
    expected = hmac.new(SIGNING_SECRET, signed, hashlib.sha256).hexdigest()
    sig = request.headers.get("", "")
    if not hmac.compare_digest(sig, expected):   # constant-time
        abort(401)
    event = request.get_json()  # now safe to parse
    enqueue(event)
    return "", 200

The three non-negotiables — raw body, constant-time compare, timestamp tolerance — are visible in both. Everything else is plumbing.

Example scenario: the forged complaint#

(Illustrative scenario, not a customer case.) A team ships a webhook handler that acts on whatever POSTs to a guessable /webhooks/email path, no signature check. Someone discovers the URL and fires fake email.complained events for a competitor's-style prank — or just noise. The handler dutifully suppresses those addresses, so real users stop getting mail with no bounce to explain it. The data corruption is invisible until customers complain that "the emails stopped." Signature verification would have rejected every forged event at the door; the fix is a dozen lines that should have been there from commit one.

Editorial disclosure: Drafted with AI assistance; reviewed, fact-checked and edited by Alkım Kaplaner. Next scheduled review: .

Frequently asked questions

Why do I need to verify webhook signatures?

Because a webhook endpoint is a public URL that changes your data. Without verification, anyone who discovers it can POST forged events — fake bounces or complaints — and corrupt your state or suppress real users. The signature proves the event came from Notifiva and wasn't tampered with.

Why must I use the raw request body?

Signatures are computed over the exact bytes sent. If your framework parses the JSON and you re-serialise it, differences in key order, whitespace or number formatting will break the hash even for genuine events. Capture the raw body before any parsing and verify against that.

What is a constant-time comparison and why does it matter?

It's a comparison that always takes the same time regardless of where two values differ. A normal == returns early on the first mismatch, leaking timing information an attacker can use to forge a signature incrementally. Use crypto.timingSafeEqual, hmac.compare_digest, or your language's equivalent.

How do I stop webhook replay attacks?

Include and check a timestamp. Reject events whose timestamp is outside a small tolerance (a few minutes), so a captured valid request can't be replayed later. Combine this with signature verification — the timestamp is usually part of what's signed.

Where should the signing secret live?

In a secret manager or environment configuration, never in source control, and ideally rotatable. If a secret leaks, rotate it and update your handler. Treat it with the same care as a production database credential.

Send transactional email you can rely on

Notifiva gives you a REST API and an SMTP relay, SPF/DKIM signing, delivery webhooks and per-message logs — so the mail your users are waiting on actually arrives.