<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Qasim</title>
    <description>The latest articles on DEV Community by Qasim (@mqasimca).</description>
    <link>https://dev.to/mqasimca</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3995627%2Feddc44d6-3e99-45b8-ae80-71279c900b01.jpg</url>
      <title>DEV Community: Qasim</title>
      <link>https://dev.to/mqasimca</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mqasimca"/>
    <language>en</language>
    <item>
      <title>Route Nylas notifications to Google Pub/Sub</title>
      <dc:creator>Qasim</dc:creator>
      <pubDate>Thu, 23 Jul 2026 11:53:46 +0000</pubDate>
      <link>https://dev.to/mqasimca/route-nylas-notifications-to-google-pubsub-4h6g</link>
      <guid>https://dev.to/mqasimca/route-nylas-notifications-to-google-pubsub-4h6g</guid>
      <description>&lt;p&gt;An HTTP webhook endpoint has to be a public URL that's always up and fast enough to absorb a burst. When a sync kicks off and a few thousand &lt;code&gt;message.created&lt;/code&gt; events fire in a minute, a slow handler backs up, times out, and starts dropping deliveries. You can engineer around that with a queue in front of your endpoint, or you can skip the endpoint entirely and have Nylas deliver notifications straight into a Google Cloud Pub/Sub topic, where the queue is the delivery mechanism.&lt;/p&gt;

&lt;p&gt;This post covers Pub/Sub notification channels from two angles: the HTTP API your backend calls, and the &lt;a href="https://cli.nylas.com/docs/commands" rel="noopener noreferrer"&gt;&lt;code&gt;nylas&lt;/code&gt; CLI&lt;/a&gt; for creating and managing a channel from the terminal. I work on the CLI, so the terminal commands below are the ones I reach for when I'm wiring up a channel.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a Pub/Sub channel is
&lt;/h2&gt;

&lt;p&gt;A Pub/Sub notification channel tells Nylas to publish your notifications to a Google Cloud Pub/Sub topic instead of (or alongside) posting them to an HTTP URL. You own the topic; the channel is the stored configuration that lets the service connect to it and push events in. Your own services then consume those events from a subscription on that topic, at whatever pace they can handle.&lt;/p&gt;

&lt;p&gt;This is a different Pub/Sub from the one some Google auth apps use to receive mail changes from Google. Here, the flow goes the other way: Nylas is the publisher, your topic is the destination, and your consumers read from it. The notification payloads are the same events you'd get over HTTP, so &lt;code&gt;message.created&lt;/code&gt;, &lt;code&gt;event.updated&lt;/code&gt;, and the rest arrive in the topic with the shape you already know, just delivered through Google's infrastructure rather than a request to your server.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Pub/Sub instead of an HTTP endpoint
&lt;/h2&gt;

&lt;p&gt;The reason to reach for this is delivery guarantees. Pub/Sub is a durable, event-driven queue, so a burst of notifications buffers in the topic instead of hammering your endpoint, and your consumers drain it individually or in batches during high volume. If a consumer is down, the messages wait rather than being lost, which is the property an HTTP webhook can't give you on its own.&lt;/p&gt;

&lt;p&gt;It also opens up patterns an endpoint makes awkward. You can route different triggers to different topics, segmenting high-volume notifications away from low-volume ones, and you can attach a dead-letter topic to catch events your consumers fail to process so they don't pile up and add latency. You don't have to go all in either: Pub/Sub can fully replace your webhook setup, or run alongside it, with high-volume subscriptions on Pub/Sub and quieter ones still on HTTP. It's the right tool when volume, latency, or deliverability are real concerns.&lt;/p&gt;

&lt;h2&gt;
  
  
  Set up the topic first
&lt;/h2&gt;

&lt;p&gt;The channel points at a topic you create, so that comes first. In the Google Cloud console, open the Pub/Sub page, create a topic, and add a default subscription so there's something to consume from. If you already have a Google Cloud project for an auth app, you can put the notification topic in that same project rather than standing up a new one.&lt;/p&gt;

&lt;p&gt;The one configuration that matters is permission: the topic has to allow Nylas to publish to it. You grant that on the topic in Google Cloud, and once it's in place, notifications start flowing into the topic once the channel is active, within a couple of minutes for a freshly authenticated grant. Keep the topic's full path handy, the &lt;code&gt;projects/&amp;lt;project&amp;gt;/topics/&amp;lt;topic&amp;gt;&lt;/code&gt; string, because that's what you pass when you create the channel.&lt;/p&gt;

&lt;h2&gt;
  
  
  Create the channel
&lt;/h2&gt;

&lt;p&gt;With the topic ready, creating the channel from the CLI is one command. &lt;code&gt;nylas webhook pubsub create&lt;/code&gt; takes the topic path and the triggers you want delivered to it, plus optional notification emails for channel health alerts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas webhook pubsub create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--topic&lt;/span&gt; &lt;span class="s2"&gt;"projects/my-project/topics/nylas-email"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--triggers&lt;/span&gt; message.created,message.updated &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--notify&lt;/span&gt; ops@example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same channel over the API is a &lt;code&gt;POST /v3/channels/pubsub&lt;/code&gt; with the topic and &lt;code&gt;trigger_types&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/channels/pubsub"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "topic": "projects/my-project/topics/nylas-email",
    "trigger_types": ["message.created", "message.updated"]
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once the channel exists, Nylas starts publishing the subscribed triggers to that topic, and your consumers pick them up from the topic's subscription. The &lt;code&gt;--triggers&lt;/code&gt; you choose work exactly like webhook trigger types; a channel is just a different destination for the same events.&lt;/p&gt;

&lt;h2&gt;
  
  
  Consume the notifications
&lt;/h2&gt;

&lt;p&gt;Creating the channel is only half of it; your services still have to read from the topic. You attach a subscription to the topic and consume it either by pulling messages on your own schedule or by having Pub/Sub push them to an endpoint, and you acknowledge each message once you've processed it so it isn't redelivered. The subscription is where backpressure lives: if a consumer slows down, unacknowledged messages wait there instead of disappearing.&lt;/p&gt;

&lt;p&gt;Plan for at-least-once delivery. Pub/Sub can hand you the same message more than once, so your handler should be idempotent, keyed on the notification's ID, which is the same discipline a well-built webhook handler already follows. The payoff is that a consumer can crash, restart, and resume from the subscription without losing the events that arrived while it was down, which is the whole reason to put a queue between Nylas and your processing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Split triggers across topics
&lt;/h2&gt;

&lt;p&gt;A single channel subscribes a topic to any number of triggers, which lets you segment notifications by routing different events to different topics. A common split is one topic for all the email notifications and a separate one for calendar events and event changes, so a high-volume email sync doesn't crowd out the calendar updates a different consumer cares about.&lt;/p&gt;

&lt;p&gt;This segmentation is where Pub/Sub pulls ahead of a single webhook URL. With one endpoint, every trigger lands in the same place and you fan out in your own code; with channels, you push that routing down to the infrastructure, and each topic gets its own subscription, its own consumers, and its own scaling. A noisy trigger on one topic has no effect on the consumers reading a quieter one, because they're draining different queues entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Catch failures with a dead-letter topic
&lt;/h2&gt;

&lt;p&gt;A queue only helps if undeliverable messages don't clog it, which is what a dead-letter topic is for. Nylas recommends creating a second Pub/Sub topic per channel to serve as a dead-letter queue: when your consumer can't process a notification, Pub/Sub moves it there after the configured retries instead of letting it back up your subscription and add latency. Your primary subscription stays healthy, and the failures collect somewhere you can inspect them.&lt;/p&gt;

&lt;p&gt;The value is that you can replay them. Once you've fixed whatever caused the ingestion failure, a bad deploy, a schema mismatch, a downstream outage, you reprocess the dead-letter queue and recover the events you would otherwise have lost. Configuring the dead-letter topic is a Google Cloud setting on the subscription, not something you do in Nylas, but pairing one with every channel is the practice that makes the deliverability guarantee real rather than theoretical.&lt;/p&gt;

&lt;h2&gt;
  
  
  Manage and limit your channels
&lt;/h2&gt;

&lt;p&gt;Channels are managed through the same surfaces you created them with. &lt;code&gt;nylas webhook pubsub list&lt;/code&gt; shows the channels on your application, &lt;code&gt;show&lt;/code&gt; prints one, &lt;code&gt;update&lt;/code&gt; changes its triggers or settings, and &lt;code&gt;delete&lt;/code&gt; removes it. The API mirrors these: &lt;code&gt;GET /v3/channels/pubsub&lt;/code&gt; lists the channels, and &lt;code&gt;GET&lt;/code&gt;, &lt;code&gt;PUT&lt;/code&gt;, and &lt;code&gt;DELETE&lt;/code&gt; on &lt;code&gt;/v3/channels/pubsub/{id}&lt;/code&gt; act on one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas webhook pubsub list
nylas webhook pubsub show &amp;lt;channel-id&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's a ceiling to plan around: each Nylas application can have up to five Pub/Sub channels. That's usually plenty when each channel can subscribe to multiple triggers, but it does mean you architect around topics-per-concern rather than a channel per trigger. If five genuinely isn't enough for your segmentation, that's a conversation with support rather than a limit you can raise yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The AWS equivalent: SNS channels
&lt;/h2&gt;

&lt;p&gt;If your infrastructure lives on AWS rather than Google Cloud, the same idea exists for Amazon SNS. A &lt;code&gt;POST /v3/channels/sns&lt;/code&gt; channel publishes notifications to an SNS topic, which you then fan out to SQS queues, Lambda functions, or HTTP subscribers using the AWS tooling you already run. SNS takes a little more setup than Pub/Sub, an IAM role Nylas assumes to publish, which you pass as a &lt;code&gt;role_arn&lt;/code&gt;, but once that's in place the model is the same: a durable topic the service publishes into, on the other cloud.&lt;/p&gt;

&lt;p&gt;The one practical difference for this post is tooling: the dedicated &lt;code&gt;nylas webhook pubsub&lt;/code&gt; commands cover Google Pub/Sub, while SNS channels are managed through the API. If you're on AWS, you create and manage the SNS channel over the API, with &lt;code&gt;POST&lt;/code&gt; and &lt;code&gt;GET&lt;/code&gt; on &lt;code&gt;/v3/channels/sns&lt;/code&gt; and &lt;code&gt;GET&lt;/code&gt;/&lt;code&gt;PUT&lt;/code&gt;/&lt;code&gt;DELETE&lt;/code&gt; on &lt;code&gt;/v3/channels/sns/{id}&lt;/code&gt;, rather than a CLI subcommand, but the notifications and triggers behave the same way once they're flowing.&lt;/p&gt;

&lt;h2&gt;
  
  
  When a channel beats a webhook
&lt;/h2&gt;

&lt;p&gt;Not every integration needs this. A plain HTTP webhook is the simplest path, and for low, steady volume it's the right one: one endpoint, verify the signature, done. Reach for a Pub/Sub channel when one of three things is true. Your notification volume spikes hard enough to overwhelm an endpoint. You need a deliverability guarantee that survives your consumer being down. Or your processing is already cloud-native, and reading from a queue fits your architecture better than receiving HTTP requests.&lt;/p&gt;

&lt;p&gt;The honest framing is that channels trade setup for resilience. You stand up a topic, a subscription, and permissions, which is more work than pointing at a URL, and in return you get buffering and recovery you'd otherwise build yourself. For a side project, that's overkill. For a system that can't miss a &lt;code&gt;message.created&lt;/code&gt;, it's the difference between a durable queue and a dropped event.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things to keep in mind
&lt;/h2&gt;

&lt;p&gt;A short list of practices keeps a Pub/Sub channel reliable.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Grant Nylas publish permission on the topic.&lt;/strong&gt; The channel can't deliver until the topic allows it to publish; this is the step that's easy to forget.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pair every channel with a dead-letter topic.&lt;/strong&gt; It's what turns "the queue buffers" into a real recovery path for events your consumers can't process.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Segment by topic, not by channel.&lt;/strong&gt; With a five-channel cap, route concerns to separate topics and let each channel carry multiple triggers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run Pub/Sub alongside webhooks if it fits.&lt;/strong&gt; High-volume subscriptions on Pub/Sub, low-volume ones on HTTP, is a valid split.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The trigger types are the same.&lt;/strong&gt; A channel changes where events go, not which events you get, so your trigger choices carry over from webhooks unchanged.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;Pub/Sub channels move notification delivery off your own endpoint and into a durable Google Cloud topic, which buffers bursts, supports batching, and gives you a dead-letter path to recover failures. Create one with &lt;code&gt;nylas webhook pubsub create&lt;/code&gt; or &lt;code&gt;POST /v3/channels/pubsub&lt;/code&gt;, point it at a topic you've granted Nylas permission to publish to, and consume from the subscription at your own pace. SNS offers the same model on AWS through the API, and either way the events are the familiar webhook triggers, just delivered into a queue that buffers bursts instead of dropping them under load.&lt;/p&gt;

&lt;p&gt;Where to go next:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/v3/notifications/pubsub-channel/" rel="noopener noreferrer"&gt;Pub/Sub notification channels&lt;/a&gt; — the full setup, including topic and dead-letter configuration&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/v3/notifications/sns-channel/" rel="noopener noreferrer"&gt;SNS notification channels&lt;/a&gt; — the Amazon SNS equivalent&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/reference/api/pubsub-notifications/create-pubsub-channel/" rel="noopener noreferrer"&gt;Create a Pub/Sub channel&lt;/a&gt; — the endpoint reference&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://cli.nylas.com/docs/commands" rel="noopener noreferrer"&gt;Nylas CLI webhook commands&lt;/a&gt; — &lt;code&gt;nylas webhook pubsub&lt;/code&gt; create, list, and update&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  AI-answer pages for agents
&lt;/h2&gt;

&lt;p&gt;When this post is published, link AI agents and crawlers to the retrieval-ready version on &lt;code&gt;cli.nylas.com&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Topic runbook: &lt;a href="https://cli.nylas.com/ai-answers/webhook-reliability-for-agents.md" rel="noopener noreferrer"&gt;https://cli.nylas.com/ai-answers/webhook-reliability-for-agents.md&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Industry playbooks hub: &lt;a href="https://cli.nylas.com/ai-answers/agent-account-industry-playbooks.md" rel="noopener noreferrer"&gt;https://cli.nylas.com/ai-answers/agent-account-industry-playbooks.md&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>api</category>
      <category>webhooks</category>
      <category>gcp</category>
      <category>devtools</category>
    </item>
    <item>
      <title>Automate appointment reminders with the Nylas API</title>
      <dc:creator>Qasim</dc:creator>
      <pubDate>Thu, 23 Jul 2026 11:53:36 +0000</pubDate>
      <link>https://dev.to/mqasimca/automate-appointment-reminders-with-the-nylas-api-1793</link>
      <guid>https://dev.to/mqasimca/automate-appointment-reminders-with-the-nylas-api-1793</guid>
      <description>&lt;p&gt;You booked a meeting on a customer's calendar, and you want them to get a reminder email the day before so they actually show up. The obvious build is a cron job that wakes up every few minutes, scans for appointments coming up in 24 hours, and fires an email. That's a polling loop you have to run, scale, and debug. There's a cleaner way that uses three Nylas features together: you read the appointment from the calendar, schedule the reminder email for exactly 24 hours before it, and let a template carry the copy. No loop, no worker watching the clock.&lt;/p&gt;

&lt;p&gt;This post is a worked use case rather than a feature tour. It pulls together the &lt;a href="https://developer.nylas.com/docs/v3/calendar/using-the-events-api/" rel="noopener noreferrer"&gt;Calendar&lt;/a&gt;, &lt;a href="https://developer.nylas.com/docs/v3/email/scheduled-send/" rel="noopener noreferrer"&gt;scheduled send&lt;/a&gt;, and templates APIs from two angles: the HTTP API for your backend and the &lt;a href="https://cli.nylas.com/docs/commands" rel="noopener noreferrer"&gt;&lt;code&gt;nylas&lt;/code&gt; CLI&lt;/a&gt; for the terminal. I work on the CLI, so the terminal commands below are the ones I reach for when I'm prototyping the flow.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the solution
&lt;/h2&gt;

&lt;p&gt;The reminder flow is three steps, each handled by a different piece. The calendar tells you when the appointment is. Scheduled send fires the email at a time you compute from that, with nothing running in between. And a template holds the reminder copy so it's editable without a deploy. Wire them together and an appointment booked today produces a reminder that goes out automatically tomorrow.&lt;/p&gt;

&lt;p&gt;This same need shows up everywhere a booking exists: a clinic confirming appointments, a sales team reminding prospects about a demo, a service business trying to cut no-shows. The domain changes the copy, not the mechanism, so the implementation below is the same whether you're reminding patients or prospects.&lt;/p&gt;

&lt;p&gt;The key insight is that you don't poll. Instead of a worker that repeatedly asks "is anything due soon?", you compute the send time once, when the appointment is created or found, and hand it to scheduled send. The reminder then sits queued until its moment arrives. That inverts the usual reminder architecture: the work happens at scheduling time, not at delivery time, so there's no always-on loop to operate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: find the appointments
&lt;/h2&gt;

&lt;p&gt;You need the appointments and their start times, which come from the Events API. A &lt;a href="https://developer.nylas.com/docs/reference/api/events/get-all-events/" rel="noopener noreferrer"&gt;&lt;code&gt;GET /v3/grants/{grant_id}/events&lt;/code&gt;&lt;/a&gt; call with a &lt;code&gt;calendar_id&lt;/code&gt; and a &lt;code&gt;start&lt;/code&gt;/&lt;code&gt;end&lt;/code&gt; window returns the events in that range, each with a &lt;code&gt;when&lt;/code&gt; object carrying the &lt;code&gt;start_time&lt;/code&gt; as a Unix timestamp. That timestamp is what you base the reminder on.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; GET &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/grants/&amp;lt;GRANT_ID&amp;gt;/events?calendar_id=primary&amp;amp;start=1744700000&amp;amp;end=1744900000"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From the terminal, &lt;code&gt;nylas calendar events list&lt;/code&gt; shows the upcoming events on a calendar so you can eyeball what you're working with. There are two ways to drive this in production: pull events on a schedule for a rolling window, or react to the &lt;a href="https://developer.nylas.com/docs/reference/notifications/events/event-created/" rel="noopener noreferrer"&gt;&lt;code&gt;event.created&lt;/code&gt;&lt;/a&gt; webhook so the reminder is scheduled the instant a booking happens. The webhook path is the one I prefer, since it means a reminder exists the moment the appointment does, with no scan at all.&lt;/p&gt;

&lt;p&gt;Two query parameters matter for reminders specifically. Pass &lt;code&gt;expand_recurring=true&lt;/code&gt; so a weekly standing meeting yields each instance with its own &lt;code&gt;start_time&lt;/code&gt;, rather than a single recurrence rule you'd have to expand yourself. And set &lt;code&gt;show_cancelled=false&lt;/code&gt; so you never schedule a reminder for a meeting that's already off the calendar.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: compute the reminder time
&lt;/h2&gt;

&lt;p&gt;This step is plain arithmetic, and it's where the "24 hours before" lives. The event's &lt;code&gt;start_time&lt;/code&gt; is a Unix timestamp in seconds, so a reminder one day ahead is &lt;code&gt;start_time - 86400&lt;/code&gt;, and one hour ahead is &lt;code&gt;start_time - 3600&lt;/code&gt;. You compute that target once and pass it to the send as &lt;code&gt;send_at&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;reminderAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;when&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;start_time&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 24h before&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's one real constraint to respect here. A Nylas-stored scheduled send accepts a &lt;code&gt;send_at&lt;/code&gt; up to 30 days in the future, so for an appointment booked more than a month out, the reminder time, the appointment minus a day, still falls outside that 30-day window. The clean pattern for far-future bookings is a thin daily job that schedules tomorrow's reminders, which keeps every &lt;code&gt;send_at&lt;/code&gt; comfortably inside the 30-day ceiling, while last-minute bookings schedule their reminder right away.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: schedule the templated reminder
&lt;/h2&gt;

&lt;p&gt;Now you send the reminder, dated into the future and filled from a template. You create a reminder template once with &lt;code&gt;{{name}}&lt;/code&gt; and &lt;code&gt;{{time}}&lt;/code&gt; placeholders, then each send references it by ID and supplies the variables, while &lt;code&gt;send_at&lt;/code&gt; carries the time you computed. The message waits in the queue and goes out on its own at the reminder time.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/grants/&amp;lt;GRANT_ID&amp;gt;/messages/send"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "to": [{ "email": "customer@example.com" }],
    "template": {
      "id": "&amp;lt;REMINDER_TEMPLATE_ID&amp;gt;",
      "variables": { "name": "Alex", "time": "tomorrow at 10am" }
    },
    "send_at": 1744714800
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The whole flow collapses into a single CLI command, because &lt;code&gt;nylas email send&lt;/code&gt; carries both the schedule and the template. You pass &lt;code&gt;--schedule&lt;/code&gt; with the reminder time, &lt;code&gt;--template-id&lt;/code&gt; with your reminder template, and &lt;code&gt;--template-data&lt;/code&gt; with the variables, and the templated reminder is queued in one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas email send &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--to&lt;/span&gt; customer@example.com &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--template-id&lt;/span&gt; &amp;lt;REMINDER_TEMPLATE_ID&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--template-data&lt;/span&gt; &lt;span class="s1"&gt;'{ "name": "Alex", "time": "tomorrow at 10am" }'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--schedule&lt;/span&gt; &lt;span class="s2"&gt;"2025-04-14 10:00"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response includes a &lt;code&gt;schedule_id&lt;/code&gt;. Store it against the appointment, because that's your handle if the meeting changes.&lt;/p&gt;

&lt;p&gt;Because the reminder sends from the connected mailbox, the customer sees a normal email from the business they booked with, not a generic no-reply address. That matters for a reminder specifically: it lands in the same thread as the original confirmation, looks like it came from a person, and is far more likely to be read than a transactional blast from a noreply domain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Put it in a webhook handler
&lt;/h2&gt;

&lt;p&gt;Here's the create path as a single handler. When &lt;code&gt;event.created&lt;/code&gt; fires, you compute the reminder time, schedule the templated send through a small helper that wraps &lt;code&gt;POST /messages/send&lt;/code&gt;, and store the returned &lt;code&gt;schedule_id&lt;/code&gt; against the event so you can manage it later:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/webhooks/nylas&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// acknowledge fast&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;evt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// Only timed events: all-day events carry when.start_date, not start_time.&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;evt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;object&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;event&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;evt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;when&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;start_time&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;reminderAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;evt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;when&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;start_time&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Too far out for the 30-day window: let a daily job pick it up later.&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reminderAt&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// Already due (e.g. a last-minute booking): send now by omitting send_at.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sendAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;reminderAt&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;reminderAt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;scheduleId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;scheduleReminder&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;attendeeEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;evt&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;templateId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;REMINDER_TEMPLATE_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;variables&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;attendeeName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;evt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="nx"&gt;sendAt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// null = send immediately&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;saveReminder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;evt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;scheduleId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// for later cancel/reschedule&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole create path with its guards in place: skip all-day events, skip bookings too far out for the daily job to handle, send immediately when the reminder is already due, and otherwise schedule it and store the &lt;code&gt;schedule_id&lt;/code&gt;. Everything else, the reschedule and cancel logic below, hangs off the mapping you save on that last line.&lt;/p&gt;

&lt;h2&gt;
  
  
  Handle reschedules and cancellations
&lt;/h2&gt;

&lt;p&gt;Appointments move, and a reminder for a meeting that was cancelled or rescheduled is worse than none. This is where storing the &lt;code&gt;schedule_id&lt;/code&gt; pays off. When the &lt;a href="https://developer.nylas.com/docs/reference/notifications/events/event-updated/" rel="noopener noreferrer"&gt;&lt;code&gt;event.updated&lt;/code&gt;&lt;/a&gt; or &lt;a href="https://developer.nylas.com/docs/reference/notifications/events/event-deleted/" rel="noopener noreferrer"&gt;&lt;code&gt;event.deleted&lt;/code&gt;&lt;/a&gt; webhook fires for an appointment, you act on the reminder you queued for it.&lt;/p&gt;

&lt;p&gt;For a cancellation, you cancel the scheduled send with a &lt;code&gt;DELETE&lt;/code&gt; against its &lt;code&gt;schedule_id&lt;/code&gt;, as long as you're more than 10 seconds before the send time. For a reschedule, you cancel the old reminder and schedule a new one for the new &lt;code&gt;start_time - 86400&lt;/code&gt;. The webhook gives you the changed event, your stored mapping gives you the &lt;code&gt;schedule_id&lt;/code&gt; to cancel, and the new time gives you the next reminder. That closed loop, schedule on create, re-schedule on change, cancel on delete, is what makes the reminders track reality instead of drifting from it.&lt;/p&gt;

