# Monitor an inbox for support tickets

Source: https://developer.nylas.com/docs/cookbook/use-cases/ingest/monitor-inbox-support-tickets/

Most support ticket systems start the same way: an email lands in a shared inbox, someone reads it, decides where it should go, and forwards it along. That triage step is the bottleneck. It delays response times, leads to misrouted tickets, and disappears entirely outside business hours.

This recipe replaces the triage step with a webhook handler. Instead of polling an inbox, Nylas pushes a notification the moment a message arrives. Your code inspects the subject, body, and sender domain, applies routing rules, and hands the ticket to whatever system you use -- Jira, Zendesk, a database, a Slack channel. Same code works for Google, Microsoft, and IMAP accounts.

If you want a lighter classify-and-archive flow on a personal inbox, see the [email triage agent](/docs/cookbook/agents/email-triage-agent/) instead. This recipe is for shared support inboxes where tickets need to fan out to teams.

## The pipeline

```
inbound message ─▶ message.created webhook ─▶ verify signature
                                                     │
                                                     ▼
                                        skip auto-replies / bounces
                                                     │
                                                     ▼
                                        fetch full body if truncated
                                                     │
                                                     ▼
                              keyword scan (subject + body) → category
                                                     │
                                                     ▼
                              sender domain check → priority adjust
                                                     │
                                                     ▼
                                        route to ticket system
```

Six steps. The classification logic is intentionally simple keyword + domain matching so you can swap in your own rules without touching the webhook plumbing.

## Before you begin


Make sure you have the following before starting this tutorial:

- A [Nylas account](https://dashboard-v3.nylas.com/) with an active application
- A valid **API key** from your Nylas Dashboard
- At least one **connected grant** (an authenticated user account) for the provider you want to work with
- **Node.js 18+** or **Python 3.8+** installed (depending on which code samples you follow)

> **Info:** 
> **New to Nylas?** Start with the [quickstart guide](/docs/v3/getting-started/) to set up your app and connect a test account before continuing here.


You also need:

- A **publicly accessible HTTPS endpoint** for Nylas to send webhook notifications to. During development, use [VS Code port forwarding](https://code.visualstudio.com/docs/editor/port-forwarding) or [Hookdeck](https://hookdeck.com/) to expose your local server.
- **Express** (Node.js) or **Flask** (Python) installed in your project.

> **Warn:** 
> **Nylas blocks requests to Ngrok URLs** because of throughput limiting concerns. Use VS Code port forwarding or Hookdeck instead.

## Set up a webhook for new messages

Create a webhook subscription that listens for the `message.created` trigger. This tells Nylas to send a `POST` request to your endpoint every time a new message arrives in any connected grant.

```bash
curl --request POST \
  --url 'https://api.us.nylas.com/v3/webhooks/' \
  --header 'Content-Type: application/json' \
  --header 'Authorization: Bearer <NYLAS_API_KEY>' \
  --data '{
    "trigger_types": ["message.created"],
    "description": "Support ticket monitor",
    "webhook_url": "<YOUR_WEBHOOK_URL>",
    "notification_email_addresses": ["you@example.com"]
  }'
```

Replace `<NYLAS_API_KEY>` with your API key from the Nylas Dashboard and `<YOUR_WEBHOOK_URL>` with the public HTTPS URL where your server is listening.

Nylas returns the webhook object with a `webhook_secret`. Save this value. You need it to verify incoming notifications.

### Handle the challenge verification

The moment you create the webhook (or reactivate one), Nylas sends a `GET` request to your endpoint with a `challenge` query parameter. Your server must return the exact value of that parameter in a `200` response within 10 seconds. If it doesn't, the webhook stays inactive and Nylas won't retry.

```bash
# Nylas sends something like this:
GET <YOUR_WEBHOOK_URL>?challenge=bc609b38-c81f-47fb-a275-1d9bd61a968b
```

Your server returns:

```
200 OK
bc609b38-c81f-47fb-a275-1d9bd61a968b
```

No JSON wrapping, no extra whitespace, no chunked encoding. Just the raw challenge string in the response body.

## Handle incoming message notifications

Once the webhook is active, Nylas sends a `POST` request to your endpoint for every new message. The payload includes the message object (or a truncated version if it exceeds 1 MB). Your handler needs to do three things: verify the signature, parse the notification, and fetch the full message if needed.

### Node.js with Express

```js [webhookHandler-Node.js]
const express = require("express");
const crypto = require("crypto");

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

// Parse raw body for signature verification
app.use("/webhook", express.raw({ type: "application/json" }));

// Handle challenge verification (GET)
app.get("/webhook", (req, res) => {
  const challenge = req.query.challenge;
  if (challenge) {
    console.log("Webhook challenge received, responding...");
    return res.status(200).send(challenge);
  }
  res.status(400).send("Missing challenge parameter");
});

// Handle webhook notifications (POST)
app.post("/webhook", async (req, res) => {
  // 1. Verify the HMAC signature
  const signature = req.headers["x-nylas-signature"];
  const digest = crypto
    .createHmac("sha256", WEBHOOK_SECRET)
    .update(req.body)
    .digest("hex");

  if (signature !== digest) {
    console.error("Invalid webhook signature");
    return res.status(401).send("Invalid signature");
  }

  // 2. Respond immediately with 200 to prevent retries
  res.status(200).send("OK");

  // 3. Parse the notification
  const notification = JSON.parse(req.body);
  const { type, data } = notification.deltas[0];

  // 4. Skip truncated notifications by fetching full message
  if (type.includes("truncated") || !data.body) {
    const fullMessage = await fetchFullMessage(data.grant_id, data.id);
    if (fullMessage) {
      processMessage(fullMessage);
    }
  } else {
    processMessage(data);
  }
});

async function fetchFullMessage(grantId, messageId) {
  const response = await fetch(
    `https://api.us.nylas.com/v3/grants/${grantId}/messages/${messageId}`,
    {
      headers: {
        Authorization: `Bearer ${NYLAS_API_KEY}`,
      },
    },
  );

  if (!response.ok) {
    console.error(`Failed to fetch message ${messageId}: ${response.status}`);
    return null;
  }

  const result = await response.json();
  return result.data;
}

