# Verify webhook signatures

Source: https://developer.nylas.com/docs/cookbook/use-cases/build/verify-webhook-signatures/

Your webhook URL is public, so anyone who finds it can POST fake JSON at it. Without a check, your handler treats a forged `message.created` the same as a real one, which means an attacker can trigger replies, writes, or downstream jobs at will. The fix is to verify a signature on every request and drop anything that doesn't match.

Nylas signs each notification with a secret only your endpoint and the API share. This recipe shows how to recompute that signature and compare it in constant time, in Node.js and Python, including the one detail that trips up most teams: compressed bodies.

## How Nylas signs webhooks

After your endpoint passes the `challenge` verification, the API generates a `webhook_secret` unique to that destination. Every notification then carries an `x-nylas-signature` (or `X-Nylas-Signature`) header: a hex-encoded HMAC-SHA256 of the exact request body, signed with that secret. The digest is a 64-character hex string you recompute and compare.

The signature covers the raw bytes Nylas sent, not a parsed object. Your framework often re-serializes JSON before your code runs, and even a reordered key or changed whitespace produces a different digest. Capture the body as raw bytes or a raw string before any parser touches it, and read the secret from the [webhook security section](/docs/v3/notifications/#secure-a-webhook).

## Verify the signature in your handler

Read the raw request body, compute an HMAC-SHA256 over those exact bytes using your `webhook_secret`, then compare the hex digest against the `x-nylas-signature` header with a constant-time function. A standard `===` comparison leaks timing information one byte at a time, so the 64-character hex digests must be checked in constant time across all 64 bytes.

In Express, the JSON body parser replaces the raw payload, so register a verify hook that stashes `req.rawBody` first. The handler below rejects any request whose recomputed digest doesn't match before it parses or acts on a single field.

```js [verifySignature-Node.js]


const WEBHOOK_SECRET = process.env.NYLAS_WEBHOOK_SECRET;
const app = express();

// Capture the raw body before JSON parsing re-serializes it.
app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }));

function isValidSignature(rawBody, signature) {
  const expected = crypto
    .createHmac("sha256", WEBHOOK_SECRET)
    .update(rawBody)
    .digest("hex");
  const a = Buffer.from(expected, "utf8");
  const b = Buffer.from(signature ?? "", "utf8");
  // timingSafeEqual throws on length mismatch, so guard first.
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

app.post("/webhooks/nylas", (req, res) => {
  const signature = req.get("x-nylas-signature");
  if (!isValidSignature(req.rawBody, signature)) {
    return res.status(401).send("Invalid signature");
  }
  // Signature is valid: safe to process req.body.
  res.status(200).send();
});
```

The Python handler follows the same shape. Flask exposes the raw payload through `request.get_data()`, which returns the bytes before `request.json` decodes them. Use `hmac.compare_digest` for the constant-time check, and return a non-2xx status on any mismatch so a forged request never reaches your business logic.

```python
from flask import Flask, request

WEBHOOK_SECRET = os.environ["NYLAS_WEBHOOK_SECRET"].encode("utf-8")
app = Flask(__name__)


def is_valid_signature(raw_body: bytes, signature: str) -> bool:
    expected = hmac.new(WEBHOOK_SECRET, raw_body, hashlib.sha256).hexdigest()
    # compare_digest runs in constant time to resist timing attacks.
    return hmac.compare_digest(expected, signature or "")


@app.post("/webhooks/nylas")
def handle_webhook():
    raw_body = request.get_data()  # raw bytes, before JSON parsing
    signature = request.headers.get("X-Nylas-Signature", "")
    if not is_valid_signature(raw_body, signature):
        return "Invalid signature", 401
    # Signature is valid: safe to parse request.json and process.
    return "", 200
```

## Verify a signature from the terminal

Before you trust your webhook code, check it against a known-good oracle. The [Nylas CLI](https://cli.nylas.com/docs/commands) computes the HMAC-SHA256 over the raw body for you: `nylas webhook verify` takes the exact payload, your `webhook_secret`, and the `x-nylas-signature` header value, then reports whether they match.

```bash
nylas webhook verify \
  --payload-file ./raw-body.json \
  --secret "<WEBHOOK_SECRET>" \
  --signature "<x-nylas-signature header value>"
```

If your handler and the CLI disagree on the same payload, your code is reformatting the body before hashing, almost always the bug. The signature is a 32 byte HMAC-SHA256 digest over the exact bytes that arrived, so re-parsed JSON won't match. See the [`webhook verify`](https://cli.nylas.com/docs/commands/webhook-verify) command reference for inline-payload and file options.

## Handle compressed request bodies

When you enable `compressed_delivery`, Nylas `gzip`-compresses the JSON and adds a `Content-Encoding: gzip` header. The signature covers the compressed bytes, so you verify it against the raw `gzip` body first and decompress only after the check passes. Decompressing first changes the bytes and breaks verification every time.

This is the single most common cause of valid notifications failing the check. Many HTTP frameworks decompress automatically before your handler runs, so confirm you're hashing the original wire bytes, not the inflated JSON. Even a 1-byte difference between the compressed and decompressed payload produces a completely different digest. The full behavior lives in the [compressed notifications section](/docs/v3/notifications/#compressed-webhook-notifications), which spells out that the digest covers the compressed payload.

```js [verifyCompressed-Node.js]


app.post(
  "/webhooks/nylas",
  express.raw({ type: "*/*" }),   // keep the body as raw bytes
  (req, res) => {
    const signature = req.get("x-nylas-signature");
    // req.body is the raw (possibly gzipped) Buffer. Hash it as-is.
    if (!isValidSignature(req.body, signature)) {
      return res.status(401).send("Invalid signature");
    }
    const gzipped = req.get("content-encoding") === "gzip";
    const json = gzipped ? zlib.gunzipSync(req.body) : req.body;
    const payload = JSON.parse(json.toString("utf8"));
    res.status(200).send();
  }
);
```

## Things to know about webhook security

Signature verification is the core defense, but a few operational habits keep your endpoint trustworthy. HMAC-SHA256 ([RFC 2104](https://datatracker.ietf.org/doc/html/rfc2104)) proves a request was signed with the shared secret, yet it only holds if that secret stays secret and the comparison stays constant-time. Treat the four points below as a checklist before you ship.

- **Rotate the `webhook_secret`.** Treat it like any credential. If it leaks, an attacker can forge valid signatures, so rotate it and update the value your handler reads. The 64-character hex digest is only as strong as the key behind it.
- **Serve the endpoint over HTTPS.** Nylas requires an HTTPS `webhook_url`, which encrypts the body and signature in transit. Plain HTTP would expose both to anyone on the network path.
- **Compare in constant time.** A byte-by-byte `===` returns faster on an early mismatch, leaking the correct digit over many tries. `crypto.timingSafeEqual` and `hmac.compare_digest` take the same time regardless of where two values differ, which closes that timing side channel.
- **Reject on mismatch with a non-2xx status.** Return `401` and stop. The retry logic only re-sends on temporary `408`, `429`, `502`, `503`, `504`, and `507` codes, so a `401` tells the API the request was refused without queueing a redelivery.

For defense in depth, you can also restrict inbound traffic to the published Nylas source addresses at your firewall. The [webhook notifications guide](/docs/v3/notifications/) documents the source-address allowlist option alongside the rest of the signing behavior.

## What's next

- [Using webhooks with Nylas](/docs/v3/notifications/) for setup, the `challenge` handshake, and failure handling
- [Get real-time updates with webhooks](/docs/cookbook/use-cases/build/realtime-webhooks/) to subscribe one endpoint to messages and events
- [Get notified of new email](/docs/cookbook/use-cases/build/new-email-webhook/) for the `message.created` trigger end to end
- [Retry failed webhooks](/docs/cookbook/use-cases/build/retry-failed-webhooks/) for handling redelivery and dropped notifications