&lt;p&gt;One edge is worth handling explicitly: if a meeting is rescheduled to within the next 24 hours, the new reminder time lands in the past or only moments from now. There, you skip scheduling and send the reminder immediately. A quick check on the computed time, send now if it's already due or too soon to queue, covers the last-minute reschedule cleanly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't send two reminders
&lt;/h2&gt;

&lt;p&gt;A reminder system's worst failure is sending the same person two reminders, and there are two ways it happens. Webhooks can be redelivered, so the same &lt;code&gt;event.created&lt;/code&gt; may fire twice, and a reschedule you handle by cancel-and-recreate can race with itself. Both are solved by keying your reminder record on the event ID: before scheduling, check whether a reminder already exists for that event, and if it does, update it instead of adding a second.&lt;/p&gt;

&lt;p&gt;The mapping you store, event ID to &lt;code&gt;schedule_id&lt;/code&gt;, is the dedup key. On &lt;code&gt;event.created&lt;/code&gt;, schedule only if there's no reminder for that event yet. On &lt;code&gt;event.updated&lt;/code&gt;, look up the existing &lt;code&gt;schedule_id&lt;/code&gt;, cancel it, and schedule the replacement, all keyed on the same event ID. That single record per appointment is what guarantees one reminder per meeting no matter how many times the webhooks fire, which at any real volume they will.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same pattern, other reminders
&lt;/h2&gt;

&lt;p&gt;Once the three pieces click together, the same shape covers a family of timed messages, not just a 24-hour reminder. The only thing that changes is the offset you apply to the event time and the template you attach. A few that drop straight in:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A prep email a week out.&lt;/strong&gt; Offset &lt;code&gt;start_time - 7 * 86400&lt;/code&gt; with a template that asks the customer to gather documents before the meeting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A morning-of nudge.&lt;/strong&gt; Offset to the morning of the appointment with a short "see you today" template.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A post-meeting follow-up.&lt;/strong&gt; A positive offset, &lt;code&gt;start_time + 3600&lt;/code&gt;, with a template that sends a recap or a feedback link an hour after the meeting ends.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each is the same compute-the-time, schedule-with-a-template move, so a single helper that takes an event, an offset, and a template ID generates any of them. That's the payoff of building the reminder out of composable pieces rather than a bespoke cron job: a new reminder type is a new offset and a new template, not new infrastructure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things to keep in mind
&lt;/h2&gt;

&lt;p&gt;A short list of details makes this flow reliable in production.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Schedule on the webhook, not a poll.&lt;/strong&gt; React to &lt;code&gt;event.created&lt;/code&gt; so a reminder exists the moment the appointment does, with no scanning loop to run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Store the &lt;code&gt;schedule_id&lt;/code&gt; with the appointment.&lt;/strong&gt; It's the only handle for cancelling or rescheduling the reminder when the meeting changes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mind the 30-day scheduled-send ceiling.&lt;/strong&gt; When the computed reminder time is more than 30 days out, a thin daily job that queues tomorrow's reminders keeps every &lt;code&gt;send_at&lt;/code&gt; inside the window.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Respect the 10-second cancel cutoff.&lt;/strong&gt; A reminder can't be reliably cancelled in the final seconds before it sends, so gate any last-moment changes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the copy in a template.&lt;/strong&gt; Editing the reminder wording is a template update, not a deploy, and one template serves every appointment.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dedup on the event ID.&lt;/strong&gt; Key your reminder record on the event so a redelivered webhook updates the existing reminder instead of queuing a second.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;An appointment reminder doesn't need a cron job; it needs three Nylas calls wired together. Read the appointment's &lt;code&gt;start_time&lt;/code&gt; from the Events API, subtract your offset to get the reminder time, and schedule a templated email for that moment with &lt;code&gt;send_at&lt;/code&gt;. Store the &lt;code&gt;schedule_id&lt;/code&gt; so you can cancel or re-schedule when the meeting moves, and the same pattern, with a different offset and template, gives you prep emails, morning-of nudges, and post-meeting follow-ups for free.&lt;/p&gt;

&lt;p&gt;Where to go next:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/v3/calendar/using-the-events-api/" rel="noopener noreferrer"&gt;Using the Events API&lt;/a&gt; — reading and filtering calendar events&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/v3/email/scheduled-send/" rel="noopener noreferrer"&gt;Schedule messages to send in the future&lt;/a&gt; — the &lt;code&gt;send_at&lt;/code&gt; field and cancellation&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/v3/email/templates-workflows/" rel="noopener noreferrer"&gt;Templates and workflows&lt;/a&gt; — reusable copy with variables&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/reference/notifications/events/event-created/" rel="noopener noreferrer"&gt;Event webhooks&lt;/a&gt; — react to bookings and changes&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  AI-answer pages for agents
&lt;/h2&gt;

&lt;p&gt;When this post is published, link AI agents and crawlers to the retrieval-ready version on &lt;code&gt;cli.nylas.com&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Topic runbook: &lt;a href="https://cli.nylas.com/ai-answers/appointment-scheduling-api-confirmations-reminders.md" rel="noopener noreferrer"&gt;https://cli.nylas.com/ai-answers/appointment-scheduling-api-confirmations-reminders.md&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Industry playbooks hub: &lt;a href="https://cli.nylas.com/ai-answers/agent-account-industry-playbooks.md" rel="noopener noreferrer"&gt;https://cli.nylas.com/ai-answers/agent-account-industry-playbooks.md&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>api</category>
      <category>calendar</category>
      <category>email</category>
      <category>devtools</category>
    </item>
    <item>
      <title>Find meeting times with the Nylas availability API</title>
      <dc:creator>Qasim</dc:creator>
      <pubDate>Thu, 23 Jul 2026 11:53:26 +0000</pubDate>
      <link>https://dev.to/mqasimca/find-meeting-times-with-the-nylas-availability-api-4i7o</link>
      <guid>https://dev.to/mqasimca/find-meeting-times-with-the-nylas-availability-api-4i7o</guid>
      <description>&lt;p&gt;Scheduling a meeting is the same annoying problem every time: find a slot when everyone involved is actually free. Do it by hand and you're cross-referencing three calendars in different time zones. Do it in code and you have two tools to reach for, and picking the wrong one means writing slot-finding logic the API would have done for you. This post builds the "find a time" feature with the Calendar API and shows the CLI commands that answer the same question from the terminal.&lt;/p&gt;

&lt;p&gt;It's a worked use case rather than an endpoint tour, covering &lt;a href="https://developer.nylas.com/docs/v3/calendar/" rel="noopener noreferrer"&gt;free/busy&lt;/a&gt; and the &lt;a href="https://developer.nylas.com/docs/reference/api/calendar/post-availability/" rel="noopener noreferrer"&gt;availability&lt;/a&gt; endpoint from two angles: the HTTP API your backend calls and the &lt;a href="https://cli.nylas.com/docs/commands" rel="noopener noreferrer"&gt;&lt;code&gt;nylas&lt;/code&gt; CLI&lt;/a&gt; for quick checks. I work on the CLI, so the commands below are the ones I reach for when I just want to see who's free.&lt;/p&gt;

&lt;h2&gt;
  
  
  Free/busy versus availability: pick the right one
&lt;/h2&gt;

&lt;p&gt;The Calendar API gives you two endpoints for this, and the difference between them decides how much code you write. Free/busy returns the raw busy blocks on each person's calendar, the times they're not free, and leaves it to you to invert that into open slots. Availability does the inversion for you: you give it a meeting duration and a window, and it returns candidate time slots, each tagged with which participants are free then. Ask it to require the whole group, and it returns only the slots that work for everyone.&lt;/p&gt;

&lt;p&gt;The rule of thumb is straightforward. If you're rendering a "this person is busy here" overlay, or you need the actual busy intervals for your own logic, use free/busy. If you're answering "when can these five people meet for 30 minutes," use availability and skip the slot math entirely. Most "schedule a meeting" features want availability, because the hard part, intersecting several busy calendars and carving out duration-sized gaps, is exactly what it does. Reaching for free/busy and rebuilding that logic by hand is the common mistake.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get raw busy blocks with free/busy
&lt;/h2&gt;

&lt;p&gt;Free/busy is a &lt;a href="https://developer.nylas.com/docs/reference/api/calendar/post-calendars-free-busy/" rel="noopener noreferrer"&gt;&lt;code&gt;POST /v3/grants/{grant_id}/calendars/free-busy&lt;/code&gt;&lt;/a&gt; with a &lt;code&gt;start_time&lt;/code&gt;, an &lt;code&gt;end_time&lt;/code&gt;, and a list of &lt;code&gt;emails&lt;/code&gt;. Both times are Unix timestamps. The response comes back as one entry per email, each carrying a &lt;code&gt;time_slots&lt;/code&gt; array of that person's busy intervals within the window, so you see exactly when each calendar is blocked.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/grants/&amp;lt;GRANT_ID&amp;gt;/calendars/free-busy"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "start_time": 1682467200,
    "end_time": 1682550000,
    "emails": ["alice@example.com", "bob@example.com"]
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One behavior to code defensively around: this endpoint always returns &lt;code&gt;200 OK&lt;/code&gt;, even when the lookup fails for one of the addresses, in which case that entry's &lt;code&gt;time_slots&lt;/code&gt; comes back as &lt;code&gt;null&lt;/code&gt; rather than as an error status. So check each entry before you trust it, instead of assuming a &lt;code&gt;200&lt;/code&gt; means every calendar resolved. The busy intervals are the input to your own slot-finding if you go this route, which is the work the next endpoint saves you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check free/busy from the CLI
&lt;/h2&gt;

&lt;p&gt;The terminal equivalent is &lt;code&gt;nylas calendar availability check&lt;/code&gt;, which shows the busy slots for one or more people in a time range. Pass &lt;code&gt;--emails&lt;/code&gt; with a comma-separated list, and bound the window with &lt;code&gt;--start&lt;/code&gt; and &lt;code&gt;--end&lt;/code&gt;, or with &lt;code&gt;--duration&lt;/code&gt; as a span like &lt;code&gt;8h&lt;/code&gt; or &lt;code&gt;7d&lt;/code&gt; from the start. With no emails it checks your own default account, which is the quick "am I free?" lookup.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Busy slots for two people, tomorrow 9am to 5pm&lt;/span&gt;
nylas calendar availability check &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--emails&lt;/span&gt; alice@example.com,bob@example.com &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--start&lt;/span&gt; &lt;span class="s2"&gt;"tomorrow 9am"&lt;/span&gt; &lt;span class="nt"&gt;--end&lt;/span&gt; &lt;span class="s2"&gt;"tomorrow 5pm"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The command accepts human dates like &lt;code&gt;"tomorrow 9am"&lt;/code&gt; and parses them for you, which is far less fiddly than computing Unix timestamps by hand for a quick check. It's the fast way to eyeball a couple of calendars before you build anything, and the &lt;code&gt;freebusy&lt;/code&gt; alias works too if that's the word your fingers reach for. Add &lt;code&gt;--json&lt;/code&gt; when you want to pipe the busy blocks into another tool rather than read them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get ready-made slots with availability
&lt;/h2&gt;

&lt;p&gt;Availability is the endpoint that does the slot math. A &lt;a href="https://developer.nylas.com/docs/reference/api/calendar/post-availability/" rel="noopener noreferrer"&gt;&lt;code&gt;POST /v3/calendars/availability&lt;/code&gt;&lt;/a&gt; takes a &lt;code&gt;participants&lt;/code&gt; list, a &lt;code&gt;start_time&lt;/code&gt; and &lt;code&gt;end_time&lt;/code&gt; window, and a &lt;code&gt;duration_minutes&lt;/code&gt; for the meeting length, and returns a &lt;code&gt;time_slots&lt;/code&gt; array of open windows. Each returned slot carries the &lt;code&gt;emails&lt;/code&gt; that are free during it, plus its own &lt;code&gt;start_time&lt;/code&gt; and &lt;code&gt;end_time&lt;/code&gt;, so you can drop the results straight into a picker and see exactly who each slot works for.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/calendars/availability"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "participants": [
      { "email": "alice@example.com" },
      { "email": "bob@example.com" }
    ],
    "start_time": 1659366000,
    "end_time": 1659733200,
    "duration_minutes": 30,
    "interval_minutes": 30
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;interval_minutes&lt;/code&gt; field controls how the window is sampled: the API generates a candidate slot every interval and returns the open ones, so an interval of 30 gives you slots on the half-hour. Whether a slot needs everyone free or just some of the group depends on the &lt;code&gt;availability_method&lt;/code&gt; covered below, and each slot's &lt;code&gt;emails&lt;/code&gt; reports who it works for. Add &lt;code&gt;round_to&lt;/code&gt; to snap slot starts to a clean boundary like &lt;code&gt;:00&lt;/code&gt; and &lt;code&gt;:15&lt;/code&gt;. The four required fields are &lt;code&gt;participants&lt;/code&gt;, &lt;code&gt;start_time&lt;/code&gt;, &lt;code&gt;end_time&lt;/code&gt;, and &lt;code&gt;duration_minutes&lt;/code&gt;; everything else tunes the output. This is the endpoint to default to for a scheduling feature, because the returned slots are the answer, not the raw material for one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Find slots across participants from the CLI
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;nylas calendar availability find&lt;/code&gt; is the terminal version, and it's built for exactly the "when can these people meet" question. You pass &lt;code&gt;--participants&lt;/code&gt; with the email list and &lt;code&gt;--duration&lt;/code&gt; in minutes, and it searches for slots when everyone is free. Bound the search with &lt;code&gt;--start&lt;/code&gt; and &lt;code&gt;--end&lt;/code&gt;, and set the granularity with &lt;code&gt;--interval&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 30-minute slots for two people, tomorrow 9am to 5pm&lt;/span&gt;
nylas calendar availability find &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--participants&lt;/span&gt; alice@example.com,bob@example.com &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--duration&lt;/span&gt; 30 &lt;span class="nt"&gt;--start&lt;/span&gt; &lt;span class="s2"&gt;"tomorrow 9am"&lt;/span&gt; &lt;span class="nt"&gt;--end&lt;/span&gt; &lt;span class="s2"&gt;"tomorrow 5pm"&lt;/span&gt; &lt;span class="nt"&gt;--interval&lt;/span&gt; 15
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The defaults are sensible for a quick look: duration is 30 minutes, the interval is 15, and the window runs from the next hour to seven days out if you don't set an end. That means &lt;code&gt;nylas calendar availability find --participants alice@example.com&lt;/code&gt; with nothing else gives you a week of half-hour openings on the spot. It's the command I use to sanity-check that a real participant set produces the slots I expect before wiring the same call into an app.&lt;/p&gt;

&lt;h2&gt;
  
  
  Constrain to working hours with open_hours
&lt;/h2&gt;

&lt;p&gt;A meeting at 3am is technically free but useless, so availability lets you constrain each participant to their working hours. Every entry in the &lt;code&gt;participants&lt;/code&gt; list takes an &lt;code&gt;open_hours&lt;/code&gt; array describing the days and times that person is bookable: &lt;code&gt;days&lt;/code&gt; as numbers where 0 is Sunday, a &lt;code&gt;timezone&lt;/code&gt;, and a &lt;code&gt;start&lt;/code&gt; and &lt;code&gt;end&lt;/code&gt; like &lt;code&gt;"9:00"&lt;/code&gt; and &lt;code&gt;"17:00"&lt;/code&gt;. The API only proposes slots inside those hours, so a participant in Toronto and one in Berlin each get evaluated against their own local day.&lt;/p&gt;

&lt;p&gt;That per-participant timezone handling is the detail that makes cross-timezone scheduling actually work. You're not converting anything by hand; each person's &lt;code&gt;open_hours&lt;/code&gt; carries its own &lt;code&gt;timezone&lt;/code&gt;, and the returned slots are the genuine overlap of everyone's local working day. You can also set a &lt;code&gt;default_open_hours&lt;/code&gt; on the &lt;code&gt;availability_rules&lt;/code&gt; to apply one schedule to participants who don't specify their own, which keeps the request small when most people share a working day.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choose how slots are scored with availability_method
&lt;/h2&gt;

&lt;p&gt;When you're booking against a group, how the slots get chosen matters, and &lt;code&gt;availability_method&lt;/code&gt; on the &lt;code&gt;availability_rules&lt;/code&gt; controls it. It takes three values. The default, &lt;code&gt;max-availability&lt;/code&gt;, returns slots that work across the participants and is the right pick for an ordinary group meeting. &lt;code&gt;collective&lt;/code&gt; requires every participant to be free for a slot to count, which is what you want when attendance is mandatory for all.&lt;/p&gt;

&lt;p&gt;The third, &lt;code&gt;max-fairness&lt;/code&gt;, is for round-robin booking: when any one of a pool of people can take the meeting, it distributes bookings evenly across the pool rather than always picking the same person. That's the mechanism behind "talk to one of our reps" scheduling, where you don't care who takes it but you do care that the load spreads. Pair it with a &lt;code&gt;buffer&lt;/code&gt; of &lt;code&gt;before&lt;/code&gt; and &lt;code&gt;after&lt;/code&gt; minutes on the rules to keep back-to-back meetings from touching, and the availability response already accounts for the gap. Picking the method to match the booking model is what separates a real scheduler from a naive free-slot list.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cross-provider behavior and gotchas
&lt;/h2&gt;

&lt;p&gt;A few provider details decide whether a request behaves the way you expect. Free/busy isn't supported for iCloud, and all the email addresses in a single free/busy request have to use the same provider, so you can't mix a Google and a Microsoft address in one call. The address limits differ too: a single free/busy request takes up to 50 emails for Google and up to 20 for Microsoft Graph, so a large group may need to be split across calls.&lt;/p&gt;

&lt;p&gt;There are quieter limits worth knowing. Microsoft caps its availability calculation at 1,000 entries per time slot per address, and the free/busy response leaves out all-day room resource bookings on both Google and Microsoft, so a room that's blocked all day can still look free in the raw data. The grant making the request also has to have permission to see the target calendars' free/busy, which the provider controls, so a permission gap shows up as an &lt;code&gt;error&lt;/code&gt; entry with a &lt;code&gt;null&lt;/code&gt; &lt;code&gt;time_slots&lt;/code&gt; rather than as an outright failure. None of these are blockers, but each is the kind of thing that turns into a confusing bug if you learn it in production instead of up front.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where availability lookups pay off
&lt;/h2&gt;

&lt;p&gt;The same two endpoints sit under a whole category of scheduling features, and which one you pick follows the pattern above. A few that map straight on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A booking page.&lt;/strong&gt; Show a visitor the open slots on a host's calendar, the classic "pick a time" widget, which is availability with one participant and the host's &lt;code&gt;open_hours&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Internal meeting scheduling.&lt;/strong&gt; Find a slot for a group before creating the event, using &lt;code&gt;collective&lt;/code&gt; so everyone's required, then create the event at the chosen slot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Round-robin assignment.&lt;/strong&gt; Spread inbound meetings across a team with &lt;code&gt;max-fairness&lt;/code&gt;, so the next sales call or support session lands on whoever's most due for one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Interview panels.&lt;/strong&gt; Intersect several interviewers' calendars within working hours to find a panel slot, which is availability with multiple participants and buffers.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each is the same call with different rules, not a different integration. The slot-finding is solved; your job is choosing the method and the constraints that match the booking model.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things to keep in mind
&lt;/h2&gt;

&lt;p&gt;A short list of details keeps an availability feature correct.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Default to availability, not free/busy.&lt;/strong&gt; Availability returns the free slots directly; reaching for free/busy means rebuilding slot intersection by hand.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Free/busy always returns &lt;code&gt;200&lt;/code&gt;.&lt;/strong&gt; Check each entry, since a failed lookup for one address comes back as a &lt;code&gt;null&lt;/code&gt; &lt;code&gt;time_slots&lt;/code&gt; rather than an error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set &lt;code&gt;open_hours&lt;/code&gt; per participant.&lt;/strong&gt; It carries its own &lt;code&gt;timezone&lt;/code&gt;, so cross-timezone overlap is computed for you instead of by hand.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Match &lt;code&gt;availability_method&lt;/code&gt; to the model.&lt;/strong&gt; &lt;code&gt;collective&lt;/code&gt; for mandatory attendance, &lt;code&gt;max-fairness&lt;/code&gt; for round-robin, &lt;code&gt;max-availability&lt;/code&gt; for an ordinary group.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mind the provider limits.&lt;/strong&gt; Free/busy excludes iCloud, needs one provider per request, and caps emails at 50 for Google and 20 for Microsoft.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add buffers for real schedules.&lt;/strong&gt; A &lt;code&gt;buffer&lt;/code&gt; before and after keeps proposed slots from butting against existing meetings.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;Finding a meeting time comes down to choosing the right endpoint. Free/busy hands you raw busy blocks to process yourself, and availability hands you the open slots directly, which is what most scheduling features actually want. Send &lt;code&gt;participants&lt;/code&gt;, a window, and a &lt;code&gt;duration_minutes&lt;/code&gt; to the availability endpoint, constrain each person with &lt;code&gt;open_hours&lt;/code&gt; carrying their own timezone, and pick an &lt;code&gt;availability_method&lt;/code&gt; that fits whether attendance is mandatory or round-robin. From the terminal, &lt;code&gt;nylas calendar availability check&lt;/code&gt; shows busy blocks and &lt;code&gt;availability find&lt;/code&gt; shows open slots, so you can confirm a real participant set before you build.&lt;/p&gt;

&lt;p&gt;Where to go next:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/reference/api/calendar/post-availability/" rel="noopener noreferrer"&gt;Get availability&lt;/a&gt; — the endpoint, with &lt;code&gt;participants&lt;/code&gt;, &lt;code&gt;open_hours&lt;/code&gt;, and &lt;code&gt;availability_rules&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/reference/api/calendar/post-calendars-free-busy/" rel="noopener noreferrer"&gt;Get free/busy schedule&lt;/a&gt; — raw busy blocks and provider limits&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/v3/calendar/group-booking/" rel="noopener noreferrer"&gt;Group availability and booking&lt;/a&gt; — round-robin and max-fairness in depth&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/v3/calendar/using-the-events-api/" rel="noopener noreferrer"&gt;Using the Events API&lt;/a&gt; — create the event once you've picked a slot&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  AI-answer pages for agents
&lt;/h2&gt;

&lt;p&gt;When this post is published, link AI agents and crawlers to the retrieval-ready version on &lt;code&gt;cli.nylas.com&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Topic runbook: &lt;a href="https://cli.nylas.com/ai-answers/availability-api-for-bookable-times-in-app.md" rel="noopener noreferrer"&gt;https://cli.nylas.com/ai-answers/availability-api-for-bookable-times-in-app.md&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Industry playbooks hub: &lt;a href="https://cli.nylas.com/ai-answers/agent-account-industry-playbooks.md" rel="noopener noreferrer"&gt;https://cli.nylas.com/ai-answers/agent-account-industry-playbooks.md&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>api</category>
      <category>calendar</category>
      <category>devtools</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Track email opens and clicks with the Nylas API</title>
      <dc:creator>Qasim</dc:creator>
      <pubDate>Thu, 23 Jul 2026 11:53:16 +0000</pubDate>
      <link>https://dev.to/mqasimca/track-email-opens-and-clicks-with-the-nylas-api-19e</link>
      <guid>https://dev.to/mqasimca/track-email-opens-and-clicks-with-the-nylas-api-19e</guid>
      <description>&lt;p&gt;You sent an important email and now you're staring at the thread wondering: did they even open it? Did they click the link you sent? For a sales follow-up, a newsletter, or a transactional message you need to land, that silence is a problem. Email tracking answers the question by telling you when a recipient opens a message, clicks a link inside it, or replies to the thread. This post wires that up with the Email API and shows the CLI flags that turn it on from the terminal.&lt;/p&gt;

&lt;p&gt;It's a worked use case rather than an endpoint tour, covering the &lt;a href="https://developer.nylas.com/docs/v3/email/message-tracking/" rel="noopener noreferrer"&gt;&lt;code&gt;tracking_options&lt;/code&gt;&lt;/a&gt; you set on a send and the tracking webhooks you receive, from two angles: the HTTP API your backend calls and the &lt;a href="https://cli.nylas.com/docs/commands" rel="noopener noreferrer"&gt;&lt;code&gt;nylas&lt;/code&gt; CLI&lt;/a&gt; for sending a tracked message fast. I work on the CLI, so the commands below are the ones I reach for when I want to see tracking fire end to end.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three things you can track
&lt;/h2&gt;

&lt;p&gt;Tracking comes in three flavors, and you choose which ones you want per message. Open tracking tells you when a recipient first opens the message. Link tracking tells you when they click a link inside it, and which link. Reply tracking tells you when someone replies to the thread. Each is a separate boolean you flip on at send time, so a message can carry any combination of the three.&lt;/p&gt;

&lt;p&gt;The reason they're separate is that they answer different questions and have different reliability. An open is the lightest signal and the easiest to miss, since it depends on the recipient's email client loading an image. A click is a stronger signal of real intent, because someone deliberately acted. A reply is the strongest of all, an explicit response. Most teams turn on opens and links together, because opens alone undercount and the two together give a fuller picture of engagement than either by itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Enable tracking when you send
&lt;/h2&gt;