app.listen(3000, () => console.log("Webhook server running on port 3000"));
```

### Python with Flask

```python
from flask import Flask, request

app = Flask(__name__)
NYLAS_API_KEY = os.environ["NYLAS_API_KEY"]
WEBHOOK_SECRET = os.environ["NYLAS_WEBHOOK_SECRET"]


@app.route("/webhook", methods=["GET"])
def handle_challenge():
    """Return the challenge parameter for webhook verification."""
    challenge = request.args.get("challenge")
    if challenge:
        print("Webhook challenge received, responding...")
        return challenge, 200
    return "Missing challenge parameter", 400


@app.route("/webhook", methods=["POST"])
def handle_notification():
    """Process incoming webhook notifications."""
    # 1. Verify the HMAC signature
    signature = request.headers.get("x-nylas-signature")
    raw_body = request.get_data()
    digest = hmac.new(
        WEBHOOK_SECRET.encode(), raw_body, hashlib.sha256
    ).hexdigest()

    if not hmac.compare_digest(signature, digest):
        print("Invalid webhook signature")
        return "Invalid signature", 401

    # 2. Parse the notification
    notification = json.loads(raw_body)
    delta = notification["deltas"][0]
    trigger_type = delta["type"]
    data = delta["data"]

    # 3. Fetch full message if truncated or body missing
    if "truncated" in trigger_type or not data.get("body"):
        full_message = fetch_full_message(
            data["grant_id"], data["id"]
        )
        if full_message:
            process_message(full_message)
    else:
        process_message(data)

    return "OK", 200


def fetch_full_message(grant_id: str, message_id: str) -> dict:
    """Fetch the complete message from the Nylas API."""
    response = requests.get(
        f"https://api.us.nylas.com/v3/grants/{grant_id}/messages/{message_id}",
        headers={"Authorization": f"Bearer {NYLAS_API_KEY}"},
    )

    if response.status_code != 200:
        print(f"Failed to fetch message {message_id}: {response.status_code}")
        return None

    return response.json()["data"]


if __name__ == "__main__":
    app.run(port=3000)
