Most support ticket systems start the same way: an email arrives in a shared inbox, someone reads it, decides where it should go, and forwards it along. That manual step is the bottleneck. It delays response times, leads to misrouted tickets, and breaks down entirely outside of business hours.
You can eliminate that bottleneck by combining the Nylas Email API with webhooks. Instead of polling an inbox on a timer, Nylas pushes a notification to your server the moment a new message lands. Your code inspects the message, applies routing rules, and hands it off to whatever system handles tickets on your end. The same code works across Google, Microsoft, and IMAP accounts without any provider-specific logic.
What you’ll build
Section titled “What you’ll build”This tutorial walks through building a webhook handler that:
- Receives
message.creatednotifications from Nylas in real time - Verifies the webhook signature to confirm the notification is authentic
- Fetches the full message content from the Nylas API
- Categorizes the message based on subject keywords, sender domain, and body content
- Assigns a priority level (high, medium, or low)
- Routes the categorized ticket to the appropriate handler
The examples use Node.js with Express and Python with Flask. The routing logic is intentionally simple so you can adapt it to your own ticketing system, whether that’s Jira, Zendesk, a database, or a Slack channel.
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)
New to Nylas? Start with the quickstart guide 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 or Hookdeck to expose your local server.
- Express (Node.js) or Flask (Python) installed in your project.
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
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)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
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}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
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.
Start with Option 1 to verify the plumbing works, then move to Option 2 to validate your categorization rules against real email content.
What’s next
Section titled “What’s next”You now have a working support ticket monitor that listens for new messages, categorizes them, and routes them to the right team. From here, you can extend the system in several directions:
- Review all webhook triggers. The webhook notification schemas reference covers every trigger type Nylas supports, including
message.updatedfor tracking status changes. - Send auto-replies. Use the Send email endpoint to acknowledge support requests automatically, so customers know their ticket was received.
- Organize processed messages. Use the Folders and labels API to move processed messages into dedicated folders like “Triaged” or “In Progress.”
- Track support responses. Enable message tracking on outgoing replies to know when a customer reads your response.