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 instead. This recipe is for shared support inboxes where tickets need to fan out to teams.
The pipeline
Section titled “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 systemSix 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
Section titled “Before you begin”Make sure you have the following before starting this tutorial:
- A Nylas account 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)
You also need:
- A publicly accessible HTTPS endpoint for Nylas to send webhook notifications to. During development, use VS Code port forwarding or Hookdeck to expose your local server.
- Express (Node.js) or Flask (Python) installed in your project.
Set up a webhook for new messages
Section titled “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.
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": ["[email protected]"] }'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
Section titled “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.
# Nylas sends something like this:GET <YOUR_WEBHOOK_URL>?challenge=bc609b38-c81f-47fb-a275-1d9bd61a968bYour server returns:
200 OKbc609b38-c81f-47fb-a275-1d9bd61a968bNo JSON wrapping, no extra whitespace, no chunked encoding. Just the raw challenge string in the response body.
Handle incoming message notifications
Section titled “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
Section titled “Node.js with Express”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 verificationapp.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
Section titled “Python with Flask”import hashlibimport hmacimport jsonimport os
import requestsfrom 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)Categorize and route messages
Section titled “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
Section titled “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
Section titled “Node.js routing logic”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 customersconst 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
Section titled “Python routing logic”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
Section titled “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.
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));}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
Section titled “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
Section titled “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:
// 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}Truncated payloads
Section titled “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
Section titled “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 by adding a simple queue or delay:
// Basic rate-limited queue using a simple delayconst 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
Section titled “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.createdwhen 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
Section titled “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
Section titled “Option 1: Use the Nylas Send Test Event endpoint”Nylas provides a Send Test Event endpoint that sends a mock message.created payload to your webhook URL. This confirms your endpoint is reachable and your signature verification works:
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
Section titled “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.
Things to know
Section titled “Things to know”- At-least-once delivery means dedup is your problem. Nylas may deliver the same
message.creatednotification 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-memorySetwon’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.truncatedon the same webhook with the body removed. Detect it (suffix or missingbody) and refetch the full message from the API. - Filter your own outbound. Sent messages also fire
message.createdfor the connected mailbox. Check thefoldersarray forSENTorDRAFTSand skip — otherwise your handler will route the team’s outgoing replies as new tickets. - Out-of-office replies are noise. Detect them by
auto-submittedheaders,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
Section titled “Next steps”- Email triage agent — LLM-based classify-and-archive for personal inboxes
- Support agent with multi-day threads — the next step up: auto-reply to common tickets in-thread
- Parse receipts and orders from email — the ExtractAI counterpart for e-commerce email
- Send auto-acknowledgments so customers know their ticket landed
- Folders and labels — move triaged messages into dedicated folders
- Webhook notification schemas — every trigger type Nylas supports