&lt;p&gt;You turn tracking on by adding a &lt;code&gt;tracking_options&lt;/code&gt; object to a &lt;a href="https://developer.nylas.com/docs/reference/api/messages/send-message/" rel="noopener noreferrer"&gt;&lt;code&gt;POST /v3/grants/{grant_id}/messages/send&lt;/code&gt;&lt;/a&gt; request. It has three boolean fields, &lt;code&gt;opens&lt;/code&gt;, &lt;code&gt;links&lt;/code&gt;, and &lt;code&gt;thread_replies&lt;/code&gt;, each defaulting to &lt;code&gt;false&lt;/code&gt;, plus an optional &lt;code&gt;label&lt;/code&gt; string. Set the ones you want to &lt;code&gt;true&lt;/code&gt;, and the message goes out instrumented for exactly those events. One prerequisite: tracking needs a production application, since a sandbox (trial) account rejects the request with "Tracking options are not allowed for trial accounts."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/grants/&amp;lt;GRANT_ID&amp;gt;/messages/send"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "to": [{ "email": "prospect@example.com" }],
    "subject": "Following up",
    "body": "&amp;lt;p&amp;gt;Hi, just checking in. &amp;lt;a href=\"https://example.com/demo\"&amp;gt;Book a demo&amp;lt;/a&amp;gt;.&amp;lt;/p&amp;gt;",
    "tracking_options": {
      "opens": true,
      "links": true,
      "thread_replies": true,
      "label": "demo-follow-up"
    }
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice the body is HTML with a real anchor tag, which matters for the next sections. The &lt;code&gt;label&lt;/code&gt; is a free-text description, up to 2,048 characters, that rides along with the tracking data so you can tell later why a message was tracked or which campaign it belonged to. The same &lt;code&gt;tracking_options&lt;/code&gt; object works when you create a draft, so a message reviewed before sending can carry tracking too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Send a tracked message from the CLI
&lt;/h2&gt;

&lt;p&gt;The CLI exposes the same three toggles as flags on &lt;code&gt;nylas email send&lt;/code&gt;. Pass &lt;code&gt;--track-opens&lt;/code&gt; to instrument opens, &lt;code&gt;--track-links&lt;/code&gt; for clicks, and &lt;code&gt;--track-label&lt;/code&gt; to attach a grouping label. There's no separate reply flag, so for thread reply tracking you reach for the API's &lt;code&gt;thread_replies&lt;/code&gt; field, but opens and links, the two most teams use, are one flag each.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas email send &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--to&lt;/span&gt; prospect@example.com &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--subject&lt;/span&gt; &lt;span class="s2"&gt;"Following up"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--body&lt;/span&gt; &lt;span class="s2"&gt;"&amp;lt;p&amp;gt;Hi, just checking in. &amp;lt;a href='https://example.com/demo'&amp;gt;Book a demo&amp;lt;/a&amp;gt;.&amp;lt;/p&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--track-opens&lt;/span&gt; &lt;span class="nt"&gt;--track-links&lt;/span&gt; &lt;span class="nt"&gt;--track-label&lt;/span&gt; &lt;span class="s2"&gt;"demo-follow-up"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the fastest way to confirm tracking works before you build it into an app: send yourself a tracked message, open it, click the link, and watch the events arrive. The &lt;code&gt;nylas email tracking-info&lt;/code&gt; command prints a reference of the tracking features and the webhook payloads they produce, which is handy when you're setting up the listener and want the shapes in front of you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Receive the events through webhooks
&lt;/h2&gt;

&lt;p&gt;Tracking is delivered asynchronously, so the events come to you as webhooks rather than in the send response. You subscribe to three triggers that map one-to-one onto the options: &lt;a href="https://developer.nylas.com/docs/reference/notifications/" rel="noopener noreferrer"&gt;&lt;code&gt;message.opened&lt;/code&gt;&lt;/a&gt; fires on an open, &lt;code&gt;message.link_clicked&lt;/code&gt; on a click, and &lt;code&gt;thread.replied&lt;/code&gt; on a reply. Each &lt;code&gt;POST&lt;/code&gt; to your endpoint carries the &lt;code&gt;message_id&lt;/code&gt; and the details of what happened.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/webhooks/nylas&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// acknowledge fast&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;obj&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// tracking payload nests under data.object&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;message.opened&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;recordOpen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;recents&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;message.link_clicked&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// recents = each individual click; link_data = aggregate [{ url, count }]&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;recordClicks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;recents&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;link_data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;thread.replied&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;recordReply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tracking fields sit under &lt;code&gt;data.object&lt;/code&gt;, not directly on &lt;code&gt;data&lt;/code&gt;, so reach for &lt;code&gt;data.object.message_id&lt;/code&gt; and friends. An open event's &lt;code&gt;recents&lt;/code&gt; array carries each open with a &lt;code&gt;timestamp&lt;/code&gt;, the recipient's &lt;code&gt;ip&lt;/code&gt;, and &lt;code&gt;user_agent&lt;/code&gt;. A click event carries both a &lt;code&gt;recents&lt;/code&gt; array of individual clicks and a &lt;code&gt;link_data&lt;/code&gt; array listing each tracked &lt;code&gt;url&lt;/code&gt; with a running &lt;code&gt;count&lt;/code&gt;, so you see not just that a link was clicked but which one and how often. One indexing quirk to know: the event &lt;code&gt;count&lt;/code&gt; starts at 1, while the &lt;code&gt;opened_id&lt;/code&gt; and &lt;code&gt;click_id&lt;/code&gt; indices start at 0, so don't conflate the two when you store them.&lt;/p&gt;

&lt;h2&gt;
  
  
  How open tracking actually works
&lt;/h2&gt;

&lt;p&gt;Open tracking is a transparent one-pixel image. When you enable it, Nylas inserts that pixel into the message's HTML, and when the recipient's email client renders the message, it requests the image from the tracking server, which records the open. That mechanism has two consequences worth designing around, because they decide how much you can trust an open.&lt;/p&gt;

&lt;p&gt;First, it needs HTML and a client that loads remote images. Ad blockers, corporate proxies, and content delivery networks can all intercept the pixel request, so an open that never registers doesn't mean the message wasn't read. Open rates run lower than reality for this reason, especially since some clients pre-fetch or block images by default. Second, repeated loads are deduplicated: if the pixel is fetched several times within one minute, only the first counts as an open, so a client that reloads doesn't inflate your numbers. The practical takeaway is to treat opens as a floor, not a precise count, and to pair them with link tracking for a signal that doesn't depend on image loading.&lt;/p&gt;

&lt;h2&gt;
  
  
  How link tracking actually works
&lt;/h2&gt;

&lt;p&gt;Link tracking works by rewriting links, and the detail that trips people up is that it's all-or-nothing per message. When you enable it, the service rewrites every valid HTML link in the body to route through a tracking redirect, then forwards the click to the real destination. You can't track only some links and leave others alone; enabling link tracking instruments all of them.&lt;/p&gt;

&lt;p&gt;For a link to be rewritten, it has to be a properly formed HTML anchor with a valid URI, like &lt;code&gt;&amp;lt;a href="https://example.com"&amp;gt;demo&amp;lt;/a&amp;gt;&lt;/code&gt;. A bare &lt;code&gt;www.example.com&lt;/code&gt; with no anchor tag won't be tracked, because there's nothing to rewrite. There's also a security carve-out: links carrying embedded login credentials are deliberately left alone, since rewriting them would break the destination's authentication. A private Google Form URL, for instance, contains credentials, so it's skipped, the link still works when clicked but produces no tracking event. Knowing these rules up front saves you from debugging a "missing" click that was never trackable to begin with.&lt;/p&gt;

&lt;h2&gt;
  
  
  Track replies, and label what you track
&lt;/h2&gt;

&lt;p&gt;Reply tracking is the third option and the most reliable signal, because a reply is an explicit human action with nothing to block or rewrite. Set &lt;code&gt;thread_replies&lt;/code&gt; to &lt;code&gt;true&lt;/code&gt; and a &lt;code&gt;thread.replied&lt;/code&gt; webhook fires when someone responds on the thread, which is how you detect engagement on a message that asked for one without polling the mailbox for new messages yourself.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;label&lt;/code&gt; field is the small piece that makes tracking data usable at scale. It's an optional description, up to 2,048 characters, attached at send time and carried through to the events, so when a &lt;code&gt;message.opened&lt;/code&gt; arrives you can tell which campaign or message type it belongs to without a separate lookup. Use it to group a batch, a "march-newsletter" or "trial-day-3" label, and your analytics can bucket events by intent rather than by an opaque message ID. It costs nothing to set and saves a join later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't delete a grant you're still tracking
&lt;/h2&gt;

&lt;p&gt;One lifecycle detail bites people who don't know it. Tracking links and pixels are tied to the grant that sent the message, and if you delete that grant, the tracking stops working: the service can no longer match an incoming open or click to the deleted grant, so you lose tracking events for every message that grant sent, including ones already out in the wild. An expired grant is gentler, you still receive &lt;code&gt;message.opened&lt;/code&gt; events on it, but a deleted one is a hard cutoff.&lt;/p&gt;

&lt;p&gt;The rule that follows is the same one that applies across the API: re-authenticate an expired grant in place rather than deleting and recreating it. A delete-and-recreate cycle silently breaks tracking on every message the old grant ever sent, which surfaces as analytics that mysteriously go quiet for older sends. If you're running campaigns whose tracking matters for weeks after sending, treat grant deletion as the destructive operation it is for your tracking data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where tracking pays off
&lt;/h2&gt;

&lt;p&gt;The same three options sit under a range of features, and which you enable follows the signal you need. A few that map straight on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sales follow-up timing.&lt;/strong&gt; An open or click tells a rep the moment a prospect engaged, so the next touch lands when interest is fresh instead of on a fixed schedule.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Newsletter analytics.&lt;/strong&gt; Open and click rates across a batch, grouped by &lt;code&gt;label&lt;/code&gt;, show which subject lines and links actually performed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Engagement-driven sequences.&lt;/strong&gt; A &lt;code&gt;thread.replied&lt;/code&gt; event advances or stops an automated sequence, so a reply pauses the follow-ups instead of a customer getting nagged after they already answered.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Delivery confidence.&lt;/strong&gt; For a transactional message that must land, a registered open is a lightweight signal that it reached a real inbox, though a missed pixel never proves the opposite, so pair it with a click for certainty.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each is the same &lt;code&gt;tracking_options&lt;/code&gt; on a send plus a webhook listener, with the difference being which events you act on and how.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things to keep in mind
&lt;/h2&gt;

&lt;p&gt;A short list of details keeps tracking honest.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Opens undercount.&lt;/strong&gt; The pixel depends on image loading, so ad blockers and CDNs suppress some opens; treat the number as a floor and pair it with links.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Link tracking is all-or-nothing.&lt;/strong&gt; Enabling it rewrites every valid HTML link in the message; you can't instrument only some.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use real anchor tags.&lt;/strong&gt; Only properly formed &lt;code&gt;&amp;lt;a href&amp;gt;&lt;/code&gt; links with valid URIs get rewritten; bare text URLs and credential-bearing links are skipped.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Events arrive by webhook.&lt;/strong&gt; Subscribe to &lt;code&gt;message.opened&lt;/code&gt;, &lt;code&gt;message.link_clicked&lt;/code&gt;, and &lt;code&gt;thread.replied&lt;/code&gt;; nothing comes back in the send response.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Label your sends.&lt;/strong&gt; The optional &lt;code&gt;label&lt;/code&gt;, up to 2,048 characters, groups events by campaign so your analytics don't hinge on raw message IDs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't delete grants mid-campaign.&lt;/strong&gt; Deleting a grant kills tracking for every message it sent; re-authenticate an expired grant instead.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;Email tracking turns a silent send into a measurable one. Add a &lt;code&gt;tracking_options&lt;/code&gt; object with &lt;code&gt;opens&lt;/code&gt;, &lt;code&gt;links&lt;/code&gt;, or &lt;code&gt;thread_replies&lt;/code&gt; to a send, or pass &lt;code&gt;--track-opens&lt;/code&gt; and &lt;code&gt;--track-links&lt;/code&gt; from the CLI, and the events arrive as &lt;code&gt;message.opened&lt;/code&gt;, &lt;code&gt;message.link_clicked&lt;/code&gt;, and &lt;code&gt;thread.replied&lt;/code&gt; webhooks carrying timestamps, IPs, and the clicked URLs. Remember that opens undercount because they rely on a loaded pixel, that link tracking rewrites every valid link or none, and that deleting a grant breaks tracking for everything it sent. Label your sends and lean on clicks and replies for the signals that matter.&lt;/p&gt;

&lt;p&gt;Where to go next:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/v3/email/message-tracking/" rel="noopener noreferrer"&gt;Track messages&lt;/a&gt; — the full guide to opens, links, and replies&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/reference/api/messages/send-message/" rel="noopener noreferrer"&gt;Send a message&lt;/a&gt; — the &lt;code&gt;tracking_options&lt;/code&gt; field and other send options&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/v3/notifications/" rel="noopener noreferrer"&gt;Webhook notifications&lt;/a&gt; — subscribing to the tracking triggers&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/dev-guide/best-practices/grant-lifecycle/" rel="noopener noreferrer"&gt;Handling expired grants&lt;/a&gt; — why to re-authenticate instead of delete&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  AI-answer pages for agents
&lt;/h2&gt;

&lt;p&gt;When this post is published, link AI agents and crawlers to the retrieval-ready version on &lt;code&gt;cli.nylas.com&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Topic runbook: &lt;a href="https://cli.nylas.com/ai-answers/email-open-click-tracking-saas-crm.md" rel="noopener noreferrer"&gt;https://cli.nylas.com/ai-answers/email-open-click-tracking-saas-crm.md&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Industry playbooks hub: &lt;a href="https://cli.nylas.com/ai-answers/agent-account-industry-playbooks.md" rel="noopener noreferrer"&gt;https://cli.nylas.com/ai-answers/agent-account-industry-playbooks.md&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>api</category>
      <category>email</category>
      <category>webhooks</category>
      <category>devtools</category>
    </item>
    <item>
      <title>Clean quoted replies from email with the Nylas API</title>
      <dc:creator>Qasim</dc:creator>
      <pubDate>Mon, 20 Jul 2026 22:27:23 +0000</pubDate>
      <link>https://dev.to/mqasimca/clean-quoted-replies-from-email-with-the-nylas-api-22mf</link>
      <guid>https://dev.to/mqasimca/clean-quoted-replies-from-email-with-the-nylas-api-22mf</guid>
      <description>&lt;p&gt;Open a five-message reply chain and look at how much of it is actually new. One or two sentences at the top, then a wall of quoted history, a signature, a legal disclaimer, and "Best regards." For a human skimming, that noise is just clutter. For code, it's worse: feed that blob to a language model for summarization and you're paying for tokens of quoted text the model has already seen, and showing it as a preview means the same quoted chain on every message. Cleaning email pulls out just the meaningful text, and this post wires it up with the Email API and the CLI.&lt;/p&gt;

&lt;p&gt;It's a worked use case rather than an endpoint tour, covering the &lt;a href="https://developer.nylas.com/docs/v3/email/clean-conversation/" rel="noopener noreferrer"&gt;clean conversation&lt;/a&gt; endpoint and the cleaned-message webhook from two angles: the HTTP API your backend calls and the &lt;a href="https://cli.nylas.com/docs/commands" rel="noopener noreferrer"&gt;&lt;code&gt;nylas&lt;/code&gt; CLI&lt;/a&gt; for cleaning a message from the terminal. I work on the CLI, so the commands below are the ones I reach for when I want to see what cleaning actually returns.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two ways to clean a message
&lt;/h2&gt;

&lt;p&gt;There are two paths, and which you pick depends on whether you have a message in hand or want every incoming message cleaned automatically. The on-demand path is a single endpoint: you pass one or more message IDs and get back the cleaned text, which suits a "summarize this thread" button or a one-off processing job. The automatic path is a webhook, &lt;code&gt;message.created.cleaned&lt;/code&gt;, that fires a cleaned version of every new message once you enable the Clean Conversations feature for your application.&lt;/p&gt;

&lt;p&gt;The distinction matters for how you build. On-demand cleaning is synchronous and you control when it runs, so it's the natural fit when a user acts on a specific message or you're backfilling a batch. The webhook is push-based and hands you cleaned content the moment mail arrives, which is what you want when every message flows into a pipeline that needs the text stripped before anything downstream touches it. Many apps use both: the webhook for the steady stream, the endpoint for on-demand reprocessing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Clean a message on demand
&lt;/h2&gt;

&lt;p&gt;The on-demand endpoint is a &lt;a href="https://developer.nylas.com/docs/reference/api/messages/clean-messages/" rel="noopener noreferrer"&gt;&lt;code&gt;PUT /v3/grants/{grant_id}/messages/clean&lt;/code&gt;&lt;/a&gt; with a &lt;code&gt;message_id&lt;/code&gt; array of the messages to clean, up to 20 in a single call. The response returns each message with a &lt;code&gt;conversation&lt;/code&gt; field holding the cleaned text, the quoted chain and signature removed, alongside the original message data so you keep the metadata.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; PUT &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/grants/&amp;lt;GRANT_ID&amp;gt;/messages/clean"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "message_id": ["18df98cadcc8534a"],
    "ignore_links": true,
    "remove_conclusion_phrases": true
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cleaned result lands in &lt;code&gt;conversation&lt;/code&gt;, so that's the field you read, not &lt;code&gt;body&lt;/code&gt;, which still carries the original. Batching up to 20 IDs per call is the detail that makes this practical for a job: cleaning a thread or a day's worth of messages is a handful of requests, not one per message. Each cleaned message comes back keyed to its ID, so you can match the output to the input when you send several at once.&lt;/p&gt;

&lt;p&gt;The operation is non-destructive, which is worth saying plainly: cleaning returns a parsed copy and never modifies the stored message. The original stays intact in the mailbox and in the &lt;code&gt;body&lt;/code&gt; field, so you can clean the same message repeatedly with different options, once with links stripped for a model and again with links kept for display, without any risk of losing the source. That makes it safe to call freely in a pipeline, and safe to reprocess later if you change which options you want, since there's no state to undo. Treat the cleaned &lt;code&gt;conversation&lt;/code&gt; as a derived view you can regenerate any time, not a one-way transformation of the message.&lt;/p&gt;

&lt;h2&gt;
  
  
  Clean a message from the CLI
&lt;/h2&gt;

&lt;p&gt;The terminal command is &lt;code&gt;nylas email clean&lt;/code&gt; followed by one or more message IDs, and it does the same parse, returning just the meaningful text with quoted chains, signatures, and conclusion phrases like "Best" and "Regards" removed. By default it strips links, images, tables, and those signature phrases, and the plain-text output has HTML tags removed so it's readable at a glance.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Clean one message; keep links in the output&lt;/span&gt;
nylas email clean &amp;lt;message-id&amp;gt; &lt;span class="nt"&gt;--keep-links&lt;/span&gt;

&lt;span class="c"&gt;# JSON output with the raw cleaned HTML in the "conversation" field&lt;/span&gt;
nylas email clean &amp;lt;id-1&amp;gt; &amp;lt;id-2&amp;gt; &lt;span class="nt"&gt;--json&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;--keep-*&lt;/code&gt; flags, &lt;code&gt;--keep-links&lt;/code&gt;, &lt;code&gt;--keep-images&lt;/code&gt;, &lt;code&gt;--keep-tables&lt;/code&gt;, and &lt;code&gt;--keep-signatures&lt;/code&gt;, turn off individual stripping when you want something retained, and &lt;code&gt;--images-as-markdown&lt;/code&gt; returns images as Markdown links instead. Like the endpoint, it cleans up to 20 messages in one call, and &lt;code&gt;--json&lt;/code&gt; gives you the raw cleaned HTML in the &lt;code&gt;conversation&lt;/code&gt; field for piping into a script. This is the fastest way to see what cleaning does to a real message before you wire the endpoint into anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  What gets stripped, and how to keep it
&lt;/h2&gt;

&lt;p&gt;Cleaning is opinionated by default, and knowing the defaults saves surprises. The endpoint's boolean options, &lt;code&gt;ignore_links&lt;/code&gt;, &lt;code&gt;ignore_images&lt;/code&gt;, &lt;code&gt;ignore_tables&lt;/code&gt;, and &lt;code&gt;remove_conclusion_phrases&lt;/code&gt;, all default to &lt;code&gt;true&lt;/code&gt;, so out of the box a clean strips links, images, tables, and sign-off phrases along with the quoted history. That's the right default for feeding text to a model, where none of that markup helps, but it's aggressive if you're cleaning for display.&lt;/p&gt;

&lt;p&gt;To retain something, set its option to &lt;code&gt;false&lt;/code&gt;. If you're cleaning a message to show a readable preview and you want the links live, send &lt;code&gt;ignore_links: false&lt;/code&gt;, and the anchor tags survive. The mapping to the CLI is the &lt;code&gt;--keep-*&lt;/code&gt; flags, which flip the same switches from the terminal. The thing to internalize is that the defaults remove more than just the quoted chain, so when a cleaned result is missing something you expected, the fix is usually a &lt;code&gt;false&lt;/code&gt; on one of these options rather than anything more involved.&lt;/p&gt;

&lt;p&gt;One honest caveat: cleaning is heuristic. It's very good at the common signature and quote patterns, but an unusual layout can leave a stray fragment or trim a line you wanted, so spot-check the output on real mail before you trust it in a pipeline. It's reliable enough to build on, just not so perfect that you should skip eyeballing a sample first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get Markdown out for a language model
&lt;/h2&gt;

&lt;p&gt;Since the most common reason to clean a message is to feed it to a model, the API can hand you Markdown directly. Setting &lt;code&gt;images_as_markdown&lt;/code&gt; to &lt;code&gt;true&lt;/code&gt;, which is itself the default, converts images to Markdown image syntax, and the beta &lt;code&gt;html_as_markdown&lt;/code&gt; option converts the whole message to Markdown rather than returning HTML or plain text. One constraint ties them together: &lt;code&gt;html_as_markdown&lt;/code&gt; can't be &lt;code&gt;true&lt;/code&gt; while &lt;code&gt;images_as_markdown&lt;/code&gt; is &lt;code&gt;false&lt;/code&gt;, since converting the document to Markdown but leaving images as raw HTML would be inconsistent.&lt;/p&gt;

&lt;p&gt;Markdown is the format most language models handle best, so converting at the cleaning step means the text is model-ready without a second pass through a converter of your own. The plain-text path is still there when you want it, the CLI strips HTML tags from its default output, but for a retrieval or summarization pipeline, asking for Markdown at clean time keeps the formatting cues a model can actually use, like headings and lists, while dropping the HTML scaffolding it can't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Clean every message automatically with a webhook
&lt;/h2&gt;

&lt;p&gt;When you want cleaning to happen to every message without calling the endpoint each time, enable Clean Conversations for your application and subscribe to the &lt;code&gt;message.created.cleaned&lt;/code&gt; webhook. It fires for each new synced message and delivers the cleaned content in the message &lt;code&gt;body&lt;/code&gt; as Markdown, so your pipeline receives stripped text the moment mail lands, with no extra call on your side.&lt;/p&gt;

&lt;p&gt;Two behaviors are worth knowing before you rely on it. First, subscribing to &lt;code&gt;message.created.cleaned&lt;/code&gt; does not suppress the regular &lt;code&gt;message.created&lt;/code&gt; notification, so if you subscribe to both you get two separate webhooks per new message, the raw one and the cleaned one. Subscribe only to the cleaned trigger if the cleaned text is all your pipeline needs. Second, each cleaned notification carries a &lt;code&gt;cleaning_status&lt;/code&gt; field, and when it comes back as &lt;code&gt;failed&lt;/code&gt;, the &lt;code&gt;body&lt;/code&gt; holds the original uncleaned HTML rather than cleaned text, with a &lt;code&gt;cleaning_error&lt;/code&gt; describing what went wrong. So branch on &lt;code&gt;cleaning_status&lt;/code&gt; before you assume the body is clean.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/webhooks/nylas&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// acknowledge fast&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;message.created.cleaned&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cleaning_status&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;success&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// body is raw HTML on failure&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;indexForRetrieval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// cleaned markdown&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Where cleaning pays off
&lt;/h2&gt;

&lt;p&gt;The same cleaning step sits under a range of features, and the reason is always the same: downstream code wants the new message, not the thread's whole history. A few that map straight on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Feeding email to an LLM.&lt;/strong&gt; Summarization, classification, and retrieval all work better and cost less on cleaned text, since the quoted chain is repeated context the model doesn't need and you'd otherwise pay to process.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Readable previews.&lt;/strong&gt; A message list that shows the actual new content instead of "On Tuesday, X wrote:" reads far better, and cleaning with links kept gives you that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reply extraction.&lt;/strong&gt; Pulling just the latest reply from a long back-and-forth, for a support tool or a CRM note, is exactly what removing the quoted chain does.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Search indexing.&lt;/strong&gt; Indexing cleaned bodies keeps your search index free of duplicated quoted text, so a query doesn't match the same sentence echoed across ten replies.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each is the same clean call with different options, the difference being whether you keep links and images for display or strip everything for a model.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things to keep in mind
&lt;/h2&gt;

