WebhooksVerifying Signatures

Verifying Signatures

Every Warpweb webhook is signed with HMAC-SHA256 over the raw request body. This is the single most important thing to get right — without verification, anyone who learns your URL can forge submissions.

The scheme

  • Algorithm: HMAC-SHA256
  • Key: the secret you passed when configuring the webhook
  • Input: the raw request body bytes, before any JSON parsing or re-serialization
  • Format: hex-encoded digest, prefixed with sha256=
  • Header: X-Warpweb-Signature

Identical to Stripe’s webhook signing scheme — most existing tooling works.

Critical: verify against raw body

Common gotcha: parsing JSON before signing strips whitespace, re-orders keys, and breaks the signature. Always compute HMAC over the exact bytes received over the wire.

In Express, that means using express.raw() for the webhook route, not express.json().

In Next.js App Router, read req.text() (or req.arrayBuffer()), verify, then JSON.parse().

Use constant-time comparison

Compare signatures with a constant-time function (crypto.timingSafeEqual, hmac.compare_digest) to avoid timing attacks. Plain === leaks information about how many characters matched.

Node.js (Express)

import crypto from 'node:crypto'
import express from 'express'
 
const app = express()
const WEBHOOK_SECRET = process.env.WARPWEB_WEBHOOK_SECRET
 
app.post(
  '/webhooks/warpweb-leads',
  express.raw({ type: 'application/json' }),  // raw, not json
  (req, res) => {
    const signature = req.headers['x-warpweb-signature']
    if (!signature || !signature.startsWith('sha256=')) {
      return res.status(401).send('missing signature')
    }
 
    const expected = crypto
      .createHmac('sha256', WEBHOOK_SECRET)
      .update(req.body)  // req.body is a Buffer thanks to express.raw
      .digest('hex')
 
    const provided = signature.slice('sha256='.length)
 
    const valid =
      provided.length === expected.length &&
      crypto.timingSafeEqual(Buffer.from(provided, 'hex'), Buffer.from(expected, 'hex'))
 
    if (!valid) return res.status(401).send('invalid signature')
 
    const payload = JSON.parse(req.body.toString('utf8'))
    // ...handle the form submission
    res.status(200).send('ok')
  }
)

Node.js (Next.js App Router)

// app/api/webhooks/warpweb-leads/route.ts
import crypto from 'node:crypto'
import { NextRequest, NextResponse } from 'next/server'
 
const WEBHOOK_SECRET = process.env.WARPWEB_WEBHOOK_SECRET!
 
export async function POST(req: NextRequest) {
  const rawBody = await req.text()
  const signature = req.headers.get('x-warpweb-signature') ?? ''
 
  if (!signature.startsWith('sha256=')) {
    return new NextResponse('missing signature', { status: 401 })
  }
 
  const expected = crypto
    .createHmac('sha256', WEBHOOK_SECRET)
    .update(rawBody, 'utf8')
    .digest('hex')
 
  const provided = signature.slice('sha256='.length)
 
  const valid =
    provided.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(provided, 'hex'), Buffer.from(expected, 'hex'))
 
  if (!valid) return new NextResponse('invalid signature', { status: 401 })
 
  const payload = JSON.parse(rawBody)
  // ...handle the form submission
 
  return NextResponse.json({ ok: true })
}

Python (Flask)

import hmac
import hashlib
import os
from flask import Flask, request, abort
 
app = Flask(__name__)
WEBHOOK_SECRET = os.environ['WARPWEB_WEBHOOK_SECRET'].encode()
 
@app.post('/webhooks/warpweb-leads')
def warpweb_webhook():
    signature = request.headers.get('X-Warpweb-Signature', '')
    if not signature.startswith('sha256='):
        abort(401, 'missing signature')
 
    raw_body = request.get_data()  # raw bytes, do not use request.json
    expected = hmac.new(WEBHOOK_SECRET, raw_body, hashlib.sha256).hexdigest()
    provided = signature[len('sha256='):]
 
    if not hmac.compare_digest(provided, expected):
        abort(401, 'invalid signature')
 
    payload = request.get_json()
    # ...handle the form submission
    return {'ok': True}, 200

Python (FastAPI)

import hmac
import hashlib
import os
from fastapi import FastAPI, Request, HTTPException
 
app = FastAPI()
WEBHOOK_SECRET = os.environ['WARPWEB_WEBHOOK_SECRET'].encode()
 
@app.post('/webhooks/warpweb-leads')
async def warpweb_webhook(request: Request):
    raw_body = await request.body()
    signature = request.headers.get('x-warpweb-signature', '')
 
    if not signature.startswith('sha256='):
        raise HTTPException(status_code=401, detail='missing signature')
 
    expected = hmac.new(WEBHOOK_SECRET, raw_body, hashlib.sha256).hexdigest()
    provided = signature[len('sha256='):]
 
    if not hmac.compare_digest(provided, expected):
        raise HTTPException(status_code=401, detail='invalid signature')
 
    import json
    payload = json.loads(raw_body)
    # ...handle the form submission
    return {'ok': True}

curl (one-off verification)

Useful for debugging in production or in a Bash-only environment:

RAW_BODY='{"event_id":"7d0b368c-...","event_type":"form_submission","site_id":"...","submitted_at":"2026-05-17T15:42:11Z","name":"Jane Doe","email":"jane@example.com","phone":"+15125550199","message":"...","raw_fields":{}}'
 
EXPECTED=$(printf '%s' "$RAW_BODY" | openssl dgst -sha256 -hmac "$WARPWEB_WEBHOOK_SECRET" | awk '{print $2}')
 
echo "sha256=$EXPECTED"
# Compare to the X-Warpweb-Signature header value

Common mistakes

  • Parsing JSON before computing the signature. Re-serialization is not byte-identical. Always use the raw bytes.
  • Using req.body after a JSON middleware ran. In Express, express.json() consumes the stream. Use express.raw() on the webhook route.
  • Trimming whitespace. Don’t. The body is signed exactly as sent.
  • Comparing with === or ==. Leaks timing information about partial matches. Use crypto.timingSafeEqual / hmac.compare_digest.
  • Truncating the hex string. The full 64-character hex digest is the signature.
  • Hashing the secret. The secret is the HMAC key, not part of the message. Don’t include it in the message.

Rotating the secret

Call POST /v1/sites/:id/webhooks/forms again with a new secret. The old secret is invalidated immediately; in-flight deliveries already in retry will be re-signed with the new secret on the next attempt.

For zero-downtime rotation, support both old and new secrets on your receiver for a few minutes while the rotation propagates.