# Guide to Nylas Webhooks: Features and Best Practices

Nylas webhooks notify your app when something changes across the email, calendar, and contacts accounts your users have connected: a message arrives, a calendar event is updated, a grant expires. Instead of polling each provider Nylas integrates with, you get a single normalized notification stream through Nylas.

This guide covers how Nylas webhooks work, the triggers you can subscribe to, the challenge handshake at setup, how to verify the `x-nylas-signature` (including the gzip trap that breaks most first attempts), the delivery behavior, and the best practices for running them in production.

## What are Nylas webhooks?

Nylas webhooks are HTTP POSTs delivered to an endpoint you register, each describing a change on a connected account. Payloads follow the CloudEvents 1.0 format, so every notification carries `specversion`, `type`, `source`, `id`, `time`, a `webhook_delivery_attempt` counter, and a `data` object with the `application_id`, `grant_id`, and the affected `object`.

Nylas signs deliveries with an `x-nylas-signature` header and verifies your endpoint at creation with a challenge handshake.

## Nylas webhook features

| Feature | Details |
| --- | --- |
| Configuration | Dashboard or `POST /v3/webhooks` |
| Endpoint verification | Challenge handshake: echo `?challenge=<value>` within 10s |
| Signature header | `x-nylas-signature` (case-insensitive) |
| Signature scheme | hex HMAC-SHA256 of the raw body, keyed with the `webhook_secret` |
| Payload format | CloudEvents 1.0 |
| Compression | Optional gzip; the signature covers the compressed bytes |
| Retries | On temporary errors only; 2 retries (3 attempts total) |
| Webhook states | `failing` after 95% non-200s over 15 min; `failed` after 72h |
| SDKs | Webhook CRUD, `rotateSecret`, `ipAddresses`; no verify helper |

## Common triggers

Nylas v3 exposes triggers across grants, messages, threads, calendars, events, folders, contacts, bookings, and notetaker activity. A few common ones:

| Trigger | Fires when |
| --- | --- |
| `message.created` | A new message is received on a connected account |
| `message.opened` | A tracked message is opened (if message tracking is enabled) |
| `thread.replied` | A thread receives a reply |
| `calendar.updated` | A calendar is updated |
| `event.created` | A calendar event is created |
| `grant.expired` | A connected account's grant expires and needs re-auth |

Subscribe only to the triggers you need, and consult the Nylas notification schema reference for the authoritative, current catalog.

## Setting up Nylas webhooks

### The challenge handshake

When you create a webhook (in the dashboard or via `POST /v3/webhooks`), Nylas sends a GET request with a `challenge` query parameter. Echo the exact value back as the response body with a 200, within 10 seconds, and without chunked encoding. Return anything other than the raw challenge value and creation fails.

```javascript
app.get("/webhook", (req, res) => {
  res.status(200).send(req.query.challenge); // echo the exact value
});

```

On successful creation Nylas returns a `webhook_secret` (unique per destination), which you use to verify deliveries. You can rotate it later via the API.

## Securing Nylas webhooks

Every notification carries an `x-nylas-signature` header (casing varies, so treat it case-insensitively): a hex-encoded HMAC-SHA256 of the raw request body, keyed with the `webhook_secret`. Recompute it over the exact bytes and compare in constant time.

The gotcha that breaks most integrations: if you enable compressed delivery, Nylas sends `Content-Encoding: gzip`, and the signature covers the compressed bytes. Verify against the raw gzip body first, then decompress only after the check passes. Decompress first and the digest never matches.

```javascript
const crypto = require("crypto");

const SECRET = process.env.NYLAS_WEBHOOK_SECRET;

function verify(rawBody, signatureHeader) {
  const expected = crypto.createHmac("sha256", SECRET).update(rawBody).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from((signatureHeader || "").toLowerCase());
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// Read the body as raw bytes; do NOT decompress before verifying
app.post("/webhook", express.raw({ type: () => true }), (req, res) => {
  const signature = req.headers["x-nylas-signature"];
  if (!verify(req.body, signature)) {
    return res.sendStatus(401);
  }

  res.sendStatus(200); // acknowledge fast
  // Decompress (if gzip) only after the signature check passes, then enqueue
  processQueue.add(req.body);
});

```

The same check in Python:

```python
import hashlib
import hmac
import os

SECRET = os.environ["NYLAS_WEBHOOK_SECRET"].encode()

def verify(raw_body: bytes, signature: str) -> bool:
    expected = hmac.new(SECRET, raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, (signature or "").lower())

```