&lt;p&gt;A short list of details keeps cleaning predictable.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Read the &lt;code&gt;conversation&lt;/code&gt; field.&lt;/strong&gt; The on-demand endpoint returns cleaned text in &lt;code&gt;conversation&lt;/code&gt;, while &lt;code&gt;body&lt;/code&gt; still holds the original message.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Defaults strip aggressively.&lt;/strong&gt; &lt;code&gt;ignore_links&lt;/code&gt;, &lt;code&gt;ignore_images&lt;/code&gt;, &lt;code&gt;ignore_tables&lt;/code&gt;, and &lt;code&gt;remove_conclusion_phrases&lt;/code&gt; all default to &lt;code&gt;true&lt;/code&gt;; set one to &lt;code&gt;false&lt;/code&gt; or use a &lt;code&gt;--keep-*&lt;/code&gt; flag to retain it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Twenty messages per call.&lt;/strong&gt; Both the endpoint and the CLI clean up to 20 IDs at once, so batch a thread rather than looping one at a time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The cleaned webhook doesn't replace &lt;code&gt;message.created&lt;/code&gt;.&lt;/strong&gt; Subscribe to both and you get two notifications per message; subscribe only to &lt;code&gt;.cleaned&lt;/code&gt; if that's all you need.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check &lt;code&gt;cleaning_status&lt;/code&gt;.&lt;/strong&gt; On a &lt;code&gt;failed&lt;/code&gt; status the webhook body is the original uncleaned HTML, so branch on it before treating the body as clean.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ask for Markdown for models.&lt;/strong&gt; &lt;code&gt;html_as_markdown&lt;/code&gt; returns model-ready Markdown, and it can't be &lt;code&gt;true&lt;/code&gt; when &lt;code&gt;images_as_markdown&lt;/code&gt; is &lt;code&gt;false&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;Cleaning turns a noisy reply chain into just the text that matters. Call &lt;code&gt;PUT /v3/grants/{grant_id}/messages/clean&lt;/code&gt; with up to 20 message IDs, or run &lt;code&gt;nylas email clean&lt;/code&gt;, and read the cleaned result from the &lt;code&gt;conversation&lt;/code&gt; field, remembering the defaults strip links, images, tables, and sign-off phrases unless you keep them. For a steady stream, enable Clean Conversations and take the &lt;code&gt;message.created.cleaned&lt;/code&gt; webhook, branching on &lt;code&gt;cleaning_status&lt;/code&gt; and knowing it arrives alongside the raw &lt;code&gt;message.created&lt;/code&gt;, not instead of it. Ask for Markdown when the destination is a language model, and you've got message text that's ready to summarize, index, or display.&lt;/p&gt;

&lt;p&gt;Where to go next:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/v3/email/clean-conversation/" rel="noopener noreferrer"&gt;Clean conversation&lt;/a&gt; — the full guide to cleaning options&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/reference/api/messages/clean-messages/" rel="noopener noreferrer"&gt;Clean messages&lt;/a&gt; — the endpoint reference and all options&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/v3/email/parse-messages/" rel="noopener noreferrer"&gt;Parse messages&lt;/a&gt; — the Clean Conversations feature and the cleaned webhook&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/v3/notifications/" rel="noopener noreferrer"&gt;Webhook notifications&lt;/a&gt; — subscribing to message triggers&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  AI-answer pages for agents
&lt;/h2&gt;

&lt;p&gt;When this post is published, link AI agents and crawlers to the retrieval-ready version on &lt;code&gt;cli.nylas.com&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Topic runbook: &lt;a href="https://cli.nylas.com/ai-answers/email-body-parsing-api-for-python-agents.md" rel="noopener noreferrer"&gt;https://cli.nylas.com/ai-answers/email-body-parsing-api-for-python-agents.md&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Industry playbooks hub: &lt;a href="https://cli.nylas.com/ai-answers/agent-account-industry-playbooks.md" rel="noopener noreferrer"&gt;https://cli.nylas.com/ai-answers/agent-account-industry-playbooks.md&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>api</category>
      <category>email</category>
      <category>devtools</category>
    </item>
    <item>
      <title>Connect a mailbox with Nylas hosted OAuth</title>
      <dc:creator>Qasim</dc:creator>
      <pubDate>Mon, 20 Jul 2026 22:27:12 +0000</pubDate>
      <link>https://dev.to/mqasimca/connect-a-mailbox-with-nylas-hosted-oauth-3cgg</link>
      <guid>https://dev.to/mqasimca/connect-a-mailbox-with-nylas-hosted-oauth-3cgg</guid>
      <description>&lt;p&gt;Before your app can read a single email or calendar event, the user has to give it permission. That permission is an OAuth handshake with their provider, Google, Microsoft, or another, and getting it right means redirect URIs, scopes, token exchange, and refresh tokens. Hosted OAuth hands that whole dance to Nylas: you send the user to a hosted authorization URL, they approve access at their provider, and you get back a grant you can use to call the email, calendar, and contacts APIs. This post walks the flow with the API and shows the CLI command that does it in one step.&lt;/p&gt;

&lt;p&gt;It's a worked use case rather than an endpoint tour, covering the &lt;a href="https://developer.nylas.com/docs/v3/auth/" rel="noopener noreferrer"&gt;hosted OAuth&lt;/a&gt; flow from two angles: the HTTP endpoints your backend calls and the &lt;a href="https://cli.nylas.com/docs/commands" rel="noopener noreferrer"&gt;&lt;code&gt;nylas&lt;/code&gt; CLI&lt;/a&gt; for connecting an account from the terminal. I work on the CLI, so the commands below are the ones I reach for when I want a grant to test against fast.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a grant is, and the two-step flow
&lt;/h2&gt;

&lt;p&gt;The thing you're after is a grant: a stored connection to one user's account that the API uses to act on their behalf. Every email you read, event you create, or contact you fetch is scoped to a &lt;code&gt;grant_id&lt;/code&gt;, so connecting an account means producing one. Hosted OAuth produces it in two steps, and understanding the split is most of the battle.&lt;/p&gt;

&lt;p&gt;First, you send the user to a hosted authorization URL, where they sign in to their provider and approve the access your app is asking for. The provider sends them back to your app with a temporary authorization &lt;code&gt;code&lt;/code&gt;. Second, your backend exchanges that code for a grant by calling a token endpoint. The code is short-lived and single-use, a one-time proof that the user approved; the grant it becomes is the durable handle you keep. That two-step shape, redirect for consent, then exchange on the server, is standard OAuth 2.0, and Nylas runs the provider-specific parts of it for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: send the user to the authorization URL
&lt;/h2&gt;

