Typeform signs every webhook delivery and sends the signature in a Typeform-Signature header. Verification looks trivial — hash the body, compare strings — which is exactly why it's easy to ship a version that "works" in testing and silently fails, or silently accepts a forged request, in production. These are the documented patterns and failure modes from Waymark's live route corpus, checked against Typeform's own developer documentation.
Part of the webhook verification series — also see: Stripe · Plaid · DocuSign · Attio
The signature Typeform sends is an HMAC-SHA256 digest of the exact raw bytes of the request body, base64-encoded and prefixed with sha256=. Most web frameworks parse the body into a JSON object before your handler code ever sees it — and once that happens, the original byte sequence is gone. Re-serializing the parsed object (JSON.stringify(req.body)) produces different bytes than what Typeform actually sent (different key order, spacing, or number formatting), so the digest you compute never matches the header, even for a completely legitimate request. The fix has to happen before parsing: capture the raw body explicitly, then parse a copy of it if you need the JSON too.
PUT /forms/{form_id}/webhooks/{tag} with a secret value in the bodysha256= and compare it to the incoming Typeform-Signature header// Express — capture raw bytes with express.raw(), not express.json()
const crypto = require('crypto');
app.use('/webhook', express.raw({ type: 'application/json' }));
app.post('/webhook', (req, res) => {
const received = req.headers['typeform-signature']; // headers arrive lowercased
if (!received) return res.status(403).send('missing signature');
const hash = crypto.createHmac('sha256', process.env.TYPEFORM_SECRET)
.update(req.body) // raw Buffer — NOT a parsed/re-stringified object
.digest('base64');
const expected = `sha256=${hash}`;
const a = Buffer.from(received);
const b = Buffer.from(expected);
const valid = a.length === b.length && crypto.timingSafeEqual(a, b);
if (!valid) return res.status(403).send('invalid signature');
const payload = JSON.parse(req.body); // safe to parse now that it's verified
res.status(200).send('ok');
// hand payload off to a queue/async worker here
});
This is the gotcha worth calling out explicitly: Typeform's own developer documentation ships a working Node.js/Express example, and that example compares the computed and received signatures with plain string equality — receivedSignature === `sha256=${hash}` — not a constant-time comparison. Their Ruby example, by contrast, uses Rack::Utils.secure_compare, which is constant-time. Copy-pasting the official Node.js snippet as-is gets you correct signature math but a timing side-channel on the comparison step. Use crypto.timingSafeEqual (Node) or hmac.compare_digest (Python) instead of === or == — and check buffer/string length first, since timingSafeEqual throws on a length mismatch rather than returning false.
HTTP headers are case-insensitive on the wire, and most frameworks normalize incoming header names to lowercase before your code sees them (Express's req.headers is always lowercase, regardless of how Typeform capitalizes Typeform-Signature on send) — look up the header as typeform-signature, not a hardcoded camelCase or Pascal-Case variant, or your existence check silently always fails. Separately: the signature header only appears at all if a secret was explicitly configured on that webhook via the Webhooks API. A webhook created without one doesn't send a header with an empty or null value — it sends no Typeform-Signature header whatsoever, which looks identical to a client that forgot to read it. Treat an absent header as a hard reject, never as "verification not applicable, allow through."
Typeform retries failed deliveries, so a handler that isn't idempotent will double-process the same form submission on any transient failure — a timeout, a 5xx, a slow downstream call. Verify the signature, respond with 200 promptly, and do the actual processing (writing to your DB, calling other APIs) asynchronously after the response, keyed on the payload's response token or event ID so a duplicate delivery is a no-op rather than a duplicate side effect.
Agents wired to Waymark's MCP server pull this route (and its gotchas — including the constant-time comparison gap) at plan time instead of copy-pasting the official example verbatim and shipping a timing side-channel. Reads are free and keyless.
# Any MCP client — one call, no key:
waymark_query({ "task": "verify typeform webhook hmac signature" })
# Or plain HTTP:
curl -s "https://mcp.waymark.network/search?q=verify%20typeform%20webhook%20hmac%20signature"