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.
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
400means "never send this again" and is permanent; a500means "try again" and is retried four more times. Returning400for 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.