```

> **Info:** 
> **Respond with `200` before doing heavy processing.** If your handler takes too long, Nylas considers the delivery failed and retries. Acknowledge the webhook immediately, then process the message asynchronously.

## Categorize and route messages

Now that you have the full message, you can inspect its content and decide where it should go. The `processMessage` function below checks the subject line and body for keywords, looks up the sender's domain, and assigns a category and priority.

### Keyword-to-category mapping

| Keywords                                            | Category             | Default priority |
| --------------------------------------------------- | -------------------- | ---------------- |
| `urgent`, `down`, `outage`, `critical`, `broken`    | **Incidents**        | High             |
| `bug`, `error`, `crash`, `not working`, `issue`     | **Bugs**             | Medium           |
| `billing`, `invoice`, `charge`, `refund`, `payment` | **Billing**          | Medium           |
| `feature`, `request`, `suggestion`, `would be nice` | **Feature requests** | Low              |
| `cancel`, `unsubscribe`, `close account`            | **Churn risk**       | High             |

### Node.js routing logic

```js [routeMessages-Node.js]
const CATEGORY_RULES = [
  {
    keywords: ["urgent", "down", "outage", "critical", "broken"],
    category: "incidents",
    priority: "high",
  },
  {
    keywords: ["bug", "error", "crash", "not working", "issue"],
    category: "bugs",
    priority: "medium",
  },
  {
    keywords: ["billing", "invoice", "charge", "refund", "payment"],
    category: "billing",
    priority: "medium",
  },
  {
    keywords: ["feature", "request", "suggestion", "would be nice"],
    category: "feature_requests",
    priority: "low",
  },
  {
    keywords: ["cancel", "unsubscribe", "close account"],
    category: "churn_risk",
    priority: "high",
  },
];

// Domains that indicate enterprise customers
const HIGH_PRIORITY_DOMAINS = ["bigcorp.com", "enterprise-client.io"];

function processMessage(message) {
  // Skip auto-replies and bounce messages
  if (isAutoReply(message)) {
    console.log(`Skipping auto-reply: ${message.id}`);
    return;
  }

  const subject = (message.subject || "").toLowerCase();
  const body = (message.body || "").toLowerCase();
  const searchText = `${subject} ${body}`;

  // Determine category
  let category = "general";
  let priority = "low";

  for (const rule of CATEGORY_RULES) {
    const matched = rule.keywords.some((kw) => searchText.includes(kw));
    if (matched) {
      category = rule.category;
      priority = rule.priority;
      break;
    }
  }

  // Boost priority for known enterprise domains
  const senderEmail = message.from?.[0]?.email || "";
  const senderDomain = senderEmail.split("@")[1];

  if (HIGH_PRIORITY_DOMAINS.includes(senderDomain)) {
    priority = "high";
  }

  const ticket = {
    messageId: message.id,
    grantId: message.grant_id,
    subject: message.subject,
    from: senderEmail,
    category,
    priority,
    receivedAt: new Date(message.date * 1000).toISOString(),
  };

  console.log("Ticket created:", JSON.stringify(ticket, null, 2));
  routeTicket(ticket);
}

function routeTicket(ticket) {
  // Replace with your actual routing logic:
  // - POST to Jira/Zendesk API
  // - Insert into a database
  // - Send to a Slack channel
  // - Add to a queue for agent assignment
  switch (ticket.category) {
    case "incidents":
      console.log(`[URGENT] Routing to on-call: ${ticket.subject}`);
      break;
    case "billing":
      console.log(`Routing to billing team: ${ticket.subject}`);
      break;
    case "churn_risk":
      console.log(`Routing to retention team: ${ticket.subject}`);
      break;
    default:
      console.log(`Routing to general queue: ${ticket.subject}`);
  }
}
```

### Python routing logic

```python
CATEGORY_RULES = [
    {
        "keywords": ["urgent", "down", "outage", "critical", "broken"],
        "category": "incidents",
        "priority": "high",
    },
    {
        "keywords": ["bug", "error", "crash", "not working", "issue"],
        "category": "bugs",
        "priority": "medium",
    },
    {
        "keywords": ["billing", "invoice", "charge", "refund", "payment"],
        "category": "billing",
        "priority": "medium",
    },
    {
        "keywords": ["feature", "request", "suggestion", "would be nice"],
        "category": "feature_requests",
        "priority": "low",
    },
    {
        "keywords": ["cancel", "unsubscribe", "close account"],
        "category": "churn_risk",
        "priority": "high",
    },
]

HIGH_PRIORITY_DOMAINS = ["bigcorp.com", "enterprise-client.io"]