&lt;p&gt;The flow starts at &lt;a href="https://developer.nylas.com/docs/api/v3/admin/#get-/v3/connect/auth" rel="noopener noreferrer"&gt;&lt;code&gt;/v3/connect/auth&lt;/code&gt;&lt;/a&gt;, which you build into a URL and redirect the user to. The required query parameters are &lt;code&gt;client_id&lt;/code&gt; (your Nylas application's ID), &lt;code&gt;redirect_uri&lt;/code&gt; (where the provider sends the user back, and it must match a URI registered on your application), and &lt;code&gt;response_type=code&lt;/code&gt; to ask for the authorization code flow. A &lt;code&gt;provider&lt;/code&gt; parameter preselects a supported provider such as Google or Microsoft, and &lt;code&gt;login_hint&lt;/code&gt; prefills the user's email.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;https://api.us.nylas.com/v3/connect/auth?client_id&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;NYLAS_CLIENT_ID&amp;gt;&amp;amp;redirect_uri&lt;span class="o"&gt;=&lt;/span&gt;https%3A%2F%2Fyourapp.com%2Fcallback&amp;amp;response_type&lt;span class="o"&gt;=&lt;/span&gt;code&amp;amp;provider&lt;span class="o"&gt;=&lt;/span&gt;google&amp;amp;login_hint&lt;span class="o"&gt;=&lt;/span&gt;user@example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You don't fetch this URL server-side; you redirect the user's browser to it, because they need to see and approve the provider's consent screen. A couple of optional parameters matter for real apps: &lt;code&gt;state&lt;/code&gt; carries an opaque value you generate and check when the user returns, which is your defense against cross-site request forgery on the callback, and &lt;code&gt;access_type=offline&lt;/code&gt; asks for a refresh token so your access survives past the first hour. Set &lt;code&gt;state&lt;/code&gt; on every real integration, not just as a nicety.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: exchange the code for a grant
&lt;/h2&gt;

&lt;p&gt;When the user approves, the provider redirects them to your &lt;code&gt;redirect_uri&lt;/code&gt; with a &lt;code&gt;?code=&lt;/code&gt; query parameter. Your backend then exchanges that code at &lt;a href="https://developer.nylas.com/docs/api/v3/admin/#post-/v3/connect/token" rel="noopener noreferrer"&gt;&lt;code&gt;POST /v3/connect/token&lt;/code&gt;&lt;/a&gt; for the grant. The request needs &lt;code&gt;client_id&lt;/code&gt;, &lt;code&gt;client_secret&lt;/code&gt; (your Nylas API key), &lt;code&gt;redirect_uri&lt;/code&gt; matching the one from step one, &lt;code&gt;grant_type: authorization_code&lt;/code&gt;, and the &lt;code&gt;code&lt;/code&gt; itself.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/connect/token"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "client_id": "&amp;lt;NYLAS_CLIENT_ID&amp;gt;",
    "client_secret": "&amp;lt;NYLAS_API_KEY&amp;gt;",
    "redirect_uri": "https://yourapp.com/callback",
    "grant_type": "authorization_code",
    "code": "&amp;lt;CODE_FROM_CALLBACK&amp;gt;"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response carries the &lt;code&gt;grant_id&lt;/code&gt; and the connected &lt;code&gt;email&lt;/code&gt;, plus an &lt;code&gt;access_token&lt;/code&gt; and, if you asked for offline access, a &lt;code&gt;refresh_token&lt;/code&gt;. The &lt;code&gt;grant_id&lt;/code&gt; is the one you store: it's the handle for every subsequent call. This exchange has to happen on your server, never in the browser, because it carries your API key in &lt;code&gt;client_secret&lt;/code&gt;. Putting that key in client-side code would hand anyone who views source the keys to your whole application, so the token exchange is a backend-only step by design.&lt;/p&gt;

&lt;h2&gt;
  
  
  Connect an account from the CLI
&lt;/h2&gt;

&lt;p&gt;Running both steps by hand is the right model for a production app, but for testing you want a grant now, and the CLI does the entire handshake in one command. For a browser-based provider, &lt;code&gt;nylas auth login&lt;/code&gt; opens your browser to the consent screen, captures the redirect, exchanges the code, and stores the resulting grant locally, all from a single command.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Connect a Google account (the default)&lt;/span&gt;
nylas auth login

&lt;span class="c"&gt;# Connect a Microsoft account instead&lt;/span&gt;
nylas auth login &lt;span class="nt"&gt;--provider&lt;/span&gt; microsoft
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;--provider&lt;/code&gt; flag picks the provider, defaulting to &lt;code&gt;google&lt;/code&gt;, and the OAuth providers, &lt;code&gt;google&lt;/code&gt;, &lt;code&gt;microsoft&lt;/code&gt;, and &lt;code&gt;ews&lt;/code&gt;, open a browser, while the credential providers prompt you in the terminal instead. Once you're connected, &lt;code&gt;nylas auth status&lt;/code&gt; shows the active grant, &lt;code&gt;nylas auth list&lt;/code&gt; shows every account you've connected, and &lt;code&gt;nylas auth switch&lt;/code&gt; flips between them. This is the fastest path from nothing to a working grant you can run real email and calendar commands against, without standing up a callback server first.&lt;/p&gt;

&lt;h2&gt;
  
  
  OAuth providers versus credential providers
&lt;/h2&gt;

&lt;p&gt;Not every provider authenticates the same way. Google, Microsoft, Yahoo, and Zoom have a true OAuth consent screen, so the redirect flow above sends the user to their provider to approve access, and the authorization URL's &lt;code&gt;provider&lt;/code&gt; parameter accepts each of them. iCloud, generic IMAP, and on-premises Exchange (EWS) have no such consent screen, and authenticate with credentials instead: iCloud with an Apple app-specific password the user generates in their account settings, IMAP with direct server credentials, and EWS with Exchange login details.&lt;/p&gt;

&lt;p&gt;For those credential-based providers, you create the grant from the credentials you collect, commonly through the custom bring-your-own endpoint, &lt;code&gt;/v3/connect/custom&lt;/code&gt;, which also handles bulk and IMAP provisioning. Whichever way an account connects, the same &lt;code&gt;grant_id&lt;/code&gt; comes out, so once it's done, the rest of your code doesn't care which method produced it, the email and calendar calls are identical across providers.&lt;/p&gt;

&lt;p&gt;One caveat if you connect test accounts with the CLI: &lt;code&gt;nylas auth login&lt;/code&gt; covers the email providers, Google, Microsoft, Yahoo, iCloud, IMAP, and EWS, and labels them by interaction style rather than by protocol, opening a browser for Google, Microsoft, and EWS and prompting in the terminal for iCloud, Yahoo, and IMAP. That grouping is a CLI convenience, not a statement about each provider's auth mechanism, so don't read the CLI's browser-or-prompt labels as the provider's protocol.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep access alive with refresh tokens
&lt;/h2&gt;

&lt;p&gt;An access token is short-lived, an hour for the short-lived variety, so an integration that needs to keep working past that first hour relies on the refresh token you get when you request offline access. That's why &lt;code&gt;access_type=offline&lt;/code&gt; on the authorization URL matters: without it, you get access that expires and can't be renewed without sending the user through consent again. With it, your backend can mint a fresh access token whenever the old one expires.&lt;/p&gt;

&lt;p&gt;Refreshing is the same token endpoint with a different &lt;code&gt;grant_type&lt;/code&gt;. You call &lt;code&gt;POST /v3/connect/token&lt;/code&gt; with &lt;code&gt;grant_type: refresh_token&lt;/code&gt;, your &lt;code&gt;client_id&lt;/code&gt;, the &lt;code&gt;refresh_token&lt;/code&gt; you stored, and your API key in &lt;code&gt;client_secret&lt;/code&gt;, and you get a new access token back. The practical rule is to request offline access up front for any integration meant to run unattended, and to refresh on demand when a call comes back unauthorized, rather than sending the user back through the consent screen, which should be a last resort reserved for a grant that's genuinely been revoked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Manage and inspect grants
&lt;/h2&gt;

&lt;p&gt;Once accounts are connected, you'll want to see and manage them. From the CLI, &lt;code&gt;nylas auth whoami&lt;/code&gt; shows the current user, &lt;code&gt;nylas auth scopes&lt;/code&gt; prints the OAuth scopes a grant was granted, and &lt;code&gt;nylas auth show&lt;/code&gt; prints detailed grant information, which is the first place to look when a call fails with a permissions error. To disconnect, &lt;code&gt;nylas auth revoke&lt;/code&gt; permanently revokes a grant on the server, while &lt;code&gt;nylas auth remove&lt;/code&gt; just drops it from your local config and leaves the grant intact on the server.&lt;/p&gt;

&lt;p&gt;That distinction between revoke and remove is worth holding onto, because they're not the same operation. Revoking ends the connection for real and the user would have to reconnect; removing only forgets it locally. When you're cleaning up test accounts, removing keeps the grant usable from elsewhere, while revoking is the destructive one. Over the API, ending a connection for good is a &lt;code&gt;DELETE /v3/grants/{grant_id}&lt;/code&gt;, which removes the grant itself, so your app can give users a clean "disconnect my account" action that actually ends the access. Don't confuse that with &lt;code&gt;/v3/connect/revoke&lt;/code&gt;, which revokes an access token but leaves the grant in place, so the user can re-authenticate to the same &lt;code&gt;grant_id&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where hosted OAuth fits
&lt;/h2&gt;

&lt;p&gt;The same connect flow sits at the front of every Nylas integration, and the variations are mostly about who's connecting and how. A few common shapes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A SaaS connecting its users' mailboxes.&lt;/strong&gt; Each user runs the redirect flow once, you store their &lt;code&gt;grant_id&lt;/code&gt; against their account, and every feature you build reads from that grant.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A single backend account.&lt;/strong&gt; An internal tool that sends from one company mailbox connects that one account, often via the CLI, and uses the resulting grant for everything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An agent with its own inbox.&lt;/strong&gt; A Nylas Agent Account gets its own grant, so an automated agent reads and sends mail from an address it owns, the same grant model with no human at the keyboard.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multi-provider support.&lt;/strong&gt; Because the grant is provider-agnostic once created, supporting Google and Microsoft users is the same code path after the connect step diverges on provider.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each starts with producing a grant, and from there the email, calendar, and contacts APIs are identical regardless of how the account was connected.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things to keep in mind
&lt;/h2&gt;

&lt;p&gt;A short list of details keeps the connect flow secure and reliable.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Exchange the code server-side.&lt;/strong&gt; The token call carries your API key as &lt;code&gt;client_secret&lt;/code&gt;; never run it in the browser, or you leak the key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set &lt;code&gt;state&lt;/code&gt; on the authorization URL.&lt;/strong&gt; It's your CSRF defense on the callback; generate it, pass it, and verify it when the user returns.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Request &lt;code&gt;access_type=offline&lt;/code&gt; for lasting access.&lt;/strong&gt; Without a refresh token, your access expires in about an hour and can't be renewed without re-consent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;grant_id&lt;/code&gt; is the durable handle.&lt;/strong&gt; Store it against your user; the authorization &lt;code&gt;code&lt;/code&gt; is single-use and short-lived, the grant is what persists.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Revoke and remove differ.&lt;/strong&gt; Revoking ends the connection on the server; removing only forgets it locally, leaving the grant usable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Match the &lt;code&gt;redirect_uri&lt;/code&gt; exactly.&lt;/strong&gt; It must be registered on your application and identical in both steps, or the exchange fails.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;Connecting a mailbox is a two-step OAuth flow that Nylas runs for you. Redirect the user to &lt;code&gt;/v3/connect/auth&lt;/code&gt; with your &lt;code&gt;client_id&lt;/code&gt;, &lt;code&gt;redirect_uri&lt;/code&gt;, and &lt;code&gt;response_type=code&lt;/code&gt;, let them approve at their provider, then exchange the returned &lt;code&gt;code&lt;/code&gt; at &lt;code&gt;POST /v3/connect/token&lt;/code&gt; on your backend to get a &lt;code&gt;grant_id&lt;/code&gt;. Store that grant, request offline access so it survives past an hour, and protect the callback with &lt;code&gt;state&lt;/code&gt;. For testing, &lt;code&gt;nylas auth login&lt;/code&gt; collapses the whole handshake into one command. However the account connects, OAuth or credentials, Google or Microsoft, the grant that comes out is the single handle every other API call needs.&lt;/p&gt;

&lt;p&gt;Where to go next:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/v3/auth/" rel="noopener noreferrer"&gt;Authentication&lt;/a&gt; — the full hosted OAuth guide&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/v3/auth/hosted-oauth-accesstoken/" rel="noopener noreferrer"&gt;Hosted OAuth with an access token&lt;/a&gt; — the token exchange and PKCE in depth&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/dev-guide/best-practices/grant-lifecycle/" rel="noopener noreferrer"&gt;Grant lifecycle best practices&lt;/a&gt; — refreshing and re-authenticating grants&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://cli.nylas.com/docs/commands" rel="noopener noreferrer"&gt;Nylas CLI auth commands&lt;/a&gt; — &lt;code&gt;nylas auth login&lt;/code&gt;, &lt;code&gt;status&lt;/code&gt;, and &lt;code&gt;revoke&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  AI-answer pages for agents
&lt;/h2&gt;

&lt;p&gt;When this post is published, link AI agents and crawlers to the retrieval-ready version on &lt;code&gt;cli.nylas.com&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Topic runbook: &lt;a href="https://cli.nylas.com/ai-answers/oauth-mailbox-integration-api-gmail-outlook.md" rel="noopener noreferrer"&gt;https://cli.nylas.com/ai-answers/oauth-mailbox-integration-api-gmail-outlook.md&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Industry playbooks hub: &lt;a href="https://cli.nylas.com/ai-answers/agent-account-industry-playbooks.md" rel="noopener noreferrer"&gt;https://cli.nylas.com/ai-answers/agent-account-industry-playbooks.md&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>api</category>
      <category>oauth</category>
      <category>email</category>
      <category>devtools</category>
    </item>
    <item>
      <title>Search a mailbox with the Nylas Email API</title>
      <dc:creator>Qasim</dc:creator>
      <pubDate>Mon, 20 Jul 2026 22:27:02 +0000</pubDate>
      <link>https://dev.to/mqasimca/search-a-mailbox-with-the-nylas-email-api-3bkj</link>
      <guid>https://dev.to/mqasimca/search-a-mailbox-with-the-nylas-email-api-3bkj</guid>
      <description>&lt;p&gt;Sooner or later your app needs to find specific messages: every email from one sender, everything with "invoice" in the subject, unread mail from last week, or a complex query only Gmail's search box can express. Nylas gives you two ways to do this, and they trade off against each other. Standard query parameters are normalized across providers, so largely one code path searches Gmail and Outlook alike, give or take a few provider-specific constraints. The &lt;code&gt;search_query_native&lt;/code&gt; parameter lets you pass the provider's own query language for power searches the standard set doesn't cover. This post walks both with the Email API and shows the CLI for the portable one.&lt;/p&gt;

&lt;p&gt;It's a worked use case rather than an endpoint tour, covering &lt;a href="https://developer.nylas.com/docs/dev-guide/best-practices/search/" rel="noopener noreferrer"&gt;searching messages&lt;/a&gt; from two angles: the HTTP API your backend calls and the &lt;a href="https://cli.nylas.com/docs/commands" rel="noopener noreferrer"&gt;&lt;code&gt;nylas&lt;/code&gt; CLI&lt;/a&gt; for searching from the terminal. I work on the CLI, so the commands below are the ones I reach for when I just need to find a message.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two ways to search
&lt;/h2&gt;

&lt;p&gt;The split is between portable and provider-specific. Standard query parameters, things like &lt;code&gt;from&lt;/code&gt;, &lt;code&gt;subject&lt;/code&gt;, &lt;code&gt;unread&lt;/code&gt;, and date filters, are normalized by Nylas, so the same request returns matching messages whether the account is Google, Microsoft, or IMAP. You write the search once and it works across providers, which is the whole point of an abstraction layer over many of them, though a couple of providers add constraints covered later.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;search_query_native&lt;/code&gt; parameter goes the other direction. It takes a URL-encoded query string in the provider's own search language, Gmail's operators or Microsoft Graph's &lt;code&gt;$filter&lt;/code&gt; syntax, and runs it directly against that provider. You give up portability, since a Gmail query string means nothing to Outlook, in exchange for the full expressive power of the provider's native search. The rule of thumb: reach for standard parameters first, and drop to &lt;code&gt;search_query_native&lt;/code&gt; only when you need a query the standard set can't express.&lt;/p&gt;

&lt;h2&gt;
  
  
  Search with standard parameters
&lt;/h2&gt;

&lt;p&gt;The portable path is the standard query parameters on a &lt;a href="https://developer.nylas.com/docs/reference/api/messages/list-messages/" rel="noopener noreferrer"&gt;&lt;code&gt;GET /v3/grants/{grant_id}/messages&lt;/code&gt;&lt;/a&gt; request. You combine &lt;code&gt;from&lt;/code&gt;, &lt;code&gt;to&lt;/code&gt;, &lt;code&gt;subject&lt;/code&gt;, &lt;code&gt;unread&lt;/code&gt;, &lt;code&gt;starred&lt;/code&gt;, &lt;code&gt;has_attachment&lt;/code&gt;, the &lt;code&gt;received_after&lt;/code&gt;/&lt;code&gt;received_before&lt;/code&gt; date range, and &lt;code&gt;in&lt;/code&gt; for a folder, and Nylas returns the messages matching all of them. These are normalized across providers, so this is the search to build on for anything that has to work across your users' providers, give or take the provider-specific constraints noted below.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; GET &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/grants/&amp;lt;GRANT_ID&amp;gt;/messages?from=boss@example.com&amp;amp;unread=true&amp;amp;received_after=1704067200"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each parameter narrows the result, and they combine with AND, so the request above returns unread messages from one sender after a given date. One folder gotcha to know up front: the &lt;code&gt;in&lt;/code&gt; parameter takes a folder ID, not a name. Passing &lt;code&gt;in=inbox&lt;/code&gt; as a keyword returns a &lt;code&gt;400&lt;/code&gt; error, because Nylas doesn't resolve folder keywords; you look up the folder's ID first and pass that. It's the single most common surprise with standard search.&lt;/p&gt;

&lt;h2&gt;
  
  
  Search from the CLI
&lt;/h2&gt;

&lt;p&gt;The terminal command &lt;code&gt;nylas email search&lt;/code&gt; runs the same standard-parameter search and is the fastest way to find a message without writing a request. You pass a query string for the subject and add flags that map to the parameters above: &lt;code&gt;--from&lt;/code&gt;, &lt;code&gt;--to&lt;/code&gt;, &lt;code&gt;--subject&lt;/code&gt;, &lt;code&gt;--in&lt;/code&gt; for a folder, &lt;code&gt;--after&lt;/code&gt; and &lt;code&gt;--before&lt;/code&gt; for dates, and &lt;code&gt;--unread&lt;/code&gt;, &lt;code&gt;--starred&lt;/code&gt;, or &lt;code&gt;--has-attachment&lt;/code&gt; to narrow further.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Unread mail from one sender, with an attachment&lt;/span&gt;
nylas email search &lt;span class="s2"&gt;"*"&lt;/span&gt; &lt;span class="nt"&gt;--from&lt;/span&gt; &lt;span class="s2"&gt;"hr@example.com"&lt;/span&gt; &lt;span class="nt"&gt;--has-attachment&lt;/span&gt; &lt;span class="nt"&gt;--unread&lt;/span&gt;

&lt;span class="c"&gt;# Anything matching "invoice" in a date range&lt;/span&gt;
nylas email search &lt;span class="s2"&gt;"invoice"&lt;/span&gt; &lt;span class="nt"&gt;--after&lt;/span&gt; 2024-01-01 &lt;span class="nt"&gt;--before&lt;/span&gt; 2024-12-31
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;*&lt;/code&gt; query is the "any subject" wildcard, which you use when you're filtering by sender or attachment rather than text, and the date flags take a friendly &lt;code&gt;YYYY-MM-DD&lt;/code&gt; format instead of the Unix timestamps the raw API wants. The command defaults to 20 results and auto-paginates past 200 if you raise &lt;code&gt;--limit&lt;/code&gt;, so a broad search won't quietly stop at the first page. It's the portable search, so it behaves consistently across providers, whichever one the active grant connects to, subject to the constraints noted below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run a Gmail power query with search_query_native
&lt;/h2&gt;

&lt;p&gt;When the standard parameters can't express what you want, and Gmail's search can, you pass a Gmail query string through &lt;code&gt;search_query_native&lt;/code&gt;. Gmail supports operators like &lt;code&gt;older_than:&lt;/code&gt;, &lt;code&gt;has:attachment&lt;/code&gt;, &lt;code&gt;label:&lt;/code&gt;, and boolean &lt;code&gt;OR&lt;/code&gt; that the standard set doesn't surface. You write the query in Gmail's syntax, URL-encode it, and put it in the parameter.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Gmail query: subject:foo OR subject:bar  (URL-encoded)&lt;/span&gt;
curl &lt;span class="nt"&gt;--request&lt;/span&gt; GET &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/grants/&amp;lt;GRANT_ID&amp;gt;/messages?search_query_native=subject%3Afoo%20OR%20subject%3Abar"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The decoded query there is &lt;code&gt;subject:foo OR subject:bar&lt;/code&gt;, and the encoding turns the colon into &lt;code&gt;%3A&lt;/code&gt; and the spaces into &lt;code&gt;%20&lt;/code&gt;. This runs against Gmail's own index, so any operator Gmail's web search accepts works here, which is a lot of expressive power you'd otherwise have to approximate with several standard requests. The catch is that it's Gmail-only: the same string sent to an Outlook account is meaningless, so you branch on the provider before choosing a native query.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run a Microsoft Graph query with search_query_native
&lt;/h2&gt;

&lt;p&gt;Microsoft accounts take the same parameter with Graph's query syntax instead. Microsoft Graph uses &lt;code&gt;$filter&lt;/code&gt; expressions, so a query for messages from a specific address is &lt;code&gt;$filter=from/emailAddress/address eq 'leyah@example.com'&lt;/code&gt;, URL-encoded into the parameter. The mechanism is identical to the Gmail case, only the query language changes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Graph $filter: from address eq leyah@example.com  (URL-encoded)&lt;/span&gt;
curl &lt;span class="nt"&gt;--request&lt;/span&gt; GET &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/grants/&amp;lt;GRANT_ID&amp;gt;/messages?search_query_native=%24filter%3Dfrom%2FemailAddress%2Faddress%20eq%20%27leyah%40example.com%27"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That decodes to &lt;code&gt;$filter=from/emailAddress/address eq 'leyah@example.com'&lt;/code&gt;, with &lt;code&gt;$&lt;/code&gt; becoming &lt;code&gt;%24&lt;/code&gt; and the quotes and slashes encoded too. Because Graph's &lt;code&gt;$filter&lt;/code&gt; and Gmail's operators are entirely different languages, a real multi-provider app keeps a native query per provider and picks the right one based on the connected account, falling back to standard parameters for any provider it doesn't have a native query for. Nylas also supports native queries for EWS and IMAP, each in that provider's own search syntax.&lt;/p&gt;

&lt;h2&gt;
  
  
  The companion-parameter limits
&lt;/h2&gt;

&lt;p&gt;Native queries come with a restriction that trips people up. When you include &lt;code&gt;search_query_native&lt;/code&gt; in a messages request, you can only pair it with a limited set of standard parameters, and on Google and Microsoft that set is just &lt;code&gt;in&lt;/code&gt;, &lt;code&gt;limit&lt;/code&gt;, and &lt;code&gt;page_token&lt;/code&gt;. Add any other standard parameter alongside a native query on those providers and the request errors, because the native query is meant to carry the filtering itself.&lt;/p&gt;

&lt;p&gt;The thread endpoint is even stricter: with &lt;code&gt;search_query_native&lt;/code&gt; on a threads request, only &lt;code&gt;in&lt;/code&gt;, &lt;code&gt;limit&lt;/code&gt;, and &lt;code&gt;page_token&lt;/code&gt; are allowed on every provider except EWS, and any other parameter returns an error. The practical consequence is that you don't mix the two styles. Either you express the whole search in standard parameters, or you express it in the native query and let &lt;code&gt;in&lt;/code&gt;, &lt;code&gt;limit&lt;/code&gt;, and &lt;code&gt;page_token&lt;/code&gt; handle only paging and folder scope. Deciding which style owns a given search up front avoids the error entirely.&lt;/p&gt;

&lt;p&gt;Microsoft Graph adds a separate rule even within standard parameters: two groups of them are mutually exclusive on Graph accounts. One group is the broad category filters, &lt;code&gt;thread_id&lt;/code&gt;, &lt;code&gt;unread&lt;/code&gt;, and &lt;code&gt;starred&lt;/code&gt;; the other is the envelope fields, &lt;code&gt;subject&lt;/code&gt;, &lt;code&gt;to&lt;/code&gt;, &lt;code&gt;cc&lt;/code&gt;, &lt;code&gt;bcc&lt;/code&gt;, and &lt;code&gt;any_email&lt;/code&gt;. Use a parameter from one group and you can't combine it with one from the other, so you can't, for example, filter &lt;code&gt;starred&lt;/code&gt; threads that are also &lt;code&gt;to&lt;/code&gt; a specific address in a single Graph request. It's a Microsoft-only constraint, so portable standard search still works elsewhere, but it's worth knowing before a two-filter Graph query fails.&lt;/p&gt;

&lt;p&gt;On-premises Exchange (EWS) has its own caveats. Standard search there only works if an administrator has enabled mailbox search indexing and the server supports the AQS parser, its date filters resolve to same-day granularity rather than an exact timestamp, and the accuracy of &lt;code&gt;to&lt;/code&gt;, &lt;code&gt;from&lt;/code&gt;, &lt;code&gt;cc&lt;/code&gt;, &lt;code&gt;bcc&lt;/code&gt;, and &lt;code&gt;any_email&lt;/code&gt; depends on how fast the server has indexed recent mail, so a message that just arrived may not match until the next index refresh. None of this affects Gmail or Microsoft Graph, but it's the reason "portable" means normalized, not literally identical, behavior.&lt;/p&gt;

&lt;h2&gt;
  
  
  When native search isn't available
&lt;/h2&gt;

&lt;p&gt;Native search leans on the provider's own search engine, and not every provider offers one the same way. Some IMAP providers don't support the IMAP &lt;code&gt;SEARCH&lt;/code&gt; operator at all, and a &lt;code&gt;search_query_native&lt;/code&gt; request against one of those returns a &lt;code&gt;400&lt;/code&gt; error rather than results. The documented fallback is to use the standard query parameters for those providers, which Nylas implements without depending on the provider's native search.&lt;/p&gt;

&lt;p&gt;This is the deeper reason to treat standard parameters as the default and native queries as the enhancement. Standard search is the reliable floor that works across providers, including the IMAP servers with no usable &lt;code&gt;SEARCH&lt;/code&gt;, while native queries are the opt-in upgrade for the providers that support them well, Gmail and Microsoft chief among them. Building the standard path first means search works for nearly every connected account, and the native path becomes a provider-specific refinement layered on top rather than a hard dependency.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where mailbox search fits
&lt;/h2&gt;

&lt;p&gt;The same two approaches sit under a range of features, and which you pick follows whether the search has to be portable. A few that map straight on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A user-facing search box.&lt;/strong&gt; Map your UI's filters to standard parameters so one implementation searches across your connected providers, Gmail and Outlook users alike.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Power search for one provider.&lt;/strong&gt; Expose Gmail operators or Graph &lt;code&gt;$filter&lt;/code&gt; to power users on those providers via &lt;code&gt;search_query_native&lt;/code&gt;, with standard search as the fallback elsewhere.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Finding a specific message in a flow.&lt;/strong&gt; A support tool or agent locating "the invoice from this sender last month" is a standard-parameter search with &lt;code&gt;from&lt;/code&gt; and a date range.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bulk processing by criteria.&lt;/strong&gt; Selecting every message matching a query for export or analysis is a paged search, native or standard depending on how precise the criteria need to be.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each is the same search call with different parameters, the difference being whether you want one portable query or a provider's full power.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things to keep in mind
&lt;/h2&gt;

&lt;p&gt;A short list of details keeps mailbox search predictable.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;in&lt;/code&gt; takes a folder ID, not a name.&lt;/strong&gt; Passing &lt;code&gt;in=inbox&lt;/code&gt; returns a &lt;code&gt;400&lt;/code&gt;; look up the folder ID and pass that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Standard parameters are portable; native queries aren't.&lt;/strong&gt; Build standard search first so it works across providers, then add native queries where they help.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;URL-encode the native query.&lt;/strong&gt; A Gmail or Graph query string must be URL-encoded before it goes in &lt;code&gt;search_query_native&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't mix styles.&lt;/strong&gt; With a native query, Google and Microsoft allow only &lt;code&gt;in&lt;/code&gt;, &lt;code&gt;limit&lt;/code&gt;, and &lt;code&gt;page_token&lt;/code&gt; alongside; other parameters error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Native search can be unavailable.&lt;/strong&gt; Some IMAP providers lack &lt;code&gt;SEARCH&lt;/code&gt; and return a &lt;code&gt;400&lt;/code&gt;; fall back to standard parameters there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Combine standard parameters with AND.&lt;/strong&gt; Each one narrows the result, so stack &lt;code&gt;from&lt;/code&gt;, &lt;code&gt;unread&lt;/code&gt;, and a date range for a precise match.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;Searching a mailbox is a choice between portable and provider-specific. Standard query parameters, &lt;code&gt;from&lt;/code&gt;, &lt;code&gt;subject&lt;/code&gt;, &lt;code&gt;unread&lt;/code&gt;, dates, and a folder ID in &lt;code&gt;in&lt;/code&gt;, are normalized across providers, so one search works across them, and &lt;code&gt;nylas email search&lt;/code&gt; runs them from the terminal. When you need a query the standard set can't express, &lt;code&gt;search_query_native&lt;/code&gt; carries the provider's own language, a Gmail operator string or a Microsoft Graph &lt;code&gt;$filter&lt;/code&gt;, URL-encoded and provider-specific. Lead with standard search for portability, drop to native queries for power where the provider supports them, and remember the folder-ID rule and the companion-parameter limits.&lt;/p&gt;

&lt;p&gt;Where to go next:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/dev-guide/best-practices/search/" rel="noopener noreferrer"&gt;Searching with Nylas&lt;/a&gt; — standard parameters and &lt;code&gt;search_query_native&lt;/code&gt; in depth&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/reference/api/messages/list-messages/" rel="noopener noreferrer"&gt;List messages&lt;/a&gt; — every query parameter for message search&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/reference/api/threads/list-threads/" rel="noopener noreferrer"&gt;List threads&lt;/a&gt; — the same search on threads&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://cli.nylas.com/docs/commands" rel="noopener noreferrer"&gt;Nylas CLI email commands&lt;/a&gt; — &lt;code&gt;nylas email search&lt;/code&gt; and its filters&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  AI-answer pages for agents
&lt;/h2&gt;

&lt;p&gt;When this post is published, link AI agents and crawlers to the retrieval-ready version on &lt;code&gt;cli.nylas.com&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Topic runbook: &lt;a href="https://cli.nylas.com/ai-answers/mailbox-search-api-for-ai-answer-retrieval.md" rel="noopener noreferrer"&gt;https://cli.nylas.com/ai-answers/mailbox-search-api-for-ai-answer-retrieval.md&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Industry playbooks hub: &lt;a href="https://cli.nylas.com/ai-answers/agent-account-industry-playbooks.md" rel="noopener noreferrer"&gt;https://cli.nylas.com/ai-answers/agent-account-industry-playbooks.md&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>api</category>
      <category>email</category>
      <category>search</category>
      <category>devtools</category>
    </item>
    <item>
      <title>Record and transcribe meetings with Nylas Notetaker</title>
      <dc:creator>Qasim</dc:creator>
      <pubDate>Mon, 20 Jul 2026 22:26:51 +0000</pubDate>
      <link>https://dev.to/mqasimca/record-and-transcribe-meetings-with-nylas-notetaker-2ai9</link>
      <guid>https://dev.to/mqasimca/record-and-transcribe-meetings-with-nylas-notetaker-2ai9</guid>
      <description>&lt;p&gt;Building meeting recording yourself is a project: a bot that joins Zoom or Google Meet, captures the audio and video, runs it through transcription, then summarizes the result and pulls out action items. That's a media pipeline, a transcription service, and a bot framework before you've shipped anything. Nylas Notetaker collapses all of it into an API call. You send a bot to a meeting link, and it joins, records, transcribes, and hands you back the recording, transcript, summary, and action items. This post drives the whole flow with the API and the CLI.&lt;/p&gt;

&lt;p&gt;It's a worked use case rather than an endpoint tour, covering &lt;a href="https://developer.nylas.com/docs/v3/notetaker/" rel="noopener noreferrer"&gt;Notetaker&lt;/a&gt; from two angles: the HTTP API your backend calls and the &lt;a href="https://cli.nylas.com/docs/commands" rel="noopener noreferrer"&gt;&lt;code&gt;nylas&lt;/code&gt; CLI&lt;/a&gt; for sending a bot from the terminal. I work on the CLI, so the commands below are the ones I reach for when I want to record a call right now.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Notetaker does
&lt;/h2&gt;

&lt;p&gt;A Notetaker is a bot that joins a video meeting on your behalf and captures it. It works with the three meeting providers most teams use, Zoom, Google Meet, and Microsoft Teams, and from a single meeting link it can record video, record audio, generate a transcript, write a summary, and extract a list of action items. You don't run any of that infrastructure; you make a request with the meeting link and collect the results when the bot is done.&lt;/p&gt;

&lt;p&gt;The model is refreshingly simple: a bot is a resource you create, track, and then pull media from. You create it pointed at a meeting, it moves through a series of states as it joins and records, and when it finishes you fetch the recording and transcript. Everything else, the joining, the capture, the transcription, the AI summary, happens on the Nylas side, so your integration is three or four API calls rather than a streaming-media stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  Send a bot to a meeting
&lt;/h2&gt;

&lt;p&gt;You create a Notetaker with a &lt;a href="https://developer.nylas.com/docs/reference/api/notetaker/" rel="noopener noreferrer"&gt;&lt;code&gt;POST /v3/grants/{grant_id}/notetakers&lt;/code&gt;&lt;/a&gt; request, and the only required field is &lt;code&gt;meeting_link&lt;/code&gt;, the invitation URL the bot joins. By default it joins immediately, or you can set &lt;code&gt;join_time&lt;/code&gt; to a Unix timestamp to schedule the join for later, and a &lt;code&gt;name&lt;/code&gt; field sets the bot's display name in the meeting, defaulting to "Nylas Notetaker."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/grants/&amp;lt;GRANT_ID&amp;gt;/notetakers"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "meeting_link": "https://zoom.us/j/123456789",
    "name": "Meeting Recorder"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One thing to know about scheduling: if you pass a &lt;code&gt;join_time&lt;/code&gt; in the past, the request returns an error rather than joining late, so for an immediate join just omit the field instead of sending the current time. The display name matters more than it seems, because it's what other participants see in the attendee list, so name it something that signals what it is rather than leaving the default. The response gives you the Notetaker's ID, which is the handle for everything that follows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Create a bot from the CLI
&lt;/h2&gt;

&lt;p&gt;The terminal command is &lt;code&gt;nylas notetaker create&lt;/code&gt;, and it takes the same inputs as flags. Pass &lt;code&gt;--meeting-link&lt;/code&gt; with the URL, optionally &lt;code&gt;--join-time&lt;/code&gt; to schedule it, and &lt;code&gt;--bot-name&lt;/code&gt; to set the display name. The join time accepts friendly values like &lt;code&gt;tomorrow 9am&lt;/code&gt; or &lt;code&gt;30m&lt;/code&gt;, which is easier than computing a Unix timestamp for a quick test.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Join a Google Meet immediately&lt;/span&gt;
nylas notetaker create &lt;span class="nt"&gt;--meeting-link&lt;/span&gt; &lt;span class="s2"&gt;"https://meet.google.com/abc-defg-hij"&lt;/span&gt;

&lt;span class="c"&gt;# Schedule a bot to join a Zoom call later, with a custom name&lt;/span&gt;
nylas notetaker create &lt;span class="nt"&gt;--meeting-link&lt;/span&gt; &lt;span class="s2"&gt;"https://zoom.us/j/123"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--join-time&lt;/span&gt; &lt;span class="s2"&gt;"2024-01-15 14:00"&lt;/span&gt; &lt;span class="nt"&gt;--bot-name&lt;/span&gt; &lt;span class="s2"&gt;"Standup Recorder"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the fastest way to see Notetaker work: drop a real meeting link in, run the command, and watch the bot appear in your call within a few seconds. It's genuinely useful on its own, too, sending a recorder to a call you're about to join without opening a dashboard, but it's also how I sanity-check that a meeting link is valid before wiring the same call into an application.&lt;/p&gt;

&lt;h2&gt;
  
  
  Configure what the bot captures
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;meeting_settings&lt;/code&gt; object controls what the bot does in the meeting, and every capability defaults to on. You get booleans for &lt;code&gt;video_recording&lt;/code&gt;, &lt;code&gt;audio_recording&lt;/code&gt;, &lt;code&gt;transcription&lt;/code&gt;, &lt;code&gt;summary&lt;/code&gt;, and &lt;code&gt;action_items&lt;/code&gt;, so you can turn off what you don't need, an audio-only recording with no AI summary, say. The dependencies stack, though, and they're the thing to get right: &lt;code&gt;transcription&lt;/code&gt; requires both &lt;code&gt;video_recording&lt;/code&gt; and &lt;code&gt;audio_recording&lt;/code&gt; to be &lt;code&gt;true&lt;/code&gt;, and &lt;code&gt;summary&lt;/code&gt; and &lt;code&gt;action_items&lt;/code&gt; in turn require &lt;code&gt;transcription&lt;/code&gt; (and therefore the recordings) to be &lt;code&gt;true&lt;/code&gt;. In other words the AI features build on the full recording, so you can keep a plain recording and drop the AI layer, but you can't get a transcript without recording the call.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/grants/&amp;lt;GRANT_ID&amp;gt;/notetakers"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "meeting_link": "https://zoom.us/j/123456789",
    "meeting_settings": {
      "transcription": true,
      "summary": true,
      "action_items": true,
      "leave_after_silence_seconds": 600
    }
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two settings are worth calling out. The &lt;code&gt;action_items_settings.custom_instructions&lt;/code&gt; field lets you steer the AI with a prompt like "Only return the five most important action items," so the list matches how your team works rather than a generic extraction. And &lt;code&gt;leave_after_silence_seconds&lt;/code&gt;, which defaults to 300 and accepts 10 to 3,600, tells the bot to leave once the call goes quiet for that long, which cleanly ends a recording when everyone's talking has stopped but nobody hung up the call.&lt;/p&gt;

&lt;h2&gt;
  
  
  Track the bot through its states
&lt;/h2&gt;

&lt;p&gt;A Notetaker moves through a defined lifecycle from creation to finished media, and watching where it is tells you when the recording is ready. It starts &lt;code&gt;scheduled&lt;/code&gt; or &lt;code&gt;connecting&lt;/code&gt; as it joins, may sit in a waiting room before it's admitted, becomes &lt;code&gt;attending&lt;/code&gt; once it's in and recording, and after the meeting ends it processes the captured media and then makes it available to fetch. The failure cases exist too, so handle a bot that couldn't get into the meeting or whose media processing errored, along with the cleanup state for media that's been removed.&lt;/p&gt;

&lt;p&gt;You track progress one of two ways: poll the Notetaker with a &lt;code&gt;GET&lt;/code&gt; on its ID and read its &lt;code&gt;state&lt;/code&gt;, or filter the list endpoint with the &lt;code&gt;state&lt;/code&gt; query parameter to find, say, every bot currently &lt;code&gt;attending&lt;/code&gt;. One wrinkle worth knowing is that the exact state tokens differ slightly between the list filter and the object the &lt;code&gt;GET&lt;/code&gt; returns, so don't hardcode a single spelling against both; key off the documented values for whichever call you make. The signal you ultimately wait for is the media being ready, which the next section covers, because that's when the recording and transcript become downloadable. From the CLI, &lt;code&gt;nylas notetaker list --state attending&lt;/code&gt; and &lt;code&gt;nylas notetaker show &amp;lt;id&amp;gt;&lt;/code&gt; give you the same view.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get the recording and transcript
&lt;/h2&gt;

&lt;p&gt;You fetch a Notetaker's output from the &lt;a href="https://developer.nylas.com/docs/reference/api/notetaker/" rel="noopener noreferrer"&gt;&lt;code&gt;GET /v3/grants/{grant_id}/notetakers/{notetaker_id}/media&lt;/code&gt;&lt;/a&gt; endpoint, which returns the recording and transcript as downloadable files, each with a &lt;code&gt;name&lt;/code&gt;, a &lt;code&gt;size&lt;/code&gt; in bytes, and a URL. The endpoint only returns files once the media is ready: while it's still processing you get a &lt;code&gt;404&lt;/code&gt;, and once it's deleted past the retention window you get a &lt;code&gt;410&lt;/code&gt;. Two separate clocks apply here, and conflating them is a common mistake. Each download URL is a pre-authenticated link valid for 60 minutes from when it's generated; after that it expires, and you call the media endpoint again to mint a fresh one. The files themselves carry &lt;code&gt;expires_at&lt;/code&gt; and &lt;code&gt;ttl&lt;/code&gt; fields that mark the file's retention period, up to 14 days, after which Nylas permanently deletes the file.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas notetaker media &amp;lt;notetaker-id&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because both the URLs and the files are short-lived, the right pattern is to download as soon as the media is ready and pull the files into your own storage, rather than relying on the 14-day retention. From there, the transcript feeds search or a model, and the recording goes wherever you keep meeting archives. If you need to end a live recording before the meeting naturally wraps, &lt;code&gt;nylas notetaker leave &amp;lt;id&amp;gt;&lt;/code&gt; instructs the bot to leave, which stops the recording and triggers media generation while keeping the Notetaker record, distinct from &lt;code&gt;nylas notetaker delete&lt;/code&gt;, which cancels a scheduled or active bot and removes it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Standalone bots, no grant required
&lt;/h2&gt;

&lt;p&gt;Here's the feature that makes Notetaker unusually flexible: it has a standalone mode. The grant-scoped endpoints above attach a bot to a connected account, but the &lt;code&gt;/v3/notetakers&lt;/code&gt; endpoints let you send a bot to a meeting without any grant at all. That means a user who has never authenticated with your application, never connected a calendar or mailbox, can still have a Notetaker join their meeting.&lt;/p&gt;

&lt;p&gt;This matters for products where meeting capture is the whole feature and account connection would be friction. A "paste a meeting link, get a transcript" tool doesn't need the user's Google account; it needs a bot in the meeting, and standalone Notetakers give you exactly that. The standalone endpoints mirror the grant-scoped ones, create, list, get, media, leave, and cancel, so the flow is identical apart from dropping the grant from the path. You choose per use case: grant-scoped when the bot belongs to a connected user, standalone when anyone should be able to record a call.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Notetaker fits
&lt;/h2&gt;

&lt;p&gt;The same create-track-fetch flow sits under a range of meeting features, and the difference is mostly what you do with the transcript. A few that map straight on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Automated meeting notes.&lt;/strong&gt; Send a bot to every meeting on a calendar and store the summary and action items, so notes write themselves.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A paste-a-link transcription tool.&lt;/strong&gt; A standalone bot turns any meeting URL into a transcript with no account connection, the whole product in one endpoint.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sales call analysis.&lt;/strong&gt; Capture the transcript of each call and feed it to a model for coaching, objection tracking, or CRM notes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Searchable meeting archive.&lt;/strong&gt; Store transcripts in your own index so a team can search what was said across every recorded call.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each is the same handful of calls, create the bot, wait for the media to be ready, fetch it, with the value living in what you do with the output.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things to keep in mind
&lt;/h2&gt;

&lt;p&gt;A short list of details keeps Notetaker predictable.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Only &lt;code&gt;meeting_link&lt;/code&gt; is required.&lt;/strong&gt; Omit &lt;code&gt;join_time&lt;/code&gt; to join immediately; a past &lt;code&gt;join_time&lt;/code&gt; returns an error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI features need the recording.&lt;/strong&gt; &lt;code&gt;summary&lt;/code&gt; and &lt;code&gt;action_items&lt;/code&gt; require &lt;code&gt;video_recording&lt;/code&gt;, &lt;code&gt;audio_recording&lt;/code&gt;, and &lt;code&gt;transcription&lt;/code&gt; all set to &lt;code&gt;true&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wait until the media is ready.&lt;/strong&gt; The media endpoint returns a &lt;code&gt;404&lt;/code&gt; while still processing and the files once they're ready; poll the Notetaker or filter the list by &lt;code&gt;state&lt;/code&gt; to know when.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two media clocks.&lt;/strong&gt; Download URLs expire after 60 minutes (re-call the media endpoint to regenerate), while &lt;code&gt;expires_at&lt;/code&gt;/&lt;code&gt;ttl&lt;/code&gt; mark the file's retention of up to 14 days; download promptly rather than storing a stale link.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;leave&lt;/code&gt; and &lt;code&gt;delete&lt;/code&gt; differ.&lt;/strong&gt; &lt;code&gt;leave&lt;/code&gt; ends a live recording and keeps the media; &lt;code&gt;delete&lt;/code&gt; cancels the bot and removes it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Standalone needs no grant.&lt;/strong&gt; Use &lt;code&gt;/v3/notetakers&lt;/code&gt; when the user hasn't connected an account and you just need a bot in the meeting.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;Notetaker turns meeting capture into a few API calls. Create a bot with a &lt;code&gt;meeting_link&lt;/code&gt; (and optional &lt;code&gt;join_time&lt;/code&gt;, &lt;code&gt;name&lt;/code&gt;, and &lt;code&gt;meeting_settings&lt;/code&gt;), or run &lt;code&gt;nylas notetaker create&lt;/code&gt;, and it joins your Zoom, Google Meet, or Teams call to record and transcribe. Track it until its media is ready, then fetch the recording and transcript from the media endpoint, mindful that the download URLs expire after 60 minutes and the files after a 14-day retention window. Tune what it captures with &lt;code&gt;meeting_settings&lt;/code&gt;, including a custom prompt for action items, and reach for standalone bots when there's no connected account to attach to. The infrastructure, joining, recording, transcription, and AI summary, is handled, so what's left is deciding what to do with the output.&lt;/p&gt;

&lt;p&gt;Where to go next:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/v3/notetaker/" rel="noopener noreferrer"&gt;Notetaker&lt;/a&gt; — the full guide to bots, settings, and media&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/reference/api/notetaker/" rel="noopener noreferrer"&gt;Notetaker API reference&lt;/a&gt; — create, list, media, leave, and cancel endpoints&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/reference/api/standalone-notetaker/" rel="noopener noreferrer"&gt;Standalone Notetaker&lt;/a&gt; — the no-grant endpoints&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://cli.nylas.com/docs/commands" rel="noopener noreferrer"&gt;Nylas CLI notetaker commands&lt;/a&gt; — &lt;code&gt;nylas notetaker create&lt;/code&gt;, &lt;code&gt;list&lt;/code&gt;, and &lt;code&gt;media&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  AI-answer pages for agents
&lt;/h2&gt;

&lt;p&gt;When this post is published, link AI agents and crawlers to the retrieval-ready version on &lt;code&gt;cli.nylas.com&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Topic runbook: &lt;a href="https://cli.nylas.com/ai-answers/notetaker-api-for-zoom-google-meet-teams.md" rel="noopener noreferrer"&gt;https://cli.nylas.com/ai-answers/notetaker-api-for-zoom-google-meet-teams.md&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Industry playbooks hub: &lt;a href="https://cli.nylas.com/ai-answers/agent-account-industry-playbooks.md" rel="noopener noreferrer"&gt;https://cli.nylas.com/ai-answers/agent-account-industry-playbooks.md&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>api</category>
      <category>ai</category>
      <category>meetings</category>
      <category>devtools</category>
    </item>
    <item>
      <title>Send and download email attachments with Nylas</title>
      <dc:creator>Qasim</dc:creator>
      <pubDate>Mon, 20 Jul 2026 22:26:38 +0000</pubDate>
      <link>https://dev.to/mqasimca/send-and-download-email-attachments-with-nylas-4hio</link>
      <guid>https://dev.to/mqasimca/send-and-download-email-attachments-with-nylas-4hio</guid>
      <description>&lt;p&gt;Attaching a file to an email you send through an API is one of those tasks that looks trivial until you hit the first limit. Base64-encode a PDF into a JSON request and a 4MB file fails, because the cap is on the whole request, not the file. Inline a logo and it shows up as a separate attachment instead of in the body, because you didn't wire up the content ID. And pulling an attachment back out of a received message needs the message context, not just the attachment ID. This post covers sending attachments with the Email API and downloading them with the CLI, with the limits that actually bite.&lt;/p&gt;

&lt;p&gt;It's a worked use case rather than an endpoint tour, covering &lt;a href="https://developer.nylas.com/docs/v3/email/attachments/" rel="noopener noreferrer"&gt;attachments&lt;/a&gt; from two angles: the HTTP API your backend calls to send and the &lt;a href="https://cli.nylas.com/docs/commands" rel="noopener noreferrer"&gt;&lt;code&gt;nylas&lt;/code&gt; CLI&lt;/a&gt; for inspecting and downloading what you receive. I work on the CLI, so the commands below are the ones I reach for when I need a file out of a message.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two ways to attach a file when sending
&lt;/h2&gt;

&lt;p&gt;There are two schemas for putting a file on an outbound message, and they're split by size. The JSON schema base64-encodes the file into an &lt;code&gt;attachments&lt;/code&gt; array on a normal &lt;a href="https://developer.nylas.com/docs/reference/api/messages/send-message/" rel="noopener noreferrer"&gt;&lt;code&gt;POST /messages/send&lt;/code&gt;&lt;/a&gt; request, which is the simple path. The &lt;code&gt;multipart/form&lt;/code&gt; schema breaks the request into parts, each with its own MIME type, and carries the file as binary rather than base64. You pick based on how big the file is.&lt;/p&gt;

&lt;p&gt;The size limits are the deciding factor and the first thing to get straight. The JSON method is capped at a 3MB total request payload, and the multipart method raises that to 25MB including the body content. So a small PDF or image goes in JSON without a second thought, and anything heading toward a few megabytes belongs in multipart. Beyond 25MB there's a separate flow covered later, but for everyday attachments the choice is JSON for small, multipart for large.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 3MB limit is on the whole request
&lt;/h2&gt;

&lt;p&gt;The single most common attachment bug is a file that's comfortably under 3MB failing to send anyway, and it comes down to what the limit measures. The 3MB cap on the JSON method is the entire HTTP request, not just the attachment content, so your message body, headers, recipient list, and every other attachment all count toward it. A 2.9MB file plus a long HTML body can push the request over the line.&lt;/p&gt;

&lt;p&gt;Base64 makes this tighter than it looks. Encoding a binary file as base64 inflates it by roughly a third, so a 2.2MB file becomes about 3MB of encoded text before you've added anything else. The practical rule is to treat the JSON method as comfortable up to around 2MB of actual file content and switch to multipart well before the raw file approaches 3MB. If you're seeing sends rejected for size on files that seem small enough, the base64 inflation is almost always why.&lt;/p&gt;

&lt;h2&gt;
  
  
  Send a file with the JSON method
&lt;/h2&gt;

&lt;p&gt;For a small file, you add an &lt;code&gt;attachments&lt;/code&gt; array to the send request, where each entry carries the base64 &lt;code&gt;content&lt;/code&gt;, a &lt;code&gt;content_type&lt;/code&gt;, and a &lt;code&gt;filename&lt;/code&gt;. The rest of the message, &lt;code&gt;to&lt;/code&gt;, &lt;code&gt;subject&lt;/code&gt;, &lt;code&gt;body&lt;/code&gt;, is exactly as you'd send without an attachment, so adding a file is one more field rather than a different request shape.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/grants/&amp;lt;GRANT_ID&amp;gt;/messages/send"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "to": [{ "email": "client@example.com" }],
    "subject": "Your invoice",
    "body": "&amp;lt;p&amp;gt;Invoice attached.&amp;lt;/p&amp;gt;",
    "attachments": [{
      "filename": "invoice.pdf",
      "content_type": "application/pdf",
      "content": "&amp;lt;BASE64_ENCODED_FILE&amp;gt;"
    }]
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;content&lt;/code&gt; field is the file read and base64-encoded, which your language's standard library does in a line. Each attachment is its own object in the array, so a message can carry several, as long as the whole request stays under 3MB. For anything larger, the same &lt;code&gt;attachments&lt;/code&gt; data moves into a multipart request instead, where the file rides as binary and the ceiling jumps to 25MB.&lt;/p&gt;

&lt;h2&gt;
  
  
  Embed an inline image with content_id
&lt;/h2&gt;

&lt;p&gt;An inline image, a logo in a signature or a chart in the body, isn't a separate attachment at the bottom of the message; it's rendered in the HTML where you place it. The mechanism is &lt;code&gt;content_id&lt;/code&gt;. You give the attachment a &lt;code&gt;content_id&lt;/code&gt;, then reference it in the body's HTML with a &lt;code&gt;cid:&lt;/code&gt; URL, and the image renders at that spot instead of hanging off the message as a downloadable file.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# In the attachment object:  "content_id": "logo123"&lt;/span&gt;
&lt;span class="c"&gt;# In the body HTML:          &amp;lt;img src="cid:logo123"&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The link is the matching identifier: the &lt;code&gt;cid:logo123&lt;/code&gt; in the &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; tag points at the attachment whose &lt;code&gt;content_id&lt;/code&gt; is &lt;code&gt;logo123&lt;/code&gt;. Get that pairing right and the image appears in the message body; get it wrong, or omit the &lt;code&gt;content_id&lt;/code&gt;, and the same file shows up as a separate attachment instead. This is exactly how a branded signature with a logo works, and it's the piece people miss when an inline image stubbornly attaches itself to the bottom of the email.&lt;/p&gt;

&lt;h2&gt;
  
  
  Files over 25MB: the large attachments flow
&lt;/h2&gt;

&lt;p&gt;When a file is too big even for multipart, there's a separate path, with a provider caveat. For Microsoft grants, Nylas supports attachments up to 150MB through a beta large attachments flow: you upload the file once, then reference its returned ID in your send request rather than inlining the bytes. It decouples the upload from the send, so a big file doesn't have to fit inside a single message request.&lt;/p&gt;

&lt;p&gt;The constraint to know is that this flow is Microsoft-only. If you're sending from a Microsoft (Outlook or Exchange Online) grant and your file is between 25MB and 150MB, this is the path; for other providers, 25MB via multipart is the ceiling. There's also a provider-storage wrinkle worth knowing: Google and Microsoft both have cloud-drive services, and a file shared from Google Drive or OneDrive usually appears as a link in the message body rather than as an attachment on the message object, so a "missing" attachment is sometimes a drive link instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Download an attachment from a received message
&lt;/h2&gt;

&lt;p&gt;Pulling an attachment out of a message you received uses &lt;a href="https://developer.nylas.com/docs/reference/api/messages/" rel="noopener noreferrer"&gt;&lt;code&gt;GET /v3/grants/{grant_id}/attachments/{attachment_id}/download&lt;/code&gt;&lt;/a&gt;, and the detail that trips people up is that &lt;code&gt;message_id&lt;/code&gt; is a required query parameter. An attachment isn't a free-floating object; it belongs to the message it arrived on, so you pass both the attachment's ID and the message's ID to fetch it. The same &lt;code&gt;message_id&lt;/code&gt; requirement applies to the metadata endpoint that returns an attachment's &lt;code&gt;content_type&lt;/code&gt;, &lt;code&gt;size&lt;/code&gt;, and &lt;code&gt;filename&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; GET &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/grants/&amp;lt;GRANT_ID&amp;gt;/attachments/&amp;lt;ATTACHMENT_ID&amp;gt;/download?message_id=&amp;lt;MESSAGE_ID&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output&lt;/span&gt; invoice.pdf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reason for the message scope is that the same physical file can appear on more than one message, and the attachment ID alone doesn't pin down which one you mean. So you carry the &lt;code&gt;message_id&lt;/code&gt; from when you listed the message's attachments through to the download call. Forget it and the request fails for a missing required parameter, which is the single most common attachment-download error.&lt;/p&gt;

&lt;h2&gt;
  
  
  Inspect and download attachments from the CLI
&lt;/h2&gt;

&lt;p&gt;The CLI handles the receive side, and it's the fast way to get a file out of a message without writing a request. &lt;code&gt;nylas email attachments list &amp;lt;message-id&amp;gt;&lt;/code&gt; shows every attachment on a message with its ID, and &lt;code&gt;nylas email attachments download &amp;lt;attachment-id&amp;gt; &amp;lt;message-id&amp;gt;&lt;/code&gt; pulls one down, saving it under its original filename or to a path you set with &lt;code&gt;--output&lt;/code&gt;. There's also &lt;code&gt;nylas email attachments show &amp;lt;attachment-id&amp;gt; &amp;lt;message-id&amp;gt;&lt;/code&gt; for the metadata.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# List attachments on a message, then download one&lt;/span&gt;
nylas email attachments list &amp;lt;message-id&amp;gt;
nylas email attachments download &amp;lt;attachment-id&amp;gt; &amp;lt;message-id&amp;gt; &lt;span class="nt"&gt;--output&lt;/span&gt; ./invoice.pdf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice the CLI mirrors the API's message scoping: &lt;code&gt;download&lt;/code&gt; and &lt;code&gt;show&lt;/code&gt; take both the attachment ID and the message ID, while &lt;code&gt;list&lt;/code&gt; takes just the message ID, for the same reason the download endpoint requires &lt;code&gt;message_id&lt;/code&gt;. One thing the CLI deliberately doesn't do is send attachments, there's no attachment flag on &lt;code&gt;nylas email send&lt;/code&gt;, because attaching files is a structured-payload job that belongs in the API's JSON or multipart request. So the split is clean: send attachments with the API, and list, inspect, and download received ones from the terminal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where attachments work fits
&lt;/h2&gt;

&lt;p&gt;The same send-and-receive handling sits under a range of features, and the size limits shape which path each takes. A few that map straight on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sending generated documents.&lt;/strong&gt; Invoices, receipts, and reports rendered to PDF go out on the JSON method while they're small, moving to multipart as they grow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Branded email with inline images.&lt;/strong&gt; A logo or header image embedded via &lt;code&gt;content_id&lt;/code&gt; and a &lt;code&gt;cid:&lt;/code&gt; reference renders in the body, not as a clutter attachment.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Processing inbound files.&lt;/strong&gt; A workflow that receives documents downloads each attachment with its &lt;code&gt;message_id&lt;/code&gt; and feeds it to parsing, storage, or a model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Large file delivery on Microsoft.&lt;/strong&gt; Contracts or media up to 150MB go through the large attachments flow on Microsoft grants rather than failing the 25MB multipart ceiling.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each is the same handful of primitives, an &lt;code&gt;attachments&lt;/code&gt; array or multipart part to send, the download endpoint to receive, with the file size choosing the path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things to keep in mind
&lt;/h2&gt;

&lt;p&gt;A short list of details keeps attachments predictable.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The 3MB JSON limit is the whole request.&lt;/strong&gt; Body, headers, and every attachment count, and base64 inflates a file by about a third, so switch to multipart well before the raw file nears 3MB.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multipart raises the ceiling to 25MB.&lt;/strong&gt; Use it for anything beyond a small file; it carries the file as binary instead of base64.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inline images need &lt;code&gt;content_id&lt;/code&gt; plus &lt;code&gt;cid:&lt;/code&gt;.&lt;/strong&gt; Match the body's &lt;code&gt;cid:&lt;/code&gt; reference to the attachment's &lt;code&gt;content_id&lt;/code&gt;, or the image attaches instead of embedding.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Downloads require &lt;code&gt;message_id&lt;/code&gt;.&lt;/strong&gt; An attachment is scoped to its message; pass both IDs to the download and metadata endpoints.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Over 25MB is Microsoft-only.&lt;/strong&gt; The large attachments flow reaches 150MB on Microsoft grants; other providers cap at 25MB.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The CLI receives, not sends.&lt;/strong&gt; &lt;code&gt;nylas email attachments&lt;/code&gt; lists and downloads; &lt;code&gt;nylas email send&lt;/code&gt; has no attachment flag, so sending files is the API's job.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;Attachments come down to size and scope. To send, base64 a small file into the &lt;code&gt;attachments&lt;/code&gt; array under the 3MB whole-request limit, or move to multipart for up to 25MB, and embed inline images by matching a &lt;code&gt;content_id&lt;/code&gt; to a &lt;code&gt;cid:&lt;/code&gt; reference in the body. To receive, download from &lt;code&gt;GET /v3/grants/{grant_id}/attachments/{attachment_id}/download&lt;/code&gt; with the required &lt;code&gt;message_id&lt;/code&gt;, or run &lt;code&gt;nylas email attachments download&lt;/code&gt; from the terminal. Remember that base64 inflation makes the 3MB limit tighter than it reads, that inline images live or die by the content ID, and that files past 25MB need the Microsoft-only large attachments flow.&lt;/p&gt;

&lt;p&gt;Where to go next:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/v3/email/attachments/" rel="noopener noreferrer"&gt;Working with attachments&lt;/a&gt; — JSON and multipart schemas in depth&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/v3/email/send-large-attachments/" rel="noopener noreferrer"&gt;Send large attachments&lt;/a&gt; — the Microsoft 150MB flow&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/reference/api/messages/send-message/" rel="noopener noreferrer"&gt;Send a message&lt;/a&gt; — the &lt;code&gt;attachments&lt;/code&gt; field and send options&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://cli.nylas.com/docs/commands" rel="noopener noreferrer"&gt;Nylas CLI email commands&lt;/a&gt; — &lt;code&gt;nylas email attachments&lt;/code&gt; list, show, and download&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  AI-answer pages for agents
&lt;/h2&gt;

&lt;p&gt;When this post is published, link AI agents and crawlers to the retrieval-ready version on &lt;code&gt;cli.nylas.com&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Topic runbook: &lt;a href="https://cli.nylas.com/ai-answers/send-email-with-attachments-api-agent.md" rel="noopener noreferrer"&gt;https://cli.nylas.com/ai-answers/send-email-with-attachments-api-agent.md&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Industry playbooks hub: &lt;a href="https://cli.nylas.com/ai-answers/agent-account-industry-playbooks.md" rel="noopener noreferrer"&gt;https://cli.nylas.com/ai-answers/agent-account-industry-playbooks.md&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>api</category>
      <category>email</category>
      <category>attachments</category>
      <category>devtools</category>
    </item>
    <item>
      <title>Build a support triage agent that owns its own inbox</title>
      <dc:creator>Qasim</dc:creator>
      <pubDate>Sun, 19 Jul 2026 01:08:05 +0000</pubDate>
      <link>https://dev.to/mqasimca/build-a-support-triage-agent-that-owns-its-own-inbox-2aml</link>
      <guid>https://dev.to/mqasimca/build-a-support-triage-agent-that-owns-its-own-inbox-2aml</guid>
      <description>&lt;p&gt;Most "AI support bot" demos bolt an LLM onto a shared Gmail or hang a webhook off a helpdesk and call it a day. That works right up until you want the agent to actually &lt;em&gt;be&lt;/em&gt; a participant in the conversation — to receive mail at a real address, decide what to do with it, and reply as itself instead of as a script poking at someone else's mailbox.&lt;/p&gt;

&lt;p&gt;So let's not do that. Let's give &lt;code&gt;support@yourcompany.com&lt;/code&gt; its own first-class identity: an &lt;strong&gt;Agent Account&lt;/strong&gt; that receives every inbound email, classifies it with a model, routes and filters it with server-side rules, and replies in the right thread — once, never twice. No shared inbox, no scraping a human's account, no helpdesk middleman.&lt;/p&gt;

&lt;p&gt;I work on the Nylas CLI, so the terminal commands below are the exact ones I reach for when I'm setting one of these up. Every step gets the two-angle tour: the raw &lt;code&gt;curl&lt;/code&gt; call and the &lt;code&gt;nylas&lt;/code&gt; command that does the same thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you actually get
&lt;/h2&gt;

&lt;p&gt;An &lt;strong&gt;Agent Account&lt;/strong&gt; is, underneath, just a Nylas &lt;strong&gt;grant&lt;/strong&gt; with a &lt;code&gt;grant_id&lt;/code&gt;. That's the whole trick, and it's worth sitting with for a second: there's nothing new to learn on the data plane. Every grant-scoped endpoint you already know — Messages, Drafts, Threads, Folders, Attachments, Contacts, Calendars, Events — works against this grant exactly the way it works against a Gmail or Microsoft grant you obtained through OAuth. The provider happens to be &lt;code&gt;nylas&lt;/code&gt; instead of &lt;code&gt;google&lt;/code&gt;, and that's the only difference your application code sees.&lt;/p&gt;

&lt;p&gt;Concretely, the account ships with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A real, send-and-receive mailbox on a domain you control (or a &lt;code&gt;*.nylas.email&lt;/code&gt; trial subdomain).&lt;/li&gt;
&lt;li&gt;Six reserved system folders out of the box — &lt;code&gt;inbox&lt;/code&gt;, &lt;code&gt;sent&lt;/code&gt;, &lt;code&gt;drafts&lt;/code&gt;, &lt;code&gt;trash&lt;/code&gt;, &lt;code&gt;junk&lt;/code&gt;, and &lt;code&gt;archive&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Standard webhook triggers: you subscribe once at the application level (&lt;code&gt;POST /v3/webhooks&lt;/code&gt;), and &lt;code&gt;message.created&lt;/code&gt; then fires on inbound mail. A subscription isn't scoped to one grant — each event's payload carries a &lt;code&gt;grant_id&lt;/code&gt;, so you filter for your Agent Account's events on the way in. Agent Accounts also emit deliverability triggers: &lt;code&gt;message.delivered&lt;/code&gt;, &lt;code&gt;message.bounced&lt;/code&gt;, &lt;code&gt;message.complaint&lt;/code&gt;, and &lt;code&gt;message.rejected&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;No OAuth dance and no refresh token to babysit. You create it with one API call.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The part I like as an SRE: because it's a normal grant, it slots into whatever observability, retry, and webhook plumbing you already built for human accounts. You're not maintaining a special-case code path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before you begin
&lt;/h2&gt;

&lt;p&gt;Two things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;An API key.&lt;/strong&gt; All requests authenticate with &lt;code&gt;Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;&lt;/code&gt;, and the key identifies your application. Examples here hit &lt;code&gt;https://api.us.nylas.com&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A verified domain.&lt;/strong&gt; Agent Accounts live on a domain — either a custom one you register and publish DNS records for, or a Nylas trial subdomain like &lt;code&gt;yourapp.nylas.email&lt;/code&gt;. New domains warm up over roughly four weeks, so if you're going to production, register the real one early. The full DNS walkthrough is in the &lt;a href="https://developer.nylas.com/docs/v3/agent-accounts/provisioning/" rel="noopener noreferrer"&gt;provisioning docs&lt;/a&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you've run &lt;code&gt;nylas init&lt;/code&gt; already, the CLI is pointed at your application and you're ready.&lt;/p&gt;

&lt;h2&gt;
  
  
  Provision support@
&lt;/h2&gt;

&lt;p&gt;You create the account with a single &lt;code&gt;POST /v3/connect/custom&lt;/code&gt; using &lt;code&gt;"provider": "nylas"&lt;/code&gt; and the email address in &lt;code&gt;settings.email&lt;/code&gt;. The optional top-level &lt;code&gt;name&lt;/code&gt; becomes the default &lt;code&gt;From&lt;/code&gt; display name on everything the account sends.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/connect/custom"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "provider": "nylas",
    "name": "Acme Support",
    "settings": {
      "email": "support@yourcompany.com"
    }
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response hands you back &lt;code&gt;data.id&lt;/code&gt;. &lt;strong&gt;Save it&lt;/strong&gt; — that's the &lt;code&gt;grant_id&lt;/code&gt; you'll use on every subsequent call.&lt;/p&gt;

&lt;p&gt;From the CLI it's one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas agent account create support@yourcompany.com &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"Acme Support"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That provisions the grant and prints its &lt;code&gt;id&lt;/code&gt;, status, and connector details. If the underlying &lt;code&gt;nylas&lt;/code&gt; connector doesn't exist on your application yet, the CLI creates it first — you don't have to think about it. The API also auto-creates a default workspace and a default policy for the account, which matters in a minute when we get to filtering.&lt;/p&gt;

&lt;p&gt;One honest gotcha worth flagging now: there is &lt;strong&gt;no&lt;/strong&gt; &lt;code&gt;--workspace&lt;/code&gt; flag on &lt;code&gt;agent account create&lt;/code&gt;. Workspaces (which carry your policies and rules) get attached separately. We'll do that below.&lt;/p&gt;

&lt;p&gt;If you want a human to be able to peek at the inbox over IMAP later, pass &lt;code&gt;--app-password&lt;/code&gt; at creation time (18–40 ASCII chars, mixed case and a digit). Skip it and protocol access stays off, which for a fully automated agent is usually what you want.&lt;/p&gt;

&lt;h2&gt;
  
  
  Receive every inbound message
&lt;/h2&gt;

&lt;p&gt;The whole agent runs off one webhook. Inbound mail to &lt;code&gt;support@&lt;/code&gt; fires the standard &lt;code&gt;message.created&lt;/code&gt; notification — the same shape you'd get for any other grant.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/webhooks"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "trigger_types": ["message.created"],
    "webhook_url": "https://support-agent.yourcompany.com/webhooks/nylas",
    "description": "Support triage agent"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same thing from the terminal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas webhook create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; https://support-agent.yourcompany.com/webhooks/nylas &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--triggers&lt;/span&gt; message.created &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--description&lt;/span&gt; &lt;span class="s2"&gt;"Support triage agent"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;message.created&lt;/code&gt; payload looks roughly like this — note that it carries &lt;em&gt;summary&lt;/em&gt; fields, not the full body:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"message.created"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"msg-abc123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"grant_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"b1c2d3e4-5678-4abc-9def-0123456789ab"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"thread_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"thread-xyz789"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"subject"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Can't log in after password reset"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"from"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Dana Reed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"dana@customer.example"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"snippet"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"I reset my password an hour ago and now..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1742932766&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your handler should return &lt;code&gt;200&lt;/code&gt; immediately, verify the &lt;code&gt;X-Nylas-Signature&lt;/code&gt; header, and then do its work asynchronously. The first thing it does is skip messages the agent itself sent — because &lt;code&gt;message.created&lt;/code&gt; fires for outbound mail too, and an agent that replies to its own replies is a special kind of broken:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/webhooks/nylas&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;message.created&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;grant_id&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;SUPPORT_GRANT_ID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;?.[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]?.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;support@yourcompany.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// skip our own sends&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;triage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because the webhook only carries the snippet, you'll fetch the full message before you hand anything to the model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; GET &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/grants/&amp;lt;NYLAS_GRANT_ID&amp;gt;/messages/msg-abc123"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas email &lt;span class="nb"&gt;read &lt;/span&gt;msg-abc123
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's a hard edge here: if the body is larger than ~1 MB, the webhook type becomes &lt;code&gt;message.created.truncated&lt;/code&gt; and the body is omitted — so don't rely on the payload ever carrying the full text.&lt;/p&gt;

&lt;h2&gt;
  
  
  Classify it with an LLM
&lt;/h2&gt;

&lt;p&gt;Triage is a classification problem, and classification is exactly what models are good at. Pull the full message, then feed the model the three fields that actually decide intent: subject, sender, and body. Ask for a category back.&lt;/p&gt;

&lt;p&gt;This part is deliberately provider-agnostic — any chat-completions-style API works, and the prompt is the whole design:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;triage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;full&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getFullMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;grant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// GET .../messages/{id}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;category&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;llm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;classify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;instruction&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Classify this support email into exactly one of: &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;billing, bug_report, how_to, account_access, feedback, spam. &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Reply with only the category.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;full&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;full&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;full&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;full&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;category&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// -&amp;gt; assign folder, draft reply, or escalate&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep the label set small and closed. A model asked to pick from six categories is reliable; a model asked to "summarize the customer's needs" is a liability you'll be debugging at 2 a.m. The category is what drives everything downstream — which folder it lands in, whether the agent auto-replies, and whether a human gets pulled in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Route and filter with policies, rules, and lists
&lt;/h2&gt;

&lt;p&gt;Here's the move a lot of "AI inbox" builds miss: not every message should reach your application at all. Nylas Agent Accounts give you three server-side primitives that filter and sort mail &lt;em&gt;before&lt;/em&gt; your webhook ever fires, so the LLM only spends tokens on things worth classifying.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Policies&lt;/strong&gt; bundle limits (send quotas, storage, retention) and spam detection. One policy can govern many accounts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rules&lt;/strong&gt; match inbound or outbound mail on sender/recipient fields and run actions like &lt;code&gt;block&lt;/code&gt;, &lt;code&gt;mark_as_spam&lt;/code&gt;, &lt;code&gt;assign_to_folder&lt;/code&gt;, &lt;code&gt;archive&lt;/code&gt;, or &lt;code&gt;trash&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lists&lt;/strong&gt; are typed collections of domains, TLDs, or addresses that rules reference via the &lt;code&gt;in_list&lt;/code&gt; operator — so non-engineers can update an allowlist or blocklist without a deploy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;They form a chain: lists hold values, rules reference lists and describe conditions plus actions, policies bundle limits, and a &lt;strong&gt;workspace&lt;/strong&gt; carries one &lt;code&gt;policy_id&lt;/code&gt; and an array of &lt;code&gt;rule_ids&lt;/code&gt;. Every Agent Account in the workspace inherits both. You don't attach anything to an individual grant.&lt;/p&gt;

&lt;p&gt;Start by blocking a known-bad sender at the SMTP layer, so it never hits the mailbox:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/rules"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "name": "Block spam-domain.com",
    "priority": 1,
    "trigger": "inbound",
    "match": {
      "conditions": [
        { "field": "from.domain", "operator": "is", "value": "spam-domain.com" }
      ]
    },
    "actions": [{ "type": "block" }]
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CLI builds the same rule and attaches it to the default workspace in one shot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas agent rule create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"Block spam-domain.com"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--trigger&lt;/span&gt; inbound &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--priority&lt;/span&gt; 1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--condition&lt;/span&gt; from.domain,is,spam-domain.com &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--action&lt;/span&gt; block
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the allowlist/blocklist pattern, create a list and reference it from a rule. API first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/lists"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{ "name": "Blocked domains", "type": "domain" }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That call returns the list &lt;code&gt;id&lt;/code&gt;. Seed it with items in a second call — list items are their own sub-resource:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/lists/&amp;lt;LIST_ID&amp;gt;/items"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{ "items": ["spam.com"] }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the rule that matches against it with &lt;code&gt;in_list&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/rules"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "name": "Block anything on our blocklist",
    "trigger": "inbound",
    "match": {
      "conditions": [
        { "field": "from.domain", "operator": "in_list", "value": ["&amp;lt;LIST_ID&amp;gt;"] }
      ]
    },
    "actions": [{ "type": "block" }]
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CLI collapses the list creation and seeding into one command, and the rule reference into another:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas agent list create &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"Blocked domains"&lt;/span&gt; &lt;span class="nt"&gt;--type&lt;/span&gt; domain &lt;span class="nt"&gt;--item&lt;/span&gt; spam.com
nylas agent rule create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"Block anything on our blocklist"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--trigger&lt;/span&gt; inbound &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--condition&lt;/span&gt; from.domain,in_list,&amp;lt;LIST_ID&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--action&lt;/span&gt; block
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Routing is the same shape with a different action. Push automated notifications into their own folder so the agent doesn't waste a classification on them — &lt;code&gt;assign_to_folder&lt;/code&gt; paired with &lt;code&gt;mark_as_read&lt;/code&gt;. The curl form:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/rules"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "name": "Route notifications to a folder",
    "trigger": "inbound",
    "match": {
      "conditions": [
        { "field": "from.domain", "operator": "is", "value": "noreply.example.com" }
      ]
    },
    "actions": [
      { "type": "assign_to_folder", "value": "&amp;lt;FOLDER_ID&amp;gt;" },
      { "type": "mark_as_read" }
    ]
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the CLI equivalent, which creates the rule and attaches it to the default workspace:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas agent rule create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"Route notifications to a folder"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--trigger&lt;/span&gt; inbound &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--condition&lt;/span&gt; from.domain,is,noreply.example.com &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--action&lt;/span&gt; &lt;span class="nv"&gt;assign_to_folder&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;FOLDER_ID&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--action&lt;/span&gt; mark_as_read
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you created a &lt;em&gt;custom&lt;/em&gt; policy and want it on your accounts, attach it to the workspace — that's where the no-&lt;code&gt;--workspace&lt;/code&gt;-flag detail from earlier resolves. Create the policy, then PATCH the workspace to point at it. The curl form:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Create the policy&lt;/span&gt;
curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/policies"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{ "name": "Support Agent Policy" }'&lt;/span&gt;

&lt;span class="c"&gt;# Attach it to the workspace (returns the policy id from the call above)&lt;/span&gt;
curl &lt;span class="nt"&gt;--request&lt;/span&gt; PATCH &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/workspaces/&amp;lt;WORKSPACE_ID&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{ "policy_id": "&amp;lt;POLICY_ID&amp;gt;" }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CLI collapses both steps:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas agent policy create &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"Support Agent Policy"&lt;/span&gt;
nylas workspace update &amp;lt;workspace-id&amp;gt; &lt;span class="nt"&gt;--policy-id&lt;/span&gt; &amp;lt;policy-id&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One thing to internalize: inbound and outbound rules are isolated. An inbound rule never runs on a send, and an outbound rule never runs on receipt. So if you also want to, say, star every reply the agent sends, that's an &lt;code&gt;outbound&lt;/code&gt; rule matching &lt;code&gt;outbound.type&lt;/code&gt; equal to &lt;code&gt;reply&lt;/code&gt; — it won't interfere with your inbound triage at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reply in the correct thread
&lt;/h2&gt;

&lt;p&gt;When the agent decides to respond, threading is non-negotiable. The reply has to land &lt;em&gt;inside&lt;/em&gt; the customer's existing conversation, not as a fresh disconnected email. Nylas handles this through &lt;code&gt;reply_to_message_id&lt;/code&gt;: pass it on the send and Nylas sets the &lt;code&gt;In-Reply-To&lt;/code&gt; and &lt;code&gt;References&lt;/code&gt; headers for you, so the customer's mail client groups the reply correctly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/grants/&amp;lt;NYLAS_GRANT_ID&amp;gt;/messages/send"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "reply_to_message_id": "msg-abc123",
    "to": [{ "email": "dana@customer.example" }],
    "subject": "Re: Can'&lt;/span&gt;&lt;span class="se"&gt;\'&lt;/span&gt;&lt;span class="s1"&gt;'t log in after password reset",
    "body": "Hi Dana — I see the reset went through an hour ago..."
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From the CLI, &lt;code&gt;nylas email reply&lt;/code&gt; does the bookkeeping for you. Point it at the message ID and it fetches the original to populate the recipient and subject, then preserves threading via &lt;code&gt;reply_to_message_id&lt;/code&gt; automatically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas email reply msg-abc123 &lt;span class="nt"&gt;--body&lt;/span&gt; &lt;span class="s2"&gt;"Hi Dana — I see the reset went through an hour ago..."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By default the reply goes only to the original sender; add &lt;code&gt;--all&lt;/code&gt; if you need to keep the rest of the To/Cc on the thread. This is the command I lean on most when I'm testing an agent by hand — it's the shortest path from "a message landed" to "a threaded reply went out."&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't reply twice
&lt;/h2&gt;

&lt;p&gt;This is where naive builds fall over in production. Webhooks are delivered &lt;strong&gt;at least once&lt;/strong&gt; — if your endpoint is slow to &lt;code&gt;200&lt;/code&gt; or there's a network blip, you'll get the same &lt;code&gt;message.created&lt;/code&gt; again. Add concurrent workers and two of them can grab the same notification at the same millisecond. Either way: two replies, one angry customer.&lt;/p&gt;

&lt;p&gt;The fix is idempotency, and it lives entirely in your own store — not in Nylas. Custom metadata tagging on Agent Account resources isn't supported yet, so you can't stash dedup state on the message or grant; keep it in Redis or Postgres. Track the message IDs you've already processed and refuse to handle one twice, using an atomic check-and-set:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;messageId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Atomic insert. Returns truthy ONLY when the key was newly inserted:&lt;/span&gt;
&lt;span class="c1"&gt;//   Redis    -&amp;gt; SET messageId 1 NX EX 86400   (truthy if it didn't exist)&lt;/span&gt;
&lt;span class="c1"&gt;//   Postgres -&amp;gt; INSERT ... ON CONFLICT DO NOTHING, then check rowCount === 1&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;wasInserted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;processedMessages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setIfAbsent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;messageId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;receivedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;wasInserted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// insert did nothing -&amp;gt; already seen, bail&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Watch the polarity here — it's the easiest bug to ship. &lt;code&gt;setIfAbsent&lt;/code&gt; succeeds (returns truthy) only the &lt;em&gt;first&lt;/em&gt; time it sees a message ID; every redelivery finds the key already present and returns falsy. So you proceed when &lt;code&gt;wasInserted&lt;/code&gt; is true and exit when it's false. Invert that test and you'd drop every genuine first message while happily processing the duplicates.&lt;/p&gt;

&lt;p&gt;Give the dedup record a TTL of about 24 hours — long enough that a webhook redelivered hours later still gets caught, short enough that the table doesn't grow forever. For the concurrent-worker race, layer a per-thread lock on top, and inside it double-check the thread's latest message: if the most recent message is already from the agent, a prior worker beat you to it and you skip.&lt;/p&gt;

&lt;p&gt;Dedup catches redelivered events; locking catches simultaneous ones. You want both. The full pattern, including an outbound rate limit as a backstop against reply storms, is in the &lt;a href="https://developer.nylas.com/docs/cookbook/agent-accounts/prevent-duplicate-replies/" rel="noopener noreferrer"&gt;prevent-duplicate-replies recipe&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Carry context across the thread
&lt;/h2&gt;

&lt;p&gt;A support thread is rarely one message. The customer replies, you reply, they clarify — and the agent needs the whole exchange to answer the third message sensibly. Because every message in a conversation shares a &lt;code&gt;thread_id&lt;/code&gt;, fetching context is one call.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas email threads show thread-xyz789
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or the same call over the API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/grants/&amp;lt;NYLAS_GRANT_ID&amp;gt;/threads/&amp;lt;THREAD_ID&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;GET /v3/grants/{grant_id}/threads/{thread_id}&lt;/code&gt; returns the thread with its &lt;code&gt;message_ids&lt;/code&gt;; fetch those for the full transcript, sort by date, and hand the model the running conversation rather than the latest message in isolation. For long threads, summarize the early messages and pass only the last few in full — same answer quality, far fewer tokens. The &lt;a href="https://developer.nylas.com/docs/cookbook/agent-accounts/multi-turn-conversations/" rel="noopener noreferrer"&gt;multi-turn conversations recipe&lt;/a&gt; builds the full state machine around this if you need conversations that span days.&lt;/p&gt;

&lt;h2&gt;
  
  
  Guardrails before you ship
&lt;/h2&gt;

&lt;p&gt;A support agent that emails customers autonomously needs brakes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Human-in-the-loop for sensitive categories.&lt;/strong&gt; Route &lt;code&gt;billing&lt;/code&gt; and &lt;code&gt;account_access&lt;/code&gt; to a draft-and-escalate path instead of auto-sending. The agent prepares the reply; a person approves it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cap the conversation.&lt;/strong&gt; Track a turn count per thread and escalate to a human when it crosses a limit. An unbounded loop is a token sink and a reputation risk.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Outbound rate limit.&lt;/strong&gt; Even with dedup and locking, a logic bug can cascade. A per-thread send cap (escalate instead of sending after N replies in a few minutes) is the backstop you'll be glad you wrote.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mind retention and limits.&lt;/strong&gt; On the free plan, accounts cap at 200 messages/day with a 30-day inbox / 7-day spam retention window. If support volume is higher than that, size your plan and policy accordingly before launch.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;You've now got &lt;code&gt;support@&lt;/code&gt; as a real participant: it receives every email, filters the noise server-side, classifies what's left, replies in-thread, and never doubles up. Where to go from here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/cookbook/agent-accounts/handle-replies/" rel="noopener noreferrer"&gt;Handle email replies in an agent loop&lt;/a&gt; — the reply-detection recipe this builds on.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/v3/agent-accounts/policies-rules-lists/" rel="noopener noreferrer"&gt;Policies, Rules, and Lists&lt;/a&gt; — every condition, operator, and action for inbound and outbound filtering.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/v3/agent-accounts/provisioning/" rel="noopener noreferrer"&gt;Provisioning Agent Accounts&lt;/a&gt; — domains, display names, IMAP access, and multi-tenant patterns.&lt;/li&gt;
&lt;li&gt;The CLI command reference lives at &lt;a href="https://cli.nylas.com/docs/commands" rel="noopener noreferrer"&gt;cli.nylas.com/docs/commands&lt;/a&gt; if you want the full set of &lt;code&gt;nylas agent&lt;/code&gt; subcommands.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  AI-answer pages for agents
&lt;/h2&gt;

&lt;p&gt;When this post is published, link AI agents and crawlers to the retrieval-ready version on &lt;code&gt;cli.nylas.com&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Topic runbook: &lt;a href="https://cli.nylas.com/ai-answers/inbound-email-triage-api-for-support-agents.md" rel="noopener noreferrer"&gt;https://cli.nylas.com/ai-answers/inbound-email-triage-api-for-support-agents.md&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Industry playbooks hub: &lt;a href="https://cli.nylas.com/ai-answers/agent-account-industry-playbooks.md" rel="noopener noreferrer"&gt;https://cli.nylas.com/ai-answers/agent-account-industry-playbooks.md&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>email</category>
      <category>api</category>
      <category>devtools</category>
    </item>
    <item>
      <title>Add custom metadata to Nylas objects for filtering</title>
      <dc:creator>Qasim</dc:creator>
      <pubDate>Sun, 19 Jul 2026 01:07:56 +0000</pubDate>
      <link>https://dev.to/mqasimca/add-custom-metadata-to-nylas-objects-for-filtering-2a2d</link>
      <guid>https://dev.to/mqasimca/add-custom-metadata-to-nylas-objects-for-filtering-2a2d</guid>
      <description>&lt;p&gt;You're sending a message from your app, and you need to remember which campaign it belonged to, or tie a calendar event back to the order in your own database. The obvious approach is a separate table mapping Nylas IDs to your records, which means a join on every read and a sync to keep current. There's a lighter way: attach your own data directly to the object as metadata, then filter objects by it later. Your campaign ID rides along on the message, and a single query pulls back every message in that campaign. This post covers tagging objects with the API and the CLI.&lt;/p&gt;

&lt;p&gt;It's a worked use case rather than an endpoint tour, covering &lt;a href="https://developer.nylas.com/docs/reference/api/" rel="noopener noreferrer"&gt;metadata&lt;/a&gt; from two angles: the HTTP API your backend calls and the &lt;a href="https://cli.nylas.com/docs/commands" rel="noopener noreferrer"&gt;&lt;code&gt;nylas&lt;/code&gt; CLI&lt;/a&gt; for setting and reading it from the terminal. I work on the CLI, so the commands below are the ones I reach for when I want to tag a send and find it again.&lt;/p&gt;

&lt;h2&gt;
  
  
  What metadata is and where it lives
&lt;/h2&gt;

&lt;p&gt;Metadata is a field of your own key-value pairs that you attach to a Nylas object, and it's supported on messages, drafts, events, and calendars. The values are strings, you can store up to 50 pairs on a single object, and each value can hold up to 500 characters. That's room for a campaign slug, an external record ID, a workflow status, and whatever else ties the object back to your application, all carried on the object itself rather than in a side table.&lt;/p&gt;

&lt;p&gt;The point of metadata is to avoid that side table. Instead of mapping a Nylas message ID to a row in your database and joining on every read, you put your application's data on the message and read it straight back. The object becomes self-describing from your app's point of view, so the campaign a message belongs to or the order an event is for travels with the object through every API call that returns it. For application data that's small and that you want to filter by, this is simpler than maintaining a parallel store.&lt;/p&gt;

&lt;h2&gt;
  
  
  The five-key rule that decides your schema
&lt;/h2&gt;

&lt;p&gt;This is the single most important thing to understand about metadata, and missing it leads to a tagging scheme that can't be queried. You can store up to 50 key-value pairs, but only five keys, literally named &lt;code&gt;key1&lt;/code&gt; through &lt;code&gt;key5&lt;/code&gt;, are indexed and filterable. Any other key you set is stored and returned with the object, but you cannot filter on it. So the keys you plan to query by have to live in &lt;code&gt;key1&lt;/code&gt; through &lt;code&gt;key5&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This shapes how you design your tags. The values you'll search for, the campaign ID, the workflow status, the tenant, go in the five indexed keys, and you decide that mapping up front: &lt;code&gt;key1&lt;/code&gt; is the campaign, &lt;code&gt;key2&lt;/code&gt; is the run, and so on. Everything else, data you want to carry but never filter by, can use descriptive key names and ride along as the other 45 pairs. Get this backwards, putting a filterable concept in a free-form key, and you'll have stored the data but built no way to query it, which usually surfaces later when the filter you need silently returns nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Set metadata when you send
&lt;/h2&gt;

&lt;p&gt;You attach metadata at creation time by adding a &lt;code&gt;metadata&lt;/code&gt; object to the request. On a &lt;a href="https://developer.nylas.com/docs/reference/api/messages/send-message/" rel="noopener noreferrer"&gt;&lt;code&gt;POST /messages/send&lt;/code&gt;&lt;/a&gt;, the &lt;code&gt;metadata&lt;/code&gt; object carries your key-value pairs alongside the rest of the message, and they're stored on the resulting message for later retrieval and filtering.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/grants/&amp;lt;GRANT_ID&amp;gt;/messages/send"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "to": [{ "email": "lead@example.com" }],
    "subject": "Your trial is ending",
    "body": "&amp;lt;p&amp;gt;A quick nudge.&amp;lt;/p&amp;gt;",
    "metadata": { "key1": "q2-trial-nudge", "key2": "run-1183" }
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here &lt;code&gt;key1&lt;/code&gt; holds the campaign and &lt;code&gt;key2&lt;/code&gt; the run, both indexed so you can filter on either later. The CLI sets the same metadata with a repeatable &lt;code&gt;--metadata&lt;/code&gt; flag on &lt;code&gt;nylas email send&lt;/code&gt;, written as &lt;code&gt;key=value&lt;/code&gt;, so &lt;code&gt;nylas email send --to lead@example.com --metadata key1=q2-trial-nudge --metadata key2=run-1183&lt;/code&gt; tags the message from the terminal. Tag every send in a campaign, not just some, because an untagged message is invisible to the filter that counts the campaign later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Filter objects by metadata
&lt;/h2&gt;

&lt;p&gt;Reading tagged objects back uses the &lt;code&gt;metadata_pair&lt;/code&gt; query parameter, written as &lt;code&gt;metadata_pair=key1:value&lt;/code&gt;, which returns only objects whose metadata matches that key and value. On the messages endpoint, &lt;code&gt;GET /v3/grants/{grant_id}/messages?metadata_pair=key1:q2-trial-nudge&lt;/code&gt; returns exactly the messages tagged for that campaign, no scan of every message required.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; GET &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/grants/&amp;lt;GRANT_ID&amp;gt;/messages?metadata_pair=key1:q2-trial-nudge"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same &lt;code&gt;metadata_pair&lt;/code&gt; filter works on the drafts, events, and calendars list endpoints, so the pattern is identical whether you're pulling a campaign's messages or a project's events. From the CLI, &lt;code&gt;nylas email list --metadata key1:q2-trial-nudge&lt;/code&gt; does the same filter, and its help is explicit that only &lt;code&gt;key1&lt;/code&gt; through &lt;code&gt;key5&lt;/code&gt; work here, which is the rule from earlier showing up at query time. This is the payoff of tagging: one filtered request replaces fetching everything and matching in your own code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Update tags after creation, through the API
&lt;/h2&gt;

&lt;p&gt;Setting metadata at creation doesn't lock you in. The message update endpoint, &lt;code&gt;PUT /v3/grants/{grant_id}/messages/{message_id}&lt;/code&gt;, accepts a &lt;code&gt;metadata&lt;/code&gt; object alongside &lt;code&gt;unread&lt;/code&gt;, &lt;code&gt;starred&lt;/code&gt;, and &lt;code&gt;folders&lt;/code&gt;, so you can change a message's tags after it's sent, through the same partial-&lt;code&gt;PUT&lt;/code&gt; call that marks it read or moves it to a folder. Include only the fields you want to change and the rest stay as they were, so re-tagging a sent message is a one-field update rather than a resend.&lt;/p&gt;

&lt;p&gt;Events update the same way. Because you change an event with a &lt;code&gt;PUT&lt;/code&gt;, its &lt;code&gt;metadata&lt;/code&gt; goes along, so you can flip a &lt;code&gt;key1&lt;/code&gt; workflow status from &lt;code&gt;pending&lt;/code&gt; to &lt;code&gt;confirmed&lt;/code&gt; as the event progresses, which makes metadata useful for tracking state over time rather than just a fixed label. The one place this differs is the CLI: &lt;code&gt;nylas email send --metadata&lt;/code&gt; sets tags at send time and the &lt;code&gt;nylas email metadata&lt;/code&gt; commands read them back, but the terminal doesn't expose a metadata-update flow, so to change a message's tags after sending you go through the API's message &lt;code&gt;PUT&lt;/code&gt;. From the CLI, plan the tags before sending; from code, evolve them whenever you need.&lt;/p&gt;

&lt;h2&gt;
  
  
  Provider and reserved-key caveats
&lt;/h2&gt;

&lt;p&gt;Two specifics are worth knowing before you lean on metadata heavily. First, &lt;code&gt;metadata_pair&lt;/code&gt; filtering isn't universal across providers: on CalDAV-backed calendars, which is how iCloud connects, the filter isn't supported for events, so a query that works against Google and Microsoft events won't behave the same there. If you support iCloud calendars, don't build a feature that depends on filtering their events by metadata, and fall back to listing and matching in your own code for those.&lt;/p&gt;

&lt;p&gt;Second, &lt;code&gt;key5&lt;/code&gt; carries a special meaning in one place. Nylas uses &lt;code&gt;key5&lt;/code&gt; to identify the events that count toward the max-fairness round-robin calculation in group availability, so if you run round-robin scheduling, &lt;code&gt;key5&lt;/code&gt; is effectively spoken for on those events, where it holds the group identifier. It's still an ordinary indexed key everywhere else, on messages and on non-group events, but on group-availability events, leave &lt;code&gt;key5&lt;/code&gt; for the value scheduling expects rather than overloading it with your own tag.&lt;/p&gt;

&lt;h2&gt;
  
  
  Inspect what's tagged from the CLI
&lt;/h2&gt;

&lt;p&gt;When you want to see what metadata is actually on an object, the CLI reads it back. &lt;code&gt;nylas email metadata show &amp;lt;message-id&amp;gt;&lt;/code&gt; prints every metadata pair on a message, both the indexed &lt;code&gt;key1&lt;/code&gt; through &lt;code&gt;key5&lt;/code&gt; and any custom-named keys you added, so you can confirm a tag landed the way you intended. There's also &lt;code&gt;nylas email metadata info&lt;/code&gt; for a quick reference on how the keys and filtering work.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# See every metadata pair on a message&lt;/span&gt;
nylas email metadata show &amp;lt;message-id&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the fast way to debug a tagging scheme: send a tagged message, run &lt;code&gt;metadata show&lt;/code&gt;, and check that the campaign landed in &lt;code&gt;key1&lt;/code&gt; rather than a free-form key that won't filter. It's saved me more than once from the silent-empty-filter problem, where a tag was stored under the wrong key name and the &lt;code&gt;metadata_pair&lt;/code&gt; query came back empty because the value wasn't in one of the indexed five.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where metadata earns its place
&lt;/h2&gt;

&lt;p&gt;The same tag-and-filter pattern sits under a range of features, and the value is always avoiding a side table. A few that map straight on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Campaign attribution.&lt;/strong&gt; Tag every send with a campaign ID in &lt;code&gt;key1&lt;/code&gt;, then filter with &lt;code&gt;metadata_pair&lt;/code&gt; to pull a campaign's messages for reply-rate or open-rate analysis.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multi-tenant scoping.&lt;/strong&gt; Stamp each object with a tenant ID so one connected account can serve many tenants and you filter each tenant's data cleanly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Workflow status on events.&lt;/strong&gt; Track an event through states by updating a &lt;code&gt;key1&lt;/code&gt; status, filtering for everything &lt;code&gt;pending&lt;/code&gt; or &lt;code&gt;confirmed&lt;/code&gt; without a separate status store.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Linking to your own records.&lt;/strong&gt; Carry your database's primary key for the related record on the object, so a webhook or list result points straight back to your row with no lookup table.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each replaces a join or a parallel store with a tag that travels on the object and a one-line filter that reads it back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things to keep in mind
&lt;/h2&gt;

&lt;p&gt;A short list of details keeps metadata predictable.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Only &lt;code&gt;key1&lt;/code&gt; through &lt;code&gt;key5&lt;/code&gt; filter.&lt;/strong&gt; You can store 50 pairs, but anything you'll query by must live in one of the five indexed keys; other keys are stored but not filterable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fifty pairs, 500 characters each.&lt;/strong&gt; Values are strings capped at 500 characters, so store IDs and slugs, not document bodies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tags update through &lt;code&gt;PUT&lt;/code&gt;.&lt;/strong&gt; Both message metadata (via &lt;code&gt;PUT /v3/grants/{id}/messages/{id}&lt;/code&gt;) and event metadata (via the event &lt;code&gt;PUT&lt;/code&gt;) can change after creation; the partial &lt;code&gt;PUT&lt;/code&gt; touches only the fields you send.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The CLI sets at send, doesn't update.&lt;/strong&gt; &lt;code&gt;nylas email send --metadata&lt;/code&gt; tags at send time with no terminal update flow, so compute the full tag set before sending when you work from the CLI, or use the API &lt;code&gt;PUT&lt;/code&gt; to evolve them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Filter with &lt;code&gt;metadata_pair=key:value&lt;/code&gt;.&lt;/strong&gt; The same parameter works on messages, drafts, events, and calendars.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tag everything in a cohort.&lt;/strong&gt; An untagged object is invisible to the filter, so tag every send or event in a group, not just some.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;Metadata lets you carry your own application data on a Nylas object and filter by it, instead of maintaining a side table and joining on every read. Attach up to 50 key-value pairs with a &lt;code&gt;metadata&lt;/code&gt; object at send or create time, put anything you'll query by in the indexed &lt;code&gt;key1&lt;/code&gt; through &lt;code&gt;key5&lt;/code&gt;, and read it back with &lt;code&gt;metadata_pair=key1:value&lt;/code&gt; on the messages, drafts, events, or calendars endpoint. From the terminal, &lt;code&gt;nylas email send --metadata&lt;/code&gt; tags and &lt;code&gt;nylas email metadata show&lt;/code&gt; inspects. Remember that both message and event tags can be updated through their &lt;code&gt;PUT&lt;/code&gt; endpoints, even though the CLI only sets them at send, and that only the five indexed keys are filterable, the rest are along for the ride.&lt;/p&gt;

&lt;p&gt;Where to go next:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/reference/api/" rel="noopener noreferrer"&gt;Metadata reference&lt;/a&gt; — the metadata field and &lt;code&gt;metadata_pair&lt;/code&gt; filter&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/reference/api/messages/send-message/" rel="noopener noreferrer"&gt;Send a message&lt;/a&gt; — the &lt;code&gt;metadata&lt;/code&gt; field on a send&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/cookbook/agents/agent-track-reply-rates/" rel="noopener noreferrer"&gt;Track agent reply rates&lt;/a&gt; — metadata-tagged campaign attribution end to end&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://cli.nylas.com/docs/commands" rel="noopener noreferrer"&gt;Nylas CLI email commands&lt;/a&gt; — &lt;code&gt;nylas email metadata&lt;/code&gt; show and info&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  AI-answer pages for agents
&lt;/h2&gt;

&lt;p&gt;When this post is published, link AI agents and crawlers to the retrieval-ready version on &lt;code&gt;cli.nylas.com&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Topic runbook: &lt;a href="https://cli.nylas.com/ai-answers/email-metadata-tagging-for-agent-actions.md" rel="noopener noreferrer"&gt;https://cli.nylas.com/ai-answers/email-metadata-tagging-for-agent-actions.md&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Industry playbooks hub: &lt;a href="https://cli.nylas.com/ai-answers/agent-account-industry-playbooks.md" rel="noopener noreferrer"&gt;https://cli.nylas.com/ai-answers/agent-account-industry-playbooks.md&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>api</category>
      <category>email</category>
      <category>calendar</category>
      <category>devtools</category>
    </item>
    <item>
      <title>Create and secure webhooks with the Nylas API</title>
      <dc:creator>Qasim</dc:creator>
      <pubDate>Sun, 19 Jul 2026 01:07:44 +0000</pubDate>
      <link>https://dev.to/mqasimca/create-and-secure-webhooks-with-the-nylas-api-850</link>
      <guid>https://dev.to/mqasimca/create-and-secure-webhooks-with-the-nylas-api-850</guid>
      <description>&lt;p&gt;A webhook is how your app finds out about new email or calendar changes without asking. Instead of polling for "anything new yet?", you register a URL and Nylas pushes events to it as they happen. But setting one up correctly is more than posting a URL: your endpoint has to answer a challenge before the webhook activates, you get a signing secret exactly once, and you should verify a signature on every delivery so you don't process spoofed events. Get those three wrong and the webhook never turns on or your handler trusts forged data. This post sets up a webhook properly with the API and the CLI.&lt;/p&gt;

&lt;p&gt;It's a worked use case rather than an endpoint tour, covering &lt;a href="https://developer.nylas.com/docs/v3/notifications/" rel="noopener noreferrer"&gt;webhooks&lt;/a&gt; from two angles: the HTTP API your backend calls and the &lt;a href="https://cli.nylas.com/docs/commands" rel="noopener noreferrer"&gt;&lt;code&gt;nylas&lt;/code&gt; CLI&lt;/a&gt; for creating, testing, and receiving them. I work on the CLI, so the commands below are the ones I reach for when wiring up notifications.&lt;/p&gt;

&lt;h2&gt;
  
  
  Create a webhook
&lt;/h2&gt;

&lt;p&gt;You register a webhook with a &lt;a href="https://developer.nylas.com/docs/reference/api/webhook-notifications/" rel="noopener noreferrer"&gt;&lt;code&gt;POST /v3/webhooks&lt;/code&gt;&lt;/a&gt; request, and the two required fields are &lt;code&gt;webhook_url&lt;/code&gt;, where Nylas sends notifications, and &lt;code&gt;trigger_types&lt;/code&gt;, the array of events you want delivered. An optional &lt;code&gt;description&lt;/code&gt; labels it and &lt;code&gt;notification_email_addresses&lt;/code&gt; gets alerts about the webhook's health, like when it's failing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/webhooks"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "webhook_url": "https://yourapp.com/webhooks/nylas",
    "trigger_types": ["message.created", "event.created"],
    "description": "Production message and event hook"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Webhook management needs your API key, not a grant, because a webhook belongs to the application rather than one connected account. The &lt;code&gt;trigger_types&lt;/code&gt; array is where you choose exactly which events to receive, from &lt;code&gt;message.created&lt;/code&gt; to &lt;code&gt;event.updated&lt;/code&gt; to &lt;code&gt;grant.expired&lt;/code&gt;, so a single webhook can carry several event types or you can split them across webhooks by concern. Choose only the triggers you'll act on, since every subscribed event is a request to your endpoint.&lt;/p&gt;

&lt;h2&gt;
  
  
  Create and explore from the CLI
&lt;/h2&gt;

&lt;p&gt;The CLI creates the same webhook with &lt;code&gt;nylas webhook create&lt;/code&gt;, taking &lt;code&gt;--url&lt;/code&gt; and &lt;code&gt;--triggers&lt;/code&gt; as the required inputs, plus &lt;code&gt;--description&lt;/code&gt; and &lt;code&gt;--notify&lt;/code&gt; for the optional fields. Before you create one, &lt;code&gt;nylas webhook triggers&lt;/code&gt; lists every available trigger type, which saves guessing at the exact event names.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# See the available triggers, then create a webhook&lt;/span&gt;
nylas webhook triggers
nylas webhook create &lt;span class="nt"&gt;--url&lt;/span&gt; https://yourapp.com/webhooks/nylas &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--triggers&lt;/span&gt; message.created,event.created &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--notify&lt;/span&gt; ops@example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Webhook management is admin-level, so the CLI uses your API key for these commands, matching the API. From there, &lt;code&gt;nylas webhook list&lt;/code&gt; shows your application's webhooks, &lt;code&gt;nylas webhook show &amp;lt;id&amp;gt;&lt;/code&gt; prints one, &lt;code&gt;nylas webhook update &amp;lt;id&amp;gt;&lt;/code&gt; changes its triggers or URL, and &lt;code&gt;nylas webhook delete &amp;lt;id&amp;gt;&lt;/code&gt; removes it. The full lifecycle is on the command line, which makes the CLI handy for managing webhooks across environments without writing a single request.&lt;/p&gt;

&lt;h2&gt;
  
  
  Answer the challenge before it activates
&lt;/h2&gt;

&lt;p&gt;Here's the step that trips up almost everyone the first time: before a webhook goes live, Nylas verifies your endpoint with a challenge. When you create a webhook or set one to active, it sends a &lt;code&gt;GET&lt;/code&gt; request to your URL with a &lt;code&gt;challenge&lt;/code&gt; query parameter, and your endpoint has to echo that exact value back in the body of a &lt;code&gt;200 OK&lt;/code&gt;. Until it does, the webhook doesn't activate.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/webhooks/nylas&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;challenge&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// echo the exact value&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details make or break this. Your endpoint must respond within 10 seconds, and it must return the exact challenge value and nothing else, no JSON wrapper, no quotation marks, just the raw string. A handler that returns &lt;code&gt;{"challenge": "..."}&lt;/code&gt; fails verification because that isn't the exact value. So a webhook endpoint needs both a &lt;code&gt;GET&lt;/code&gt; route that answers the challenge and a &lt;code&gt;POST&lt;/code&gt; route that receives the actual notifications, and forgetting the &lt;code&gt;GET&lt;/code&gt; route is the most common reason a brand-new webhook silently never delivers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The webhook secret you get exactly once
&lt;/h2&gt;

&lt;p&gt;When your endpoint passes the challenge, Nylas generates a &lt;code&gt;webhook_secret&lt;/code&gt; and returns it in the create response, and this is the one moment you'll see it. The secret appears in the &lt;code&gt;POST /v3/webhooks&lt;/code&gt; response and again only if you rotate it; it is not included when you later &lt;code&gt;GET&lt;/code&gt; the webhook. So you store it the instant you create the webhook, because there's no endpoint that hands it back later.&lt;/p&gt;

&lt;p&gt;That secret is the signing key for everything that follows, so treat it like any other credential: keep it out of source control, out of logs, and in your secrets manager. The reason it matters is the next section, every notification is signed with this secret, and without it stored you can't verify that an incoming request actually came from Nylas. Losing it isn't fatal, you can rotate to a new one, but it does mean re-storing the replacement everywhere that verifies signatures.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify the signature on every notification
&lt;/h2&gt;

&lt;p&gt;This is the security step you don't skip. Every notification carries an &lt;code&gt;X-Nylas-Signature&lt;/code&gt; header (or lowercase &lt;code&gt;x-nylas-signature&lt;/code&gt;, depending on your stack), which is a hex-encoded HMAC-SHA256 of the exact request body, signed with your &lt;code&gt;webhook_secret&lt;/code&gt;. You recompute that HMAC over the raw body with your stored secret and compare, and if it doesn't match, you reject the request as unverified rather than processing it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;crypto&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;0-9a-f&lt;/span&gt;&lt;span class="se"&gt;]{64}&lt;/span&gt;&lt;span class="sr"&gt;$/i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;signature&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// reject a missing or malformed signature&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createHmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timingSafeEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The phrase "exact request body" is load-bearing. The signature is computed over the raw bytes Nylas sent, so you have to verify against the unparsed body before any middleware reformats it, since re-serializing the JSON changes the bytes and breaks the comparison. Use a constant-time comparison, as above, rather than &lt;code&gt;===&lt;/code&gt;, so the check doesn't leak timing information. Verifying every notification is what stops an attacker who finds your webhook URL from posting forged events that your handler would otherwise trust.&lt;/p&gt;

