waymark.networkguides → typeform-webhook-hmac-verification

Verify Typeform webhook HMAC signatures

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.

Sourced from 1 live route record · regenerated 2026-07-10

Part of the webhook verification series — also see: Stripe · Plaid · DocuSign · Attio

Why naive verification silently fails

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.

Documented pattern

Verify Typeform webhook payloads using HMAC-SHA256 signature checking

typeform.com/developersverified

The verification procedure

  1. Set a secret on the webhook — PUT /forms/{form_id}/webhooks/{tag} with a secret value in the body
  2. In the handler, capture the raw unparsed request bytes before any JSON-parsing middleware runs
  3. Compute HMAC-SHA256 over the raw bytes using the secret as the key, then base64-encode the digest
  4. Prefix the result with sha256= and compare it to the incoming Typeform-Signature header
  5. Reject if the header is missing or the signatures don't match; otherwise return 200 quickly and do heavy work asynchronously
// 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
});

The constant-time comparison gap in Typeform's own example

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.

Header casing and the "missing secret" trap

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."

Retries and idempotency

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.

How agents avoid this automatically

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"
Wire up waymark_query — free → Need a route for your exact stack? $25, delivered in 24h → For teams: verify your whole stack — Pilot $750/mo →