def process_message(message: dict):
    """Categorize and route an incoming message."""
    # Skip auto-replies and bounce messages
    if is_auto_reply(message):
        print(f"Skipping auto-reply: {message['id']}")
        return

    subject = (message.get("subject") or "").lower()
    body = (message.get("body") or "").lower()
    search_text = f"{subject} {body}"

    # Determine category
    category = "general"
    priority = "low"

    for rule in CATEGORY_RULES:
        if any(kw in search_text for kw in rule["keywords"]):
            category = rule["category"]
            priority = rule["priority"]
            break

    # Boost priority for known enterprise domains
    sender_email = ""
    if message.get("from") and len(message["from"]) > 0:
        sender_email = message["from"][0].get("email", "")

    sender_domain = sender_email.split("@")[-1] if "@" in sender_email else ""

    if sender_domain in HIGH_PRIORITY_DOMAINS:
        priority = "high"

    ticket = {
        "message_id": message["id"],
        "grant_id": message["grant_id"],
        "subject": message.get("subject"),
        "from": sender_email,
        "category": category,
        "priority": priority,
        "received_at": message.get("date"),
    }

    print(f"Ticket created: {ticket}")
    route_ticket(ticket)


def route_ticket(ticket: dict):
    """Route the ticket to the appropriate handler."""
    category = ticket["category"]

    if category == "incidents":
        print(f"[URGENT] Routing to on-call: {ticket['subject']}")
    elif category == "billing":
        print(f"Routing to billing team: {ticket['subject']}")
    elif category == "churn_risk":
        print(f"Routing to retention team: {ticket['subject']}")
    else:
        print(f"Routing to general queue: {ticket['subject']}")
```

### Detect auto-replies and bounces

Not every incoming message is a real support request. Auto-replies, out-of-office messages, and delivery failure notifications should be filtered out before your routing logic runs.

```js [autoReply-Node.js]
function isAutoReply(message) {
  const headers = message.headers || {};

  // Check standard auto-reply headers
  if (headers["auto-submitted"] && headers["auto-submitted"] !== "no") {
    return true;
  }
  if (headers["x-auto-response-suppress"]) {
    return true;
  }
  if (headers["precedence"] === "bulk" || headers["precedence"] === "junk") {
    return true;
  }

  // Check for common auto-reply subject patterns
  const subject = (message.subject || "").toLowerCase();
  const autoReplyPhrases = [
    "out of office",
    "automatic reply",
    "auto-reply",
    "delivery status notification",
    "undeliverable",
    "mail delivery failed",
  ];

  return autoReplyPhrases.some((phrase) => subject.includes(phrase));
}
```

```python
def is_auto_reply(message: dict) -> bool:
    """Check if a message is an auto-reply or bounce."""
    headers = message.get("headers", {})

    # Check standard auto-reply headers
    if headers.get("auto-submitted", "no") != "no":
        return True
    if headers.get("x-auto-response-suppress"):
        return True
    if headers.get("precedence") in ("bulk", "junk"):
        return True

    # Check for common auto-reply subject patterns
    subject = (message.get("subject") or "").lower()
    auto_reply_phrases = [
        "out of office",
        "automatic reply",
        "auto-reply",
        "delivery status notification",
        "undeliverable",
        "mail delivery failed",
    ]

    return any(phrase in subject for phrase in auto_reply_phrases)
```

## Handle edge cases

A production-ready webhook handler needs to deal with several real-world scenarios that don't show up during development.

### Duplicate deliveries

Nylas guarantees at-least-once delivery. That means your endpoint might receive the same notification more than once, especially if your server was slow to respond the first time. Track processed message IDs and skip duplicates:

```js [dedup-Node.js]
// Simple in-memory dedup (use Redis or a database in production)
const processedMessages = new Set();