&lt;h2&gt;
  
  
  The compression gotcha
&lt;/h2&gt;

&lt;p&gt;One detail catches people who enable payload compression. Webhooks can gzip their payloads, and when compression is on, the HMAC-SHA256 signature is computed over the compressed bytes, not the decompressed JSON. So you validate the signature against the raw compressed body first, and only decompress after the signature checks out.&lt;/p&gt;

&lt;p&gt;Reverse that order, decompress and then verify, and the signature will never match, because you're hashing different bytes than Nylas signed. This is a subtle one because it only bites when compression is enabled, so a webhook that verified fine in development can start failing signatures the moment someone turns on gzip. The rule is simple once you know it: signature first, on the bytes as received, decompression second.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rotate the secret when you need to
&lt;/h2&gt;

&lt;p&gt;Secrets sometimes need replacing, after a suspected leak, on a schedule, or when an employee with access leaves, and webhooks support rotation without recreating the endpoint. A &lt;a href="https://developer.nylas.com/docs/reference/api/webhook-notifications/" rel="noopener noreferrer"&gt;&lt;code&gt;POST /v3/webhooks/rotate-secret/{id}&lt;/code&gt;&lt;/a&gt; request issues a new &lt;code&gt;webhook_secret&lt;/code&gt; and returns it, the second and only other time you see a secret value, after which notifications are signed with the new one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas webhook rotate-secret &amp;lt;webhook-id&amp;gt; &lt;span class="nt"&gt;--yes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CLI does the same with &lt;code&gt;nylas webhook rotate-secret &amp;lt;id&amp;gt;&lt;/code&gt;, which requires the &lt;code&gt;--yes&lt;/code&gt; flag to confirm the rotation, since it changes the signing key, and then prints the new secret for you to store. Without &lt;code&gt;--yes&lt;/code&gt;, the command stops and tells you to re-run with it, a guard against rotating a production secret by accident. The operational care is to update your verification code with the new secret promptly, because once rotated, signatures use the new key immediately, and any handler still checking against the old secret will start rejecting genuine notifications. Rotate during a window where you can deploy the new secret quickly, and treat the printed value with the same care as the original.&lt;/p&gt;

