Security

Verify that a delivery is genuinely from Rovela before you act on it.

Your webhook endpoint is a public URL that causes work when it is called — mailing someone, updating a contact, recording a bounce. Anyone who knows the URL can call it. The signature is what separates a real event from a stranger who guessed your path.

Verify on every request, and reject when verification fails. An endpoint that verifies "when convenient" is an unauthenticated endpoint with extra steps.

What is signed

The delivery carries a signature in this header:

x-resend-signature: t=<unix_timestamp>,v1=<hmac_sha256_hex>

The signature is HMAC-SHA256 over the string "<timestamp>.<raw body>" — the timestamp, a literal full stop, then the request body exactly as it arrived — keyed with the endpoint's signing secret and hex-encoded. The timestamp is the one from the header, not one you generate.

Verifying in Node

The important part is rawBody: these are the bytes you received, before any JSON parsing and re-serializing. Signing is byte-exact, and JSON.stringify(JSON.parse(body)) is not the same string as body — key order, whitespace and number formatting all differ.

import crypto from "node:crypto"

const MAX_AGE_SECONDS = 300

function verify(secret, signatureHeader, rawBody) {
  // "t=1712345678,v1=abcdef..."
  const parts = Object.fromEntries(
    signatureHeader.split(",").map((p) => p.split("="))
  )
  const timestamp = parts.t
  const presented = parts.v1
  if (!timestamp || !presented) return false

  // Replay window. The timestamp is inside the signed payload precisely so that
  // you can check this -- a captured request replayed tomorrow still verifies,
  // unless you look at the clock.
  const age = Math.abs(Date.now() / 1000 - Number(timestamp))
  if (!Number.isFinite(age) || age > MAX_AGE_SECONDS) return false

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.`)
    .update(rawBody)          // Buffer, not a string: byte-exact
    .digest("hex")

  // Constant-time. A plain === leaks, one byte at a time, how much of the
  // signature an attacker has guessed correctly.
  const a = Buffer.from(expected, "utf8")
  const b = Buffer.from(presented, "utf8")
  return a.length === b.length && crypto.timingSafeEqual(a, b)
}

Getting the raw body

Most frameworks parse JSON for you, which is convenient everywhere except here. Keep the raw bytes as well:

// Next.js App Router
export async function POST(request) {
  const rawBody = Buffer.from(await request.arrayBuffer())
  const sig = request.headers.get("x-resend-signature")

  if (!verify(process.env.ROVELA_WEBHOOK_SECRET, sig, rawBody)) {
    return new Response("invalid signature", { status: 400 })
  }

  const event = JSON.parse(rawBody.toString("utf8"))
  // ...
  return new Response(null, { status: 200 })
}

Replay

The timestamp is part of the signed string so that a delivery captured off the wire cannot be replayed later — but that only works if you check it. Rovela does not reject replayed requests on your behalf; it hands you a signed timestamp and you decide how much clock skew to allow. Five minutes is generous for a network round trip and short enough to be useless to an attacker.

For anything with a side effect you cannot undo, also deduplicate on the event id in the body. A replay window bounds how long a captured request is useful; it does not stop the same genuine event arriving twice through a retry.

Responding safely

  • Return 2xx fast. Store the event and process it asynchronously. A slow handler is a handler that gets retried, which means duplicate work.
  • Distinguish reject from fail. A 400 means "never send this again" and is permanent; a 500 means "try again" and is retried four more times. Returning 400 for a transient problem silently loses the event.
  • Do not echo the body. It contains recipient addresses, and your endpoint is public.
  • Treat the secret as a credential. It goes in environment variables, never in the repository, and never in client-side code.

If a secret leaks

Delete the endpoint and create a new one. There is no rotate-in-place: the secret is issued at creation, and a new endpoint gets a new one. Deleting drops any deliveries already queued for the old endpoint, so do it at a quiet moment and expect to miss whatever was in flight.