The official Nylas SDKs expose webhook CRUD, `rotateSecret`, `ipAddresses`, and a challenge-parameter helper, but no signature-verify helper, so you implement the HMAC check yourself with a constant-time comparison.

## Nylas webhook limitations and pain points

### The gzip signature trap

The Problem: With compressed delivery enabled, the signature is computed over the gzip bytes. Any framework or code path that decompresses (or re-serializes) the body before verification produces a different digest, and every notification fails the check.

Why It Happens: Signing the bytes on the wire is correct, but it collides with the common instinct to normalize the body before verifying.

Workarounds:

* Capture the raw body as bytes and verify before touching `Content-Encoding`.
* Decompress only after the signature passes.

How Hookdeck Can Help: Hookdeck verifies the `x-nylas-signature` at the edge against the bytes as received, so your app never has to reason about compressed-vs-decompressed body handling.

### Retries only on temporary errors

The Problem: Nylas retries only on a specific set of temporary errors (408, 429, 502, 503, 504, 507), with just 2 retries for 3 attempts total. A 4xx you return for any other reason, or a bug that 500s, may not be retried.

Why It Happens: Nylas distinguishes transient infrastructure errors from application errors it assumes won't succeed on retry.

Workarounds:

* Return one of the retryable status codes when you want a delivery retried, and a 2xx once you've safely enqueued it.
* Persist notifications on receipt so a downstream failure doesn't depend on Nylas retrying.

How Hookdeck Can Help: Hookdeck retries to your downstream on a schedule you control regardless of the status code semantics, and preserves failed deliveries for replay, so a non-retryable error no longer means a lost notification.

### Webhooks go `failing`, then `failed`

The Problem: If more than 95% of deliveries return non-200 over a 15-minute window, Nylas marks the webhook `failing`; 72 hours of that marks it `failed`, and a failed webhook needs manual reactivation. A broken endpoint can silently take your webhook offline.

Why It Happens: Nylas stops delivering to endpoints that look permanently broken.

Workarounds:

* Acknowledge fast with a 200 so transient downstream issues don't push you toward `failing`.
* Monitor webhook state via the API and alert on `failing`.

How Hookdeck Can Help: Hookdeck always accepts deliveries from Nylas and absorbs downstream failures itself, keeping your Nylas webhook healthy while it retries to your service. Its observability surfaces failures long before they reach the 72-hour `failed` threshold.

### Duplicate deliveries

The Problem: Retries and the `webhook_delivery_attempt` counter make clear the same notification can arrive more than once. Acting on duplicates double-processes.

Why It Happens: At-least-once delivery prioritizes not losing notifications over exactly-once semantics.

Workarounds:

* Dedupe on the CloudEvents `id`.
* Make side effects idempotent at the destination.

How Hookdeck Can Help: Hookdeck deduplicates deliveries at the edge, so retries don't reach your handler twice. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

## Best practices

### Verify against the raw (possibly gzip) body

Recompute the hex HMAC-SHA256 over the exact bytes with the `webhook_secret`, treat the header casing as case-insensitive, and never decompress before the check.

### Handle the challenge handshake

Echo the `?challenge=` value exactly, with a 200, within 10 seconds, and without chunked encoding.

### Acknowledge fast, process asynchronously

Return 200 as soon as the signature verifies and defer real work to a queue, so transient slowness doesn't drive you toward the `failing` state. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Dedupe on the CloudEvents `id`

Use the event `id` as an idempotency key so retries are no-ops, and keep side effects idempotent.

### Monitor webhook state and rotate secrets safely

Watch for the `failing` state, and use `rotateSecret` with an overlap window when rotating the `webhook_secret`.

## Conclusion

Nylas webhooks give you one normalized, CloudEvents-formatted stream across every email, calendar, and contacts account your users connect. The mechanics are specific: a challenge handshake at setup, an `x-nylas-signature` you verify against the raw body, and a gzip mode where the signature covers the compressed bytes and trips up most first implementations. Delivery adds its own edges: retries only on certain errors, a limited retry count, and `failing`/`failed` states that can quietly disable a webhook.

That puts verification correctness, fast acknowledgement, deduplication, and webhook-state monitoring on you. [Hookdeck](https://hookdeck.com) verifies the signature (compressed or not), deduplicates, durably queues, and keeps your Nylas webhook healthy, so your app only ever processes verified, unique notifications.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Nylas webhooks reliably in minutes.