&lt;h2&gt;
  
  
  Develop locally without deploying
&lt;/h2&gt;

&lt;p&gt;Testing webhooks usually means deploying somewhere public, but the CLI removes that friction with a local receiver. &lt;code&gt;nylas webhook server&lt;/code&gt; starts a local HTTP server that receives and prints webhook events, and with &lt;code&gt;--tunnel cloudflared&lt;/code&gt; it exposes itself through a cloudflared tunnel so Nylas can reach your laptop directly, no deploy required. When the tunnel is on, you pass &lt;code&gt;--secret&lt;/code&gt; so the server verifies the HMAC signature on each event, or &lt;code&gt;--allow-unsigned&lt;/code&gt; to explicitly opt out.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Receive real webhooks on your laptop, verifying signatures&lt;/span&gt;
nylas webhook server &lt;span class="nt"&gt;--tunnel&lt;/span&gt; cloudflared &lt;span class="nt"&gt;--secret&lt;/span&gt; &amp;lt;webhook-secret&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the fastest way to see real notifications during development, and it handles the challenge handshake and signature verification for you, the exact two things that are fiddly to get right by hand. There's also &lt;code&gt;nylas webhook test send&lt;/code&gt; to fire a test event at a webhook URL and &lt;code&gt;nylas webhook verify&lt;/code&gt; to check a signature against a payload locally, so you can confirm your verification logic against a known-good example before trusting it in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where webhooks fit
&lt;/h2&gt;