function processMessage(message) {
  if (processedMessages.has(message.id)) {
    console.log(`Duplicate message, skipping: ${message.id}`);
    return;
  }

  processedMessages.add(message.id);

  // ... your categorization logic
}
```

> **Warn:** 
> **An in-memory `Set` works for development but won't survive a server restart.** In production, store processed message IDs in Redis with a TTL (24 hours is usually enough) or in your database.

### Truncated payloads

When a message exceeds 1 MB (common with large attachments or rich HTML), Nylas sends a `message.created.truncated` notification with the body removed. The handler code above already covers this: check the trigger type for `.truncated` or check whether the `body` field is missing, then fetch the full message from the API.

You don't need to subscribe to a separate `message.created.truncated` trigger. Nylas sends it automatically on the same webhook subscription.

### Rate limiting when fetching messages

If your inbox receives a burst of messages, your handler will fire multiple `fetch` calls to the Nylas API in quick succession. Respect the [rate limits](/docs/dev-guide/best-practices/rate-limits/) by adding a simple queue or delay:

```js [rateLimiting-Node.js]
// Basic rate-limited queue using a simple delay
const messageQueue = [];
let isProcessing = false;

async function enqueueMessage(grantId, messageId) {
  messageQueue.push({ grantId, messageId });
  if (!isProcessing) {
    processQueue();
  }
}

async function processQueue() {
  isProcessing = true;
  while (messageQueue.length > 0) {
    const { grantId, messageId } = messageQueue.shift();
    const message = await fetchFullMessage(grantId, messageId);
    if (message) {
      processMessage(message);
    }
    // Wait 100ms between API calls to stay well under limits
    await new Promise((resolve) => setTimeout(resolve, 100));
  }
  isProcessing = false;
}
```

### Messages you should skip

Beyond auto-replies, consider filtering out:

- **Calendar invitations** that generate email notifications
- **Messages from your own application** (check if the sender matches your support address to avoid infinite loops)
- **Messages in Sent or Drafts folders** that trigger `message.created` when synced

You can check the folder by looking at the `folders` array on the message object. If it contains `SENT` or `DRAFTS`, skip processing.

## Test the workflow

You have two options for testing without waiting for real email to arrive.

### Option 1: Use the Nylas Send Test Event endpoint

Nylas provides a [Send Test Event endpoint](/docs/reference/api/webhook-notifications/send_test_event/) that sends a mock `message.created` payload to your webhook URL. This confirms your endpoint is reachable and your signature verification works:

```bash
curl --request POST \
  --url 'https://api.us.nylas.com/v3/webhooks/send-test-event' \
  --header 'Content-Type: application/json' \
  --header 'Authorization: Bearer <NYLAS_API_KEY>' \
  --data '{
    "trigger_type": "message.created"
  }'
```

### Option 2: Send yourself a test email

Send an email to the inbox connected to your Nylas grant with a subject like "Urgent: Production database is down" or "Billing question about last invoice." Watch your server logs to confirm the webhook fires, the message gets categorized correctly, and the routing logic triggers.

> **Success:** 
> **Start with Option 1** to verify the plumbing works, then move to Option 2 to validate your categorization rules against real email content.

## Things to know

- **At-least-once delivery means dedup is your problem.** Nylas may deliver the same `message.created` notification more than once -- usually because your handler was slow on the first attempt. Track processed message IDs in Redis or a database table with a TTL. An in-memory `Set` won't survive a restart.
- **Truncated payloads come through the same subscription.** When a message exceeds 1 MB (large attachments, rich HTML), Nylas sends `message.created.truncated` on the same webhook with the body removed. Detect it (suffix or missing `body`) and refetch the full message from the API.
- **Filter your own outbound.** Sent messages also fire `message.created` for the connected mailbox. Check the `folders` array for `SENT` or `DRAFTS` and skip -- otherwise your handler will route the team's outgoing replies as new tickets.
- **Out-of-office replies are noise.** Detect them by `auto-submitted` headers, `precedence: bulk`, and subject patterns ("out of office", "automatic reply"). Don't ship a ticket monitor that creates a ticket every time a customer goes on vacation.
- **Respond 200 fast, process async.** Nylas considers a slow response a failed delivery and retries. Acknowledge immediately, queue the work, and process behind the response.

## Next steps

- [Email triage agent](/docs/cookbook/agents/email-triage-agent/) -- LLM-based classify-and-archive for personal inboxes
- [Support agent with multi-day threads](/docs/cookbook/use-cases/act/support-agent-multi-day-threads/) -- the next step up: auto-reply to common tickets in-thread
- [Send auto-acknowledgments](/docs/v3/email/send-email/) so customers know their ticket landed
- [Folders and labels](/docs/v3/email/folders/) -- move triaged messages into dedicated folders
- [Webhook notification schemas](/docs/reference/notifications/) -- every trigger type Nylas supports