Envello
Engineering

Verifying webhook signatures correctly (most guides get one step wrong)

Envello Team·2026-07-17·6 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.

Envello

EU-hosted transactional email, done right by default.