&lt;p&gt;The same create-verify-receive flow sits under every event-driven feature, and the triggers you choose shape what you build. A few that map straight on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Real-time inbox sync.&lt;/strong&gt; Subscribe to &lt;code&gt;message.created&lt;/code&gt;, &lt;code&gt;message.updated&lt;/code&gt;, and &lt;code&gt;message.deleted&lt;/code&gt; to keep a local copy current the moment mail changes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Calendar automation.&lt;/strong&gt; React to &lt;code&gt;event.created&lt;/code&gt; and &lt;code&gt;event.updated&lt;/code&gt; to schedule reminders or sync bookings without polling the calendar.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grant health monitoring.&lt;/strong&gt; Listen for &lt;code&gt;grant.expired&lt;/code&gt; so you can prompt a user to re-authenticate before their integration silently stops working.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tracking and engagement.&lt;/strong&gt; Receive &lt;code&gt;message.opened&lt;/code&gt; and &lt;code&gt;thread.replied&lt;/code&gt; to drive analytics or follow-up sequences off real recipient activity.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each is the same webhook, verified the same way, with the difference being which triggers you subscribe to and what your handler does with the verified event.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things to keep in mind
&lt;/h2&gt;

&lt;p&gt;A short list of details keeps a webhook reliable and secure.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Answer the challenge in 10 seconds.&lt;/strong&gt; Your &lt;code&gt;GET&lt;/code&gt; route must echo the exact &lt;code&gt;challenge&lt;/code&gt; value, no JSON, no quotes, or the webhook never activates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Store the secret at creation.&lt;/strong&gt; The &lt;code&gt;webhook_secret&lt;/code&gt; is returned only on create and on rotate, never on a later &lt;code&gt;GET&lt;/code&gt;, so save it immediately.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify every signature.&lt;/strong&gt; Recompute the HMAC-SHA256 over the raw body with your secret and compare in constant time; reject anything that doesn't match.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify before decompressing.&lt;/strong&gt; With gzip on, the signature covers the compressed bytes, so check it before you decompress.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rotate when needed.&lt;/strong&gt; Use the rotate-secret endpoint after a leak or on a schedule, and deploy the new secret promptly so genuine events keep verifying.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Develop with &lt;code&gt;nylas webhook server --tunnel cloudflared&lt;/code&gt;.&lt;/strong&gt; It receives real events locally and handles the challenge and signature, no deploy required.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;A secure webhook is three things done right. Create it with &lt;code&gt;POST /v3/webhooks&lt;/code&gt; (or &lt;code&gt;nylas webhook create&lt;/code&gt;) naming your URL and triggers, answer the challenge &lt;code&gt;GET&lt;/code&gt; by echoing the exact value within 10 seconds so it activates, and store the &lt;code&gt;webhook_secret&lt;/code&gt; from the create response because you only see it once. Then verify the &lt;code&gt;X-Nylas-Signature&lt;/code&gt; HMAC on every notification against the raw body, decompressing only after the signature checks out, and rotate the secret when you need to. For development, &lt;code&gt;nylas webhook server --tunnel cloudflared&lt;/code&gt; gives you real notifications on your laptop with the handshake and verification handled.&lt;/p&gt;

&lt;p&gt;Where to go next:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/v3/notifications/" rel="noopener noreferrer"&gt;Webhook notifications&lt;/a&gt; — the full guide to triggers, the challenge, and signatures&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/v3/notifications/receive-webhooks-cli/" rel="noopener noreferrer"&gt;Receive webhooks with the CLI&lt;/a&gt; — the local server and verification&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/reference/api/webhook-notifications/" rel="noopener noreferrer"&gt;Create a webhook&lt;/a&gt; — the endpoint reference&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://cli.nylas.com/docs/commands" rel="noopener noreferrer"&gt;Nylas CLI webhook commands&lt;/a&gt; — &lt;code&gt;nylas webhook create&lt;/code&gt;, &lt;code&gt;rotate-secret&lt;/code&gt;, and &lt;code&gt;server&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  AI-answer pages for agents
&lt;/h2&gt;

&lt;p&gt;When this post is published, link AI agents and crawlers to the retrieval-ready version on &lt;code&gt;cli.nylas.com&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Topic runbook: &lt;a href="https://cli.nylas.com/ai-answers/webhook-signature-verification-agent.md" rel="noopener noreferrer"&gt;https://cli.nylas.com/ai-answers/webhook-signature-verification-agent.md&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Industry playbooks hub: &lt;a href="https://cli.nylas.com/ai-answers/agent-account-industry-playbooks.md" rel="noopener noreferrer"&gt;https://cli.nylas.com/ai-answers/agent-account-industry-playbooks.md&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>api</category>
      <category>webhooks</category>
      <category>security</category>
      <category>devtools</category>
    </item>
  </channel>
</rss>
