# Verifying signatures

> Verify the HMAC signature on every delivery before trusting the body.

---
title: Verifying signatures
description: Verify the HMAC signature on every delivery before trusting the body.
---

Every delivery is signed with your webhook's secret, shown once when the webhook is created (or rotated). Verify before processing: recompute the HMAC over the exact raw bytes you received and compare in constant time. Re-serializing parsed JSON will break the signature.

The `X-ROASForm-Signature` header has the form:

```
t=1753712000,v1=5f8a3c…
```

where `t` is a Unix timestamp (seconds) and `v1` is `HMAC-SHA256(secret, "<t>.<rawBody>")` hex-encoded. Rejecting old timestamps blunts replay attacks; 5 minutes is a reasonable tolerance.

```js title="verify.js"
import crypto from "crypto";

// rawBody MUST be the exact bytes received (do not re-serialize parsed JSON).
export function verifyRoasformWebhook(rawBody, signatureHeader, secret, toleranceSec = 300) {
  const parts = Object.fromEntries(
    signatureHeader.split(",").map((kv) => kv.split("=")),
  );
  const t = Number(parts.t);
  const v1 = parts.v1;
  if (!t || !v1) return false;

  // Optional replay window.
  if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSec) return false;

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

  const a = Buffer.from(expected);
  const b = Buffer.from(v1);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

## Rotating a secret

Rotate from the webhook editor in the dashboard. The new secret is shown once; update your endpoint immediately, since deliveries sign with the new secret from that point on.

## Endpoint requirements

Endpoints must be public HTTPS URLs. Private and internal addresses (localhost, internal IP ranges) are rejected, and redirects are never followed.
