Envello
Engineering

Verifying webhook signatures, the step most guides miss

Envello Team·2026-07-17·7 min read

Envello signs every webhook with HMAC-SHA256, in a scheme deliberately modeled on Stripe's, since most backend frameworks already have a verifier pattern for that shape. The scheme itself is simple. The part that trips up most implementations isn't the cryptography, it's verifying against the wrong bytes.

The exact scheme

Every webhook request carries an Envello-Signature header in the form t=<unix-seconds>,v1=<hex-encoded HMAC-SHA256>. The signed content is the string ${timestamp}.${rawRequestBody}, the timestamp, a literal period, then the exact request body as it was sent, not a parsed-and-reserialized version of it.

The step most guides get wrong

If your framework automatically parses the JSON body before your webhook handler sees it, and you then verify the signature against JSON.stringify(req.body), that will fail intermittently, or worse, succeed in testing and fail in production. JSON serialization isn't guaranteed to reproduce byte-for-byte identical output (key ordering, whitespace, and number formatting can all differ). You have to verify against the raw, untouched request body, captured before any JSON parsing middleware touches it. In Express this usually means using a raw body parser specifically for the webhook route; in most frameworks it means finding the equivalent "give me the bytes before you parse them" option.

Verification steps

  • Capture the raw request body as a string, before any JSON parsing
  • Parse the Envello-Signature header into its t and v1 components
  • Reject the request if the timestamp is more than 5 minutes old (the default tolerance), to prevent replay of a captured request
  • Recompute HMAC-SHA256 over `${timestamp}.${rawBody}` using your endpoint's webhook secret, hex-encode the result
  • Compare the computed signature to v1 using a constant-time comparison, not a plain string equality check, to avoid timing attacks

Why constant-time comparison matters here specifically

A naive string comparison (=== or similar) short-circuits on the first mismatched character, which leaks timing information an attacker could theoretically use to guess the correct signature byte by byte. It's a narrow attack surface, but the fix costs nothing: use your language's constant-time comparison function (Node's crypto.timingSafeEqual, for example) instead of a plain equality check.

Why the timestamp tolerance exists

Without a timestamp check, a captured, valid webhook request could be replayed indefinitely, since the signature itself would still verify correctly no matter how old the request is. The 5-minute tolerance window means a captured request is only replayable for a short period, and it also gives a reasonable margin for legitimate clock drift between Envello's servers and yours, so it's not so tight that a slightly out-of-sync system clock starts rejecting genuine webhooks.

Handling framework-specific raw body access

The exact mechanism for getting the raw body before parsing varies by framework: Express needs express.raw() or a similar middleware scoped to just the webhook route (not applied globally, since your other routes presumably still want parsed JSON), Next.js API routes need the bodyParser config disabled for that specific route, and most other frameworks have an equivalent option, usually documented under "raw body" or "webhook verification" in their own docs since this exact problem, not just for Envello's webhooks, is common enough that framework authors have anticipated it.

Testing your verification logic

Don't test signature verification only against real webhooks from Envello's dashboard test-send feature. Write a unit test that constructs a known payload, computes the expected signature with the same secret and algorithm, and asserts your verification function accepts it, then asserts it rejects a tampered payload or an expired timestamp. This catches the raw-body bug described above immediately in CI, rather than only discovering it once a production webhook happens to hit the exact edge case that breaks re-serialized JSON comparison.

Envello

EU-hosted transactional email, done right by default.