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
Section titled “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.
Verify the signature in your handler
Section titled “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.
import express from "express";import crypto from "crypto";
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.
import hmacimport hashlibimport osfrom 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 "", 200Handle compressed request bodies
Section titled “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, which spells out that the digest covers the compressed payload.
import zlib from "zlib";
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
Section titled “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) 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.timingSafeEqualandhmac.compare_digesttake the same time regardless of where two values differ, which closes that timing side channel. - Reject on mismatch with a non-2xx status. Return
401and stop. The retry logic only re-sends on temporary408,429,502,503,504, and507codes, so a401tells 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 documents the source-address allowlist option alongside the rest of the signing behavior.
What’s next
Section titled “What’s next”- Using webhooks with Nylas for setup, the
challengehandshake, and failure handling - Get real-time updates with webhooks to subscribe one endpoint to messages and events
- Get notified of new email for the
message.createdtrigger end to end - Retry failed webhooks for handling redelivery and dropped notifications