← Back to blog

Verifying Webhook Signatures: Block Forged Requests Before They Hit Your Logic

Rovela signs every webhook delivery with HMAC-SHA256 in the x-resend-signature header (t=…,v1=…). This article walks through verification step by step, with special attention to three common mistakes: raw body handling, replay protection, and constant-time comparison.

Updated October 13, 2026
Verifying Webhook Signatures: Block Forged Requests Before They Hit Your Logic

Webhooks receive requests over the open internet. Anyone can POST to your endpoint, so without signature verification you cannot tell a real Rovela event from a forged one — and a forged event can trigger refunds, mark complaints, or do anything else your handler is written to do.

Rovela signs every webhook delivery. Verifying the signature is a few lines of code, but each step matters.

The signature header

Every delivery includes:

x-resend-signature: t=1728820800,v1=a3f2c1d4e5b6...
x-resend-event-type: email.delivered
x-resend-delivery-id: del_...

The x-resend-signature header has two comma-separated fields: t is the Unix timestamp (seconds) at send time, and v1 is the hex-encoded HMAC-SHA256 of the string ${t}.${rawBody}, computed with the webhook's signing_secret. That secret is the whsec_... string returned when you create the webhook — it appears once, so store it immediately.

Verification step by step

Step 1: preserve the raw body.

The signature covers the original bytes, not a re-serialized JSON object. Register the raw-body parser before any JSON middleware:

// Express — register before the JSON parser
app.use('/webhooks/rovela', express.raw({ type: 'application/json' }))

Step 2: parse the header.

function parseSignatureHeader(header) {
  return Object.fromEntries(
    header.split(',').map((p) => p.split('=', 2))
  )
}

Step 3: recompute the expected signature and compare.

import { createHmac, timingSafeEqual } from 'crypto'

function verifyWebhook(secret, header, rawBody) {
  const { t, v1 } = parseSignatureHeader(header)
  if (!t || !v1) return false

  // Reject replays older than 5 minutes
  const age = Math.abs(Date.now() / 1000 - Number(t))
  if (age > 300) return false

  const expected = createHmac('sha256', secret)
    .update(`${t}.${rawBody}`)
    .digest('hex')

  // Constant-time comparison to prevent timing attacks
  return timingSafeEqual(Buffer.from(expected), Buffer.from(v1))
}

Step 4: use it in the handler.

app.post('/webhooks/rovela', (req, res) => {
  const header = req.headers['x-resend-signature']
  const rawBody = req.body // Buffer, because of express.raw()

  if (!verifyWebhook(process.env.ROVELA_WEBHOOK_SECRET, header, rawBody)) {
    return res.status(401).end()
  }

  const event = JSON.parse(rawBody)
  // handle event...
  res.status(200).end()
})

Using the Rovela SDK

The rovela package includes a verification helper that handles all of the above:

import { verifyWebhookSignature } from 'rovela/webhook'

app.post('/webhooks/rovela', (req, res) => {
  try {
    const event = verifyWebhookSignature(
      process.env.ROVELA_WEBHOOK_SECRET,
      req.headers['x-resend-signature'],
      req.body // still needs to be raw bytes, not a parsed object
    )
    // event is already parsed
    res.status(200).end()
  } catch {
    res.status(401).end()
  }
})

Three things that break verification

Parsing the body before verifying. Most frameworks parse JSON into an object before your route handler runs. Re-serializing that object produces a different byte sequence than the original — different key order, different whitespace — so the HMAC will never match and every legitimate delivery is rejected. The raw-body parser must run before any JSON middleware, not after.

Skipping the timestamp check. The signature proves the request came from Rovela, not that it just arrived. Without a time window, an attacker who captures a valid delivery can replay it days later. The five-minute window in the example above is a reasonable default.

String equality instead of timingSafeEqual. A regular === comparison returns as soon as the first differing byte is found. In some runtimes that timing difference is measurable and can be used to guess the signature byte by byte. timingSafeEqual always takes the same time regardless of how much of the strings match.

One endpoint receiving multiple webhooks

If you send all webhooks to the same endpoint, each webhook has its own signing_secret. The x-resend-delivery-id header tells you which delivery this is, and you can look up the corresponding secret to verify with. Alternatively, verify against all known secrets and accept if any matches — though a per-webhook lookup is cleaner.

See the Webhooks API reference for delivery headers, retry behavior, and the full event envelope format.