Verifying webhook signatures

Verify the HMAC-SHA256 signature on Nerva webhooks to confirm authenticity and prevent tampering.

When you configure a secret on a webhook, Nerva signs every request with HMAC-SHA256. Verifying the signature on your server is the single most important security step in your webhook handler — without it, anyone who knows your endpoint URL can forge events.

How it works

For every delivery, Nerva:

  1. Takes the raw JSON body of the request (exactly the bytes sent over the wire — no re-serialization).
  2. Computes HMAC-SHA256(secret, raw_body).
  3. Sends the hex digest in the X-Nerva-Signature header.

Your server:

  1. Reads the X-Nerva-Signature header.
  2. Computes the same HMAC over the raw body you received.
  3. Compares the two using a constant-time comparison (not ===).

If they match, the request came from Nerva and the body wasn't tampered with.

You must compute the HMAC over the raw request body bytes, not over a re-serialized JavaScript / Python object. JSON serialization isn't deterministic — key ordering and whitespace differ — and any mismatch will cause verification to fail. Always buffer the raw body before parsing.

Node.js / Express

import express from "express";
import crypto from "node:crypto";
 
const app = express();
 
app.post(
  "/webhooks/nerva",
  // IMPORTANT: use express.raw, not express.json — we need the raw body.
  express.raw({ type: "application/json" }),
  (req, res) => {
    const signature = req.header("X-Nerva-Signature");
    if (!signature) return res.status(401).send("Missing signature");
 
    const expected = crypto
      .createHmac("sha256", process.env.NERVA_WEBHOOK_SECRET)
      .update(req.body) // raw Buffer
      .digest("hex");
 
    const sigBuf = Buffer.from(signature, "hex");
    const expBuf = Buffer.from(expected, "hex");
 
    if (
      sigBuf.length !== expBuf.length ||
      !crypto.timingSafeEqual(sigBuf, expBuf)
    ) {
      return res.status(401).send("Bad signature");
    }
 
    const payload = JSON.parse(req.body.toString("utf-8"));
    // process payload.event, payload.data ...
    res.status(200).send("OK");
  },
);

Next.js Route Handler

import { NextRequest, NextResponse } from "next/server";
import { createHmac, timingSafeEqual } from "node:crypto";
 
export const runtime = "nodejs";
 
export async function POST(req: NextRequest) {
  const signature = req.headers.get("x-nerva-signature");
  if (!signature) {
    return NextResponse.json({ error: "Missing signature" }, { status: 401 });
  }
 
  const rawBody = await req.text(); // raw string, not parsed
  const expected = createHmac("sha256", process.env.NERVA_WEBHOOK_SECRET!)
    .update(rawBody)
    .digest("hex");
 
  const sigBuf = Buffer.from(signature, "hex");
  const expBuf = Buffer.from(expected, "hex");
 
  if (
    sigBuf.length !== expBuf.length ||
    !timingSafeEqual(sigBuf, expBuf)
  ) {
    return NextResponse.json({ error: "Bad signature" }, { status: 401 });
  }
 
  const payload = JSON.parse(rawBody);
  // process payload.event, payload.data ...
  return NextResponse.json({ ok: true });
}

Python / FastAPI

import hmac
import hashlib
import os
from fastapi import FastAPI, Request, HTTPException
 
app = FastAPI()
SECRET = os.environ["NERVA_WEBHOOK_SECRET"].encode()
 
 
@app.post("/webhooks/nerva")
async def nerva_webhook(request: Request):
    signature = request.headers.get("x-nerva-signature")
    if not signature:
        raise HTTPException(status_code=401, detail="Missing signature")
 
    raw_body = await request.body()  # bytes
    expected = hmac.new(SECRET, raw_body, hashlib.sha256).hexdigest()
 
    if not hmac.compare_digest(signature, expected):
        raise HTTPException(status_code=401, detail="Bad signature")
 
    payload = await request.json()
    # process payload["event"], payload["data"] ...
    return {"ok": True}

Ruby / Sinatra

require "sinatra"
require "openssl"
require "json"
 
SECRET = ENV.fetch("NERVA_WEBHOOK_SECRET")
 
post "/webhooks/nerva" do
  signature = request.env["HTTP_X_NERVA_SIGNATURE"]
  halt 401, "Missing signature" unless signature
 
  raw_body = request.body.read
  expected = OpenSSL::HMAC.hexdigest("sha256", SECRET, raw_body)
 
  unless Rack::Utils.secure_compare(signature, expected)
    halt 401, "Bad signature"
  end
 
  payload = JSON.parse(raw_body)
  # process payload["event"], payload["data"] ...
  status 200
end

PHP

$signature = $_SERVER["HTTP_X_NERVA_SIGNATURE"] ?? null;
if (!$signature) {
    http_response_code(401);
    exit("Missing signature");
}
 
$rawBody = file_get_contents("php://input");
$expected = hash_hmac("sha256", $rawBody, getenv("NERVA_WEBHOOK_SECRET"));
 
if (!hash_equals($expected, $signature)) {
    http_response_code(401);
    exit("Bad signature");
}
 
$payload = json_decode($rawBody, true);
// process $payload["event"], $payload["data"] ...
http_response_code(200);
echo "OK";

Go

package main
 
import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"io"
	"net/http"
	"os"
)
 
var secret = []byte(os.Getenv("NERVA_WEBHOOK_SECRET"))
 
func handler(w http.ResponseWriter, r *http.Request) {
	signature := r.Header.Get("X-Nerva-Signature")
	if signature == "" {
		http.Error(w, "Missing signature", http.StatusUnauthorized)
		return
	}
 
	body, err := io.ReadAll(r.Body)
	if err != nil {
		http.Error(w, "Cannot read body", http.StatusBadRequest)
		return
	}
 
	mac := hmac.New(sha256.New, secret)
	mac.Write(body)
	expected := hex.EncodeToString(mac.Sum(nil))
 
	if !hmac.Equal([]byte(signature), []byte(expected)) {
		http.Error(w, "Bad signature", http.StatusUnauthorized)
		return
	}
 
	// process body ...
	w.WriteHeader(http.StatusOK)
}

Common verification failures

SymptomLikely cause
Always failsBody was parsed/re-serialized before HMAC was computed. Use the raw body.
Sometimes failsReverse proxy / load balancer is rewriting the body or stripping the header.
Test endpoint passes, real events failUnicode handling differs. Webhooks are UTF-8; ensure your raw-body reader doesn't transcode.
Header missing entirelyNo secret is configured on the integration. Set one in Dashboard → Integrations → your webhook → Secret.

Replay protection (coming soon)

Nerva will add a X-Nerva-Timestamp header and sign timestamp.body instead of just body. This lets you reject replayed requests with a stale timestamp window. The current single-body HMAC will continue to work; the new header will be additive.

See also

On this page