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
secretyou 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}, 200Python (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 valueCommon mistakes
- Parsing JSON before computing the signature. Re-serialization is not byte-identical. Always use the raw bytes.
- Using
req.bodyafter a JSON middleware ran. In Express,express.json()consumes the stream. Useexpress.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. Usecrypto.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.