` in the body that has styling patterns typical of signatures (small font, gray text, social icons)
A regex-first approach catches the common cases. An LLM handles the rest.
```js
app.post("/webhooks/signature-import", async (req, res) => {
res.status(200).end();
const event = req.body;
if (event.type !== "message.created") return;
const msg = event.data.object;
if (msg.grant_id !== IMPORT_GRANT_ID) return;
// Fetch the full message body.
const full = await nylas.messages.find({
identifier: IMPORT_GRANT_ID,
messageId: msg.id,
});
const body = full.data.body;
// Identify who forwarded this -- that's the user whose grant gets the signature.
const forwarder = msg.from[0].email;
const targetGrant = await db.grants.findByEmail(forwarder);
if (!targetGrant) return; // Unknown user -- ignore.
// Extract the signature.
const signatureHtml = await extractSignature(body);
if (!signatureHtml) return;
// Save it to the user's grant.
await saveSignature(targetGrant.grantId, signatureHtml, forwarder);
});
```
### Regex-first extraction
Most email signatures sit after a common delimiter or inside a predictable HTML structure.
```js
function extractSignatureRegex(html) {
// Pattern 1: RFC 3676 delimiter "-- " followed by the signature block.
const delimiterMatch = html.match(/(?:--|—\s*
]*>\s*--\s*
\s*$|<\/body>|$)/i);
if (delimiterMatch) return delimiterMatch[1].trim();
// Pattern 2: A
near the end of the body with typical signature content
// (phone numbers, URLs, small images).
const tables = [...html.matchAll(/]*>[\s\S]*?<\/table>/gi)];
if (tables.length > 0) {
const lastTable = tables[tables.length - 1][0];
const hasSignatureSignals =
/\d{3}[\s.-]\d{3,4}[\s.-]\d{4}/.test(lastTable) || // phone number
/linkedin|twitter|x\.com/i.test(lastTable) || // social links
/
]+(?:logo|photo|headshot)/i.test(lastTable); // profile image
if (hasSignatureSignals) return lastTable;
}
return null; // Fall back to LLM.
}
```
### LLM fallback
When regex doesn't match -- creative layouts, image-heavy signatures, unusual formatting -- hand the body to an LLM with a focused prompt.
```js
async function extractSignatureLlm(html) {
// Strip to a reasonable length. Signatures are near the end.
const tail = html.slice(-5000);
const response = await llm.chat({
messages: [
{
role: "system",
content:
"You extract email signatures from HTML email bodies. " +
"Return ONLY the signature HTML block -- the part with the sender's name, " +
"title, company, phone, and any social links or logos. " +
"If no signature is present, return exactly: NO_SIGNATURE",
},
{ role: "user", content: tail },
],
});
const result = response.content.trim();
if (result === "NO_SIGNATURE") return null;
return result;
}
async function extractSignature(html) {
return extractSignatureRegex(html) ?? (await extractSignatureLlm(html));
}
```
## 4. Save the signature to the target grant
Once you have the HTML, create it on the user's grant via the [Signatures API](/docs/v3/email/signatures/).
```js
async function saveSignature(grantId, signatureHtml, userEmail) {
const signature = await nylas.signatures.create({
identifier: grantId,
requestBody: {
name: "Imported signature",
body: signatureHtml,
},
});
// Optionally notify the user that their signature was imported.
// Store the signature ID so your app can reference it on outbound sends.
await db.userSettings.update(userEmail, {
defaultSignatureId: signature.data.id,
});
}
```
The signature is now available on that grant. Pass `signature_id` when sending messages to append it automatically:
```bash
curl --request POST \
--url "https://api.us.nylas.com/v3/grants//messages/send" \
--header "Authorization: Bearer " \
--header "Content-Type: application/json" \
--data '{
"to": [{ "email": "recipient@example.com" }],
"subject": "Hello",
"body": "Message body here.
",
"signature_id": ""
}'
```
This works on connected grants and Agent Accounts alike. An Agent Account that sends outreach from `sales-agent@yourcompany.com` can use a signature imported this way, so its emails look like they come from a real person.
## 5. Handle edge cases
### Multiple signatures in one email
A forwarded email can contain signatures from multiple people in the reply chain. If you only want the most recent one, extract from the top of the forwarded body (before the first `---------- Forwarded message ----------` or `From:` block).
### Image-heavy signatures
Many corporate signatures use inline images hosted on the company's servers. The `
` URLs in the extracted HTML will keep working as long as those servers are up. If you want the signatures to be self-contained, download the images, host them on your own CDN, and rewrite the URLs before saving.
### Sanitization
The Signatures API sanitizes HTML on input, stripping unsafe tags and attributes. You don't need to sanitize the extracted HTML yourself, but you should still check that the extracted block looks reasonable before saving -- a 50 KB block is probably not just a signature.
```js
if (signatureHtml.length > 20_000) {
// Probably extracted too much. Log and skip.
return;
}
```
## Things to know
- **One import inbox serves all users.** You don't need a separate Agent Account per user. Route by the `from` address on the forwarded email to match the right grant.
- **The user must be known.** Your app needs a mapping from the user's email address to their Nylas `grant_id`. If an unknown address forwards a message, log it and ignore it.
- **Signatures are HTML only.** The Signatures API stores HTML. Plain-text signatures aren't directly supported — if the forwarded email has no HTML body, there's nothing to extract.
- **Each grant supports up to 10 signatures.** Check the count before creating a new one. If the user already has 10, update an existing one instead.
- **Tell the user what to forward.** In your UI, include a one-line instruction: "Forward any email that has the signature you'd like to use to `signatureimport@agents.yourcompany.com`." The simpler the instruction, the higher the conversion.
## Next steps
- [Sign an agent up for a third-party service](/docs/cookbook/agent-accounts/sign-up-for-a-service/) — the same provision-and-webhook pattern, applied to signups
- [Migrate from transactional email](/docs/cookbook/agent-accounts/migrate-from-transactional-email/) — when outbound senders need imported signatures
- [Using email signatures](/docs/v3/email/signatures/) — the full Signatures API documentation
- [Provisioning and domains](/docs/v3/agent-accounts/provisioning/) — set up the domain for your import inbox
- [Using webhooks with Nylas](/docs/v3/notifications/) — signature verification and retry handling
────────────────────────────────────────────────────────────────────────────────
title: "How to migrate from transactional email to bidirectional agent email"
description: "Move from outbound-only transactional email (SendGrid, Resend, Postmark) to a full send-and-receive Agent Account on Nylas, with threading, webhooks, and reply handling built in."
source: "https://developer.nylas.com/docs/cookbook/agent-accounts/migrate-from-transactional-email/"
────────────────────────────────────────────────────────────────────────────────
:::info
**Agent Accounts are in beta.** The APIs this guide uses may change before general availability.
:::
If your agent sends through a transactional provider like SendGrid, Resend, or Postmark, you have outbound covered. What you don't have is a receive path. When someone replies, that reply either bounces, lands at a no-reply nobody reads, or goes to a human inbox the agent can't access programmatically. The agent is sending into a void.
Nylas Agent Accounts give the agent a full mailbox — send *and* receive, with threading, webhooks, and folder management built in. This recipe is the migration path.
## What changes and what doesn't
| Concern | Transactional provider | Agent Account |
| --- | --- | --- |
| **Outbound** | API call to send | Same -- API call to send (`POST /messages/send`) |
| **Inbound** | No receive path (or manual polling of a shared inbox) | Built-in mailbox. Replies land automatically, fire `message.created` webhook |
| **Threading** | You manage `Message-ID` tracking yourself | Nylas preserves headers and groups messages into threads automatically |
| **Reply detection** | Parse forwarded email or poll a separate inbox | Webhook fires within seconds of a reply arriving |
| **Domain / DNS** | SPF, DKIM, DMARC records for the transactional provider | MX, SPF, DKIM, DMARC records for Nylas (or use a `*.nylas.email` trial domain) |
| **Deliverability** | Provider handles warm-up, reputation | Nylas handles outbound pipeline; you manage your domain's reputation |
| **State** | External -- you build everything | Threads API gives you conversation history; state mapping is still yours |
The core change is: instead of the agent talking into a void and hoping someone reads the output, replies flow back through the same channel and the agent can act on them.
## Step 1: Provision the Agent Account
From the [Nylas CLI](https://cli.nylas.com/):
```bash
nylas agent account create outreach@agents.yourcompany.com
```
Or through the API:
```bash
curl --request POST \
--url "https://api.us.nylas.com/v3/connect/custom" \
--header "Authorization: Bearer " \
--header "Content-Type: application/json" \
--data '{
"provider": "nylas",
"settings": {
"email": "outreach@agents.yourcompany.com"
}
}'
```
Save the `grant_id` from the response. This is the Agent Account's identity for every subsequent API call.
If you're using a custom domain, you'll need MX and TXT records pointed at Nylas before the account can receive mail. See [Provisioning and domains](/docs/v3/agent-accounts/provisioning/) for the DNS setup. For prototyping, a `*.nylas.email` trial subdomain works out of the box.
## Step 2: Replace the send call
The API shape is similar to what you already have. Here's the before and after.
**Before (transactional provider):**
```js
// SendGrid / Resend / Postmark -- outbound only
await sendgrid.send({
to: "prospect@example.com",
from: "outreach@yourcompany.com",
subject: "Following up on your demo request",
html: "Hi Alice -- wanted to follow up on...
",
});
// That's it. If Alice replies, the agent never sees it.
```
**After (Nylas Agent Account):**
```js
const sent = await nylas.messages.send({
identifier: AGENT_GRANT_ID,
requestBody: {
to: [{ email: "prospect@example.com", name: "Alice" }],
subject: "Following up on your demo request",
body: "Hi Alice -- wanted to follow up on...
",
},
});
// Store the thread_id so you can match replies later.
await db.conversations.create({
threadId: sent.data.threadId,
contactEmail: "prospect@example.com",
step: "awaiting_reply",
});
```
The key difference: after sending, you store the `thread_id`. When Alice replies, you'll match it back.
## Step 3: Subscribe to inbound
From the CLI:
```bash
nylas webhook create \
--url https://youragent.example.com/webhooks/nylas \
--triggers message.created
```
Or through the API:
```bash
curl --request POST \
--url "https://api.us.nylas.com/v3/webhooks" \
--header "Authorization: Bearer " \
--header "Content-Type: application/json" \
--data '{
"trigger_types": ["message.created"],
"webhook_url": "https://youragent.example.com/webhooks/nylas"
}'
```
This is the receive path you didn't have before. Nylas fires `message.created` within seconds of a reply arriving.
## Step 4: Handle replies
When Alice replies, the webhook fires. Look up the thread and restore context.
```js
app.post("/webhooks/nylas", async (req, res) => {
res.status(200).end();
const event = req.body;
if (event.type !== "message.created") return;
const msg = event.data.object;
if (msg.grant_id !== AGENT_GRANT_ID) return;
// Skip the agent's own outbound messages.
if (msg.from?.[0]?.email === "outreach@agents.yourcompany.com") return;
// Look up the conversation.
const conversation = await db.conversations.findByThreadId(msg.thread_id);
if (!conversation) {
// New inbound -- not a reply to something we sent.
return;
}
// Fetch the full body.
const full = await nylas.messages.find({
identifier: AGENT_GRANT_ID,
messageId: msg.id,
});
// The agent now has:
// - The reply body (full.data.body)
// - The conversation context (conversation.step, conversation.metadata)
// - The full thread via Nylas Threads API if needed
// Hand it to the LLM or workflow engine.
await processReply(full.data, conversation);
});
```
This is the loop that didn't exist with a transactional provider. The agent sends, the recipient replies, and the agent sees the reply -- all on the same address, in the same thread.
## Step 5: Reply in-thread
When the agent responds, pass `reply_to_message_id` so the conversation threads correctly.
```js
await nylas.messages.send({
identifier: AGENT_GRANT_ID,
requestBody: {
replyToMessageId: inboundMessage.id,
to: inboundMessage.from,
subject: `Re: ${inboundMessage.subject}`,
body: agentResponse,
},
});
```
The recipient sees a normal threaded reply. No "sent via" branding, no relay footer.
## DNS considerations
If you're moving from a transactional provider to a Nylas Agent Account on the same domain, you'll need to update your MX records to point at Nylas instead of the transactional provider. This is only relevant if you want inbound mail on the domain to route to Nylas.
A common pattern is to use a subdomain:
- Keep `yourcompany.com` MX records as-is (pointed at Google Workspace, Microsoft 365, or wherever your team's email lives).
- Register `agents.yourcompany.com` with Nylas and point its MX records at Nylas.
- The agent sends from `outreach@agents.yourcompany.com` and receives replies there.
This isolates the agent's domain reputation from your primary domain, which matters at volume.
## Things to know
- **You don't have to replace the transactional provider entirely.** If you're happy with your outbound setup for receipts, password resets, or marketing, keep it. Use an Agent Account specifically for the conversations where the agent needs to see replies. Different addresses, different use cases.
- **Warm up the domain.** A new domain sending hundreds of emails on day one will get flagged. Start with low volume and ramp up over a week or two. See [Domain warm up](/docs/v3/email/domain-warming/).
- **The 100-message-per-day default is real.** Agent Accounts have a soft send cap. If your agent sends at volume, request a higher limit through your plan or provision multiple Agent Accounts across multiple domains.
- **Threading is automatic.** You don't need to generate `Message-ID` values or set `In-Reply-To` headers yourself. Nylas handles all of it. Just pass `reply_to_message_id` when you want to reply in-thread.
- **Don't ship the receive path without dedup.** Webhooks are at-least-once. The same `message.created` notification can arrive twice — see [Prevent duplicate agent replies](/docs/cookbook/agent-accounts/prevent-duplicate-replies/) before going live.
## Next steps
- [Handle email replies in an agent loop](/docs/cookbook/agent-accounts/handle-replies/) — the detailed reply detection and routing recipe
- [Build a multi-turn email conversation](/docs/cookbook/agent-accounts/multi-turn-conversations/) — the full send-receive-respond loop with state management
- [Prevent duplicate agent replies](/docs/cookbook/agent-accounts/prevent-duplicate-replies/) — dedup, locking, and rate limiting for the new receive path
- [Email threading for agents](/docs/v3/agent-accounts/email-threading/) — how threading headers work and how Nylas preserves them
- [Provisioning and domains](/docs/v3/agent-accounts/provisioning/) — DNS setup and multi-domain patterns
────────────────────────────────────────────────────────────────────────────────
title: "How to build a multi-turn email conversation"
description: "Run an agent that carries on email conversations over hours or days — send, receive replies, restore context, generate the next turn, and repeat, all on webhooks and the Threads API."
source: "https://developer.nylas.com/docs/cookbook/agent-accounts/multi-turn-conversations/"
────────────────────────────────────────────────────────────────────────────────
:::info
**Agent Accounts are in beta.** The APIs this guide uses may change before general availability.
:::
A single send-and-forget email is easy. A conversation that spans five exchanges over three days is harder. The agent has to remember what it said, what the other person said, what it's waiting for, and where in the workflow it is — across process restarts, deploys, and hours of silence between replies.
This recipe builds that loop. The agent sends, waits for a reply, restores context, decides what to say, replies, and waits again. It runs entirely on webhooks and the Threads API, so there's no polling and no missed messages.
:::info
**Prerequisites.** An Agent Account with a `message.created` webhook subscribed (see [Give your agent its own email](/docs/v3/getting-started/agent-own-email/)). A durable data store (Postgres, Redis, DynamoDB, or similar) for conversation state — in-memory won't survive restarts, and these conversations span hours. Access to an LLM for generating replies; any OpenAI-compatible API works.
:::
## The conversation state model
Every active conversation needs a record that maps the Nylas `thread_id` to the agent's internal state.
```js
// What the agent stores per conversation
const conversationRecord = {
threadId: "nylas-thread-id",
grantId: AGENT_GRANT_ID,
contactEmail: "prospect@example.com",
contactName: "Alice Chen",
purpose: "demo_followup", // What started this conversation
step: "awaiting_reply", // Where in the workflow we are
turnCount: 1, // How many exchanges have happened
maxTurns: 10, // Safety cap before escalation
createdAt: "2026-04-14T10:00:00Z",
lastActivityAt: "2026-04-14T10:00:00Z",
metadata: {}, // Workflow-specific data
};
```
The `step` field is the heart of it. It tracks what the agent is waiting for and determines how it handles the next inbound message.
## Start the conversation
When the agent initiates contact, it sends the first message and creates the conversation record.
```js
async function startConversation({ to, subject, body, purpose, metadata }) {
const sent = await nylas.messages.send({
identifier: AGENT_GRANT_ID,
requestBody: {
to: [{ email: to.email, name: to.name }],
subject,
body,
},
});
// Persist the conversation state keyed by thread_id.
await db.conversations.create({
threadId: sent.data.threadId,
grantId: AGENT_GRANT_ID,
contactEmail: to.email,
contactName: to.name,
purpose,
step: "awaiting_reply",
turnCount: 1,
maxTurns: 10,
createdAt: new Date().toISOString(),
lastActivityAt: new Date().toISOString(),
metadata: metadata ?? {},
});
return sent.data;
}
```
## Handle inbound replies
When a reply arrives, the webhook handler looks up the conversation, rebuilds history, and passes it to the LLM.
```js
app.post("/webhooks/nylas", async (req, res) => {
res.status(200).end();
const event = req.body;
if (event.type !== "message.created") return;
const msg = event.data.object;
if (msg.grant_id !== AGENT_GRANT_ID) return;
// Skip messages the agent sent (outbound fires message.created too).
const agentEmail = "agent@agents.yourcompany.com";
if (msg.from?.[0]?.email === agentEmail) return;
const conversation = await db.conversations.findByThreadId(msg.thread_id);
if (!conversation) {
// New inbound message, not a reply to something we sent.
await triageNewInbound(msg);
return;
}
await continueConversation(msg, conversation);
});
```
## Restore context and generate a reply
Fetch the full thread from Nylas so the LLM has the complete conversation, not just the latest message.
```js
async function continueConversation(msg, conversation) {
// Fetch full body (webhook only has summary fields).
const fullMessage = await nylas.messages.find({
identifier: AGENT_GRANT_ID,
messageId: msg.id,
});
// Pull the entire thread so the LLM sees the full exchange.
const thread = await nylas.threads.find({
identifier: AGENT_GRANT_ID,
threadId: conversation.threadId,
});
// Fetch every message in the thread for full conversation history.
const allMessages = await Promise.all(
thread.data.messageIds.map((id) =>
nylas.messages.find({ identifier: AGENT_GRANT_ID, messageId: id }),
),
);
// Format as a conversation transcript for the LLM.
const transcript = allMessages
.map((m) => m.data)
.sort((a, b) => a.date - b.date)
.map((m) => ({
role: m.from[0].email === "agent@agents.yourcompany.com" ? "agent" : "contact",
body: m.body,
date: new Date(m.date * 1000).toISOString(),
}));
// Check lifecycle constraints before generating a reply.
if (conversation.turnCount >= conversation.maxTurns) {
await escalate(conversation, "max turns reached");
return;
}
// Generate the reply.
const replyBody = await llm.generateReply({
purpose: conversation.purpose,
step: conversation.step,
transcript,
metadata: conversation.metadata,
});
// Send in-thread.
const sent = await nylas.messages.send({
identifier: AGENT_GRANT_ID,
requestBody: {
replyToMessageId: msg.id,
to: [{ email: conversation.contactEmail, name: conversation.contactName }],
subject: `Re: ${thread.data.subject}`,
body: replyBody.text,
},
});
// Update state.
await db.conversations.update(conversation.threadId, {
step: replyBody.nextStep ?? "awaiting_reply",
turnCount: conversation.turnCount + 1,
lastActivityAt: new Date().toISOString(),
metadata: { ...conversation.metadata, ...replyBody.metadata },
});
}
```
The LLM receives the full transcript and the current workflow step, so it can generate a contextually appropriate reply. It also returns a `nextStep` value that advances the conversation state machine.
## Handle lifecycle events
Not every conversation ends neatly. Build handlers for the edges.
### Escalation
When the agent hits its turn limit, encounters a topic it can't handle, or detects frustration, hand the conversation to a human.
```js
async function escalate(conversation, reason) {
await db.conversations.update(conversation.threadId, {
step: "escalated",
metadata: { ...conversation.metadata, escalationReason: reason },
});
// Notify the human -- Slack, PagerDuty, internal API, whatever fits.
await notifyHumanOperator({
threadId: conversation.threadId,
contact: conversation.contactEmail,
reason,
});
}
```
### Completion
When the agent determines the conversation's purpose is fulfilled (the prospect booked a meeting, the support question was answered), mark it done so future messages on the same thread get handled correctly.
```js
async function completeConversation(conversation) {
await db.conversations.update(conversation.threadId, {
step: "completed",
lastActivityAt: new Date().toISOString(),
});
}
```
### Dormant threads
Someone might reply to a conversation that's been inactive for weeks. Decide up front what happens: re-read the thread and resume, escalate, or send a fresh introduction.
```js
// In the webhook handler, before calling continueConversation:
const hoursSinceLastActivity =
(Date.now() - new Date(conversation.lastActivityAt).getTime()) / 3600000;
if (hoursSinceLastActivity > 168) {
// Over a week of silence -- escalate instead of auto-replying.
await escalate(conversation, "dormant thread reopened after 7+ days");
return;
}
```
## Things to know
- **Filter out the agent's own messages.** `message.created` fires for outbound too. If you don't check `msg.from`, the agent will try to reply to itself.
- **Batch rapid replies.** If someone sends two messages in quick succession, a short delay (30–60 seconds) before responding lets you treat them as one turn instead of generating two separate replies.
- **Cap the conversation length.** An unbounded conversation loop is a token sink and a risk. The `maxTurns` field is there for a reason — set it based on what's realistic for the workflow.
- **Persist conversation state durably.** Redis with AOF, Postgres, DynamoDB — anything that survives restarts. The gap between messages can be days.
- **The LLM doesn't need every message.** For long threads, summarize earlier messages and pass only the last 3–4 in full. This keeps token usage reasonable without losing critical context.
- **Don't ship without dedup and locking.** The race between webhook redelivery and concurrent workers shows up at any volume; treat it as a first-class concern, not an edge case.
## Next steps
- [Handle email replies in an agent loop](/docs/cookbook/agent-accounts/handle-replies/) — the simpler single-reply recipe this builds on
- [Prevent duplicate agent replies](/docs/cookbook/agent-accounts/prevent-duplicate-replies/) — dedup patterns for when multiple webhooks fire close together
- [Migrate from transactional email](/docs/cookbook/agent-accounts/migrate-from-transactional-email/) — context if you're moving from SendGrid/Resend/Postmark to a full mailbox
- [Email threading for agents](/docs/v3/agent-accounts/email-threading/) — how Message-ID, In-Reply-To, and References headers work
- [Policies, Rules, and Lists](/docs/v3/agent-accounts/policies-rules-lists/) — filter inbound to reduce noise before it reaches the agent
────────────────────────────────────────────────────────────────────────────────
title: "How to prevent duplicate and conflicting agent replies"
description: "Stop an agent from sending duplicate or conflicting replies — webhook deduplication, per-thread locking, inbox isolation, and outbound rate limiting under real load."
source: "https://developer.nylas.com/docs/cookbook/agent-accounts/prevent-duplicate-replies/"
────────────────────────────────────────────────────────────────────────────────
:::info
**Agent Accounts are in beta.** The APIs this guide uses may change before general availability.
:::
An agent that replies twice to the same message looks broken. An agent that replies to a message another agent already handled looks worse. Both happen more often than you'd expect under load — webhooks get delivered more than once, concurrent workers race each other, and shared inboxes create ambiguity about who should respond.
This recipe covers the patterns that prevent it: webhook deduplication, per-thread locking, one-agent-per-inbox isolation, and outbound rate limiting. Combine all four if you're operating at any meaningful volume.
## Where duplicates come from
Three common sources:
1. **Webhook redelivery.** Nylas guarantees at-least-once delivery. If your endpoint doesn't return `200` fast enough, or there's a transient network issue, you'll get the same `message.created` notification again. If the agent processes both, it sends two replies.
2. **Concurrent workers.** If your webhook handler runs on multiple instances (Lambda, ECS tasks, worker processes), two instances can pick up the same notification simultaneously and both start generating a reply.
3. **Shared inboxes.** Two different agents -- or an agent and a human -- watching the same mailbox can both decide to respond to the same message. This is harder to solve at the application layer because the conflict isn't a duplicate event, it's a coordination problem.
## Deduplicate webhook deliveries
Track which message IDs you've already processed. Before doing anything, check whether you've seen this one.
```js
app.post("/webhooks/nylas", async (req, res) => {
res.status(200).end();
const event = req.body;
if (event.type !== "message.created") return;
const messageId = event.data.object.id;
// Atomic check-and-set. If the key already exists, bail.
const alreadyProcessed = await db.processedMessages.setIfAbsent(messageId, {
receivedAt: Date.now(),
});
if (alreadyProcessed) return;
// Safe to proceed -- this is the first time we're handling this message.
await handleMessage(event.data.object);
});
```
The `setIfAbsent` operation must be atomic. In Postgres, that's an `INSERT ... ON CONFLICT DO NOTHING` with a check on the returned row count. In Redis, it's `SET messageId 1 NX EX 86400`. The TTL should be long enough that a redelivered webhook hours later still gets caught -- 24 hours is a safe default.
## Lock before replying
Even with webhook dedup, two concurrent workers can race past the check-and-set within the same millisecond window. A per-thread lock prevents both from generating a reply.
```js
async function handleMessage(msg) {
// Acquire a lock on this thread. If another worker holds it, wait or skip.
const lock = await db.acquireLock(`thread:${msg.thread_id}`, {
ttlMs: 30_000, // Release after 30 seconds if the worker crashes.
});
if (!lock.acquired) {
// Another worker is already handling this thread.
return;
}
try {
// Double-check: has a reply already been sent since this message arrived?
const thread = await nylas.threads.find({
identifier: AGENT_GRANT_ID,
threadId: msg.thread_id,
});
const latestMessage = thread.data.latestDraftOrMessage;
if (latestMessage && latestMessage.from[0]?.email === AGENT_EMAIL) {
// The agent already replied (from a prior worker or retry). Skip.
return;
}
await generateAndSendReply(msg);
} finally {
await lock.release();
}
}
```
The double-check inside the lock is important. Between the webhook arriving and the lock being acquired, another worker might have already finished. Checking the thread's latest message catches this.
## One agent per inbox
The cleanest way to prevent conflicting replies is to eliminate the shared inbox. Agent Accounts make this trivial -- each agent gets its own `agent@yourdomain.com` address, its own inbox, and its own webhook stream. There's no coordination problem because there's no overlap.
If you're running multiple agents, give each one its own Agent Account:
- `sales-agent@agents.yourcompany.com` handles outbound prospecting.
- `support-agent@agents.yourcompany.com` handles inbound support.
- `scheduling@agents.yourcompany.com` handles meeting coordination.
Each agent's webhook handler only processes messages for its own `grant_id`. No two agents are ever looking at the same message.
```js
// Each agent process only handles its own grant.
if (msg.grant_id !== MY_GRANT_ID) return;
```
When you do need shared access -- a human reviewing what the agent sent, an ops team monitoring the inbox -- use [IMAP access](/docs/v3/agent-accounts/mail-clients/) for read-only oversight rather than having multiple automated writers on the same mailbox.
## Rate-limit outbound replies
Even with dedup and locking, a bug in your agent logic can produce a reply storm -- the agent responds, the response triggers another webhook (outbound fires `message.created` too), and the cycle repeats.
Guard against this with a per-thread send rate limit:
```js
async function sendReply(threadId, messageId, body) {
// Check how many messages the agent has sent on this thread recently.
const recentSends = await db.recentAgentSends(threadId, { withinMinutes: 5 });
if (recentSends >= 3) {
// Something is wrong -- escalate instead of sending.
await escalateToHuman(threadId, "reply rate limit hit");
return;
}
await nylas.messages.send({
identifier: AGENT_GRANT_ID,
requestBody: {
replyToMessageId: messageId,
to: [{ email: recipientEmail }],
body,
},
});
await db.recordAgentSend(threadId);
}
```
And always filter out the agent's own messages at the top of your webhook handler:
```js
// First check in every handler -- skip messages from the agent itself.
const sender = msg.from?.[0]?.email;
if (sender === AGENT_EMAIL) return;
```
## Use rules to route inbound
For Agent Accounts, [rules](/docs/v3/agent-accounts/policies-rules-lists/) can pre-sort inbound messages before the webhook fires, reducing the chance of conflicting logic. Route messages from known domains to specific folders, block spam at the SMTP layer, and auto-archive notifications that don't need a reply.
```bash
# Create a rule that routes all messages from a known domain to a specific folder.
curl --request POST \
--url "https://api.us.nylas.com/v3/rules" \
--header "Authorization: Bearer " \
--header "Content-Type: application/json" \
--data '{
"match": [{ "field": "from.domain", "operator": "equals", "value": "noreply.example.com" }],
"actions": [{ "action": "assign_to_folder", "value": "notifications" }],
"description": "Route automated notifications to a separate folder"
}'
```
Your webhook handler can then check which folder a message landed in and skip folders the agent shouldn't reply to.
## Things to know
- **Dedup and locking are both necessary.** Dedup catches redelivered webhooks (same event, delivered twice). Locking catches concurrent workers (same event, processed simultaneously). You need both.
- **Set TTLs on your dedup records.** A message ID you processed yesterday doesn't need to stay in the dedup table forever. 24–48 hours is enough. After that, a webhook for the same message ID is almost certainly a bug, not a redelivery.
- **Log, don't swallow.** When you skip a message because it's a duplicate or another worker holds the lock, log that it happened. Silent skips make debugging harder.
- **Test the race condition.** Synthetic load testing with concurrent webhook deliveries is the only reliable way to verify your dedup and locking work. A single-threaded test won't surface the problem.
- **Don't ship without an outbound rate limit.** Even with dedup and locking, a logic bug can cascade into a reply storm. The per-thread send cap above is a safety net you'll be glad to have.
## Next steps
- [Handle email replies in an agent loop](/docs/cookbook/agent-accounts/handle-replies/) — the core reply detection recipe
- [Build a multi-turn email conversation](/docs/cookbook/agent-accounts/multi-turn-conversations/) — the full conversation state machine
- [Migrate from transactional email](/docs/cookbook/agent-accounts/migrate-from-transactional-email/) — adopt these patterns when moving from SendGrid/Resend/Postmark
- [Policies, Rules, and Lists](/docs/v3/agent-accounts/policies-rules-lists/) — pre-filter inbound at the server level
- [Using webhooks with Nylas](/docs/v3/notifications/) — delivery guarantees, retries, and signature verification
────────────────────────────────────────────────────────────────────────────────
title: "How to sign an agent up for a third-party service"
description: "Provision a Nylas Agent Account, point a third-party signup flow at it, and let the agent pick up the verification email and complete onboarding — no human in the loop."
source: "https://developer.nylas.com/docs/cookbook/agent-accounts/sign-up-for-a-service/"
────────────────────────────────────────────────────────────────────────────────
:::info
**Agent Accounts are in beta.** The APIs this guide uses may change before general availability.
:::
AI agents often need to sign up for the services they work with — a research agent needs a developer account on a data source, a QA agent registers for a SaaS every test run, a purchasing agent needs a buyer profile on a marketplace. The hard part is receiving the verification email without routing through a human inbox.
Nylas Agent Accounts solve this directly. The agent gets its own mailbox, signs up with that address, catches the verification email via webhook, and finishes onboarding on its own. This recipe is the end-to-end flow.
:::info
**Prerequisites.** You need a Nylas API key, a domain registered with Nylas (trial `*.nylas.email` subdomains work for prototyping — see [Provisioning and domains](/docs/v3/agent-accounts/provisioning/)), and a publicly reachable HTTPS endpoint for the `message.created` webhook. For local development, [VS Code port forwarding](https://code.visualstudio.com/docs/editor/port-forwarding) or [Hookdeck](https://hookdeck.com/) work well. The CLI commands below assume you've installed and authenticated the [Nylas CLI](https://cli.nylas.com/).
:::
## 1. Provision the Agent Account
The quickest path is the [Nylas CLI](https://cli.nylas.com/):
```bash
nylas agent account create signup-agent@agents.yourdomain.com
```
The CLI prints the new grant ID — save it as `AGENT_GRANT_ID`. If you prefer the API, use [`POST /v3/connect/custom`](/docs/reference/api/manage-grants/byo_auth/):
```bash
curl --request POST \
--url "https://api.us.nylas.com/v3/connect/custom" \
--header "Authorization: Bearer " \
--header "Content-Type: application/json" \
--data '{
"provider": "nylas",
"settings": {
"email": "signup-agent@agents.yourdomain.com"
}
}'
```
One Agent Account can be reused across many signup runs, or you can provision a fresh one per run and tear it down afterwards.
## 2. Subscribe to inbound mail
From the CLI:
```bash
nylas webhook create \
--url https://youragent.example.com/webhooks/signup \
--triggers message.created
```
Or through the API:
```bash
curl --request POST \
--url "https://api.us.nylas.com/v3/webhooks" \
--header "Authorization: Bearer " \
--header "Content-Type: application/json" \
--data '{
"trigger_types": ["message.created"],
"webhook_url": "https://youragent.example.com/webhooks/signup",
"description": "Agent signup verification"
}'
```
Nylas fires `message.created` within a second or two of mail arriving. The payload carries the Message object's summary fields; fetch the full body from the API when you need it.
## 3. Submit the signup form
Use the Agent Account address when you fill in the form — whatever the target service sends a verification email to.
```js
// Whatever flow makes sense for the target service:
// - a direct API call, if they expose one
// - a headless browser step (Playwright / Puppeteer)
// - a simple fetch POST, as below
await fetch("https://saas-you-care-about.example.com/signup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: "signup-agent@agents.yourdomain.com",
name: "Automation Agent",
}),
});
```
## 4. Handle the verification email
When the target service's verification email lands in the Agent Account, your webhook handler gets the notification. Fetch the body, find the confirmation link, and follow it.
```js
// Node.js / Express handler sketch
app.post("/webhooks/signup", async (req, res) => {
// (Verify X-Nylas-Signature here — see /docs/v3/notifications/.)
res.status(200).end();
const event = req.body;
if (event.type !== "message.created") return;
const { grant_id, id: messageId, from } = event.data.object;
if (grant_id !== AGENT_GRANT_ID) return;
// Only react to mail from the service you signed up with.
const sender = from[0]?.email ?? "";
if (!sender.endsWith("@saas-you-care-about.example.com")) return;
// Pull the full body.
const resp = await fetch(
`https://api.us.nylas.com/v3/grants/${AGENT_GRANT_ID}/messages/${messageId}`,
{ headers: { Authorization: `Bearer ${NYLAS_API_KEY}` } },
);
const message = (await resp.json()).data;
// Find the confirmation link. Two common patterns:
// 1. A specific-looking URL in the HTML body
// 2. A button/link labeled "Confirm", "Verify", etc.
const match = /https:\/\/saas-you-care-about\.example\.com\/confirm\?token=[^"\s<]+/.exec(
message.body,
);
if (!match) return;
// Visit the confirmation URL to complete signup.
await fetch(match[0]);
});
```
For more complex flows (multi-step confirmation, captchas, OAuth redirects), drop in a headless browser on this step and have it follow the link instead of a raw `fetch`.
If the service sends an OTP or 2FA code instead of a link, the parsing shape is the same — see [Extract an OTP or 2FA code from an agent's inbox](/docs/cookbook/agent-accounts/extract-otp-code/).
## 5. Tear down (optional)
For per-run agents, delete the grant when you're done so you're not accumulating inactive accounts. From the CLI:
```bash
nylas agent account delete signup-agent@agents.yourdomain.com --yes
```
Or through the API:
```bash
curl --request DELETE \
--url "https://api.us.nylas.com/v3/grants/" \
--header "Authorization: Bearer "
```
## Things to know
- **Attach a strict policy.** Limit inbound acceptance to the domains you actually expect mail from by pairing a [list](/docs/v3/agent-accounts/policies-rules-lists/) of allowed `from.domain` values with a `block` rule for everything else. That keeps the inbox clean even if the test address leaks.
- **Don't trust the first message that arrives.** Some services send a "Welcome" email before the verification email. Match the sender *and* the expected URL pattern before clicking anything.
- **Respect the service's ToS.** Programmatic signup is fine for your own testing and for first-party integrations; scraping third parties is a different conversation. Nylas doesn't enforce anything here, but your legal team will.
- **Rate limits matter more than you think.** The default Agent Account send quota is 100 messages per day; inbound is generous but not infinite. If you're running a large test matrix, provision multiple grants rather than reusing one.
- **Don't ship per-run agents without tear-down.** Inactive grants accumulate. If you provision a fresh Agent Account per signup, delete it on completion or fail.
## Next steps
- [Extract an OTP or 2FA code from an agent's inbox](/docs/cookbook/agent-accounts/extract-otp-code/) — the paired recipe for code-based verification
- [Handle email replies in an agent loop](/docs/cookbook/agent-accounts/handle-replies/) — for services that send follow-up confirmations
- [Agent Accounts overview](/docs/v3/agent-accounts/) — the full product doc
- [Policies, Rules, and Lists](/docs/v3/agent-accounts/policies-rules-lists/) — constrain what the inbox accepts
- [Using webhooks with Nylas](/docs/v3/notifications/) — signature verification and retry handling
────────────────────────────────────────────────────────────────────────────────
title: "Map communication patterns between organizations"
description: "Score every external contact 0–100 using frequency, recency, reciprocity, and shared meetings. Roll up to org-level scores, surface single-threaded accounts at risk, and find warm-intro paths across your team's mailboxes."
source: "https://developer.nylas.com/docs/cookbook/agents/communication-patterns/"
────────────────────────────────────────────────────────────────────────────────
Most CRMs track what someone typed into a field. The actual relationships — who emails whom, how often, who shows up to meetings together — sit in your team's mailboxes and calendars, untracked. This recipe scores each external contact across four signals (frequency, recency, reciprocity, meetings), rolls those scores up to organizations, and surfaces the two questions that matter most: which accounts are single-threaded (and therefore at churn risk), and who on your team is the warmest path to a given prospect.
## The data extraction
Run a per-team-member pull from the Nylas CLI. The script below loops over connected grants, exports email and calendar to JSON, and dedupes:
```bash
#!/usr/bin/env bash
set -euo pipefail
OUT=./network-data
mkdir -p "$OUT"
for grant in $(nylas auth list --json | jq -r '.[].email'); do
nylas auth switch "$grant"
nylas email list --days 90 --limit 5000 --json > "$OUT/email-$grant.json"
nylas calendar events list --days 90 --json > "$OUT/cal-$grant.json"
done
# Dedupe and combine
jq -s 'add | unique_by(.id)' "$OUT"/email-*.json > "$OUT/email-all.json"
jq -s 'add | unique_by(.id)' "$OUT"/cal-*.json > "$OUT/cal-all.json"
```
Adjust `--days 90` to your analysis window. Ninety days is the right default — long enough to smooth out vacation gaps, short enough that defunct relationships don't pollute the score.
## The scoring formula
Four weighted signals produce a 0–100 score per external contact:
| Signal | Weight | Definition |
| --- | --- | --- |
| Frequency | 40% | Messages per month, normalized |
| Recency | 25% | Time since last interaction (full credit ≤7d, linear decay to 0 at 90d) |
| Reciprocity | 20% | `min(sent, received) / max(sent, received)` — balanced exchange scores higher |
| Meetings | 15% | Shared calendar events; weighted heavier per event because a meeting is a deliberate time investment |
```python
def score(contact, msgs, events, now=datetime.utcnow()):
days_since_last = (now - contact["last_msg_at"]).days
sent = len([m for m in msgs if m["from"] == ME and contact["email"] in m["to"]])
recv = len([m for m in msgs if m["from"] == contact["email"]])
freq = min(40, ((sent + recv) / 3) * 4) # 30 msgs/3mo → 40 pts
rec = max(0, 25 * (1 - days_since_last / 90))
recip = 20 * (min(sent, recv) / max(sent, recv, 1))
meets = min(15, len(events) * 5) # 3+ meetings → 15 pts
return round(freq + rec + recip + meets)
```
That's the whole formula. Tweak the weights to match your team's reality (frontline sales might want frequency at 50%; account managers might want meetings at 25%).
## Roll up to organizations
Group contacts by email domain:
```python
from collections import defaultdict
orgs = defaultdict(list)
for c in scored_contacts:
domain = c["email"].split("@")[1]
orgs[domain].append(c)
org_table = []
for domain, contacts in orgs.items():
org_table.append({
"company": domain,
"avg_score": sum(c["score"] for c in contacts) / len(contacts),
"max_score": max(c["score"] for c in contacts),
"contacts": len(contacts),
"team_owner": max(contacts, key=lambda c: c["score"])["team_owner"],
})
```
`avg_score` tells you overall relationship health with the company; `max_score` tells you who your best advocate there is.
## The two questions worth answering
### 1. Which accounts are single-threaded?
A single-threaded account is one where exactly one person on your team has any meaningful relationship. Per published account-management research, single-threaded accounts churn at roughly 64% higher rates than accounts with two or more relationships. They're the highest-priority retention work.
```python
def single_threaded(orgs, threshold=40):
risky = []
for org in orgs:
strong_relationships = [c for c in org["contacts"] if c["score"] >= threshold]
if len(set(c["team_owner"] for c in strong_relationships)) == 1:
risky.append(org)
return risky
```
These show up in a weekly report. The action: introduce a second teammate to the existing strong contact, or to anyone scoring above 30 at the org.
### 2. Who's the warmest intro path?
For any target company you're trying to break into, find the highest-scoring contact your team has there:
```python
def warm_intro_path(target_domain: str):
contacts = orgs[target_domain]
if not contacts:
return None
best = max(contacts, key=lambda c: c["score"])
if best["score"] >= 50:
return ("strong intro", best)
elif best["score"] >= 20:
return ("warm but tepid — warm them up first", best)
return ("cold", best)
```
Score thresholds are heuristic — calibrate against your own team's hit rates.
## Detecting decline
A contact with high *historical* email volume but low recent score is showing relationship decay:
```python
def declining(scored_contacts):
return [c for c in scored_contacts
if c["historical_messages"] > 50 and c["recent_score"] < 30]
```
These are early warning signs of churn. Surface them to the account owner with a "haven't heard from X in N days" note.
## Output
Three formats cover most downstream needs:
- **CSV** — drop into a spreadsheet or upload to a CRM custom field
- **JSON** — for any further programmatic processing
- **DOT (Graphviz)** — for actual visualization
```python
import csv, json
with open("relationships.csv", "w") as f:
w = csv.DictWriter(f, fieldnames=["email", "score", "company", "team_owner"])
w.writeheader()
w.writerows(scored_contacts)
with open("relationships.json", "w") as f:
json.dump(scored_contacts, f, indent=2)
```
For a graph view:
```python
print("digraph G {")
for c in scored_contacts:
print(f' "me" -> "{c["email"]}" [weight={c["score"]}];')
print("}")
```
`dot -Tpng relationships.dot -o relationships.png` and you have a relationship map you can show the team.
## Things to know
- **Privacy.** This script reads team mailboxes. Get explicit consent and document the analysis in your data-handling policy. The output should be access-controlled — relationship data is sensitive.
- **Calendar weighting.** A 30-minute 1:1 and a 60-person all-hands shouldn't count the same. Filter calendar events by attendee count (≤10 typically) before scoring.
- **Auto-replies.** Out-of-office responders inflate `recv` counts. Filter them out before scoring.
## Next steps
- [Parse email signatures for contact enrichment](/docs/cookbook/agents/signature-enrichment/) — fills in the missing CRM fields
- [Email triage agent](/docs/cookbook/agents/email-triage-agent/)
- [Sync email to a CRM](/docs/cookbook/use-cases/sync/sync-email-crm/)
────────────────────────────────────────────────────────────────────────────────
title: "Build an email support agent"
description: "Poll a support inbox, match incoming questions against a knowledge base, draft tier-appropriate replies, and route to a human reviewer before anything goes out. Risk tiering, confidence gates, the whole pattern."
source: "https://developer.nylas.com/docs/cookbook/agents/email-support-agent/"
────────────────────────────────────────────────────────────────────────────────
A support agent sounds like a triage agent with extra steps — but it isn't. Triage decides what to *do*; support decides what to *say*. Saying the wrong thing in a customer-facing reply is the kind of mistake that ends up on a slide deck. The pattern in this recipe defends against that with two gates: a confidence threshold on knowledge-base matches, and a risk tier that escalates anything legally or commercially sensitive away from the agent entirely.
## The five-step loop
1. Poll the support inbox for unread messages.
2. Read each one, extract the question.
3. Match against the knowledge base; get back a confidence score.
4. Risk-tier the ticket and route accordingly.
5. Draft a reply if the tier allows it; queue everything for human review.
The agent never hits send.
## Confidence gating
Confidence comes from your KB lookup (typically a vector search over articles + a re-ranker). The agent uses the score to decide what to do:
| Confidence | Action |
| --- | --- |
| `>= 0.85` | Draft directly from the matched article |
| `0.60 – 0.85` | Draft conservatively, *cite the article inline* so the reviewer can verify |
| `< 0.60` | Don't draft. Flag for manual review with a "best guess" KB article attached |
The two-tier draft (confident vs. citation-required) is what keeps the reviewer's job manageable — they trust the high-confidence drafts, scrutinize the medium-confidence ones, and write the low-confidence ones from scratch.
## Risk tiering
Independent from confidence:
- **Low** — password resets, FAQ-shaped questions. Draft → human approves.
- **Medium** — refund requests, account changes, anything affecting billing. Draft → human approves with extra scrutiny.
- **High** — legal threats, regulatory matters, fraud reports. Skip drafting. Escalate immediately to a real person with full context attached.
Risk doesn't care about confidence: a high-confidence KB match for a refund question still goes through human review. Compounding mistakes is what produced the [Air Canada chatbot refund ruling](https://www.cbc.ca/news/canada/british-columbia/air-canada-chatbot-lawsuit-1.7116416) — never let an agent commit your company to anything without a human in the loop.
## Skill configuration (Manus pattern)
If you're running this on Manus, the agent is configured through a `SKILL.md` rather than code:
```markdown
# Support agent
## Reply style
- Replies are under 120 words.
- Cite KB articles inline: [KB-1234](https://kb.example.com/1234).
- Match the tone of the inbound message.
## Drafting rules
- Always show the draft before sending. Never auto-send.
- If confidence < 0.6, do not draft — flag for human.
- Refunds, account changes, legal threats: never draft. Escalate.
## Polling
- Check the support inbox every 10 minutes.
- Process at most 5 tickets per cycle while the agent is in shakedown.
```
The "always show the draft before sending" rule is the load-bearing constraint. Don't remove it.
## Drafting code (subprocess version)
If you're rolling this yourself instead of using Manus, the loop looks like:
```python
def handle(msg):
question = extract_question(msg)
article, conf = kb.search(question)
if classify_risk(msg) == "high":
escalate_to_human(msg, reason="high-risk topic")
return
if conf < 0.60:
flag_for_review(msg, article)
return
draft = generate_draft(msg, article, cite_inline=(conf < 0.85))
queue_for_approval(msg, draft, article)
```
`queue_for_approval` is the choke point. In production it usually drops the draft into a Slack channel or an internal review tool, not directly into the support inbox.
## Scaling tips
- **Start with `--limit 5`.** Process five tickets per cycle while you're tuning the KB matcher and the risk classifier. Bump to 20 once the false-positive rate is acceptable.
- **Group similar tickets.** If the agent sees three "where's my receipt?" tickets in a row, batch them — same KB article, same draft template, one reviewer pass.
- **Mind KB drift.** Tickets the agent can't confidently match are the strongest signal of where to write new KB articles. Track them.
## Things to know
- **Polling, not webhooks.** Support inboxes typically have multiple recipients; webhook fan-out gets complicated. Polling every 5–15 minutes is simpler and the latency is acceptable for support contexts.
- **Human-in-the-loop is non-negotiable.** Even at 99% accuracy, the 1% that makes legal commitments destroys trust faster than the 99% builds it.
- **Audit everything.** For support, you'll want a complete record of which articles were matched and which drafts were sent — log every classification, lookup, and approval decision to your own store.
## Next steps
- [Email triage agent](/docs/cookbook/agents/email-triage-agent/)
- [Reach inbox zero](/docs/cookbook/agents/inbox-zero/)
────────────────────────────────────────────────────────────────────────────────
title: "Build an AI email triage agent"
description: "Run a 15-minute cron that classifies unread mail into URGENT / ACTION / FYI / NOISE, drafts replies for the top two, and archives the noise. Cheap, deterministic, and works with any LLM."
source: "https://developer.nylas.com/docs/cookbook/agents/email-triage-agent/"
────────────────────────────────────────────────────────────────────────────────
A triage agent does what you'd ask an over-caffeinated EA to do: open each unread message, decide whether it needs you in the next hour, the next day, the next week, or never, draft replies for the urgent ones, and bulk-archive the rest. The pattern below runs as a cron job every fifteen minutes and costs roughly $0.002 per 100 emails using GPT-4o-mini for classification.
Most of the work happens in two prompts and three CLI calls.
## The four buckets
| Bucket | Meaning | Action |
| --- | --- | --- |
| `URGENT` | Production incident, executive ask | Draft a reply within the hour |
| `ACTION` | Code review, meeting follow-up | Draft a reply same-day |
| `FYI` | Status update, FYI thread | Leave it alone |
| `NOISE` | Newsletter, marketing, automated alert | Archive |
Four is the right number. Three loses fidelity (everything ends up in "important"). Five and the model starts confusing categories.
## The classification prompt
Run it with `temperature=0` and `max_tokens=10` — you want deterministic output and a single token of output, not a paragraph. The model gets sender + subject + 200-char snippet, not the full body. That's plenty for >90% accuracy and it keeps the per-email cost trivial.
```text
You triage email into one of four categories:
URGENT — production incidents, executive requests; reply within 1 hour
ACTION — code reviews, meeting follow-ups; reply same day
FYI — informational, no response needed
NOISE — newsletters, marketing, automated notifications
From: {sender}
Subject: {subject}
Snippet: {snippet}
Return ONLY the category name. Nothing else.
```
Always validate the output against the four valid strings — LLMs occasionally invent a category. Fall back to `FYI` on anything unrecognized.
## The loop
```python
import json, subprocess
from openai import OpenAI
VALID = {"URGENT", "ACTION", "FYI", "NOISE"}
client = OpenAI()
def fetch_unread(limit=50):
out = subprocess.run(
["nylas", "email", "list", "--unread", "--limit", str(limit), "--json"],
capture_output=True, text=True, check=True,
)
return json.loads(out.stdout)
def classify(msg):
resp = client.chat.completions.create(
model="gpt-4o-mini",
temperature=0,
max_tokens=10,
messages=[{"role": "user", "content": CLASSIFY_PROMPT.format(
sender=msg["from"][0]["email"],
subject=msg["subject"],
snippet=msg["snippet"][:200],
)}],
)
cat = resp.choices[0].message.content.strip()
return cat if cat in VALID else "FYI"
def draft_reply(msg):
resp = client.chat.completions.create(
model="gpt-4o",
temperature=0.7,
messages=[{"role": "user", "content": DRAFT_PROMPT.format(
sender=msg["from"][0]["email"],
subject=msg["subject"],
body=msg["body"],
)}],
)
body = resp.choices[0].message.content
subprocess.run(
["nylas", "email", "draft",
"--to", msg["from"][0]["email"],
"--subject", "Re: " + msg["subject"],
"--body", body, "--json"],
check=True,
)
def archive(msg):
subprocess.run(["nylas", "email", "archive", msg["id"]], check=True)
for msg in fetch_unread():
cat = classify(msg)
if cat in ("URGENT", "ACTION"):
draft_reply(msg)
elif cat == "NOISE":
archive(msg)
# FYI: do nothing
```
Drafts land in your drafts folder — the agent never sends. You review, edit, and hit send (or not).
## The drafting prompt
```text
Write a short, professional reply to this email. Three sentences max. Be direct.
From: {sender}
Subject: {subject}
Body: {body}
Reply:
```
Higher temperature here (0.7) lets the model produce natural prose. The "three sentences max" is load-bearing — without it, you'll get drafts that read like a politely overcompensating intern.
## Cron it
```cron
*/15 * * * * /usr/bin/python3 /opt/triage/triage.py >> /var/log/triage.log 2>&1
```
The whole thing is idempotent — only `--unread` messages are pulled, and once you read a draft (or the original) it falls out of subsequent runs.
## Cost math
GPT-4o-mini classification: ~$0.15 per 1M input tokens. A 200-char snippet plus the prompt is ~150 tokens. 100 emails ≈ 15K tokens ≈ $0.002. Drafting uses GPT-4o (~$2.50 / 1M input) but only on the URGENT + ACTION subset — typically under 20% of the inbox. A heavy day at 200 unread emails costs roughly a nickel.
## Privacy mode
For mail you don't want hitting OpenAI / Anthropic, swap to a local Ollama:
```python
client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")
# model="llama3.1" or similar
```
Llama 3.1 classifies almost as well as GPT-4o-mini for this task. Drafting quality drops noticeably with smaller models — keep that on a hosted LLM unless you're running a 70B+ parameter local model.
## Things to know
- **Only `--unread`.** The agent never re-classifies what it already drafted. If you re-read a draft, the original message stays read; the next cron skips it.
- **No auto-send.** Always land in drafts. The cost of a wrong send (to the wrong person, at the wrong tone) is way higher than the friction of one extra click.
- **Tune per inbox.** Eng inboxes hit URGENT differently than sales inboxes. Customize the four categories and the prompt to your context.
## Next steps
- [Email support agent](/docs/cookbook/agents/email-support-agent/) — adds knowledge-base lookup
- [Reach inbox zero](/docs/cookbook/agents/inbox-zero/) — interactive variant
- [Build an LLM agent with email & calendar tools](/docs/cookbook/cli/llm-agent-with-tools/)
────────────────────────────────────────────────────────────────────────────────
title: "Reach inbox zero with an AI agent"
description: "An interactive triage flow — pull 50 unread, sort into four buckets, draft replies for the action items, archive the noise. You approve everything; the agent never sends. 5-minute loop, daily."
source: "https://developer.nylas.com/docs/cookbook/agents/inbox-zero/"
────────────────────────────────────────────────────────────────────────────────
The hard part of inbox zero isn't the cleaning — it's the *daily* cleaning. Most people stick with it for a week and then quit when the queue fills back up. An interactive agent flips the calculus: the agent does the sorting and drafting, you spend five minutes approving. The 50-message backlog from yesterday is empty before your second coffee.
This recipe is the interactive cousin of the [triage agent](/docs/cookbook/agents/email-triage-agent/) — same four-bucket model, but with a human in the loop.
## The four-step flow
**1. Pull a manageable batch.**
```bash
nylas email list --unread --limit 50 --json
```
50 is the sweet spot. Smaller and you'll feel like the agent isn't doing much. Larger and you'll exceed the LLM context budget and the approval review will get tedious.
**2. Categorize into four buckets.**
| Bucket | Reply window |
| --- | --- |
| **Urgent** | Within hours — client issue, manager request |
| **Action required** | Today — meeting follow-up, review |
| **FYI** | No response — newsletter, status, shared doc |
| **Archive** | Now — marketing, automated alerts |
The agent shows you a summary table:
```
Urgent (3) Action (8) FYI (24) Archive (15)
──────────── ────────── ──────── ─────────────
Ada / Q2 plan Rin / Review Eng team / … Newsletter / …
… … … …
```
Audit the categories before going further. If "FYI" should have been "Action", recategorize at this step — the agent will draft what you correct it to.
**3. Draft replies for Urgent and Action.**
The agent generates a draft per item. You see each one:
```
TO: ada@acme.test
RE: Q2 plan
DRAFT:
Hey Ada — let me block 30 minutes tomorrow morning to walk through this.
I can offer 9am or 11am PT — what works?
```
You can edit, approve, or skip. Skipped drafts stay in the queue and you can revisit them at the end.
**4. Execute approved actions.**
After your approvals:
- Approved drafts get sent (`nylas email send --yes`).
- Items marked Archive get archived (`nylas email archive `).
- FYI items are left untouched.
## What the agent never does
**Never send without explicit approval.** This is the rule. The agent drafts; the human ships. Even if a draft is obviously fine, the click matters — it's the difference between "AI wrote this" and "I wrote this with help".
## Customize the rules over time
The first run will misclassify some messages. Encode the corrections as ongoing rules:
```yaml
# Inbox-zero rules
always_fyi:
- "from: sales@*"
- "from: noreply@*"
- "subject: ^\\[GitHub\\]"
always_urgent:
- "from: *@board.example.com"
- "subject: \\b(p0|incident|outage)\\b"
```
Drop these in your skill or your agent prompt. Each pass gets faster as the agent learns your context.
## Daily habit
The compounding interest of inbox zero comes from doing it every day. Five minutes a day is sustainable. A two-hour purge once a month is not.
A pattern that works:
- 8:30 AM — `agent run inbox-zero` while the coffee brews
- 8:35 AM — done, all action items in your drafts folder, noise archived
- The rest of the day — the inbox only has new mail, and you can decide in real-time whether it's urgent
## Run it from the CLI
If you're not using Manus, the same flow works as a Python script driving the CLI directly. The shape:
```python
unread = fetch_unread(limit=50)
buckets = classify_all(unread) # 4-bucket categorization
print_summary_table(buckets)
for msg in input_corrections(buckets): # interactive correction
pass
drafts = [draft_reply(m) for m in buckets["URGENT"] + buckets["ACTION"]]
for draft in interactive_approval(drafts): # one-by-one Y/N/edit
if draft.approved:
send(draft)
for msg in buckets["ARCHIVE"]:
archive(msg)
```
The interactive bit is what differentiates this from the cron-driven [triage agent](/docs/cookbook/agents/email-triage-agent/). Both share the four-bucket model.
## Things to know
- **20–50 per session is the sweet spot.** Below 20 you waste setup overhead. Above 50 you exhaust both your patience and the LLM context.
- **Multiple passes for backlogs.** If you're starting from 800 unread, run the loop a few times rather than asking the agent to handle the lot at once.
- **Custom buckets later.** Some teams add a fifth "delegate" bucket that auto-cc's a teammate. Doable; do it after the four-bucket version is bedded in.
## Next steps
- [Email triage agent](/docs/cookbook/agents/email-triage-agent/) — the cron-driven version
- [Email support agent](/docs/cookbook/agents/email-support-agent/)
- [Build a Manus skill for Nylas](/docs/cookbook/cli/manus-skill/)
────────────────────────────────────────────────────────────────────────────────
title: "Parse email signatures for contact enrichment"
description: "Pull titles, phone numbers, LinkedIn URLs, and company affiliation out of inbound mail signatures using regex (faster, cheaper, deterministic). Cross-reference 3+ messages to lift accuracy from 67% to 91%."
source: "https://developer.nylas.com/docs/cookbook/agents/signature-enrichment/"
────────────────────────────────────────────────────────────────────────────────
Email signatures are structured data masquerading as prose. Roughly 82% of business email contains a signature with at least name and title. Most platforms ignore it; with a few hundred lines of regex you can extract titles, phone numbers, LinkedIn URLs, websites, and company affiliations and ship them straight into your CRM.
This recipe argues for regex over LLM (deterministic + free) and shows the cross-referencing trick that lifts accuracy from "decent" to "production-usable".
## Why regex, not an LLM
For unstructured prose, an LLM wins. Signatures aren't unstructured — they're *predictably* structured. They're 3–6 lines, separated from the body by `--` (per RFC 3676), with field types in a small set: name, title, company, phone, email, URL, social handle. A regex catches >95% of well-formed signatures, runs in microseconds, and costs nothing per message.
The case for an LLM fallback exists, but only as the last 5%. Skip it for the first version.
## Detect the signature boundary
```python
import re
SIG_DELIMITERS = [
r"\n--\s*\n", # RFC 3676 standard
r"\nSent from my (iPhone|iPad|Android)",
r"\nGet Outlook for iOS",
r"\nThanks?,?\s*\n",
r"\nBest,?\s*\n",
r"\nRegards,?\s*\n",
r"\nCheers,?\s*\n",
]
def split_signature(body: str) -> tuple[str, str]:
for pat in SIG_DELIMITERS:
m = re.search(pat, body)
if m:
return body[:m.start()], body[m.end():]
return body, ""
```
You'll miss inline signatures with no delimiter — but they're a small minority and the cross-referencing step (below) backfills the gaps.
## Extract the fields
```python
def extract(sig: str) -> dict:
return {
"phone": re.search(r"(?:\+?1[-.\s]?)?\(?[\d]{3}\)?[-.\s]?[\d]{3}[-.\s]?[\d]{4}", sig),
"linkedin": re.search(r"linkedin\.com/in/[\w-]+", sig),
"website": re.search(r"https?://(?!.*linkedin\.com)[\w./-]+", sig),
"title": extract_title(sig),
"company": extract_company(sig),
}
```
`extract_title` and `extract_company` deserve their own functions because they need a keyword vocabulary:
```python
TITLE_KEYWORDS = {
"C-suite": ["CEO", "CTO", "CFO", "COO", "CIO", "CMO"],
"VP": ["VP", "Vice President"],
"Director": ["Director", "Head of"],
"Manager": ["Manager", "Lead"],
"IC": ["Engineer", "Designer", "Analyst", "Specialist"],
}
def extract_title(sig: str) -> dict | None:
for tier, keywords in TITLE_KEYWORDS.items():
for kw in keywords:
m = re.search(rf"\b({kw}[^\n,]*)", sig, re.IGNORECASE)
if m:
return {"raw": m.group(1).strip(), "tier": tier}
return None
```
The tier classification is what makes this useful for sales outreach — you want "C-suite" as a separate signal from "raw title text".
## Cross-reference for accuracy
Single emails give incomplete signatures. The "Sent from my iPhone" reply has nothing. The thank-you note has just a name. The mid-thread message has the full block.
Pull the last N messages from the same sender, extract the signature from each, and merge:
```python
def enrich(sender_email: str, n: int = 3) -> dict:
messages = list_messages_from(sender_email, limit=n)
signatures = [split_signature(m["body"])[1] for m in messages]
fields = [extract(s) for s in signatures]
return merge_fields(fields) # take the most complete value for each key
```
The lift is large: per the original analysis, single-message extraction nets ~67% completeness across the five fields; three-message cross-reference hits ~91%.
`list_messages_from` is straightforward via the CLI:
```python
def list_messages_from(email: str, limit: int = 3) -> list[dict]:
out = subprocess.run(
["nylas", "email", "search", f"from:{email}", "--limit", str(limit), "--json"],
capture_output=True, text=True, check=True,
)
return json.loads(out.stdout)
```
## Bonus: DNS-derived intelligence
The sender's email domain reveals more than the signature does:
- **MX records** — Google Workspace vs. Microsoft 365 vs. self-hosted (sales-relevant signal)
- **SPF records** — what tools the company integrates (SendGrid, Salesforce, Mailgun)
- **DMARC** — email-security maturity (sometimes a buying signal in security tooling)
```python
import dns.resolver
def domain_intel(domain: str) -> dict:
return {
"mx": [r.exchange.to_text() for r in dns.resolver.resolve(domain, "MX")],
"spf": [r.to_text() for r in dns.resolver.resolve(domain, "TXT") if "v=spf1" in r.to_text()],
"dmarc": [r.to_text() for r in dns.resolver.resolve(f"_dmarc.{domain}", "TXT")],
}
```
These three queries enrich every contact for free without touching the email body.
## Things to know
- **GDPR / privacy.** The data is in the email; you have it because the sender sent it. But surfacing inferred attributes (job tier, sales-readiness) into a CRM is a different processing context. Document it in your privacy notice.
- **International phone formats.** The regex above is North America-leaning. Add patterns for E.164 (`\+\d{6,15}`) and country-specific shapes if your inbox has international correspondents.
- **LinkedIn deprecated `/pub/` URLs.** Match `/in/` only — the `/pub/` shape was retired years ago.
## Next steps
- [Map communication patterns between organizations](/docs/cookbook/agents/communication-patterns/)
- [Email triage agent](/docs/cookbook/agents/email-triage-agent/)
- [Email recipes (API)](/docs/cookbook/email/messages/list-messages-google/)
────────────────────────────────────────────────────────────────────────────────
title: "How to use Nylas MCP with Claude Code"
description: "Connect Claude Code to the Nylas MCP server for email, calendar, and contacts. Set up Bearer token auth, configure .mcp.json, and manage messages and events from the CLI."
source: "https://developer.nylas.com/docs/cookbook/ai/mcp/claude-code/"
────────────────────────────────────────────────────────────────────────────────
[Claude Code](https://code.claude.com/) is Anthropic's AI coding agent for the terminal. By connecting it to the Nylas MCP server, you give Claude direct access to [email](/docs/v3/email/), [calendar](/docs/v3/calendar/), and [contacts](/docs/v3/email/contacts/) data across Gmail, Outlook, Exchange, and any IMAP provider, all without leaving your development workflow.
The connection uses streamable HTTP with Bearer token authentication. You can set it up in about two minutes with a single CLI command or a short JSON config file.
## Why connect Claude Code to Nylas?
Without MCP, using email or calendar data in a coding session means switching to a browser, copying IDs or timestamps, and pasting them back into your terminal. Connecting Claude Code to Nylas through MCP removes that friction:
- **Inline access to real data.** Ask Claude to look up a message, check a calendar, or search contacts without leaving your session.
- **Multi-provider support.** One connection covers [Gmail](/docs/provider-guides/google/), [Outlook](/docs/provider-guides/microsoft/), [Yahoo](/docs/provider-guides/yahoo-authentication/), [iCloud](/docs/provider-guides/icloud/), [Exchange](/docs/provider-guides/imap/), and any IMAP server.
- **Two-step send safety.** The MCP server requires a confirmation hash before sending any email, so Claude can never send a message without you seeing a confirmation step first.
- **No SDK or API code needed.** Claude calls the Nylas API through MCP tools directly; you don't need to write integration code.
## Before you begin
Before you start, make sure you have:
1. **A Nylas account.** Sign up at [dashboard-v3.nylas.com](https://dashboard-v3.nylas.com) if you don't have one.
2. **A Nylas application.** Go to All apps > Create new app > Choose your region (US or EU).
3. **An API key.** Go to API Keys > Create new key.
4. **At least one connected grant.** Go to Grants > Add Account and authenticate an email account (Gmail, Outlook, etc.).
:::info
**New to Nylas?** The [Getting started guide](/docs/v3/getting-started/) walks through account setup, application creation, and connecting your first grant in detail.
:::
You also need **Claude Code** installed. If you haven't set it up yet, follow the [Claude Code installation guide](https://code.claude.com/docs/en/quickstart).
## Set up the Nylas MCP server
You can connect Claude Code to Nylas using the CLI or by editing a configuration file directly. The CLI is fastest for getting started; the config file is better for sharing setup across a team.
### Option 1: Add with the CLI
Run this command in your terminal:
```bash [claudeCodeSetup-CLI]
claude mcp add --transport http nylas \
--header "Authorization: Bearer YOUR_NYLAS_API_KEY" \
https://mcp.us.nylas.com
```
Replace `YOUR_NYLAS_API_KEY` with the API key from your [Nylas Dashboard](https://dashboard-v3.nylas.com). If your application is in the EU region, use `https://mcp.eu.nylas.com` instead.
By default, this adds the server to your **local** scope (available only to you in the current project). Use `--scope user` to make it available across all your projects.
### Option 2: Add with JSON config
You can also use `claude mcp add-json` for more control over the configuration:
```bash [claudeCodeSetup-add-json]
claude mcp add-json nylas '{"type":"http","url":"https://mcp.us.nylas.com","headers":{"Authorization":"Bearer YOUR_NYLAS_API_KEY"}}'
```
:::warn
**Do not commit API keys to version control.** The `claude mcp add` commands above store your resolved API key in the config. If you use `--scope project`, the key is written to `.mcp.json` in your project root. Add `.mcp.json` to your `.gitignore` or have each team member run the setup command locally with their own key.
:::
### Verify the connection
Restart Claude Code (or start a new session), then check that the Nylas server is connected:
```bash [claudeCodeVerify-CLI]
claude mcp list
```
You should see `nylas` listed with its URL. Inside Claude Code, you can also run `/mcp` to see all connected servers and their status.
## Example workflows
Once connected, you can interact with email and calendar data using natural language. Claude translates your requests into MCP tool calls automatically.
### Triage your inbox
```
Show me unread emails from the last 24 hours and summarize them by priority
```
Claude calls `get_grant` to resolve your account, then `list_messages` with date and unread filters. It groups the results by sender or urgency and returns a summary you can act on. This is particularly useful during morning standup prep or when catching up after time off.
### Check your schedule
```
What meetings do I have tomorrow? Flag any conflicts.
```
Claude uses `list_calendars` to find your calendars, then `list_events` with a date range filter. It analyzes the results for overlapping time slots and highlights back-to-back meetings. This works across Google Calendar, Outlook, and any other connected provider.
### Draft a reply
```
Draft a reply to the last email from sarah@example.com saying I'll review the proposal by Friday
```
Claude finds the relevant message with `list_messages`, then calls `create_draft` with the original thread ID to keep the reply in the same conversation. The draft lands in your email client for review. Nothing is sent until you explicitly send from your email client or ask Claude to send it (which triggers the two-step confirmation).
### Schedule a meeting
```
Create a 30-minute meeting with alex@example.com tomorrow at 2pm called "API Review"
```
Claude calls `create_event` with the title, participants, start time, and duration. For better scheduling, you can ask Claude to check availability first:
```
Check if alex@example.com is free tomorrow at 2pm, then create the meeting if they are
```
Claude runs `availability` before `create_event`, so you don't end up double-booking.
### Search across accounts
If you have multiple grants connected (work and personal email), specify which one:
```
List emails from my work account that mention "quarterly review"
```
Claude uses `get_grant` with your work email address to resolve the correct grant, then runs `list_messages` with a search query filter. You can also ask Claude to search across all connected accounts if you're not sure where a message landed.
## Things to know about Claude Code and Nylas MCP
- **Send confirmations are enforced.** The Nylas MCP server requires a two-step process for sending email: call `confirm_send_message` first, then `send_message` with the confirmation hash. Claude Code handles this automatically, but it means you'll always see a confirmation step before any email is actually sent.
- **Token limits matter.** Large email bodies and long message lists consume Claude's context window. If you're working with high-volume mailboxes, use filters (date range, folder, search query) to keep responses focused. You can also set `MAX_MCP_OUTPUT_TOKENS` to increase the limit if you're hitting truncation.
- **The 90-second timeout applies.** The Nylas MCP server enforces a 90-second timeout on all requests. This is the same limit as the Nylas REST API. For most operations this is plenty, but if you're querying a large date range of events, narrow the window.
- **Grant discovery happens per request.** Unlike the OpenClaw plugin, the MCP server doesn't auto-discover grants at startup. You (or Claude) need to provide an email address, and the server resolves it to a grant ID using `get_grant`. This is more explicit but means Claude needs to know which account to query.
- **Be careful with `--scope project`.** This flag writes your resolved API key into `.mcp.json` at the project root. If you commit that file, you leak the key. Either add `.mcp.json` to `.gitignore` or have each developer run the setup command locally with their own key using the default `local` scope.
- **EU region requires a different URL.** If your Nylas application is in the EU region, use `https://mcp.eu.nylas.com` instead of the US endpoint. The tools and behavior are identical.
## Available tools
The Nylas MCP server exposes these tools to your AI agent:
| Tool | Description |
|---|---|
| `list_messages` | List and search email messages with filters (folder, date range, search query) |
| `send_message` | Send an email (requires `confirm_send_message` first) |
| `create_draft` | Create a draft email for review before sending |
| `update_draft` | Update an existing draft |
| `send_draft` | Send a previously created draft (requires `confirm_send_draft` first) |
| `list_threads` | List and search email threads |
| `get_folder_by_id` | Get folder details by ID |
| `list_calendars` | List all calendars for a connected account |
| `list_events` | List calendar events with date range and calendar filters |
| `create_event` | Create a new calendar event |
| `update_event` | Update an existing event |
| `availability` | Check free/busy availability for participants |
| `get_grant` | Look up a grant by email address |
| `current_time` | Get the current time in epoch and ISO 8601 format |
| `epoch_to_datetime` | Convert epoch timestamps to human-readable dates |
The `confirm_send_message` and `confirm_send_draft` tools are safety gates that generate a confirmation hash before sending. Your AI agent must call the confirmation tool first, then pass the hash to the send tool. This prevents accidental sends from prompt injection or misinterpreted instructions.
For full tool documentation, see the [Nylas MCP reference](/docs/dev-guide/mcp/).
## What's next
- [Nylas MCP reference](/docs/dev-guide/mcp/) for full MCP server documentation, client setup for other tools, and example conversations
- [Email API reference](/docs/api/v3/ecc/#tag--Messages) for endpoint documentation for messages
- [Calendar API reference](/docs/api/v3/ecc/#tag--Events) for endpoint documentation for events
- [Authentication overview](/docs/v3/auth/) to learn how grants and OAuth work
- [Webhooks](/docs/v3/notifications/) for real-time notifications when email or calendar data changes
- [Use Nylas MCP with Codex CLI](/docs/cookbook/ai/mcp/codex-cli/) for the same setup with OpenAI's Codex CLI
- [Claude Code MCP documentation](https://code.claude.com/docs/en/mcp) for the full guide to MCP in Claude Code
- [Build an email agent with Nylas CLI](https://cli.nylas.com/guides/build-email-agent-cli) for a step-by-step CLI walkthrough of building AI email agents
- [Audit AI agent activity](https://cli.nylas.com/guides/audit-ai-agent-activity) to monitor and log what your MCP-connected agents do with email and calendar data
────────────────────────────────────────────────────────────────────────────────
title: "How to use Nylas MCP with Codex CLI"
description: "Connect OpenAI Codex CLI to the Nylas MCP server for email, calendar, and contacts. Configure config.toml with Bearer token auth and start managing messages and events from the terminal."
source: "https://developer.nylas.com/docs/cookbook/ai/mcp/codex-cli/"
────────────────────────────────────────────────────────────────────────────────
[Codex CLI](https://developers.openai.com/codex/) is OpenAI's AI coding agent for the terminal. It connects to MCP servers through a `config.toml` file, which makes adding the Nylas MCP server a matter of adding a few lines of TOML. Once connected, Codex can search your inbox, check your schedule, draft messages, and create events across [Gmail](/docs/provider-guides/google/), [Outlook](/docs/provider-guides/microsoft/), [Exchange](/docs/provider-guides/imap/), and any IMAP provider.
The Nylas MCP server uses streamable HTTP with Bearer token authentication. Codex handles this natively through its `bearer_token_env_var` config option, so there is no need for proxy wrappers or OAuth flows.
## Why connect Codex to Nylas?
Without MCP, working with email or calendar data during a coding session means switching to a browser, finding the information you need, and pasting it back. Connecting Codex to Nylas through MCP removes that context-switching overhead:
- **Access real email and calendar data inline.** Ask Codex to look up messages, check availability, or find contacts without leaving your terminal.
- **Multi-provider coverage.** One connection covers [Gmail](/docs/provider-guides/google/), [Outlook](/docs/provider-guides/microsoft/), [Yahoo](/docs/provider-guides/yahoo-authentication/), [iCloud](/docs/provider-guides/icloud/), [Exchange](/docs/provider-guides/imap/), and any IMAP server.
- **Safe email sending.** The MCP server requires a two-step confirmation before sending any message, preventing accidental sends from misinterpreted prompts.
- **Shared config between CLI and IDE.** MCP servers in `~/.codex/config.toml` work in both the terminal and the VS Code extension, so you set this up once.
## Before you begin
Before you start, make sure you have:
1. **A Nylas account.** Sign up at [dashboard-v3.nylas.com](https://dashboard-v3.nylas.com) if you don't have one.
2. **A Nylas application.** Go to All apps > Create new app > Choose your region (US or EU).
3. **An API key.** Go to API Keys > Create new key.
4. **At least one connected grant.** Go to Grants > Add Account and authenticate an email account (Gmail, Outlook, etc.).
:::info
**New to Nylas?** The [Getting started guide](/docs/v3/getting-started/) walks through account setup, application creation, and connecting your first grant in detail.
:::
You also need **Codex CLI** installed. If you haven't set it up yet, follow the [Codex CLI quickstart](https://developers.openai.com/codex/quickstart/).
## Set up the Nylas MCP server
Codex stores MCP server configuration in `config.toml`. You can configure it globally (`~/.codex/config.toml`) or per-project (`.codex/config.toml` in a trusted project directory).
### Option 1: Add to global config
Edit `~/.codex/config.toml` and add the Nylas MCP server:
```toml [codexSetup-config.toml (global)]
[mcp_servers.nylas]
url = "https://mcp.us.nylas.com"
bearer_token_env_var = "NYLAS_API_KEY"
```
This tells Codex to read your API key from the `NYLAS_API_KEY` environment variable and pass it as a Bearer token. Set the variable in your shell profile:
```bash [codexSetup-Shell profile]
# Add to ~/.zshrc, ~/.bashrc, or equivalent
export NYLAS_API_KEY="nyl_v0_your_key_here"
```
If your Nylas application is in the EU region, use `https://mcp.eu.nylas.com` instead.
### Option 2: Add to project config
For project-scoped configuration, create `.codex/config.toml` in your project root:
```toml [codexSetup-config.toml (project)]
[mcp_servers.nylas]
url = "https://mcp.us.nylas.com"
bearer_token_env_var = "NYLAS_API_KEY"
```
Project-scoped MCP servers only work in trusted projects. Codex will prompt you to trust the project directory the first time you run it.
:::warn
**Do not put your API key directly in `config.toml`**. Use `bearer_token_env_var` to reference an environment variable. This keeps your credentials out of version control.
:::
### Advanced configuration options
Codex supports additional options for fine-tuning MCP server behavior:
```toml [codexSetup-config.toml (advanced)]
[mcp_servers.nylas]
url = "https://mcp.us.nylas.com"
bearer_token_env_var = "NYLAS_API_KEY"
startup_timeout_sec = 10
tool_timeout_sec = 90
enabled = true
```
| Option | Default | Description |
|---|---|---|
| `startup_timeout_sec` | 10 | How long to wait for the MCP server to respond on first connection |
| `tool_timeout_sec` | 60 | Maximum time for individual tool calls |
| `enabled` | true | Set to `false` to temporarily disable without removing the config |
### Verify the connection
Start a new Codex session and check that the Nylas tools are available:
```bash [codexVerify-CLI]
codex
```
Once inside Codex, ask it to list your connected accounts:
```
List my connected email accounts using the Nylas MCP tools
```
If the connection is working, Codex will call the `get_grant` tool and return your grant details. If you see an error about the MCP server not being found, double-check that `NYLAS_API_KEY` is set in your current shell session.
## Example workflows
Once connected, Codex translates your natural language requests into MCP tool calls. Here are practical examples you can try immediately.
### Triage your inbox
```
Show me unread emails from the last 24 hours and group them by sender
```
Codex calls `get_grant` to resolve your account, then `list_messages` with date and unread filters. It groups messages by sender and surfaces anything that looks urgent. This works well for morning standup prep or catching up after being heads-down on code.
### Check your schedule
```
What meetings do I have this week? Show me any days with back-to-back meetings.
```
Codex uses `list_calendars` and `list_events` with a date range, then identifies scheduling patterns like consecutive meetings with no buffer. This works across Google Calendar, Outlook, and any connected calendar provider.
### Draft a reply
```
Draft a reply to the most recent email from the engineering team saying I'll have the PR ready by end of day
```
Codex finds the relevant message with `list_messages`, then calls `create_draft` with the original thread ID so the reply stays in the same conversation. The draft lands in your email client for review. Nothing is sent until you explicitly approve it.
### Check availability and schedule
```
Check if both alex@example.com and jamie@example.com are free tomorrow at 3pm, then create a 45-minute meeting called "Sprint Planning"
```
Codex calls `availability` first. If the slot is open, it calls `create_event` with the title, participants, and duration. If there's a conflict, Codex tells you and can suggest alternative times.
### Search across accounts
If you have multiple grants connected (work and personal email):
```
Search my work email for messages about "deployment pipeline" from the last week
```
Codex resolves the right grant using `get_grant` with your work email address, then runs `list_messages` with a search query filter. You can also ask Codex to search all connected accounts if you're not sure where a conversation lives.
## Things to know about Codex CLI and Nylas MCP
- **Send confirmations are enforced.** The Nylas MCP server requires a two-step flow for sending email: call `confirm_send_message` first to get a confirmation hash, then pass it to `send_message`. This prevents accidental sends from prompt injection or misinterpreted instructions. Codex handles the two-step flow automatically, but you'll see the confirmation step in the tool call output.
- **Tool timeouts default to 60 seconds.** Codex's default `tool_timeout_sec` is 60 seconds, but the Nylas MCP server allows up to 90 seconds. If you're querying large mailboxes or wide date ranges and hitting timeouts, increase `tool_timeout_sec` to 90 in your config.
- **Grant discovery is per-request.** The MCP server doesn't cache grant lookups between sessions. Codex (or you) need to provide an email address, and the server resolves it to a grant ID using `get_grant`. If you always use the same account, you can tell Codex your email upfront to save a round trip.
- **The CLI and IDE extension share config.** If you also use Codex in VS Code or another IDE, MCP servers configured in `~/.codex/config.toml` are available in both. You only need to set this up once.
- **EU region requires a different URL.** If your Nylas application is in the EU region, use `https://mcp.eu.nylas.com`. The tools and behavior are identical.
- **Use filters to stay within token limits.** Listing hundreds of messages or events at once consumes a lot of context. Always use date range, folder, or search filters to keep responses focused and avoid truncation.
## Available tools
The Nylas MCP server exposes these tools to your AI agent:
| Tool | Description |
|---|---|
| `list_messages` | List and search email messages with filters (folder, date range, search query) |
| `send_message` | Send an email (requires `confirm_send_message` first) |
| `create_draft` | Create a draft email for review before sending |
| `update_draft` | Update an existing draft |
| `send_draft` | Send a previously created draft (requires `confirm_send_draft` first) |
| `list_threads` | List and search email threads |
| `get_folder_by_id` | Get folder details by ID |
| `list_calendars` | List all calendars for a connected account |
| `list_events` | List calendar events with date range and calendar filters |
| `create_event` | Create a new calendar event |
| `update_event` | Update an existing event |
| `availability` | Check free/busy availability for participants |
| `get_grant` | Look up a grant by email address |
| `current_time` | Get the current time in epoch and ISO 8601 format |
| `epoch_to_datetime` | Convert epoch timestamps to human-readable dates |
The `confirm_send_message` and `confirm_send_draft` tools are safety gates that generate a confirmation hash before sending. Your AI agent must call the confirmation tool first, then pass the hash to the send tool. This prevents accidental sends from prompt injection or misinterpreted instructions.
For full tool documentation, see the [Nylas MCP reference](/docs/dev-guide/mcp/).
## What's next
- [Nylas MCP reference](/docs/dev-guide/mcp/) for full MCP server documentation, client setup for other tools, and example conversations
- [Email API reference](/docs/api/v3/ecc/#tag--Messages) for endpoint documentation for messages
- [Calendar API reference](/docs/api/v3/ecc/#tag--Events) for endpoint documentation for events
- [Authentication overview](/docs/v3/auth/) to learn how grants and OAuth work
- [Webhooks](/docs/v3/notifications/) for real-time notifications when email or calendar data changes
- [Use Nylas MCP with Claude Code](/docs/cookbook/ai/mcp/claude-code/) for the same setup with Anthropic's Claude Code
- [Codex CLI documentation](https://developers.openai.com/codex/) for the full guide to Codex CLI and MCP configuration
- [Build an email agent with Nylas CLI](https://cli.nylas.com/guides/build-email-agent-cli) for a step-by-step CLI walkthrough of building AI email agents
- [Set up Nylas MCP from the CLI](https://cli.nylas.com/guides/ai-agent-email-mcp) to install and configure the Nylas MCP server using the Nylas CLI
────────────────────────────────────────────────────────────────────────────────
title: "How to install the OpenClaw Nylas plugin"
description: "Install and configure the @nylas/openclaw-nylas-plugin to give OpenClaw agents access to email, calendar, and contacts through the Nylas API. Covers setup, multi-account configuration, and available tools."
source: "https://developer.nylas.com/docs/cookbook/ai/openclaw/install-plugin/"
────────────────────────────────────────────────────────────────────────────────
The `@nylas/openclaw-nylas-plugin` gives OpenClaw agents access to the Nylas API for email, calendar, and contacts. Once installed, your agents can send email, create calendar events, search contacts, and manage messages across Gmail, Outlook, Exchange, and any IMAP provider through a unified set of tools.
The plugin handles grant discovery automatically, so agents can work with multiple connected accounts without hardcoding grant IDs.
## Prerequisites
1. **Create Nylas account** - Sign up at [dashboard-v3.nylas.com](https://dashboard-v3.nylas.com)
2. **Create application** - All apps > Create new app > Choose region (US/EU)
3. **Get API key** - API Keys section > Create new key
4. **Add grants** - Grants section > Add Account > Authenticate your email accounts
5. **Grant IDs are auto-discovered** - The plugin resolves them from just the API key
## Install the plugin
You can install the plugin through the OpenClaw CLI or directly with npm.
### Install with OpenClaw CLI
```bash [installPlugin-OpenClaw CLI]
openclaw plugins install @nylas/openclaw-nylas-plugin
openclaw gateway restart
```
### Install with npm
```bash [installPlugin-npm]
npm install @nylas/openclaw-nylas-plugin
```
## Configure the plugin
The plugin needs your Nylas API key to authenticate requests. You can configure it through the OpenClaw CLI or environment variables.
### Configure with OpenClaw CLI
Set your API key and optional settings through the OpenClaw config:
```bash [configPlugin-OpenClaw CLI]
# Required: set your API key
openclaw config set 'plugins.entries.nylas.config.apiKey' 'nyl_v0_your_key_here'
# Optional: set the API region (defaults to US)
openclaw config set 'plugins.entries.nylas.config.apiUri' 'https://api.us.nylas.com'
# Optional: set a default timezone
openclaw config set 'plugins.entries.nylas.config.defaultTimezone' 'America/New_York'
# Restart the gateway to apply changes
openclaw gateway restart
```
### Configure with environment variables
If you're using the plugin directly with npm (outside of OpenClaw), set these environment variables:
| Variable | Required | Description |
| ---------------- | -------- | ----------------------------------------------------------------------- |
| `NYLAS_API_KEY` | Yes | Your API key from the [Nylas Dashboard](https://dashboard-v3.nylas.com) |
| `NYLAS_GRANT_ID` | No | Explicit grant ID (skip auto-discovery) |
| `NYLAS_API_URI` | No | API region endpoint (defaults to `https://api.us.nylas.com`) |
| `NYLAS_TIMEZONE` | No | Default timezone for calendar operations (defaults to UTC) |
## Set up multi-account access
The plugin supports multiple connected accounts (grants) through named aliases. This lets agents reference accounts by name instead of raw grant IDs.
```bash [multiAccount-OpenClaw CLI]
openclaw config set 'plugins.entries.nylas.config.grants' '{"work":"grant-id-1","personal":"grant-id-2"}'
```
Once configured, agents can target a specific account by name when calling any tool:
```typescript [multiAccount-TypeScript]
import { createNylasClient } from "@nylas/openclaw-nylas-plugin";
const { client, discovered } = await createNylasClient({
apiKey: "nyl_v0_your_key_here",
});
// Use the default grant
await client.listMessages({ limit: 5 });
// Use a named grant
await client.listMessages({ grant: "work", limit: 5 });
// Use a raw grant ID
await client.listMessages({ grant: "abc123-grant-id", limit: 5 });
```
If you don't configure named grants, the plugin auto-discovers available grants from your Nylas application.
## Available tools
After installation, the plugin exposes these tools to your OpenClaw agents:
### Email tools
| Tool | Description |
| -------------------- | ---------------------------------------------------------------------- |
| `nylas_list_emails` | List email messages with optional filters (folder, date range, search) |
| `nylas_get_email` | Retrieve a single message by ID, including full body and attachments |
| `nylas_send_email` | Send an email with recipients, subject, body, and optional attachments |
| `nylas_create_draft` | Create a draft message without sending |
| `nylas_list_threads` | List email threads with filters |
| `nylas_list_folders` | List all folders and labels for the connected account |
### Calendar tools
| Tool | Description |
| -------------------------- | ------------------------------------------------------------------------ |
| `nylas_list_calendars` | List all calendars for the connected account |
| `nylas_list_events` | List calendar events with optional date range and calendar filters |
| `nylas_get_event` | Retrieve a single event by ID |
| `nylas_create_event` | Create a new calendar event with title, time, participants, and location |
| `nylas_update_event` | Update an existing event |
| `nylas_delete_event` | Delete a calendar event |
| `nylas_check_availability` | Check free/busy availability for one or more participants |
### Contact tools
| Tool | Description |
| --------------------- | ---------------------------------------- |
| `nylas_list_contacts` | List contacts with optional search query |
| `nylas_get_contact` | Retrieve a single contact by ID |
### Discovery tools
| Tool | Description |
| ----------------------- | ------------------------------------------------------------------------ |
| `nylas_discover_grants` | Auto-discover available grants (connected accounts) for your application |
## Verify the installation
After installing and configuring the plugin, verify it's working:
```bash [verify-OpenClaw CLI]
# Check the plugin is loaded
openclaw plugins list
# Test grant discovery
openclaw run "List my connected email accounts" --plugin nylas
```
You can also verify programmatically:
```typescript [verify-TypeScript]
import { createNylasClient } from "@nylas/openclaw-nylas-plugin";
const { client, discovered } = await createNylasClient({
apiKey: process.env.NYLAS_API_KEY!,
});
console.log(`Discovered ${discovered.length} grants`);
const calendars = await client.listCalendars();
console.log(`Found ${calendars.length} calendars`);
```
## Things to know
- **Auto-discovery** queries all grants on your Nylas application at startup. If you have many grants, set `NYLAS_GRANT_ID` or use named grants to skip discovery and reduce startup time.
- **Rate limits** apply per grant, not per plugin instance. If multiple agents share the same grant, they share the same rate limit budget. See [Rate limits best practices](/docs/dev-guide/best-practices/rate-limits/) for details.
- **The plugin uses Nylas API v3.** All tool calls go through the [Nylas v3 REST API](/docs/reference/api/), so provider-specific behaviors (folder naming, sync timing, search syntax) are the same as documented in the [provider guides](/docs/cookbook/).
- **TypeScript types** are included. If you're extending the plugin or building custom tools on top of it, you get full type safety for all Nylas objects (messages, events, contacts, grants).
- **MoltBot compatibility** is built in. The plugin works as both a standalone Node.js client and as an OpenClaw/MoltBot gateway plugin.
## What's next
- [Email API reference](/docs/api/v3/ecc/#tag--Messages) -- full endpoint documentation for messages
- [Calendar API reference](/docs/api/v3/ecc/#tag--Events) -- full endpoint documentation for events
- [Authentication overview](/docs/v3/auth/) -- learn about connecting accounts with grants
- [Webhooks](/docs/v3/notifications/) -- get real-time notifications when email or calendar data changes
- [Rate limits](/docs/dev-guide/best-practices/rate-limits/) -- understand per-grant rate limiting
- [Use cases](/docs/cookbook/use-cases/) -- end-to-end tutorials combining multiple Nylas APIs
- [Install the OpenClaw Nylas plugin with the CLI](https://cli.nylas.com/guides/install-openclaw-nylas-plugin) -- install and verify the plugin using the Nylas CLI
- [Build a personal assistant with OpenClaw](https://cli.nylas.com/guides/nylas-openclaw-personal-assistant) -- end-to-end guide for building an OpenClaw agent with email and calendar access
────────────────────────────────────────────────────────────────────────────────
title: "How to create Exchange calendar events"
description: "Create calendar events on Exchange on-premises servers using the Nylas Calendar API. Covers EWS vs Microsoft Graph, write scopes, recurring event restrictions, and on-prem networking."
source: "https://developer.nylas.com/docs/cookbook/calendar/events/create-events-ews/"
────────────────────────────────────────────────────────────────────────────────
Exchange on-premises servers remain widespread in enterprise environments, particularly in regulated industries, government, and organizations that have not yet migrated to Microsoft 365. Creating calendar events on these servers means talking to Exchange Web Services (EWS), a SOAP-based XML protocol that predates modern REST APIs.
Nylas abstracts the EWS complexity behind the same [Events API](/docs/reference/api/events/) you use for Google Calendar, Outlook, and iCloud. You send a JSON payload, and Nylas translates it into the correct EWS SOAP envelope, handles autodiscovery, and manages credentials. This guide covers the EWS-specific details you need to know when creating events on Exchange on-prem.
## EWS vs. Microsoft Graph: which one?
This is the first thing to figure out. The two provider types target different Exchange deployments:
| Provider type | Connector | Use when |
| --------------------- | ----------- | ------------------------------------------------------- |
| Microsoft Graph | `microsoft` | Exchange Online, Microsoft 365, Office 365, Outlook.com |
| Exchange Web Services | `ews` | Self-hosted Exchange servers (on-premises) |
If the user's calendar is hosted by Microsoft in the cloud, use the [Microsoft guide](/docs/cookbook/calendar/events/create-events-microsoft/) instead. The `ews` connector is specifically for organizations that run their own Exchange servers.
:::warn
**Microsoft announced EWS retirement** and recommends migrating to Microsoft Graph. However, many organizations still run on-premises Exchange servers where EWS is the only option. Nylas continues to support EWS for these environments.
:::
## Why use Nylas instead of EWS directly?
Creating a calendar event through EWS means constructing a SOAP XML envelope with deeply nested elements for the event title, body, start and end times, timezone definitions, attendees, recurrence patterns, and reminders. A single create-event call can easily exceed 50 lines of XML. You also need to handle autodiscovery to find the correct EWS endpoint (which is frequently misconfigured), manage credential-based authentication with support for two-factor app passwords, parse SOAP fault responses when something goes wrong, and build retry logic around Exchange's admin-configured throttling policies.
Nylas replaces all of that with a single `POST` request containing a JSON body. No XML, no WSDL, no SOAP. Authentication, autodiscovery, and timezone conversion are handled automatically. Your event-creation code stays the same whether you target Exchange on-prem, Exchange Online, Google Calendar, or iCloud.
If you have deep EWS experience and only target Exchange on-prem, direct integration is an option. For multi-provider calendar support or faster time-to-integration, Nylas is the simpler path.
## Before you begin
You'll need:
- A [Nylas application](/docs/v3/getting-started/) with a valid API key
- A [grant](/docs/v3/auth/) for an Exchange on-premises account
- An EWS connector configured with the `ews.calendars` scope (read and write access)
- The Exchange server accessible from outside the corporate network (not behind a VPN or firewall that blocks external access)
:::info
**New to Nylas?** Start with the [quickstart guide](/docs/v3/getting-started/) to set up your app and connect a test account before continuing here.
:::
### Autodiscovery and authentication
EWS uses credential-based authentication. During the auth flow, users sign in with their Exchange credentials, typically the same username and password they use for Windows login. The username format is usually `user@example.com` or `DOMAIN\username`.
If EWS autodiscovery is configured on the server, Nylas automatically locates the correct EWS endpoint. If autodiscovery is disabled or misconfigured, users can click "Additional settings" during authentication and manually enter the Exchange server address (for example, `mail.company.com`).
:::info
**Users with two-factor authentication** must generate an app password instead of using their regular password. See [Microsoft's app password documentation](https://support.microsoft.com/en-us/help/12409/) for instructions.
:::
The full setup walkthrough is in the [Exchange on-premises provider guide](/docs/provider-guides/exchange-on-prem/).
## Create an event
Make a [Create Event request](/docs/reference/api/events/create-event/) with the grant ID and a `calendar_id`. You can use `primary` as the `calendar_id` to target the account's default calendar.
:::error
**You're about to send a real event invite!** The following samples send an email from the account you connected to the Nylas API to any email addresses you put in the `participants` sub-object. Make sure you actually want to send this invite to those addresses before running this command!
:::
```bash [createEvents-Request]
curl --compressed --request POST \
--url 'https://api.us.nylas.com/v3/grants//events?calendar_id=' \
--header 'Accept: application/json' \
--header 'Authorization: Bearer ' \
--header 'Content-Type: application/json' \
--data '{
"title": "Annual Philosophy Club Meeting",
"busy": true,
"conferencing": {
"provider": "Zoom Meeting",
"autocreate": {
"conf_grant_id": "",
"conf_settings": {
"settings": {
"join_before_host": true,
"waiting_room": false,
"mute_upon_entry": false,
"auto_recording": "none"
}
}
}
},
"participants": [
{
"name": "Leyah Miller",
"email": "leyah@example.com"
},
{
"name": "Nyla",
"email": "nyla@example.com"
}
],
"resources": [{
"name": "Conference room",
"email": "conference-room@example.com"
}],
"description": "Come ready to talk philosophy!",
"when": {
"start_time": 1674604800,
"end_time": 1722382420,
"start_timezone": "America/New_York",
"end_timezone": "America/New_York"
},
"location": "New York Public Library, Cave room",
"recurrence": [
"RRULE:FREQ=WEEKLY;BYDAY=MO",
"EXDATE:20211011T000000Z"
],
}'
```
```json [createEvents-Response]
{
"request_id": "1",
"data": {
"busy": true,
"calendar_id": "primary",
"conferencing": {
"details": {
"meeting_code": "",
"url": ""
},
"provider": "Google Meet"
},
"created_at": 1701974804,
"creator": {
"email": "leyah@example.com",
"name": "Leyah Miller"
},
"description": null,
"grant_id": "",
"hide_participants": false,
"html_link": "",
"ical_uid": "6aaaaaaame8kpgcid6hvd0q@google.com",
"id": "",
"object": "event",
"organizer": {
"email": "leyah@example.com",
"name": "Leyah Miller"
},
"participants": [
{
"email": "jenna.doe@example.com",
"status": "yes"
},
{
"email": "leyah@example.com",
"status": "yes"
}
],
"read_only": true,
"reminders": {
"overrides": null,
"use_default": true
},
"status": "confirmed",
"title": "Holiday check in",
"updated_at": 1701974915,
"when": {
"end_time": 1701978300,
"end_timezone": "America/Los_Angeles",
"object": "timespan",
"start_time": 1701977400,
"start_timezone": "America/Los_Angeles"
}
}
}
```
```js [createEvents-Node.js SDK]
import Nylas from "nylas";
const nylas = new Nylas({
apiKey: "",
apiUri: "",
});
const now = Math.floor(Date.now() / 1000); // Time in Unix timestamp format (in seconds)
async function createAnEvent() {
try {
const event = await nylas.events.create({
identifier: "",
requestBody: {
title: "Build With Nylas",
when: {
startTime: now,
endTime: now + 3600,
},
},
queryParams: {
calendarId: "",
},
});
console.log("Event:", event);
} catch (error) {
console.error("Error creating event:", error);
}
}
createAnEvent();
```
```python [createEvents-Python SDK]
from nylas import Client
nylas = Client(
"",
""
)
grant_id = ""
events = nylas.events.create(
grant_id,
request_body={
"title": 'Build With Nylas',
"when": {
"start_time": 1609372800,
"end_time": 1609376400
},
},
query_params={
"calendar_id": ""
}
)
print(events)
```
```ruby [createEvents-Ruby SDK]
!!!include(v3_code_samples/events/POST/ruby.rb)!!!
```
```java [createEvents-Java SDK]
!!!include(v3_code_samples/events/POST/java.java)!!!
```
```kt [createEvents-Kotlin SDK]
!!!include(v3_code_samples/events/POST/kotlin.kt)!!!
```
The `calendar_id=primary` shortcut works for EWS accounts, targeting the user's default calendar. The response format is identical across providers, so your event-creation logic works the same for Exchange on-prem, Exchange Online, Google, and iCloud.
## Add participants and send invitations
When you include a `participants` array in your create request, Exchange handles the meeting invitations through its internal mail transport. The `notify_participants` query parameter controls whether invitations are sent:
- `notify_participants=true` (the default) sends a meeting invitation to every address in the `participants` array. Exchange delivers these through its own transport, not through a separate email send.
- `notify_participants=false` creates the event on the organizer's calendar without notifying anyone. Participants do not receive a message or an ICS file, and the event does not appear on their calendars.
This behavior is consistent with how Exchange handles meeting requests natively. One thing to watch for: if the participant is on the same Exchange server, the event may appear on their calendar almost instantly. External participants receive a standard ICS invitation email.
## Things to know about Exchange
Exchange on-prem behaves differently from Exchange Online (Microsoft Graph) in several ways that matter when creating calendar events.
### EWS connector scope
The `ews.calendars` scope on your EWS connector grants both read and write access to the Calendar API. Without this scope, create requests fail with a permissions error. You can configure scopes when setting up the connector:
| Scope | Access |
| --------------- | ------------------------------------- |
| `ews.messages` | Email API (messages, drafts, folders) |
| `ews.calendars` | Calendar API |
| `ews.contacts` | Contacts API |
### No conferencing auto-create
EWS does not support automatically creating conferencing links (Teams, Zoom, or otherwise) when you create an event. If you need a video conferencing link on the event, generate it through the conferencing provider's API first, then include the URL in the event's `location` or `description` field. This is a platform limitation of Exchange on-prem, not a Nylas restriction.
### Recurring event restrictions
Microsoft Exchange has specific constraints around recurring events that do not apply to Google Calendar:
- **No overlapping instances.** You cannot reschedule an instance of a recurring event to fall on the same day as, or the day before, the previous instance. Exchange rejects the update to prevent overlapping occurrences within a series.
- **Overrides removed on recurrence change.** If you modify the recurrence pattern of a series (for example, changing from weekly to daily), Exchange removes all existing overrides. Google keeps them if they still fit the new pattern.
- **EXDATE recovery is not possible.** Once you remove an occurrence from a recurring series, there is no way to restore it. You would need to create a standalone event to fill the gap.
- **No multi-day monthly BYDAY.** You cannot create a monthly recurring event on multiple days of the week (like the first and third Thursday). Exchange's recurrence model does not support different indices within a single rule.
For the full breakdown of provider-specific recurring event behavior, see [Recurring events](/docs/v3/calendar/recurring-events/).
### Timezone handling
Nylas accepts IANA timezone identifiers (like `America/New_York` or `Europe/London`) in your create request. You do not need to convert to Windows timezone IDs like "Eastern Standard Time." Nylas handles the translation to Exchange's internal format automatically.
### Room resources
Exchange supports booking room resources through the `resources` field on an event. If the room is configured as an Exchange resource mailbox, you can include it as a resource when creating the event. The resource mailbox's auto-accept policy determines whether the room is automatically confirmed or requires approval.
### On-prem networking
The Exchange server's EWS endpoint must be reachable from Nylas infrastructure. This is the most common source of connection failures for on-prem deployments.
- **EWS must be enabled** on the server and exposed outside the corporate network
- If the server is behind a **firewall**, you need to allow Nylas's IP addresses (available on contract plans with [static IPs](/docs/dev-guide/platform/#static-ips))
- A **reverse proxy** in front of the Exchange server is a common workaround if direct firewall rules are not feasible
- Accounts in admin groups are not supported
If event creation is failing for an Exchange account, verify that the EWS endpoint is accessible before investigating other causes.
### Rate limits
Unlike Google and Microsoft's cloud services, Exchange on-prem rate limits are set by the server administrator. Write operations like event creation may be more restricted than read operations. Nylas cannot predict what the limits will be. If the Exchange server throttles a request, Nylas returns a `Retry-After` header with the number of seconds to wait.
For apps that create events frequently, consider batching operations and building backoff logic around the `Retry-After` response.
### Sync timing
Created events depend on the EWS server's responsiveness and network latency between Nylas infrastructure and the Exchange server. On-prem servers with high load or limited bandwidth may introduce noticeable delays before the event appears in sync results.
For apps that need confirmation that an event was created successfully, use [webhooks](/docs/v3/notifications/) to receive a notification as soon as the event syncs. This is more reliable than polling.
## What's next
- [Events API reference](/docs/reference/api/events/) for full endpoint documentation and all available parameters
- [Using the Events API](/docs/v3/calendar/using-the-events-api/) for updating, deleting, and managing events
- [List Exchange events](/docs/cookbook/calendar/events/list-events-ews/) for reading events from Exchange on-prem accounts
- [Recurring events](/docs/v3/calendar/recurring-events/) for series creation, overrides, and provider-specific behavior
- [Availability](/docs/v3/calendar/calendar-availability/) to check free/busy across multiple calendars
- [Webhooks](/docs/v3/notifications/) for real-time notifications when events are created or updated
- [Exchange on-premises provider guide](/docs/provider-guides/exchange-on-prem/) for full Exchange setup including authentication and network requirements
- [Microsoft create events guide](/docs/cookbook/calendar/events/create-events-microsoft/) for cloud-hosted Exchange (Microsoft 365, Exchange Online)
────────────────────────────────────────────────────────────────────────────────
title: "How to create Google calendar events"
description: "Create calendar events on Google Calendar and Workspace accounts using the Nylas Calendar API. Covers restricted scopes, Google Meet auto-create, participant notifications, and recurring events."
source: "https://developer.nylas.com/docs/cookbook/calendar/events/create-events-google/"
────────────────────────────────────────────────────────────────────────────────
Creating events on Google Calendar through the native API means dealing with Google's restricted scope requirements right away. Unlike reading events, which only needs a sensitive scope, write access requires the `calendar` scope, classified as restricted, and that triggers a full third-party security assessment before your app can go to production. On top of that, Google has its own conferencing auto-creation behavior, event type restrictions, and color ID system that you'll need to account for.
Nylas gives you a single [Events API](/docs/reference/api/events/) that handles event creation across Google, Microsoft, iCloud, and Exchange. This guide walks through creating events on Google Calendar accounts and covers the Google-specific behavior you should know about.
## Why use Nylas instead of the Google Calendar API directly?
Writing to Google Calendar introduces more friction than most developers expect:
- **Restricted scope required for writes** - Reading events uses the sensitive `calendar.events.readonly` scope, but creating events requires the restricted `calendar` scope. That means a third-party security assessment before you can launch.
- **Google Meet auto-creation** - Google's conferencing auto-attach behavior is provider-specific. Setting it up through the native API requires understanding `conferenceData` and `createRequest` fields that don't exist on other providers.
- **Event type restrictions** - You can't create `focusTime`, `outOfOffice`, or `workingLocation` events through any API. These are managed exclusively through the Google Calendar UI.
- **Provider-specific fields** - Color IDs, room resources, and event visibility settings all work differently on Google than on Microsoft or iCloud.
If Google Calendar is your only target and you want full control over every Google-specific field, the native API works. But if you need multi-provider support or want to avoid the security assessment process, Nylas is the faster path to production.
## Before you begin
You'll need:
- A [Nylas application](/docs/v3/getting-started/) with a valid API key
- A [grant](/docs/v3/auth/) for a Google Calendar or Google Workspace account
- The appropriate [Google OAuth scopes](/docs/provider-guides/google/) configured in your GCP project, including write access
:::info
**New to Nylas?** Start with the [quickstart guide](/docs/v3/getting-started/) to set up your app and connect a test account before continuing here.
:::
### Google OAuth scopes for write access
Creating events requires the `calendar` scope, which Google classifies as restricted. This is a step up from what you need to just read events:
| Scope tier | Example | What's required |
| ------------- | ----------------------------------- | ------------------------------------------------- |
| Non-sensitive | `calendar.readonly` (metadata only) | No verification needed |
| Sensitive | `calendar.events.readonly` | OAuth consent screen verification |
| Restricted | `calendar.events`, `calendar` | Full security assessment by a third-party auditor |
The `calendar.events` scope is enough for creating and modifying events, but most apps use the broader `calendar` scope to also manage calendars. Both are restricted and require a [security assessment](/docs/provider-guides/google/google-verification-security-assessment-guide/) before production use.
Nylas handles token refresh and scope management, but your GCP project still needs the correct scopes configured. See the [Google provider guide](/docs/provider-guides/google/) for the full setup.
## Create an event
Make a [Create Event request](/docs/reference/api/events/create-event/) with the grant ID and a `calendar_id` query parameter. You can use `primary` to target the user's default calendar.
:::error
**You're about to send a real event invite!** The code samples below send an email from the connected account to any email addresses in the `participants` field. Make sure you actually want to invite those addresses before running this.
:::
```bash [createEvents-Request]
curl --compressed --request POST \
--url 'https://api.us.nylas.com/v3/grants//events?calendar_id=' \
--header 'Accept: application/json' \
--header 'Authorization: Bearer ' \
--header 'Content-Type: application/json' \
--data '{
"title": "Annual Philosophy Club Meeting",
"busy": true,
"conferencing": {
"provider": "Zoom Meeting",
"autocreate": {
"conf_grant_id": "",
"conf_settings": {
"settings": {
"join_before_host": true,
"waiting_room": false,
"mute_upon_entry": false,
"auto_recording": "none"
}
}
}
},
"participants": [
{
"name": "Leyah Miller",
"email": "leyah@example.com"
},
{
"name": "Nyla",
"email": "nyla@example.com"
}
],
"resources": [{
"name": "Conference room",
"email": "conference-room@example.com"
}],
"description": "Come ready to talk philosophy!",
"when": {
"start_time": 1674604800,
"end_time": 1722382420,
"start_timezone": "America/New_York",
"end_timezone": "America/New_York"
},
"location": "New York Public Library, Cave room",
"recurrence": [
"RRULE:FREQ=WEEKLY;BYDAY=MO",
"EXDATE:20211011T000000Z"
],
}'
```
```json [createEvents-Response]
{
"request_id": "1",
"data": {
"busy": true,
"calendar_id": "primary",
"conferencing": {
"details": {
"meeting_code": "",
"url": ""
},
"provider": "Google Meet"
},
"created_at": 1701974804,
"creator": {
"email": "leyah@example.com",
"name": "Leyah Miller"
},
"description": null,
"grant_id": "",
"hide_participants": false,
"html_link": "",
"ical_uid": "6aaaaaaame8kpgcid6hvd0q@google.com",
"id": "",
"object": "event",
"organizer": {
"email": "leyah@example.com",
"name": "Leyah Miller"
},
"participants": [
{
"email": "jenna.doe@example.com",
"status": "yes"
},
{
"email": "leyah@example.com",
"status": "yes"
}
],
"read_only": true,
"reminders": {
"overrides": null,
"use_default": true
},
"status": "confirmed",
"title": "Holiday check in",
"updated_at": 1701974915,
"when": {
"end_time": 1701978300,
"end_timezone": "America/Los_Angeles",
"object": "timespan",
"start_time": 1701977400,
"start_timezone": "America/Los_Angeles"
}
}
}
```
```js [createEvents-Node.js SDK]
import Nylas from "nylas";
const nylas = new Nylas({
apiKey: "",
apiUri: "",
});
const now = Math.floor(Date.now() / 1000); // Time in Unix timestamp format (in seconds)
async function createAnEvent() {
try {
const event = await nylas.events.create({
identifier: "",
requestBody: {
title: "Build With Nylas",
when: {
startTime: now,
endTime: now + 3600,
},
},
queryParams: {
calendarId: "",
},
});
console.log("Event:", event);
} catch (error) {
console.error("Error creating event:", error);
}
}
createAnEvent();
```
```python [createEvents-Python SDK]
from nylas import Client
nylas = Client(
"",
""
)
grant_id = ""
events = nylas.events.create(
grant_id,
request_body={
"title": 'Build With Nylas',
"when": {
"start_time": 1609372800,
"end_time": 1609376400
},
},
query_params={
"calendar_id": ""
}
)
print(events)
```
```ruby [createEvents-Ruby SDK]
!!!include(v3_code_samples/events/POST/ruby.rb)!!!
```
```java [createEvents-Java SDK]
!!!include(v3_code_samples/events/POST/java.java)!!!
```
```kt [createEvents-Kotlin SDK]
!!!include(v3_code_samples/events/POST/kotlin.kt)!!!
```
Nylas returns the created event with an `id` you can use for subsequent updates or deletions. The same code works for Microsoft, iCloud, and Exchange accounts with no provider-specific changes.
## Add participants and send invitations
The `notify_participants` query parameter controls whether Google sends email invitations to people listed in the `participants` array. It defaults to `true`, so participants receive calendar invitations automatically unless you explicitly disable it.
```bash
curl --request POST \
--url "https://api.us.nylas.com/v3/grants//events?calendar_id=primary¬ify_participants=true" \
--header 'Accept: application/json' \
--header 'Authorization: Bearer ' \
--header 'Content-Type: application/json' \
--data '{
"title": "Project sync",
"when": {
"start_time": 1700000000,
"end_time": 1700003600
},
"participants": [
{ "email": "colleague@example.com" }
]
}'
```
:::warn
**When `notify_participants=false`**, Google creates the event on the organizer's calendar only. Participants don't receive an email invitation or an ICS file, and the event does not appear on their calendars.
:::
## Things to know about Google
A few provider-specific details that matter when creating events on Google Calendar and Google Workspace accounts.
### Restricted scope required for writes
This is the biggest difference from reading events. Any operation that creates, updates, or deletes events needs the `calendar` or `calendar.events` scope, both of which are restricted. Google requires a third-party security assessment before your app can request these scopes in production. During development, you can use the scopes with test users, but plan for the assessment timeline (it can take several weeks) before launching.
See the [security assessment guide](/docs/provider-guides/google/google-verification-security-assessment-guide/) for details on what the process involves.
### Google Meet auto-creation
You can automatically generate a Google Meet link when creating an event by including `conferencing.autocreate` in your request body:
```json
{
"conferencing": {
"provider": "Google Meet",
"autocreate": {}
}
}
```
No extra OAuth scopes are needed for Google Meet auto-creation since conferencing is considered part of the event. You can also manually attach a Meet, Zoom, or Microsoft Teams link by passing the `conferencing.details` object instead. See the [conferencing guide](/docs/v3/calendar/add-conferencing/) for all the options.
### Event types are read-only
Google Calendar supports special event types like `focusTime`, `outOfOffice`, and `workingLocation`, but you can't create these through any API. They're managed exclusively through the Google Calendar UI. The Events API only creates `default` type events. If your app needs to display these special types, you can [read them](/docs/cookbook/calendar/events/list-events-google/#filter-by-event-type) from existing calendars, but you can't programmatically create them.
### Color IDs
Google supports numeric color IDs for event-level color overrides. Pass a string value from `"1"` through `"11"` in the event's `color_id` field to set the color. These map to Google Calendar's fixed color palette. Other providers handle event colors differently or not at all, so don't rely on this field if you're building for multiple providers.
### All-day events
To create an all-day event, use the `datespan` format in the `when` object instead of `start_time`/`end_time`. The end date is exclusive, meaning it should be the day after the last day of the event:
```json
{
"when": {
"start_date": "2025-06-15",
"end_date": "2025-06-16"
}
}
```
A two-day event on June 15-16 would have `end_date` set to `"2025-06-17"`. This matches the iCalendar spec and Google's own behavior, but it catches people off guard.
### Room resources
Google Workspace accounts support booking meeting rooms by including room resource email addresses in the `resources` field. Rooms must belong to the user's Google Workspace organization. Personal Google accounts don't have access to room resources.
```json
{
"resources": [
{
"email": "conference-room@resource.calendar.google.com"
}
]
}
```
### Recurring events
You can create recurring events by including an `recurrence` array with RRULE strings. Google keeps existing overrides when you modify a recurrence pattern, which is different from Microsoft where overrides get removed on pattern changes. For all the details on creating and managing recurring events, see the [recurring events guide](/docs/v3/calendar/recurring-events/).
### Rate limits
Google enforces calendar API quotas at two levels:
- **Per-user:** Each authenticated user has per-minute and daily limits for API calls
- **Per-project:** Your GCP project has an overall daily limit across all users
Write operations are more heavily rate-limited than reads. If your app creates events for many users, you'll hit project quotas faster than you might expect. Use [webhooks](/docs/v3/notifications/) instead of polling to track event changes, and consider setting up [Google Pub/Sub](/docs/provider-guides/google/connect-google-pub-sub/) for real-time sync with lower latency.
## What's next
- [Events API reference](/docs/reference/api/events/) for full endpoint documentation and all available parameters
- [Using the Events API](/docs/v3/calendar/using-the-events-api/) for updating and deleting events
- [List Google events](/docs/cookbook/calendar/events/list-events-google/) for retrieving events from Google Calendar accounts
- [Add conferencing](/docs/v3/calendar/add-conferencing/) to attach Google Meet, Zoom, or Teams links to events
- [Recurring events](/docs/v3/calendar/recurring-events/) for creating and managing repeating events
- [Availability](/docs/v3/calendar/calendar-availability/) to check free/busy status before creating events
- [Webhooks](/docs/v3/notifications/) for real-time notifications instead of polling
- [Google Pub/Sub](/docs/provider-guides/google/connect-google-pub-sub/) for real-time sync with Google accounts
- [Google provider guide](/docs/provider-guides/google/) for full Google setup including OAuth scopes and verification
- [Google verification and security assessment](/docs/provider-guides/google/google-verification-security-assessment-guide/), required for restricted scopes in production
────────────────────────────────────────────────────────────────────────────────
title: "How to create iCloud calendar events"
description: "Create calendar events on iCloud Calendar accounts using the Nylas Calendar API. Covers app-specific passwords, CalDAV limitations, participant notifications, and the simpler feature set."
source: "https://developer.nylas.com/docs/cookbook/calendar/events/create-events-icloud/"
────────────────────────────────────────────────────────────────────────────────
Apple has no public calendar REST API. Creating events on iCloud Calendar natively means constructing iCalendar (ICS) payloads wrapped in XML envelopes and sending them over CalDAV, a protocol that requires persistent connections and manual credential management. Nylas handles all of that for you and exposes iCloud Calendar through the same [Events API](/docs/reference/api/events/) you use for Google and Microsoft.
This guide walks through creating events on iCloud Calendar accounts, including the app-specific password requirement, participant notification behavior, and the iCloud-specific limitations you should plan around.
## Why use Nylas instead of CalDAV directly?
CalDAV is functional, but building event creation on it directly comes with real costs:
- **ICS format construction.** Creating an event means building a valid iCalendar object with correct VTIMEZONE blocks, VEVENT properties, and RRULE syntax, all wrapped in a CalDAV PUT request. Nylas gives you a JSON body and a single POST endpoint.
- **No conferencing auto-create.** Google can generate Meet links automatically when you create an event. Microsoft can attach Teams links. CalDAV has nothing comparable. You would need to integrate with a conferencing provider separately.
- **No room resources.** CalDAV does not support the concept of room or resource booking. If your app needs meeting rooms, iCloud cannot provide them natively.
- **No programmatic password generation.** Every user must manually create an app-specific password through their Apple ID settings. This step cannot be automated.
- **Connection management.** You need to maintain CalDAV sessions per user, handle reconnections, and manage sync state yourself. Nylas does this behind the scenes.
If you only target iCloud and are comfortable with iCalendar format, CalDAV works. For multi-provider apps or faster development, Nylas removes the protocol-level complexity entirely.
## Before you begin
You'll need:
- A [Nylas application](/docs/v3/getting-started/) with a valid API key
- A [grant](/docs/v3/auth/) for an iCloud account using the **iCloud connector** (not generic IMAP)
- An iCloud connector configured in your Nylas application
:::info
**New to Nylas?** Start with the [quickstart guide](/docs/v3/getting-started/) to set up your app and connect a test account before continuing here.
:::
### App-specific passwords
iCloud requires **app-specific passwords** for third-party access. Unlike Google or Microsoft OAuth, there's no way to generate these programmatically. Each user must create one manually in their Apple ID settings.
Nylas supports two authentication flows for iCloud:
| Method | Best for |
| ----------------------------------- | ---------------------------------------------------------------------- |
| Hosted OAuth | Production apps where Nylas guides users through the app password flow |
| Bring Your Own (BYO) Authentication | Custom auth pages where you collect credentials directly |
With either method, users need to:
1. Go to [appleid.apple.com](https://appleid.apple.com/) and sign in
2. Navigate to **Sign-In and Security** then **App-Specific Passwords**
3. Generate a new app password
4. Use that password (not their regular iCloud password) when authenticating
:::warn
**App-specific passwords can't be generated via API.** Your app's onboarding flow should include clear instructions telling users how to create one. Users who enter their regular iCloud password will fail authentication.
:::
The full setup walkthrough is in the [iCloud provider guide](/docs/provider-guides/icloud/) and the [app passwords guide](/docs/provider-guides/app-passwords/).
## Create an event
Make a [Create Event request](/docs/reference/api/events/create-event/) with the grant ID and a `calendar_id` query parameter. Nylas creates the event on the specified calendar and returns the new event object with its `id`.
:::warn
**iCloud does not support `calendar_id=primary`.** You must call the [List Calendars endpoint](/docs/reference/api/calendar/get-all-calendars/) first to get the actual calendar ID for the account. The default calendar name varies by language and region, so always discover calendar IDs dynamically.
:::
:::error
**You're about to send a real event invite!** The following samples send an email from the account you connected to the Nylas API to any email addresses in the `participants` sub-object. Make sure you actually want to invite those addresses before running this request.
:::
```bash [createEvents-Request]
curl --compressed --request POST \
--url 'https://api.us.nylas.com/v3/grants//events?calendar_id=' \
--header 'Accept: application/json' \
--header 'Authorization: Bearer ' \
--header 'Content-Type: application/json' \
--data '{
"title": "Annual Philosophy Club Meeting",
"busy": true,
"conferencing": {
"provider": "Zoom Meeting",
"autocreate": {
"conf_grant_id": "",
"conf_settings": {
"settings": {
"join_before_host": true,
"waiting_room": false,
"mute_upon_entry": false,
"auto_recording": "none"
}
}
}
},
"participants": [
{
"name": "Leyah Miller",
"email": "leyah@example.com"
},
{
"name": "Nyla",
"email": "nyla@example.com"
}
],
"resources": [{
"name": "Conference room",
"email": "conference-room@example.com"
}],
"description": "Come ready to talk philosophy!",
"when": {
"start_time": 1674604800,
"end_time": 1722382420,
"start_timezone": "America/New_York",
"end_timezone": "America/New_York"
},
"location": "New York Public Library, Cave room",
"recurrence": [
"RRULE:FREQ=WEEKLY;BYDAY=MO",
"EXDATE:20211011T000000Z"
],
}'
```
```json [createEvents-Response]
{
"request_id": "1",
"data": {
"busy": true,
"calendar_id": "primary",
"conferencing": {
"details": {
"meeting_code": "",
"url": ""
},
"provider": "Google Meet"
},
"created_at": 1701974804,
"creator": {
"email": "leyah@example.com",
"name": "Leyah Miller"
},
"description": null,
"grant_id": "",
"hide_participants": false,
"html_link": "",
"ical_uid": "6aaaaaaame8kpgcid6hvd0q@google.com",
"id": "",
"object": "event",
"organizer": {
"email": "leyah@example.com",
"name": "Leyah Miller"
},
"participants": [
{
"email": "jenna.doe@example.com",
"status": "yes"
},
{
"email": "leyah@example.com",
"status": "yes"
}
],
"read_only": true,
"reminders": {
"overrides": null,
"use_default": true
},
"status": "confirmed",
"title": "Holiday check in",
"updated_at": 1701974915,
"when": {
"end_time": 1701978300,
"end_timezone": "America/Los_Angeles",
"object": "timespan",
"start_time": 1701977400,
"start_timezone": "America/Los_Angeles"
}
}
}
```
```js [createEvents-Node.js SDK]
import Nylas from "nylas";
const nylas = new Nylas({
apiKey: "",
apiUri: "",
});
const now = Math.floor(Date.now() / 1000); // Time in Unix timestamp format (in seconds)
async function createAnEvent() {
try {
const event = await nylas.events.create({
identifier: "",
requestBody: {
title: "Build With Nylas",
when: {
startTime: now,
endTime: now + 3600,
},
},
queryParams: {
calendarId: "",
},
});
console.log("Event:", event);
} catch (error) {
console.error("Error creating event:", error);
}
}
createAnEvent();
```
```python [createEvents-Python SDK]
from nylas import Client
nylas = Client(
"",
""
)
grant_id = ""
events = nylas.events.create(
grant_id,
request_body={
"title": 'Build With Nylas',
"when": {
"start_time": 1609372800,
"end_time": 1609376400
},
},
query_params={
"calendar_id": ""
}
)
print(events)
```
```ruby [createEvents-Ruby SDK]
!!!include(v3_code_samples/events/POST/ruby.rb)!!!
```
```java [createEvents-Java SDK]
!!!include(v3_code_samples/events/POST/java.java)!!!
```
```kt [createEvents-Kotlin SDK]
!!!include(v3_code_samples/events/POST/kotlin.kt)!!!
```
For iCloud accounts, replace `` in these samples with an actual calendar ID from the [List Calendars](/docs/reference/api/calendar/get-all-calendars/) response. The `primary` shortcut that works for Google and Microsoft is not available on iCloud.
## Add participants and send invitations
When you include participants in your create event request and set `notify_participants=true` (the default), Nylas sends invitation emails to each participant. On iCloud, these invitations go out as ICS file attachments via email, which is how CalDAV handles event notifications natively.
This is simpler than the notification systems on Google and Microsoft. There are no push notifications, no in-app notification bells, and no rich invitation cards. Participants receive a standard email with an ICS attachment they can accept or decline.
:::warn
**When `notify_participants=false`**, Nylas creates the event on the organizer's calendar only. Participants do not receive an invitation email and the event does not appear on their calendars.
:::
A few things to keep in mind:
- CalDAV sends invitations as ICS files. Some email clients render these as calendar invites with accept/decline buttons, while others show them as plain attachments.
- There is no way to customize the invitation email body through CalDAV. The content is generated automatically based on the event details.
- Participant response status (`accepted`, `declined`, `tentative`) syncs back through CalDAV, but with the latency you would expect from a polling-based protocol.
## Things to know about iCloud
iCloud Calendar runs on CalDAV, which gives it a different feature profile than Google or Microsoft. Here's what matters when creating events.
### No `primary` calendar shortcut
Google and Microsoft both support `calendar_id=primary` as a shorthand for the user's default calendar. iCloud does not. You must call the [List Calendars endpoint](/docs/reference/api/calendar/get-all-calendars/) first and pick the correct calendar ID from the response.
The default calendar name varies by language and region. English accounts typically have a calendar called "Calendar" or "Home", but don't hardcode that. Always discover calendar IDs dynamically.
### No conferencing auto-create
Google can automatically generate Meet links when you create an event, and Microsoft can attach Teams links. iCloud has no equivalent. CalDAV does not support any conferencing integration.
If your users need a video call link on the event, you can include the URL in the `location` or `description` field manually. This works, but you will need to handle the conferencing provider integration yourself.
### No room resources
iCloud does not support the `resources` field. CalDAV has no concept of room or resource booking. If your app needs meeting room scheduling alongside event creation, iCloud cannot provide it. Events that include `resources` in the request body will have that field ignored.
### Simpler event model
CalDAV supports the core event fields and not much else. On iCloud:
- `title`, `when`, `participants`, `description`, `location`, and `recurrence` all work as expected
- `event_type` is not available (no focus time, out of office, or working location support)
- `color_id` is not exposed through CalDAV. Calendar and event colors are managed locally in the Apple Calendar app
- `capacity` is not supported
The core fields cover most use cases. If your app depends on extended event properties, test against iCloud specifically to confirm what comes back.
### All-day events
Use the `datespan` format for all-day events, the same as Google and Microsoft. Set the `when` object with a `start_date` and `end_date` in `YYYY-MM-DD` format. The end date is exclusive, so a single-day event on March 5 would use `start_date: "2026-03-05"` and `end_date: "2026-03-06"`.
### Recurring events
Standard RRULE support works through CalDAV using iCalendar (RFC 5545) recurrence rules. Nylas expands recurring events into individual instances, just like it does for other providers. You can create recurring events by including an `recurrence` array in the request body.
For details on managing recurring event series, see the [recurring events guide](/docs/v3/calendar/recurring-events/).
### App-specific passwords can break
If a user revokes their app-specific password through their Apple ID settings, all API calls for that grant will fail. There is no way to detect a revoked password proactively. Use [webhooks](/docs/v3/notifications/) to listen for `grant.expired` events so your app can prompt the user to re-authenticate.
Your onboarding flow should set clear expectations: if the user deletes their app password, their calendar integration stops working until they create a new one.
### Sync timing
CalDAV sync can be slower than Google's push notifications or Microsoft's change subscriptions. Events you create through the API may take a few minutes to appear in Apple Calendar apps on the user's devices. This latency is inherent to CalDAV and not something Nylas or your app can control.
Use [webhooks](/docs/v3/notifications/) rather than polling to detect changes. Nylas monitors for updates and sends notifications when events are created, updated, or deleted.
## What's next
- [Events API reference](/docs/reference/api/events/) for full endpoint documentation and all available parameters
- [Using the Events API](/docs/v3/calendar/using-the-events-api/) for updating, deleting, and managing events
- [List iCloud calendar events](/docs/cookbook/calendar/events/list-events-icloud/) for retrieving events from iCloud accounts
- [Recurring events](/docs/v3/calendar/recurring-events/) for expanding and managing recurring event series
- [Availability](/docs/v3/calendar/calendar-availability/) for checking free/busy status across calendars
- [Webhooks](/docs/v3/notifications/) for real-time notifications instead of polling
- [iCloud provider guide](/docs/provider-guides/icloud/) for full iCloud setup including authentication
- [App passwords guide](/docs/provider-guides/app-passwords/) for generating app-specific passwords for iCloud and other providers
────────────────────────────────────────────────────────────────────────────────
title: "How to create Microsoft calendar events"
description: "Create calendar events on Microsoft 365 and Outlook accounts using the Nylas Calendar API. Covers write scopes, Teams conferencing, participant notifications, recurring events, and room resources."
source: "https://developer.nylas.com/docs/cookbook/calendar/events/create-events-microsoft/"
────────────────────────────────────────────────────────────────────────────────
Creating events on Microsoft 365 and Outlook calendars through Microsoft Graph means registering an Azure AD app, managing MSAL tokens, passing Windows timezone IDs in request bodies, and configuring admin consent for write access. If you want to support multiple calendar providers, you also need to build and maintain separate integrations for each one.
Nylas handles all of that behind a single REST API. You send the same create event request whether the account is Microsoft, Google, or iCloud. Nylas takes care of authentication, timezone conversion, and provider-specific formatting. This guide walks through creating events on Microsoft accounts, including participants, conferencing, recurring events, and the write-specific details you need to know.
## Why use Nylas instead of Microsoft Graph directly?
Writing to Microsoft calendars through Graph is more involved than reading. You need `Calendars.ReadWrite` permissions, which are more likely to require admin consent in enterprise tenants. Request bodies need Windows timezone IDs. Attaching a Teams meeting requires a specific conferencing object structure. Notification behavior differs depending on how you configure the request, and error messages from Graph can be opaque when something goes wrong.
Nylas simplifies all of this. You pass IANA timezones (like `America/New_York`), and Nylas converts them for Microsoft. Conferencing auto-creation works through a single `autocreate` object. Participant notifications are controlled with one query parameter. Your create event code works identically across providers.
That said, if you only target Microsoft accounts and already have a working Graph integration, there's no need to switch.
## Before you begin
You'll need:
- A [Nylas application](/docs/v3/getting-started/) with a valid API key
- A [grant](/docs/v3/auth/) for a Microsoft 365 or Outlook account
- The `Calendars.ReadWrite` scope enabled in your Azure AD app registration (note: creating events requires the **write** scope, not just `Calendars.Read`)
:::info
**New to Nylas?** Start with the [quickstart guide](/docs/v3/getting-started/) to set up your app and connect a test account before continuing here.
:::
### Microsoft admin consent
Microsoft organizations often require admin approval before third-party apps can access calendar data. Write scopes like `Calendars.ReadWrite` are more likely to trigger this requirement than read-only scopes. If your users see a "Need admin approval" screen during auth, their organization restricts user consent.
You have two options:
- **Ask the tenant admin** to grant consent for your app via the Azure portal
- **Configure your Azure app** to request only permissions that don't need admin consent
Nylas has a detailed walkthrough: [Configuring Microsoft admin approval](/docs/provider-guides/microsoft/admin-approval/). If you're targeting enterprise customers, you'll almost certainly need to deal with this.
You also need to be a [verified publisher](/docs/provider-guides/microsoft/verification-guide/). Microsoft requires publisher verification since November 2020, and without it users see an error during auth.
## Create an event
:::error
**You're about to send a real event invite!** The following samples send an email from the account you connected to the Nylas API to any email addresses you put in the `participants` sub-object. Make sure you actually want to send this invite to those addresses before running this command!
:::
Make a [Create Event request](/docs/reference/api/events/create-event/) with the grant ID and a `calendar_id` query parameter. You can use `primary` as the `calendar_id` to target the account's default calendar.
```bash [createEvents-Request]
curl --compressed --request POST \
--url 'https://api.us.nylas.com/v3/grants//events?calendar_id=' \
--header 'Accept: application/json' \
--header 'Authorization: Bearer ' \
--header 'Content-Type: application/json' \
--data '{
"title": "Annual Philosophy Club Meeting",
"busy": true,
"conferencing": {
"provider": "Zoom Meeting",
"autocreate": {
"conf_grant_id": "",
"conf_settings": {
"settings": {
"join_before_host": true,
"waiting_room": false,
"mute_upon_entry": false,
"auto_recording": "none"
}
}
}
},
"participants": [
{
"name": "Leyah Miller",
"email": "leyah@example.com"
},
{
"name": "Nyla",
"email": "nyla@example.com"
}
],
"resources": [{
"name": "Conference room",
"email": "conference-room@example.com"
}],
"description": "Come ready to talk philosophy!",
"when": {
"start_time": 1674604800,
"end_time": 1722382420,
"start_timezone": "America/New_York",
"end_timezone": "America/New_York"
},
"location": "New York Public Library, Cave room",
"recurrence": [
"RRULE:FREQ=WEEKLY;BYDAY=MO",
"EXDATE:20211011T000000Z"
],
}'
```
```json [createEvents-Response]
{
"request_id": "1",
"data": {
"busy": true,
"calendar_id": "primary",
"conferencing": {
"details": {
"meeting_code": "",
"url": ""
},
"provider": "Google Meet"
},
"created_at": 1701974804,
"creator": {
"email": "leyah@example.com",
"name": "Leyah Miller"
},
"description": null,
"grant_id": "",
"hide_participants": false,
"html_link": "",
"ical_uid": "6aaaaaaame8kpgcid6hvd0q@google.com",
"id": "",
"object": "event",
"organizer": {
"email": "leyah@example.com",
"name": "Leyah Miller"
},
"participants": [
{
"email": "jenna.doe@example.com",
"status": "yes"
},
{
"email": "leyah@example.com",
"status": "yes"
}
],
"read_only": true,
"reminders": {
"overrides": null,
"use_default": true
},
"status": "confirmed",
"title": "Holiday check in",
"updated_at": 1701974915,
"when": {
"end_time": 1701978300,
"end_timezone": "America/Los_Angeles",
"object": "timespan",
"start_time": 1701977400,
"start_timezone": "America/Los_Angeles"
}
}
}
```
```js [createEvents-Node.js SDK]
import Nylas from "nylas";
const nylas = new Nylas({
apiKey: "",
apiUri: "",
});
const now = Math.floor(Date.now() / 1000); // Time in Unix timestamp format (in seconds)
async function createAnEvent() {
try {
const event = await nylas.events.create({
identifier: "",
requestBody: {
title: "Build With Nylas",
when: {
startTime: now,
endTime: now + 3600,
},
},
queryParams: {
calendarId: "",
},
});
console.log("Event:", event);
} catch (error) {
console.error("Error creating event:", error);
}
}
createAnEvent();
```
```python [createEvents-Python SDK]
from nylas import Client
nylas = Client(
"",
""
)
grant_id = ""
events = nylas.events.create(
grant_id,
request_body={
"title": 'Build With Nylas',
"when": {
"start_time": 1609372800,
"end_time": 1609376400
},
},
query_params={
"calendar_id": ""
}
)
print(events)
```
```ruby [createEvents-Ruby SDK]
!!!include(v3_code_samples/events/POST/ruby.rb)!!!
```
```java [createEvents-Java SDK]
!!!include(v3_code_samples/events/POST/java.java)!!!
```
```kt [createEvents-Kotlin SDK]
!!!include(v3_code_samples/events/POST/kotlin.kt)!!!
```
The `calendar_id` query parameter is required. For Microsoft accounts, calendar IDs are long base64-encoded strings, but `primary` works as a shortcut to target the default calendar. Nylas returns the created event with its `id`, which you can use for subsequent updates or deletions.
## Add participants and send invitations
The `notify_participants` query parameter controls whether Microsoft sends email invitations to everyone in the `participants` array. It defaults to `true`, so participants receive calendar invitations automatically unless you explicitly disable it.
When `notify_participants` is set to `false`, the event is created only on the organizer's calendar. Participants don't receive an email, an ICS file, or any notification. The event won't appear on their calendars at all.
Here's an example with notifications explicitly enabled:
```bash
curl --request POST \
--url 'https://api.us.nylas.com/v3/grants//events?calendar_id=primary¬ify_participants=true' \
--header 'Authorization: Bearer ' \
--header 'Content-Type: application/json' \
--data '{
"title": "Project kickoff",
"when": {
"start_time": 1674604800,
"end_time": 1674608400,
"start_timezone": "America/New_York",
"end_timezone": "America/New_York"
},
"participants": [
{
"name": "Jordan Lee",
"email": "jordan@example.com"
}
]
}'
```
:::warn
**Keep in mind**: When `notify_participants=false`, your request doesn't create an event for the participant. Participants don't receive a message or an ICS file.
:::
## Things to know about Microsoft
A few provider-specific details that matter when you're creating events on Microsoft calendar accounts.
### Write scopes require admin consent more often
The `Calendars.ReadWrite` scope is more likely to need admin approval than `Calendars.Read`, especially in enterprise tenants with strict consent policies. If your app previously worked with read-only access but fails on event creation, this is probably why. Check that the grant has the write scope and that the tenant admin has approved it.
### Teams conferencing
You can automatically create a Microsoft Teams meeting when creating an event by using the `conferencing` object with `autocreate`. Nylas provisions the Teams link and attaches the join URL and dial-in details to the event. You can also manually add a Teams link by passing a `conferencing` object with `provider` set to `"Microsoft Teams"` and the meeting URL in `details`.
For the full setup, including configuring connectors for auto-creation, see [Adding conferencing to events](/docs/v3/calendar/add-conferencing/). You need an active Microsoft 365 subscription for Teams conferencing to work.
### Timezones in request bodies
Nylas accepts IANA timezone identifiers in `start_timezone` and `end_timezone` (like `America/New_York` or `Europe/London`). You don't need to convert to Windows timezone IDs the way you would with Microsoft Graph directly. Nylas handles the conversion before sending the request to Microsoft.
If you omit the timezone fields, Nylas uses the account's default timezone.
### All-day events
To create an all-day event, use a `datespan` type with `start_date` and `end_date` as date strings (formatted `YYYY-MM-DD`). The end date is exclusive, meaning a single-day event on December 1st should have `start_date: "2024-12-01"` and `end_date: "2024-12-02"`. This matches how Microsoft Graph represents all-day events internally and is consistent across providers.
```json
"when": {
"start_date": "2024-12-01",
"end_date": "2024-12-02"
}
```
### Room resources
Microsoft supports booking conference rooms and other resources when creating events. Pass the room's email address in the `resources` array:
```json
"resources": [{
"name": "Board Room 3A",
"email": "boardroom-3a@example.com"
}]
```
The room must be accessible to the organizer's account. If the room has an approval workflow or is restricted to certain groups, the booking may be declined. Check your organization's room mailbox settings if bookings aren't going through.
### Recurring events
You can create recurring events by including a `recurrence` array with RRULE strings. Microsoft has a few limitations worth knowing:
- **Overrides are removed on recurrence change.** If you modify a recurring series pattern (for example, changing from weekly to daily), Microsoft removes all existing overrides. Google keeps them if they still fit the pattern.
- **No multi-day monthly BYDAY.** You can't create a monthly recurring event on multiple days of the week (like the first and third Thursday). Microsoft's recurrence model doesn't support different indices within a single rule.
- **EXDATE recovery isn't possible.** Once you remove an occurrence from a recurring series, you can't undo it through Nylas. You'd need to create a separate standalone event to fill the gap.
For the full breakdown of recurring event behavior and provider differences, see [Recurring events](/docs/v3/calendar/recurring-events/).
### Rate limits
Write operations count toward Microsoft's per-mailbox rate limits, and create requests are heavier than reads. If your app triggers a `429` response, Nylas handles the retry automatically with appropriate backoff.
If you're creating events in bulk (for example, migrating a calendar), space out requests to avoid hitting limits. For real-time awareness of event changes after creation, use [webhooks](/docs/v3/notifications/) instead of polling.
## What's next
- [Events API reference](/docs/reference/api/events/) for full endpoint documentation and all available parameters
- [Using the Events API](/docs/v3/calendar/using-the-events-api/) for updating, deleting, and managing events
- [List Microsoft calendar events](/docs/cookbook/calendar/events/list-events-microsoft/) to read events from Microsoft accounts
- [Add conferencing to events](/docs/v3/calendar/add-conferencing/) to attach Teams, Zoom, or other meeting links
- [Recurring events](/docs/v3/calendar/recurring-events/) for series creation, overrides, and provider-specific behavior
- [Availability](/docs/v3/calendar/calendar-availability/) to check free/busy across multiple calendars
- [Webhooks](/docs/v3/notifications/) for real-time notifications when events change
- [Microsoft admin approval](/docs/provider-guides/microsoft/admin-approval/) to configure consent for enterprise organizations
- [Microsoft publisher verification](/docs/provider-guides/microsoft/verification-guide/), required for production apps
────────────────────────────────────────────────────────────────────────────────
title: "How to list Exchange calendar events"
description: "Retrieve calendar events from Exchange on-premises servers using the Nylas Calendar API. Covers EWS vs Microsoft Graph, autodiscovery, on-prem networking, and recurring event restrictions."
source: "https://developer.nylas.com/docs/cookbook/calendar/events/list-events-ews/"
────────────────────────────────────────────────────────────────────────────────
Exchange on-premises servers are still common in enterprise environments, especially in regulated industries and government organizations. If your users run self-hosted Exchange (2007 or later), Nylas connects to their calendars through Exchange Web Services (EWS) - a separate protocol from the Microsoft Graph API used for Exchange Online and Microsoft 365.
The same [Events API](/docs/reference/api/events/) you use for Google Calendar and Outlook works for Exchange on-prem accounts. This guide covers the EWS-specific details: when to use EWS vs. Microsoft Graph, authentication and autodiscovery, recurring event restrictions, and on-prem networking considerations.
## EWS vs. Microsoft Graph: which one?
This is the first thing to figure out. The two provider types target different Exchange deployments:
| Provider type | Connector | Use when |
| --------------------- | ----------- | ------------------------------------------------------- |
| Microsoft Graph | `microsoft` | Exchange Online, Microsoft 365, Office 365, Outlook.com |
| Exchange Web Services | `ews` | Self-hosted Exchange servers (on-premises) |
If the user's calendar is hosted by Microsoft in the cloud, use the [Microsoft guide](/docs/cookbook/calendar/events/list-events-microsoft/) instead. The `ews` connector is specifically for organizations that run their own Exchange servers.
:::warn
**Microsoft announced EWS retirement** and recommends migrating to Microsoft Graph. However, many organizations still run on-premises Exchange servers where EWS is the only option. Nylas continues to support EWS for these environments.
:::
## Why use Nylas instead of EWS directly?
EWS is a SOAP-based XML API. Every request requires building XML SOAP envelopes, every response needs XML parsing, and errors come back as SOAP faults with nested XML structures. Calendar operations are particularly verbose in EWS - creating a recurring event with attendees, timezone rules, and reminders means constructing deeply nested XML payloads. You also need to handle autodiscovery to find the right server endpoint (which is frequently misconfigured), manage credential-based authentication with support for two-factor app passwords, and deal with Exchange's recurrence model that doesn't map cleanly to iCalendar standards.
Nylas replaces all of that with a JSON REST API. No XML, no WSDL, no SOAP. Authentication and autodiscovery are handled automatically. Your code stays the same whether you're reading calendar events from Exchange on-prem, Exchange Online, Google Calendar, or iCloud.
If you have deep EWS experience and only target Exchange on-prem, direct integration is an option. For multi-provider calendar support or faster time-to-integration, Nylas is the simpler path.
## Before you begin
You'll need:
- A [Nylas application](/docs/v3/getting-started/) with a valid API key
- A [grant](/docs/v3/auth/) for an Exchange on-premises account
- An EWS connector configured with the `ews.calendars` scope
- The Exchange server accessible from outside the corporate network (not behind a VPN or firewall that blocks external access)
:::info
**New to Nylas?** Start with the [quickstart guide](/docs/v3/getting-started/) to set up your app and connect a test account before continuing here.
:::
### Autodiscovery and authentication
EWS uses credential-based authentication. During the auth flow, users sign in with their Exchange credentials - typically the same username and password they use for Windows login. The username format is usually `user@example.com` or `DOMAIN\username`.
If EWS autodiscovery is configured on the server, Nylas automatically locates the correct EWS endpoint. If autodiscovery is disabled or misconfigured, users can click "Additional settings" during authentication and manually enter the Exchange server address (for example, `mail.company.com`).
:::info
**Users with two-factor authentication** must generate an app password instead of using their regular password. See [Microsoft's app password documentation](https://support.microsoft.com/en-us/help/12409/) for instructions.
:::
Create an EWS connector with the scopes your app needs:
| Scope | Access |
| --------------- | ------------------------------------- |
| `ews.messages` | Email API (messages, drafts, folders) |
| `ews.calendars` | Calendar API |
| `ews.contacts` | Contacts API |
The full setup walkthrough is in the [Exchange on-premises provider guide](/docs/provider-guides/exchange-on-prem/).
## List events
Make a [List Events request](/docs/reference/api/events/get-all-events/) with the grant ID and a `calendar_id`. Nylas returns the most recent events by default. You can use `primary` as the `calendar_id` to target the account's default calendar. These examples limit results to 5:
```bash [listEvents-Request]
curl --compressed --request GET \
--url 'https://api.us.nylas.com/v3/grants//events?calendar_id=&start=&end=' \
--header 'Accept: application/json' \
--header 'Authorization: Bearer ' \
--header 'Content-Type: application/json'
```
```json [listEvents-Response]
{
"request_id": "cbd60372-df33-41d3-b203-169ad5e3AAAA",
"data": [
{
"busy": true,
"calendar_id": "primary",
"conferencing": {
"details": {
"meeting_code": "ist-****-tcz",
"url": "https://meet.google.com/ist-****-tcz"
},
"provider": "Google Meet"
},
"created_at": 1701974804,
"creator": {
"email": "anna.molly@example.com",
"name": ""
},
"description": null,
"grant_id": "1e3288f6-124e-405d-a13a-635a2ee54eb2",
"hide_participants": false,
"html_link": "https://www.google.com/calendar/event?eid=NmE0dXIwabQAAAA",
"ical_uid": "6aaaaaaame8kpgcid6hvd0q@google.com",
"id": "6aaaaaaame8kpgcid6hvd",
"object": "event",
"organizer": {
"email": "anna.molly@example.com",
"name": ""
},
"participants": [
{
"email": "jenna.doe@example.com",
"status": "yes"
},
{
"email": "anna.molly@example.com",
"status": "yes"
}
],
"read_only": true,
"reminders": {
"overrides": null,
"use_default": true
},
"status": "confirmed",
"title": "Holiday check in",
"updated_at": 1701974915,
"when": {
"end_time": 1701978300,
"end_timezone": "America/Los_Angeles",
"object": "timespan",
"start_time": 1701977400,
"start_timezone": "America/Los_Angeles"
}
}
]
}
```
```js [listEvents-Node.js SDK]
import Nylas from "nylas";
const nylas = new Nylas({
apiKey: "",
apiUri: "",
});
async function fetchAllEventsFromCalendar() {
try {
const events = await nylas.events.list({
identifier: "",
queryParams: {
calendarId: "",
},
});
console.log("Events:", events);
} catch (error) {
console.error("Error fetching calendars:", error);
}
}
fetchAllEventsFromCalendar();
```
```python [listEvents-Python SDK]
from nylas import Client
nylas = Client(
"",
""
)
grant_id = ""
events = nylas.events.list(
grant_id,
query_params={
"calendar_id": ""
}
)
print(events)
```
```ruby [listEvents-Ruby SDK]
!!!include(v3_code_samples/events/GET/ruby.rb)!!!
```
```java [listEvents-Java SDK]
!!!include(v3_code_samples/events/GET/java.java)!!!
```
```kt [listEvents-Kotlin SDK]
!!!include(v3_code_samples/events/GET/kotlin.kt)!!!
```
The `calendar_id=primary` shortcut works for EWS accounts, targeting the user's default calendar. The response format is identical across providers, so your parsing logic works the same for Exchange on-prem, Exchange Online, Google, and iCloud.
## Filter events
You can narrow results with query parameters. Here's what works with Exchange accounts:
| Parameter | What it does | Example |
| --------------- | -------------------------------------------- | ---------------------------------- |
| `calendar_id` | **Required.** Filter by calendar | `?calendar_id=primary` |
| `title` | Match on event title (case insensitive) | `?title=standup` |
| `description` | Match on description (case insensitive) | `?description=quarterly` |
| `location` | Match on location (case insensitive) | `?location=Room%20A` |
| `start` | Events starting at or after a Unix timestamp | `?start=1706000000` |
| `end` | Events ending at or before a Unix timestamp | `?end=1706100000` |
| `attendees` | Filter by attendee email (comma-delimited) | `?attendees=alex@example.com` |
| `busy` | Filter by busy status | `?busy=true` |
| `metadata_pair` | Filter by metadata key-value pair | `?metadata_pair=project_id:abc123` |
Exchange (EWS) supports several additional filter parameters:
- `tentative_as_busy` - treat tentative events as busy when checking availability (defaults to `true`)
- `updated_after` / `updated_before` - filter by last-modified timestamp, useful for incremental sync
- `ical_uid` - find a specific event by its iCalendar UID
- `master_event_id` - list all instances and overrides for a specific recurring series
A few parameters are **not supported** on EWS:
- `show_cancelled` - Exchange does not support retrieving cancelled events
- `event_type` - this filter is Google-only
Combining filters works the way you'd expect. This example pulls events in a specific time range:
```bash [filterEvents-curl]
curl --request GET \
--url "https://api.us.nylas.com/v3/grants//events?calendar_id=primary&title=standup&start=1706000000&end=1706100000&limit=10" \
--header 'Accept: application/json' \
--header 'Authorization: Bearer '
```
```js [filterEvents-Node.js SDK]
const events = await nylas.events.list({
identifier: grantId,
queryParams: {
calendarId: "primary",
title: "standup",
start: 1706000000,
end: 1706100000,
limit: 10,
},
});
```
```python [filterEvents-Python SDK]
events = nylas.events.list(
grant_id,
query_params={
"calendar_id": "primary",
"title": "standup",
"start": 1706000000,
"end": 1706100000,
"limit": 10,
}
)
```
## Things to know about Exchange
Exchange on-prem behaves differently from Exchange Online (Microsoft Graph) in several ways that matter for calendar integrations.
### Tentative events
Exchange treats tentative events as busy by default when calculating availability, the same behavior as Microsoft Graph. The `tentative_as_busy` query parameter controls this. If your app needs to distinguish between confirmed and tentative events, check the `status` field on each event object rather than relying on the default availability calculation.
### No cancelled event retrieval
The `show_cancelled` parameter is not supported on EWS. When an organizer cancels an event or removes an occurrence from a recurring series, Exchange deletes it entirely rather than marking it as cancelled. You cannot retrieve cancelled events from Exchange on-prem accounts through the Nylas API.
This is important if you're building audit or compliance features that need to track event cancellations. Consider using [webhooks](/docs/v3/notifications/) to capture deletion events in real time before they become unrecoverable.
### Recurring event restrictions
Microsoft Exchange has specific constraints around recurring events that don't apply to Google Calendar:
- **No overlapping instances.** You cannot reschedule an instance of a recurring event to fall on the same day as, or the day before, the previous instance. Exchange rejects the update to prevent overlapping occurrences within a series.
- **Overrides removed on recurrence change.** If you modify the recurrence pattern of a series (for example, changing from weekly to daily), Exchange removes all existing overrides. Google keeps them if they still fit the new pattern.
- **EXDATE recovery is not possible.** Once you remove an occurrence from a recurring series, there is no way to restore it. You would need to create a standalone event to fill the gap.
- **No multi-day monthly BYDAY.** You cannot create a monthly recurring event on multiple days of the week (like the first and third Thursday). Exchange's recurrence model does not support different indices within a single rule.
For the full breakdown of provider-specific recurring event behavior, see [Recurring events](/docs/v3/calendar/recurring-events/).
### On-prem networking
The Exchange server's EWS endpoint must be reachable from Nylas infrastructure. This is the most common source of connection failures for on-prem deployments.
- **EWS must be enabled** on the server and exposed outside the corporate network
- If the server is behind a **firewall**, you need to allow Nylas's IP addresses (available on contract plans with [static IPs](/docs/dev-guide/platform/#static-ips))
- A **reverse proxy** in front of the Exchange server is a common workaround if direct firewall rules are not feasible
- Accounts in admin groups are not supported
If calendar data is not syncing for an Exchange account, verify that the EWS endpoint is accessible before investigating other causes.
### Autodiscovery
Nylas uses Exchange autodiscovery to locate the EWS endpoint automatically during authentication. This works well when autodiscovery is properly configured on the Exchange server. When it is not, users must manually provide the server address.
If users report authentication failures, the Exchange administrator can test autodiscovery using Microsoft's [Remote Connectivity Analyzer](https://testconnectivity.microsoft.com/). Misconfigured autodiscovery is one of the most common issues with Exchange on-prem integrations.
### Timezone handling
Exchange stores timezone information using Windows timezone identifiers like "Eastern Standard Time" or "Pacific Standard Time." Nylas normalizes these to IANA identifiers (like "America/New_York" or "America/Los_Angeles") automatically, so event times in API responses always use IANA format. You do not need to maintain a Windows-to-IANA mapping table in your application.
### Sync timing
Calendar sync performance for Exchange on-prem depends on the EWS server's responsiveness and network latency between Nylas infrastructure and the Exchange server. On-prem servers with high load or limited bandwidth may introduce noticeable sync delays compared to cloud-hosted Exchange.
For apps that need real-time awareness of calendar changes, use [webhooks](/docs/v3/notifications/) instead of polling. Nylas pushes a notification to your server as soon as the event syncs, regardless of the underlying server speed.
### Rate limits are admin-configured
Unlike Google and Microsoft's cloud services, Exchange on-prem rate limits are set by the server administrator. Nylas cannot predict what they will be. If the Exchange server throttles a request, Nylas returns a `Retry-After` header with the number of seconds to wait.
For apps that check calendars frequently, [webhooks](/docs/v3/notifications/) are the best way to avoid hitting rate limits. Let Nylas notify you of changes instead of polling.
## Paginate through results
The Events API returns paginated responses. When there are more results, the response includes a `next_cursor` value. Pass it back as `page_token` to get the next page:
```bash [paginateEvents-curl]
curl --request GET \
--url "https://api.us.nylas.com/v3/grants//events?calendar_id=primary&limit=10&page_token=" \
--header 'Accept: application/json' \
--header 'Authorization: Bearer '
```
```js [paginateEvents-Node.js SDK]
let pageCursor = undefined;
do {
const result = await nylas.events.list({
identifier: grantId,
queryParams: {
calendarId: "primary",
limit: 10,
pageToken: pageCursor,
},
});
// Process result.data here
pageCursor = result.nextCursor;
} while (pageCursor);
```
```python [paginateEvents-Python SDK]
page_cursor = None
while True:
query = {"calendar_id": "primary", "limit": 10}
if page_cursor:
query["page_token"] = page_cursor
result = nylas.events.list(grant_id, query_params=query)
# Process result.data here
page_cursor = result.next_cursor
if not page_cursor:
break
```
Keep paginating until the response comes back without a `next_cursor`.
## What's next
- [Events API reference](/docs/reference/api/events/) for full endpoint documentation and all available parameters
- [Using the Events API](/docs/v3/calendar/using-the-events-api/) for creating, updating, and deleting events
- [Recurring events](/docs/v3/calendar/recurring-events/) for series creation, overrides, and provider-specific behavior
- [Availability](/docs/v3/calendar/calendar-availability/) to check free/busy across multiple calendars
- [Webhooks](/docs/v3/notifications/) for real-time notifications instead of polling
- [Exchange on-premises provider guide](/docs/provider-guides/exchange-on-prem/) for full Exchange setup including authentication and network requirements
- [Microsoft guide](/docs/cookbook/calendar/events/list-events-microsoft/) for cloud-hosted Exchange (Microsoft 365, Exchange Online)
────────────────────────────────────────────────────────────────────────────────
title: "How to list Google calendar events"
description: "Retrieve calendar events from Google Calendar and Google Workspace accounts using the Nylas Calendar API. Covers event types, Google Meet conferencing, OAuth scopes, and rate limits."
source: "https://developer.nylas.com/docs/cookbook/calendar/events/list-events-google/"
────────────────────────────────────────────────────────────────────────────────
Google Calendar is the most common calendar provider developers integrate with, and the Google Calendar API comes with more setup friction than you might expect. Between the GCP project configuration, the tiered OAuth scope system, and Google's restricted-scope security assessment, there's a lot of overhead before you can make your first API call. On top of that, Google has its own concepts like event types (focus time, out of office), numeric color IDs, and non-standard recurring event behavior that don't map cleanly to other providers.
Nylas normalizes all of that. You get a single [Events API](/docs/reference/api/events/) that works across Google, Microsoft, iCloud, and Exchange without provider-specific branching. This guide covers listing events from Google Calendar accounts and the Google-specific details you should know about.
## Why use Nylas instead of the Google Calendar API directly?
The Google Calendar API requires more scaffolding than most developers anticipate:
- **GCP project and OAuth consent screen** - You need a GCP project, an OAuth consent screen, and the correct calendar scopes configured before anything works.
- **Three-tier scope system** - Google classifies calendar scopes as non-sensitive, sensitive, or restricted. Restricted scopes (like full read-write) require a third-party security assessment before you can go to production.
- **Google-specific data model** - Event types (`outOfOffice`, `focusTime`, `workingLocation`), numeric color IDs, and Google Meet auto-attachment are all concepts that don't exist on other providers.
- **Recurring event quirks** - Google marks cancelled occurrences as hidden events instead of removing them, and returns unsorted results when querying by `master_event_id`.
Nylas handles token management, scope negotiation, and data normalization. If you only need Google Calendar and want fine-grained control over every Google-specific field, the native API works. If you need multi-provider support or want to skip the verification process, Nylas is the faster path.
## Before you begin
You'll need:
- A [Nylas application](/docs/v3/getting-started/) with a valid API key
- A [grant](/docs/v3/auth/) for a Google Calendar or Google Workspace account
- The appropriate [Google OAuth scopes](/docs/provider-guides/google/) configured in your GCP project
:::info
**New to Nylas?** Start with the [quickstart guide](/docs/v3/getting-started/) to set up your app and connect a test account before continuing here.
:::
### Google OAuth scopes and verification
Google classifies OAuth scopes into three tiers, and each one comes with different verification requirements:
| Scope tier | Example | What's required |
| ------------- | ----------------------------------- | ------------------------------------------------- |
| Non-sensitive | `calendar.readonly` (metadata only) | No verification needed |
| Sensitive | `calendar.events.readonly` | OAuth consent screen verification |
| Restricted | `calendar.events`, `calendar` | Full security assessment by a third-party auditor |
If your app only needs to read events, the `calendar.events.readonly` scope is classified as sensitive. For full read-write access to calendars and events, you'll need the `calendar` scope, which is restricted and requires a [security assessment](/docs/provider-guides/google/google-verification-security-assessment-guide/).
Nylas handles token refresh and scope management, but your GCP project still needs the right scopes configured. See the [Google provider guide](/docs/provider-guides/google/) for the full setup.
## List events
Make a [List Events request](/docs/reference/api/events/get-all-events/) with the grant ID and a `calendar_id`. The `calendar_id` parameter is required for all events requests. You can use `primary` to target the user's default calendar. By default, Nylas returns the 50 most recent events sorted by start date:
```bash [listEvents-Request]
curl --compressed --request GET \
--url 'https://api.us.nylas.com/v3/grants//events?calendar_id=&start=&end=' \
--header 'Accept: application/json' \
--header 'Authorization: Bearer ' \
--header 'Content-Type: application/json'
```
```json [listEvents-Response]
{
"request_id": "cbd60372-df33-41d3-b203-169ad5e3AAAA",
"data": [
{
"busy": true,
"calendar_id": "primary",
"conferencing": {
"details": {
"meeting_code": "ist-****-tcz",
"url": "https://meet.google.com/ist-****-tcz"
},
"provider": "Google Meet"
},
"created_at": 1701974804,
"creator": {
"email": "anna.molly@example.com",
"name": ""
},
"description": null,
"grant_id": "1e3288f6-124e-405d-a13a-635a2ee54eb2",
"hide_participants": false,
"html_link": "https://www.google.com/calendar/event?eid=NmE0dXIwabQAAAA",
"ical_uid": "6aaaaaaame8kpgcid6hvd0q@google.com",
"id": "6aaaaaaame8kpgcid6hvd",
"object": "event",
"organizer": {
"email": "anna.molly@example.com",
"name": ""
},
"participants": [
{
"email": "jenna.doe@example.com",
"status": "yes"
},
{
"email": "anna.molly@example.com",
"status": "yes"
}
],
"read_only": true,
"reminders": {
"overrides": null,
"use_default": true
},
"status": "confirmed",
"title": "Holiday check in",
"updated_at": 1701974915,
"when": {
"end_time": 1701978300,
"end_timezone": "America/Los_Angeles",
"object": "timespan",
"start_time": 1701977400,
"start_timezone": "America/Los_Angeles"
}
}
]
}
```
```js [listEvents-Node.js SDK]
import Nylas from "nylas";
const nylas = new Nylas({
apiKey: "",
apiUri: "",
});
async function fetchAllEventsFromCalendar() {
try {
const events = await nylas.events.list({
identifier: "",
queryParams: {
calendarId: "",
},
});
console.log("Events:", events);
} catch (error) {
console.error("Error fetching calendars:", error);
}
}
fetchAllEventsFromCalendar();
```
```python [listEvents-Python SDK]
from nylas import Client
nylas = Client(
"",
""
)
grant_id = ""
events = nylas.events.list(
grant_id,
query_params={
"calendar_id": ""
}
)
print(events)
```
```ruby [listEvents-Ruby SDK]
!!!include(v3_code_samples/events/GET/ruby.rb)!!!
```
```java [listEvents-Java SDK]
!!!include(v3_code_samples/events/GET/java.java)!!!
```
```kt [listEvents-Kotlin SDK]
!!!include(v3_code_samples/events/GET/kotlin.kt)!!!
```
The same code works for Microsoft, iCloud, and Exchange accounts. Just swap the grant ID and Nylas handles the provider differences.
## Filter events
You can narrow results with query parameters. Here's what the Events API supports:
| Parameter | What it does | Example |
| --------------- | -------------------------------------------- | ---------------------------------- |
| `calendar_id` | **Required.** Filter by calendar | `?calendar_id=primary` |
| `title` | Match on event title (case insensitive) | `?title=standup` |
| `description` | Match on description (case insensitive) | `?description=quarterly` |
| `location` | Match on location (case insensitive) | `?location=Room%20A` |
| `start` | Events starting at or after a Unix timestamp | `?start=1706000000` |
| `end` | Events ending at or before a Unix timestamp | `?end=1706100000` |
| `attendees` | Filter by attendee email (comma-delimited) | `?attendees=alex@example.com` |
| `busy` | Filter by busy status | `?busy=true` |
| `metadata_pair` | Filter by metadata key-value pair | `?metadata_pair=project_id:abc123` |
Google accounts also support these additional filter parameters:
| Parameter | What it does | Example |
| ----------------- | -------------------------------------- | ----------------------------- |
| `show_cancelled` | Include cancelled events | `?show_cancelled=true` |
| `updated_after` | Events updated after a Unix timestamp | `?updated_after=1706000000` |
| `updated_before` | Events updated before a Unix timestamp | `?updated_before=1706100000` |
| `ical_uid` | Filter by iCalendar UID | `?ical_uid=abc123@google.com` |
| `master_event_id` | Get occurrences of a recurring event | `?master_event_id=evt_abc123` |
Here's how to combine filters. This pulls events with "standup" in the title within a specific time range:
```bash [filterEvents-curl]
curl --request GET \
--url "https://api.us.nylas.com/v3/grants//events?calendar_id=primary&title=standup&start=1706000000&end=1706100000&limit=10" \
--header 'Accept: application/json' \
--header 'Authorization: Bearer '
```
```js [filterEvents-Node.js SDK]
const events = await nylas.events.list({
identifier: grantId,
queryParams: {
calendarId: "primary",
title: "standup",
start: 1706000000,
end: 1706100000,
limit: 10,
},
});
```
```python [filterEvents-Python SDK]
events = nylas.events.list(
grant_id,
query_params={
"calendar_id": "primary",
"title": "standup",
"start": 1706000000,
"end": 1706100000,
"limit": 10,
}
)
```
### Filter by event type
Google Calendar supports event types beyond standard calendar events. These are Google-only and won't appear on other providers. By default, the Events API returns only `default` events. To retrieve other types, you must explicitly filter for them using the `event_type` parameter.
| Event type | Description |
| ----------------- | --------------------------------------------------------------------------------------------------------- |
| `default` | Standard calendar events. Returned by default if `event_type` is not specified. |
| `outOfOffice` | Out-of-office blocks set by the user. Automatically declines new invitations during the blocked time. |
| `focusTime` | Focus time blocks. Mutes notifications during the blocked time. |
| `workingLocation` | Working location events that indicate where the user is working from (office, home, or another location). |
```bash [eventType-curl]
curl --request GET \
--url "https://api.us.nylas.com/v3/grants//events?calendar_id=primary&event_type=outOfOffice" \
--header 'Accept: application/json' \
--header 'Authorization: Bearer '
```
```js [eventType-Node.js SDK]
const events = await nylas.events.list({
identifier: grantId,
queryParams: {
calendarId: "primary",
eventType: "outOfOffice",
},
});
```
```python [eventType-Python SDK]
events = nylas.events.list(
grant_id,
query_params={
"calendar_id": "primary",
"event_type": "outOfOffice",
}
)
```
:::info
**Event types don't mix in a single request.** You can only filter for one event type at a time. If you need both `default` and `outOfOffice` events, make two separate requests.
:::
## Things to know about Google
A few provider-specific details that matter when you're building against Google Calendar and Google Workspace accounts.
### Event types are Google-only
The `outOfOffice`, `focusTime`, and `workingLocation` event types only exist on Google Calendar. Microsoft has similar concepts (like focus time in Viva), but they surface differently through the API. If you're building a multi-provider app, you'll need to handle the case where these event types simply don't exist for non-Google accounts.
Also worth noting: these event types are only returned when you explicitly filter for them. A standard list request without `event_type` returns only `default` events, so you won't accidentally pull in out-of-office blocks.
### Google Meet conferencing
When a Google Calendar user has Google Meet enabled (most do), new events often get a Meet link auto-attached. Nylas returns this in the `conferencing` object on the event:
```json
{
"conferencing": {
"provider": "Google Meet",
"details": {
"url": "https://meet.google.com/abc-defg-hij"
}
}
}
```
You can also [manually add conferencing](/docs/v3/calendar/add-conferencing/) when creating events through Nylas, including Google Meet, Zoom, and Microsoft Teams links.
### Color IDs on events
Google supports numeric color IDs for event-level color overrides. These map to a fixed set of colors in Google Calendar's UI. The color ID appears in the event response as a string (like `"1"` through `"11"`). Other providers handle event colors differently or not at all, so don't rely on this field for cross-provider consistency.
### Recurring events return unsorted
When you use `master_event_id` to fetch occurrences of a recurring event from a Google account, the results come back in a non-deterministic order. Unchanged occurrences typically appear first, followed by modified occurrences, but this isn't guaranteed. Sort the results by `start_time` on your end if order matters.
For more details on how Google handles recurring event modifications and deletions compared to Microsoft, see the [recurring events guide](/docs/v3/calendar/recurring-events/).
### Cancelled events work differently
When a user deletes a single occurrence from a recurring series in the Google Calendar UI, Google doesn't actually remove the event. Instead, it marks the occurrence as cancelled and creates a hidden master event to track deleted occurrences. That master event has a different ID from the individual occurrences.
To retrieve cancelled events, pass `show_cancelled=true` in your request. One important detail: Google does not reflect cancelled occurrences in the `EXDATE` of the primary recurring event. If you're using `EXDATE` to track which occurrences were removed, you'll get incomplete results for Google accounts.
### Rate limits are per-user and per-project
Google enforces calendar API quotas at two levels:
- **Per-user:** Each authenticated user has a per-minute and daily quota for API calls
- **Per-project:** Your GCP project has an overall daily limit across all users
Nylas handles retries when you hit rate limits, but if your app polls aggressively for many users, you may exhaust your project quota. Two ways to reduce this:
- Use [webhooks](/docs/v3/notifications/) instead of polling so Nylas notifies your server when calendar events change
- Set up [Google Pub/Sub](/docs/provider-guides/google/connect-google-pub-sub/) for real-time sync, which gives faster notification delivery for Google accounts
### Google Workspace vs. personal accounts
Both personal Google accounts and Google Workspace accounts work with Nylas, but there are differences worth knowing:
- **Workspace admins** can restrict which third-party apps have access. If a Workspace user can't authenticate, their admin may need to allow your app in the Google Admin console.
- **Service accounts** are available for Google Workspace Calendar access, which lets you read and write events without individual user OAuth flows. See the [service accounts guide](/docs/provider-guides/google/google-workspace-service-accounts/).
- **Domain-wide delegation** lets a Workspace admin grant your service account access to all users in the organization, which is useful for enterprise calendar integrations.
## Paginate through results
The Events API returns paginated responses. When there are more results, the response includes a `next_cursor` value. Pass it back as `page_token` to get the next page:
```bash [paginateEvents-curl]
curl --request GET \
--url "https://api.us.nylas.com/v3/grants//events?calendar_id=primary&limit=10&page_token=" \
--header 'Accept: application/json' \
--header 'Authorization: Bearer '
```
```js [paginateEvents-Node.js SDK]
let pageCursor = undefined;
do {
const result = await nylas.events.list({
identifier: grantId,
queryParams: {
calendarId: "primary",
limit: 10,
pageToken: pageCursor,
},
});
// Process result.data here
pageCursor = result.nextCursor;
} while (pageCursor);
```
```python [paginateEvents-Python SDK]
page_cursor = None
while True:
query = {"calendar_id": "primary", "limit": 10}
if page_cursor:
query["page_token"] = page_cursor
result = nylas.events.list(grant_id, query_params=query)
# Process result.data here
page_cursor = result.next_cursor
if not page_cursor:
break
```
Keep paginating until the response comes back without a `next_cursor`.
## What's next
- [Events API reference](/docs/reference/api/events/) for full endpoint documentation and all available parameters
- [Using the Events API](/docs/v3/calendar/using-the-events-api/) for creating, updating, and deleting events
- [Recurring events](/docs/v3/calendar/recurring-events/) for working with repeating events and their occurrences
- [Add conferencing](/docs/v3/calendar/add-conferencing/) to attach Google Meet, Zoom, or Teams links to events
- [Availability](/docs/v3/calendar/calendar-availability/) to check free/busy status before creating events
- [Webhooks](/docs/v3/notifications/) for real-time notifications instead of polling
- [Google Pub/Sub](/docs/provider-guides/google/connect-google-pub-sub/) for real-time sync with Google accounts
- [Google provider guide](/docs/provider-guides/google/) for full Google setup including OAuth scopes and verification
- [Google verification and security assessment](/docs/provider-guides/google/google-verification-security-assessment-guide/), required for restricted scopes in production
- [Manage calendar from the terminal](https://cli.nylas.com/guides/manage-calendar-from-terminal) -- list events, check availability, and create events using the Nylas CLI
────────────────────────────────────────────────────────────────────────────────
title: "How to list iCloud calendar events"
description: "Retrieve calendar events from iCloud Calendar accounts using the Nylas Calendar API. Covers app-specific passwords, CalDAV limitations, the one-year range cap, and restricted filter support."
source: "https://developer.nylas.com/docs/cookbook/calendar/events/list-events-icloud/"
────────────────────────────────────────────────────────────────────────────────
Apple has no public calendar REST API. iCloud Calendar runs on CalDAV, an XML-based protocol that requires persistent connections and manual credential management. There's no developer portal, no SDK, and no way to programmatically generate the app-specific passwords that Apple requires for third-party access. Nylas handles the CalDAV connection for you and exposes iCloud Calendar through the same [Events API](/docs/reference/api/events/) you use for Google and Microsoft.
This guide covers listing events from iCloud Calendar accounts, including the app-specific password requirement, the one-year time range cap, and iCloud-specific behaviors that affect how you query events.
## Why use Nylas instead of CalDAV directly?
CalDAV works, but building on it directly is a significant investment:
- **XML everywhere.** CalDAV uses WebDAV extensions with XML request and response bodies. Nylas gives you REST endpoints with JSON.
- **Persistent connections required.** You need to maintain long-lived CalDAV sessions per user. Nylas manages connection pooling and reconnection behind the scenes.
- **No programmatic password generation.** Every user must manually create an app-specific password through their Apple ID settings. Nylas guides users through this during authentication, but the manual step can't be eliminated.
- **No real-time push.** CalDAV has no native push notification system comparable to Google's or Microsoft's. Nylas provides [webhooks](/docs/v3/notifications/) for change notifications across all providers.
- **Limited query capabilities.** CalDAV supports basic time-range queries but lacks the rich filtering that Google Calendar or Microsoft Graph offer. Nylas normalizes what it can and clearly documents the gaps.
If you're comfortable with XML parsing and only need iCloud, CalDAV works fine. For multi-provider apps or faster development, Nylas saves you from building and maintaining a CalDAV client.
## Before you begin
You'll need:
- A [Nylas application](/docs/v3/getting-started/) with a valid API key
- A [grant](/docs/v3/auth/) for an iCloud account using the **iCloud connector** (not generic IMAP)
- An iCloud connector configured in your Nylas application
:::info
**New to Nylas?** Start with the [quickstart guide](/docs/v3/getting-started/) to set up your app and connect a test account before continuing here.
:::
### App-specific passwords
iCloud requires **app-specific passwords** for third-party access. Unlike Google or Microsoft OAuth, there's no way to generate these programmatically. Each user must create one manually in their Apple ID settings.
Nylas supports two authentication flows for iCloud:
| Method | Best for |
| ----------------------------------- | ---------------------------------------------------------------------- |
| Hosted OAuth | Production apps where Nylas guides users through the app password flow |
| Bring Your Own (BYO) Authentication | Custom auth pages where you collect credentials directly |
With either method, users need to:
1. Go to [appleid.apple.com](https://appleid.apple.com/) and sign in
2. Navigate to **Sign-In and Security** then **App-Specific Passwords**
3. Generate a new app password
4. Use that password (not their regular iCloud password) when authenticating
:::warn
**App-specific passwords can't be generated via API.** Your app's onboarding flow should include clear instructions telling users how to create one. Users who enter their regular iCloud password will fail authentication.
:::
The full setup walkthrough is in the [iCloud provider guide](/docs/provider-guides/icloud/) and the [app passwords guide](/docs/provider-guides/app-passwords/).
## List events
Make a [List Events request](/docs/reference/api/events/get-all-events/) with the grant ID and a `calendar_id`. By default, Nylas returns up to 50 events sorted by start time.
:::warn
**iCloud does not support `calendar_id=primary`.** You must call the [List Calendars endpoint](/docs/reference/api/calendar/get-all-calendars/) first to get the actual calendar ID for the account.
:::
```bash [listEvents-Request]
curl --compressed --request GET \
--url 'https://api.us.nylas.com/v3/grants//events?calendar_id=&start=&end=' \
--header 'Accept: application/json' \
--header 'Authorization: Bearer ' \
--header 'Content-Type: application/json'
```
```json [listEvents-Response]
{
"request_id": "cbd60372-df33-41d3-b203-169ad5e3AAAA",
"data": [
{
"busy": true,
"calendar_id": "primary",
"conferencing": {
"details": {
"meeting_code": "ist-****-tcz",
"url": "https://meet.google.com/ist-****-tcz"
},
"provider": "Google Meet"
},
"created_at": 1701974804,
"creator": {
"email": "anna.molly@example.com",
"name": ""
},
"description": null,
"grant_id": "1e3288f6-124e-405d-a13a-635a2ee54eb2",
"hide_participants": false,
"html_link": "https://www.google.com/calendar/event?eid=NmE0dXIwabQAAAA",
"ical_uid": "6aaaaaaame8kpgcid6hvd0q@google.com",
"id": "6aaaaaaame8kpgcid6hvd",
"object": "event",
"organizer": {
"email": "anna.molly@example.com",
"name": ""
},
"participants": [
{
"email": "jenna.doe@example.com",
"status": "yes"
},
{
"email": "anna.molly@example.com",
"status": "yes"
}
],
"read_only": true,
"reminders": {
"overrides": null,
"use_default": true
},
"status": "confirmed",
"title": "Holiday check in",
"updated_at": 1701974915,
"when": {
"end_time": 1701978300,
"end_timezone": "America/Los_Angeles",
"object": "timespan",
"start_time": 1701977400,
"start_timezone": "America/Los_Angeles"
}
}
]
}
```
```js [listEvents-Node.js SDK]
import Nylas from "nylas";
const nylas = new Nylas({
apiKey: "",
apiUri: "",
});
async function fetchAllEventsFromCalendar() {
try {
const events = await nylas.events.list({
identifier: "",
queryParams: {
calendarId: "",
},
});
console.log("Events:", events);
} catch (error) {
console.error("Error fetching calendars:", error);
}
}
fetchAllEventsFromCalendar();
```
```python [listEvents-Python SDK]
from nylas import Client
nylas = Client(
"",
""
)
grant_id = ""
events = nylas.events.list(
grant_id,
query_params={
"calendar_id": ""
}
)
print(events)
```
```ruby [listEvents-Ruby SDK]
!!!include(v3_code_samples/events/GET/ruby.rb)!!!
```
```java [listEvents-Java SDK]
!!!include(v3_code_samples/events/GET/java.java)!!!
```
```kt [listEvents-Kotlin SDK]
!!!include(v3_code_samples/events/GET/kotlin.kt)!!!
```
For iCloud accounts, replace `` in these samples with an actual calendar ID from the [List Calendars](/docs/reference/api/calendar/get-all-calendars/) response. The `primary` shortcut that works for Google and Microsoft is not available.
## Filter events
You can narrow results with query parameters. Here's the full set supported by the Events API:
| Parameter | What it does | Example |
| --------------- | -------------------------------------------- | ---------------------------------- |
| `calendar_id` | **Required.** Filter by calendar | `?calendar_id=primary` |
| `title` | Match on event title (case insensitive) | `?title=standup` |
| `description` | Match on description (case insensitive) | `?description=quarterly` |
| `location` | Match on location (case insensitive) | `?location=Room%20A` |
| `start` | Events starting at or after a Unix timestamp | `?start=1706000000` |
| `end` | Events ending at or before a Unix timestamp | `?end=1706100000` |
| `attendees` | Filter by attendee email (comma-delimited) | `?attendees=alex@example.com` |
| `busy` | Filter by busy status | `?busy=true` |
| `metadata_pair` | Filter by metadata key-value pair | `?metadata_pair=project_id:abc123` |
:::warn
**iCloud does not support several filter parameters.** The following will either be ignored or return an error:
- `attendees` - not supported on CalDAV
- `busy` - not supported on CalDAV
- `metadata_pair` - not supported on CalDAV
Additionally, the time range between `start` and `end` cannot exceed **one year**. Requests with a wider range will fail.
:::
Here's how to filter events by title within a time range:
```bash [filterEvents-curl]
curl --request GET \
--url "https://api.us.nylas.com/v3/grants//events?calendar_id=primary&title=standup&start=1706000000&end=1706100000&limit=10" \
--header 'Accept: application/json' \
--header 'Authorization: Bearer '
```
```js [filterEvents-Node.js SDK]
const events = await nylas.events.list({
identifier: grantId,
queryParams: {
calendarId: "primary",
title: "standup",
start: 1706000000,
end: 1706100000,
limit: 10,
},
});
```
```python [filterEvents-Python SDK]
events = nylas.events.list(
grant_id,
query_params={
"calendar_id": "primary",
"title": "standup",
"start": 1706000000,
"end": 1706100000,
"limit": 10,
}
)
```
Remember to replace `calendar_id=primary` in the shared examples with an actual iCloud calendar ID.
## Things to know about iCloud
iCloud Calendar runs on CalDAV, which gives it a different behavior profile than Google or Microsoft. Here's what you should plan for.
### No `primary` calendar shortcut
Google and Microsoft both support `calendar_id=primary` as a shorthand for the user's default calendar. iCloud does not. You must call the [List Calendars endpoint](/docs/reference/api/calendar/get-all-calendars/) first and pick the correct calendar ID from the response.
The default calendar name varies by language and region. English accounts typically have a calendar called "Calendar" or "Home", but don't count on it. Always discover calendar IDs dynamically.
### One-year maximum time range
The `start` to `end` range in a List Events request cannot exceed one year for iCloud accounts. If you need events across a longer window, split the request into multiple calls with consecutive one-year ranges and paginate through each.
### Limited filter support
CalDAV supports a narrower set of query capabilities than Google Calendar or Microsoft Graph. For iCloud accounts:
- `title`, `description`, `location`, `start`, and `end` work as expected
- `attendees`, `busy`, and `metadata_pair` are **not supported**
- There is no equivalent to Google's `event_type` filter
- `show_cancelled`, `ical_uid`, `updated_after`, `updated_before`, and `master_event_id` are not available
If your app targets multiple providers, test your filter combinations against iCloud specifically. A query that works on Google may silently return different results on iCloud.
### App-specific passwords and user experience
The biggest friction point for iCloud integration is the app-specific password. A few things to keep in mind:
- Passwords cannot be generated programmatically. Every user must create one manually.
- Users can revoke an app password at any time through their Apple ID settings, which invalidates the grant.
- There's no way for your app to detect a revoked password before the next sync attempt fails. Use [webhooks](/docs/v3/notifications/) to catch `grant.expired` events.
- Your onboarding flow should include step-by-step instructions with screenshots showing users how to create an app password.
### CalDAV-based sync
iCloud Calendar's CalDAV foundation means a simpler feature set compared to Google or Microsoft:
- **No conferencing auto-attach.** Google can automatically generate Meet links when you create events. iCloud has no equivalent. You can still include conferencing details manually in the event body.
- **No event types.** Google supports `focusTime`, `outOfOffice`, and `workingLocation` event types. iCloud treats all events the same.
- **No color IDs.** Calendar and event colors are managed locally in Apple Calendar and are not exposed through CalDAV.
- **Recurring events** work through standard iCalendar (RFC 5545) recurrence rules. Nylas expands recurring events into individual instances, just like it does for other providers.
### Sync timing
CalDAV sync can be slower than Google's push notifications or Microsoft's change subscriptions. A few practical notes:
- Changes made in Apple Calendar may take a few minutes to appear through the Nylas API.
- Use [webhooks](/docs/v3/notifications/) rather than polling. Nylas monitors for changes and sends notifications when events are created, updated, or deleted.
- If you need near-real-time sync and iCloud is your only provider, be aware that CalDAV introduces inherent latency that does not exist with Google or Microsoft.
## Paginate through results
The Events API returns paginated responses. When there are more results, the response includes a `next_cursor` value. Pass it back as `page_token` to get the next page:
```bash [paginateEvents-curl]
curl --request GET \
--url "https://api.us.nylas.com/v3/grants//events?calendar_id=primary&limit=10&page_token=" \
--header 'Accept: application/json' \
--header 'Authorization: Bearer '
```
```js [paginateEvents-Node.js SDK]
let pageCursor = undefined;
do {
const result = await nylas.events.list({
identifier: grantId,
queryParams: {
calendarId: "primary",
limit: 10,
pageToken: pageCursor,
},
});
// Process result.data here
pageCursor = result.nextCursor;
} while (pageCursor);
```
```python [paginateEvents-Python SDK]
page_cursor = None
while True:
query = {"calendar_id": "primary", "limit": 10}
if page_cursor:
query["page_token"] = page_cursor
result = nylas.events.list(grant_id, query_params=query)
# Process result.data here
page_cursor = result.next_cursor
if not page_cursor:
break
```
Keep paginating until the response comes back without a `next_cursor`.
For iCloud accounts, replace `calendar_id=primary` in the pagination examples with the actual calendar ID from the List Calendars response.
## What's next
- [Events API reference](/docs/reference/api/events/) for full endpoint documentation and all available parameters
- [Using the Events API](/docs/v3/calendar/using-the-events-api/) for creating, updating, and deleting events
- [Recurring events](/docs/v3/calendar/recurring-events/) for expanding and managing recurring event series
- [Availability](/docs/v3/calendar/calendar-availability/) for checking free/busy status across calendars
- [Webhooks](/docs/v3/notifications/) for real-time notifications instead of polling
- [iCloud provider guide](/docs/provider-guides/icloud/) for full iCloud setup including authentication
- [App passwords guide](/docs/provider-guides/app-passwords/) for generating app-specific passwords for iCloud and other providers
────────────────────────────────────────────────────────────────────────────────
title: "How to list Microsoft calendar events"
description: "Retrieve calendar events from Microsoft 365 and Outlook accounts using the Nylas Calendar API. Covers timezone normalization, Teams conferencing, admin consent, recurring events, and filtering."
source: "https://developer.nylas.com/docs/cookbook/calendar/events/list-events-microsoft/"
────────────────────────────────────────────────────────────────────────────────
If you're building an app that reads calendar data from Microsoft 365 or Outlook accounts, you can either work directly with the Microsoft Graph API or use Nylas as a unified layer that handles the provider differences for you.
With Nylas, the API call to list events is identical whether the account is Microsoft, Google, or iCloud. The differences show up in timezone handling, Teams conferencing data, recurring event behavior, and admin consent requirements. This guide covers all of that.
## Why use Nylas instead of Microsoft Graph directly?
The Microsoft Graph Calendar API is powerful, but integrating it means dealing with Azure AD app registration, OAuth scope configuration, MSAL token refresh logic, admin consent flows for enterprise tenants, and Windows timezone ID mapping (Graph returns identifiers like "Eastern Standard Time" instead of IANA timezones). You also need to handle Microsoft's specific data formats for recurring events, conferencing links, and all-day event boundaries.
Nylas normalizes all of that behind a single REST API. Your code stays the same whether you're reading from Outlook, Google Calendar, or iCloud. No Azure AD setup, no MSAL token lifecycle, no mapping Windows timezone IDs to IANA in your application code. If you need to support multiple calendar providers or want to skip the Graph onboarding, Nylas is the faster path.
That said, if you only need Microsoft calendars and already have Graph experience, direct integration works fine.
## Before you begin
You'll need:
- A [Nylas application](/docs/v3/getting-started/) with a valid API key
- A [grant](/docs/v3/auth/) for a Microsoft 365 or Outlook account
- The `Calendars.Read` scope enabled in your Azure AD app registration
:::info
**New to Nylas?** Start with the [quickstart guide](/docs/v3/getting-started/) to set up your app and connect a test account before continuing here.
:::
### Microsoft admin consent
Microsoft organizations often require admin approval before third-party apps can access calendar data. If your users see a "Need admin approval" screen during auth, their organization restricts user consent.
You have two options:
- **Ask the tenant admin** to grant consent for your app via the Azure portal
- **Configure your Azure app** to request only permissions that don't need admin consent
Nylas has a detailed walkthrough: [Configuring Microsoft admin approval](/docs/provider-guides/microsoft/admin-approval/). If you're targeting enterprise customers, you'll almost certainly need to deal with this.
You also need to be a [verified publisher](/docs/provider-guides/microsoft/verification-guide/). Microsoft requires publisher verification since November 2020, and without it users see an error during auth.
## List events
Make a [List Events request](/docs/reference/api/events/get-all-events/) with the grant ID and a `calendar_id`. Nylas returns the most recent events by default. You can use `primary` as the `calendar_id` to target the account's default calendar. These examples limit results to 5:
```bash [listEvents-Request]
curl --compressed --request GET \
--url 'https://api.us.nylas.com/v3/grants//events?calendar_id=&start=&end=' \
--header 'Accept: application/json' \
--header 'Authorization: Bearer ' \
--header 'Content-Type: application/json'
```
```json [listEvents-Response]
{
"request_id": "cbd60372-df33-41d3-b203-169ad5e3AAAA",
"data": [
{
"busy": true,
"calendar_id": "primary",
"conferencing": {
"details": {
"meeting_code": "ist-****-tcz",
"url": "https://meet.google.com/ist-****-tcz"
},
"provider": "Google Meet"
},
"created_at": 1701974804,
"creator": {
"email": "anna.molly@example.com",
"name": ""
},
"description": null,
"grant_id": "1e3288f6-124e-405d-a13a-635a2ee54eb2",
"hide_participants": false,
"html_link": "https://www.google.com/calendar/event?eid=NmE0dXIwabQAAAA",
"ical_uid": "6aaaaaaame8kpgcid6hvd0q@google.com",
"id": "6aaaaaaame8kpgcid6hvd",
"object": "event",
"organizer": {
"email": "anna.molly@example.com",
"name": ""
},
"participants": [
{
"email": "jenna.doe@example.com",
"status": "yes"
},
{
"email": "anna.molly@example.com",
"status": "yes"
}
],
"read_only": true,
"reminders": {
"overrides": null,
"use_default": true
},
"status": "confirmed",
"title": "Holiday check in",
"updated_at": 1701974915,
"when": {
"end_time": 1701978300,
"end_timezone": "America/Los_Angeles",
"object": "timespan",
"start_time": 1701977400,
"start_timezone": "America/Los_Angeles"
}
}
]
}
```
```js [listEvents-Node.js SDK]
import Nylas from "nylas";
const nylas = new Nylas({
apiKey: "",
apiUri: "",
});
async function fetchAllEventsFromCalendar() {
try {
const events = await nylas.events.list({
identifier: "",
queryParams: {
calendarId: "",
},
});
console.log("Events:", events);
} catch (error) {
console.error("Error fetching calendars:", error);
}
}
fetchAllEventsFromCalendar();
```
```python [listEvents-Python SDK]
from nylas import Client
nylas = Client(
"",
""
)
grant_id = ""
events = nylas.events.list(
grant_id,
query_params={
"calendar_id": ""
}
)
print(events)
```
```ruby [listEvents-Ruby SDK]
!!!include(v3_code_samples/events/GET/ruby.rb)!!!
```
```java [listEvents-Java SDK]
!!!include(v3_code_samples/events/GET/java.java)!!!
```
```kt [listEvents-Kotlin SDK]
!!!include(v3_code_samples/events/GET/kotlin.kt)!!!
```
The `calendar_id` parameter is required for all Events endpoints. For Microsoft accounts, calendar IDs are long base64-encoded strings, but you can use `primary` as a shortcut to target the default calendar. The response format is the same regardless of provider, so your parsing logic works across Microsoft, Google, and iCloud without changes.
## Filter events
You can narrow results with query parameters. Here's what works with Microsoft accounts:
| Parameter | What it does | Example |
| --------------- | -------------------------------------------- | ---------------------------------- |
| `calendar_id` | **Required.** Filter by calendar | `?calendar_id=primary` |
| `title` | Match on event title (case insensitive) | `?title=standup` |
| `description` | Match on description (case insensitive) | `?description=quarterly` |
| `location` | Match on location (case insensitive) | `?location=Room%20A` |
| `start` | Events starting at or after a Unix timestamp | `?start=1706000000` |
| `end` | Events ending at or before a Unix timestamp | `?end=1706100000` |
| `attendees` | Filter by attendee email (comma-delimited) | `?attendees=alex@example.com` |
| `busy` | Filter by busy status | `?busy=true` |
| `metadata_pair` | Filter by metadata key-value pair | `?metadata_pair=project_id:abc123` |
Microsoft also supports several additional filter parameters beyond the standard set:
- `show_cancelled` - include cancelled events in results (defaults to `false`)
- `tentative_as_busy` - treat tentative events as busy when checking availability
- `updated_after` / `updated_before` - filter by last-modified timestamp, useful for incremental sync
- `ical_uid` - find a specific event by its iCalendar UID
- `master_event_id` - list all instances and overrides for a specific recurring series
Combining filters works the way you'd expect. This example pulls events in a specific time range:
```bash [filterEvents-curl]
curl --request GET \
--url "https://api.us.nylas.com/v3/grants//events?calendar_id=primary&title=standup&start=1706000000&end=1706100000&limit=10" \
--header 'Accept: application/json' \
--header 'Authorization: Bearer '
```
```js [filterEvents-Node.js SDK]
const events = await nylas.events.list({
identifier: grantId,
queryParams: {
calendarId: "primary",
title: "standup",
start: 1706000000,
end: 1706100000,
limit: 10,
},
});
```
```python [filterEvents-Python SDK]
events = nylas.events.list(
grant_id,
query_params={
"calendar_id": "primary",
"title": "standup",
"start": 1706000000,
"end": 1706100000,
"limit": 10,
}
)
```
## Things to know about Microsoft
A few provider-specific details that matter when you're building against Microsoft calendar accounts.
### Timezone handling
Microsoft Graph stores timezone information using Windows timezone identifiers like "Eastern Standard Time" or "Pacific Standard Time" rather than IANA identifiers like "America/New_York" or "America/Los_Angeles". Nylas normalizes these automatically, so event times in API responses always use IANA timezone identifiers. You don't need to maintain a Windows-to-IANA mapping table in your application.
### All-day events end one day later
When Nylas returns an all-day event (a `datespan` object) from Microsoft, the `end_date` is set to the day _after_ the event actually ends. This matches how Microsoft Graph represents all-day events internally. A single-day event on December 1st comes back with `start_date: "2024-12-01"` and `end_date: "2024-12-02"`.
If you're displaying events in a calendar UI, subtract one day from the `end_date` to show the correct range. This behavior is the same for Google, so your display logic doesn't need to be provider-specific.
### Teams meeting links
Events created with Microsoft Teams conferencing include a `conferencing` object in the response with `provider` set to `"Microsoft Teams"`. The `details` array contains the join URL and any dial-in information. If you're building a meeting list or join button, check for this field on every event - it's only present when the organizer added Teams to the invite.
### Tentative events
Microsoft treats tentative events as busy by default when calculating availability. The `tentative_as_busy` query parameter controls this. If your app needs to distinguish between confirmed and tentative events, check the `status` field on each event object.
### Calendar IDs
Microsoft calendar IDs are long base64-encoded strings like `AAMkAGI2TG93AAA=`. These are stable and safe to store, but they take up more space than Google's shorter numeric IDs. For the account's default calendar, use `primary` as a shortcut instead of fetching the full ID. If you need to list events from a non-default calendar, call [List Calendars](/docs/reference/api/calendar/get-all-calendars/) first to get the available calendar IDs.
### Recurring event quirks
Microsoft handles recurring events differently from Google in a few important ways:
- **Overrides are removed on recurrence change.** If you modify a recurring series (for example, changing from weekly to daily), Microsoft removes all existing overrides. Google keeps them if they still fit the pattern.
- **No multi-day monthly BYDAY.** You can't create a monthly recurring event on multiple days of the week (like the first and third Thursday). Microsoft's recurrence model doesn't support different indices within a single rule.
- **EXDATE recovery isn't possible.** Once you remove an occurrence from a recurring series, you can't undo it through Nylas. You'd need to create a separate standalone event to fill the gap.
- **Rescheduling constraints.** Microsoft Exchange won't let you move a recurring instance to the same day as, or the day before, the previous instance. Overlapping instances within a series aren't allowed.
For the full breakdown of Google vs. Microsoft recurring event differences, see [Recurring events](/docs/v3/calendar/recurring-events/).
### Cancelled events
When the organizer deletes a recurring event instance, Microsoft and Google handle it differently. Google marks deleted occurrences as "cancelled" and keeps them retrievable with `show_cancelled=true`. Microsoft removes them entirely from the system - you can't get them back through the API.
For non-recurring events, deletion behavior is more straightforward: the event disappears from list results. Use `show_cancelled=true` if you need to see events that participants declined or that the organizer cancelled but hasn't fully removed yet.
### Rate limits
Microsoft throttles API requests at the per-mailbox level. If your app triggers a `429` response, Nylas handles the retry automatically with appropriate backoff, so you don't need to implement retry logic yourself.
If you're polling a calendar frequently, you'll burn through rate limits fast. [Webhooks](/docs/v3/notifications/) solve this by notifying you of changes in real time without any polling requests.
### Sync timing
Calendar events typically appear within seconds of being created or updated. If an event you know exists isn't showing up in list results yet, wait a moment and retry. This is a Microsoft-side sync delay, not a Nylas one.
For apps that need real-time awareness of calendar changes, use [webhooks](/docs/v3/notifications/) instead of polling. Nylas pushes a notification to your server as soon as the event syncs.
## Paginate through results
The Events API returns paginated responses. When there are more results, the response includes a `next_cursor` value. Pass it back as `page_token` to get the next page:
```bash [paginateEvents-curl]
curl --request GET \
--url "https://api.us.nylas.com/v3/grants//events?calendar_id=primary&limit=10&page_token=" \
--header 'Accept: application/json' \
--header 'Authorization: Bearer '
```
```js [paginateEvents-Node.js SDK]
let pageCursor = undefined;
do {
const result = await nylas.events.list({
identifier: grantId,
queryParams: {
calendarId: "primary",
limit: 10,
pageToken: pageCursor,
},
});
// Process result.data here
pageCursor = result.nextCursor;
} while (pageCursor);
```
```python [paginateEvents-Python SDK]
page_cursor = None
while True:
query = {"calendar_id": "primary", "limit": 10}
if page_cursor:
query["page_token"] = page_cursor
result = nylas.events.list(grant_id, query_params=query)
# Process result.data here
page_cursor = result.next_cursor
if not page_cursor:
break
```
Keep paginating until the response comes back without a `next_cursor`.
## What's next
- [Events API reference](/docs/reference/api/events/) for full endpoint documentation and all available parameters
- [Using the Events API](/docs/v3/calendar/using-the-events-api/) for creating, updating, and deleting events
- [Recurring events](/docs/v3/calendar/recurring-events/) for series creation, overrides, and provider-specific behavior
- [Add conferencing to events](/docs/v3/calendar/add-conferencing/) to attach Teams, Zoom, or other meeting links
- [Availability](/docs/v3/calendar/calendar-availability/) to check free/busy across multiple calendars
- [Webhooks](/docs/v3/notifications/) for real-time notifications instead of polling
- [Microsoft admin approval](/docs/provider-guides/microsoft/admin-approval/) to configure consent for enterprise organizations
- [Microsoft publisher verification](/docs/provider-guides/microsoft/verification-guide/), required for production apps
────────────────────────────────────────────────────────────────────────────────
title: "Connect voice agents to email & calendar"
description: "Bridge LiveKit, Vapi, Retell, Bland.ai, or OpenAI Realtime to a real mailbox and calendar via the Nylas CLI. Subprocess tool patterns, voice UX rules, and the 30-second timeout you'll forget if you don't set it."
source: "https://developer.nylas.com/docs/cookbook/cli/connect-voice-agents/"
────────────────────────────────────────────────────────────────────────────────
Voice agents have a slightly different shape from text agents — speech in, speech out, hard latency targets, no scrollback. But the email/calendar integration is the same idea: define tools, run them as subprocesses, return results to the LLM. This recipe shows the LiveKit / Vapi / generic patterns side by side, plus the voice-specific UX rules that make the experience feel like a real assistant rather than a robot reading mail at you.
## The flow
```
speech → STT → LLM (function-calling) → subprocess(nylas …) → JSON → LLM → TTS → speech
```
The agent transcribes the user, the LLM decides on a tool, the runtime spawns `nylas --json`, the result comes back, the LLM composes a spoken response, TTS speaks it. The CLI absorbs every provider difference, so the agent is identical against Gmail, Microsoft 365, Exchange, Yahoo, iCloud, or IMAP.
## LiveKit Agents
LiveKit's `@function_tool()` decorator is the cleanest path:
```python
from livekit.agents import function_tool
import subprocess, json
@function_tool()
async def list_recent_emails(limit: int = 5) -> str:
"""List the last few emails. Keep limit small for voice."""
out = subprocess.run(
["nylas", "email", "list", "--limit", str(limit), "--json"],
capture_output=True, text=True, timeout=30,
)
return out.stdout if out.returncode == 0 else "Could not fetch emails."
```
The decorator turns the function into a tool definition the agent sees. Nothing else is special; everything you know from a normal Python LiveKit agent applies.
## Vapi (webhook-based)
Vapi posts JSON to your backend when the LLM calls a tool. Your handler executes the CLI and returns the result in Vapi's expected envelope:
```javascript
app.post("/vapi/tools", async (req, res) => {
const { name, parameters } = req.body.message.toolCall;
const args = ["nylas", "email", "list",
"--limit", String(parameters.limit ?? 5),
"--json"];
const result = await execAsync(args, { timeout: 30000 });
res.json({
results: [{
toolCallId: req.body.message.toolCall.id,
result: result.stdout,
}],
});
});
```
## Generic (Retell, Bland.ai, OpenAI Realtime)
The pattern is the same as the [LLM agent recipe](/docs/cookbook/cli/llm-agent-with-tools/) — define tool schemas, dispatch to subprocess wrappers, return results. The voice runtime is just the I/O layer around it.
## Voice-specific UX rules
These aren't optional — voice surfaces every UX mistake immediately:
1. **Cap list responses at 5.** Reading a 50-message inbox out loud takes minutes. Default `--limit 5` and let the user say "more".
2. **Summarize, don't read.** Don't TTS the full subject + sender + snippet for each message. Have the LLM produce "You've got three emails from Ada about the contract, one from accounting, and a calendar invite from Rin" and let the user drill in.
3. **Confirm before send.** Always. *Always.* Speech-to-text mishears recipients and subjects in ways that send the wrong mail to the wrong person:
```
AGENT: "Send to Ada at acme.test, subject 'pricing', body 'I'm in'?"
USER: "Yes."
```
Only after the explicit yes does the agent invoke `send_email`.
4. **Translate errors.** "Error 401: invalid grant" is not a voice response. Map errors to short user-friendly lines: "I couldn't fetch your email — you may need to re-authenticate."
## Set the timeout
Subprocess calls **must** have a timeout. Voice users won't wait 60 seconds; the framework's silence detection will kick in and the conversation falls apart. 30 seconds is the right number for both LiveKit and Vapi-style flows:
```python
subprocess.run([...], timeout=30)
```
If the CLI hits the timeout, return a graceful "I'm having trouble reaching email right now" instead of bubbling up the exception.
## Why subprocess, not MCP
MCP is great for chat agents that speak JSON-RPC natively. Voice runtimes generally don't — they expect function-call-style tools where you hand back a JSON blob. Subprocess + `--json` is a cleaner fit for the voice request/response model than running an MCP server alongside the voice runtime.
## Things to know
- **Active grant.** Voice agents serving multiple users need per-user grant routing. Either run a CLI process per user with their grant active, or pass `--api-key` and `--grant-id` explicitly per command.
- **Audit logs are still useful.** Even for voice, log every send to your own store — recipient, subject, agent run id, and approval source.
- **Latency budget.** Aim for subprocess round-trip under 2 seconds. `nylas email list --limit 5 --json` is comfortably under; large mailbox lists may not be.
## Next steps
- [Build an LLM agent with email & calendar tools](/docs/cookbook/cli/llm-agent-with-tools/)
- [Use Nylas MCP with Claude Code](/docs/cookbook/ai/mcp/claude-code/) — if your runtime does support MCP
- [Build a Manus skill for Nylas](/docs/cookbook/cli/manus-skill/)
────────────────────────────────────────────────────────────────────────────────
title: "Extract OTP and 2FA codes from email"
description: "Pull one-time passwords out of inbound mail without opening an inbox. Single-shot capture, polling watch mode, and a CI-friendly --raw output for shell scripts."
source: "https://developer.nylas.com/docs/cookbook/cli/extract-otp-codes/"
────────────────────────────────────────────────────────────────────────────────
OTPs are the single most annoying thing in test automation: a CI runner can't open Gmail to grab a six-digit code. The Nylas CLI has two commands that solve this — `nylas otp get` for a single fetch and `nylas otp watch` for a streaming poll loop. Both work across every provider Nylas supports.
This recipe covers single-shot capture, scripted CI usage, and the watch mode for long-running flows.
## Grab the latest code
```bash
nylas otp get
```
That command scans recent messages on the active grant, identifies a verification code, and copies it to your clipboard. The whole round-trip is sub-second on a healthy mailbox.
For scripts and CI, you want the value on stdout instead of the clipboard:
```bash
CODE=$(nylas otp get --raw)
```
`--raw` returns the bare code with no formatting, so you can drop it into the next command without parsing.
## A typical CI flow
```bash
# Trigger the signup
curl -X POST https://api.example.com/signup -d '{"email":"qa@example.com"}'
# Give the verification email a moment to land
sleep 3
# Pull the code
CODE=$(nylas otp get --raw)
# Hand it back to the API
curl -X POST https://api.example.com/verify -d "{\"code\":\"$CODE\"}"
```
This pattern eats the entire problem — your tests no longer need a fixture inbox, a mocked provider, or a per-environment SMTP server. They just point at a real Nylas-managed grant and read the code.
## Watch for codes as they arrive
For long-running flows where you don't know when the OTP will land:
```bash
nylas otp watch --interval 5
```
The watcher polls the inbox every 5 seconds and prints each new code as it arrives. Pipe it through `head -n 1` to grab just the next one and exit:
```bash
NEXT_CODE=$(nylas otp watch | head -n 1)
```
Authentication scales the same way: drop `NYLAS_API_KEY` into the environment of an ephemeral CI runner and `nylas otp get` works without a config file.
## Across providers
The OTP commands abstract over Gmail, Microsoft 365, Exchange (EWS), Yahoo Mail, iCloud Mail, and any IMAP server you've connected. The matching logic — currently 6-digit numerics with sender heuristics — sits in the Nylas backend, so you don't tune regex per provider.
## Things to know
- **The active grant matters.** `nylas otp get` reads from whatever account `nylas auth list` shows as current. Switch with `nylas auth switch`.
- **Recency window.** The CLI considers messages from the last few minutes only. Older codes won't match — you don't want yesterday's GitHub OTP picked up by today's signup test.
- **Multiple codes in one message.** When more than one number-shaped string appears, the CLI prefers the one closest to phrases like "verification code", "OTP", or "PIN". For pathological mail formats, fall back to a custom regex over `nylas email read --raw`.
## Next steps
- [Sign up for a service](/docs/cookbook/agent-accounts/sign-up-for-a-service/) — the Agent Accounts version
- [E2E email testing with Playwright](/docs/cookbook/use-cases/build/e2e-email-testing/)
────────────────────────────────────────────────────────────────────────────────
title: "Build an LLM agent with email & calendar tools"
description: "Wrap the Nylas CLI as subprocess tools an LLM can call. Skip ~300 lines of OAuth boilerplate, get list/send/calendar tools the agent can drive across Gmail, Microsoft 365, Exchange, Yahoo, iCloud, and IMAP."
source: "https://developer.nylas.com/docs/cookbook/cli/llm-agent-with-tools/"
────────────────────────────────────────────────────────────────────────────────
If you're rolling a custom agent — your own loop, not Claude Code or Cursor — you've got two options for giving it email access. Option A: implement Gmail OAuth, Microsoft Graph OAuth, IMAP, refresh-token plumbing, attachment handling. That's ~300 lines of boilerplate before the agent gets to do anything useful. Option B: shell out to `nylas` and let the CLI handle all of it.
This recipe is option B. We wrap `nylas email list`, `nylas email send`, and a couple of calendar commands as subprocess-callable Python functions, define the matching tool schemas, and plug them into a standard tool-calling loop.
## The wrapper functions
```python
import json, subprocess
def list_emails(limit: int = 10, unread_only: bool = False) -> str:
"""List recent emails on the active grant."""
cmd = ["nylas", "email", "list", "--limit", str(limit), "--json"]
if unread_only:
cmd.append("--unread")
out = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
return out.stdout if out.returncode == 0 else f"Error: {out.stderr}"
def send_email(to: str, subject: str, body: str) -> str:
"""Send an email. --yes is required so we don't hang on prompts."""
out = subprocess.run(
["nylas", "email", "send", "--to", to, "--subject", subject,
"--body", body, "--yes", "--json"],
capture_output=True, text=True, timeout=30,
)
if out.returncode != 0:
return f"Error: {out.stderr}"
return "Email sent."
def list_events(days: int = 7) -> str:
"""List upcoming events on the active calendar."""
out = subprocess.run(
["nylas", "calendar", "events", "list", "--days", str(days), "--json"],
capture_output=True, text=True, timeout=30,
)
return out.stdout if out.returncode == 0 else f"Error: {out.stderr}"
```
Two non-obvious flags carry the load:
- **`--yes`** is critical. Without it, send commands wait for a "send this?" prompt that an agent loop will never answer.
- **`--json`** gives the LLM something it can parse. Plain output works for humans; structured output works for tools.
## Tool definitions
OpenAI-style schemas (Anthropic's are nearly identical):
```python
tools = [
{
"type": "function",
"function": {
"name": "list_emails",
"description": "List recent emails from the user's inbox. Returns JSON.",
"parameters": {
"type": "object",
"properties": {
"limit": {"type": "integer", "default": 10},
"unread_only": {"type": "boolean", "default": False},
},
},
},
},
{
"type": "function",
"function": {
"name": "send_email",
"description": "Send an email. Confirm recipient, subject, and body with the user before calling.",
"parameters": {
"type": "object",
"properties": {
"to": {"type": "string"},
"subject": {"type": "string"},
"body": {"type": "string"},
},
"required": ["to", "subject", "body"],
},
},
},
{
"type": "function",
"function": {
"name": "list_events",
"description": "List upcoming calendar events.",
"parameters": {
"type": "object",
"properties": {"days": {"type": "integer", "default": 7}},
},
},
},
]
```
## The dispatch helper
```python
DISPATCH = {
"list_emails": list_emails,
"send_email": send_email,
"list_events": list_events,
}
def run_tool(tool_call) -> dict:
name = tool_call.function.name
args = json.loads(tool_call.function.arguments or "{}")
return {
"role": "tool",
"tool_call_id": tool_call.id,
"content": DISPATCH[name](**args),
}
```
## Plug it into your loop
The standard agent loop doesn't change shape:
```python
messages = [{"role": "user", "content": "Did Ada respond about the contract?"}]
while True:
resp = client.chat.completions.create(
model="...",
messages=messages,
tools=tools,
)
msg = resp.choices[0].message
messages.append(msg)
if not msg.tool_calls:
print(msg.content)
break
for call in msg.tool_calls:
messages.append(run_tool(call))
```
The LLM may issue several tool calls before producing a final answer — the loop just keeps running until it returns a message with no tool calls.
## Why this beats the alternative
A hand-rolled Gmail OAuth integration is ~300 lines just for token management. Add Microsoft Graph and you're at 600. Add IMAP fallback and you're past 1,000. The subprocess approach gives you 16 tools across all six providers in under 50 lines of Python, plus you get OAuth refresh, multi-account switching, and rate-limit handling for free.
## Things to know
- **Output size.** `nylas email list --limit 100` produces a lot of JSON. Cap `limit` aggressively in the tool schema (default 10) to keep context manageable.
- **Active grant.** The CLI uses whichever grant is current in `nylas auth list`. For multi-tenant agents, run a per-tenant CLI process or pass `--api-key` explicitly.
- **Error surface.** Subprocess errors come back as strings. The LLM is good at deciding what to do with them ("Looks like the grant expired — should I re-authenticate?") if you let the stderr through.
## Next steps
- [Use Nylas MCP with Claude Code](/docs/cookbook/ai/mcp/claude-code/) — for hosts that already speak MCP
- [Connect voice agents to email & calendar](/docs/cookbook/cli/connect-voice-agents/) — same pattern, voice-runtime variant
- [Build a Manus skill for Nylas](/docs/cookbook/cli/manus-skill/)
────────────────────────────────────────────────────────────────────────────────
title: "CLI mail merge with send-time optimization"
description: "Variable substitution from a CSV, per-recipient timezone scheduling, throttling that respects provider reputation rules, and a dry-run mode by default — a personalized batch send from your terminal."
source: "https://developer.nylas.com/docs/cookbook/cli/mail-merge/"
────────────────────────────────────────────────────────────────────────────────
A "mail merge" worth shipping has three properties: it actually personalizes (not just `Hi $FNAME`), it respects each recipient's timezone so the message lands during their morning, and it doesn't burn your sender reputation by firing 5,000 messages in 12 seconds. This recipe is the CLI version of that — a CSV, a template with `${VAR}` placeholders, and `nylas email send` in a loop with sane throttling and a dry-run mode that's on by default.
## The CSV
`outbound_list.csv`:
```csv
email,name,company,title,last_subject,days_since_contact,timezone
ada@acme.test,Ada,Acme,VP Eng,Q1 review,42,America/Los_Angeles
rin@globex.test,Rin,Globex,CTO,Renewal,17,Europe/Berlin
```
The columns become variables in the template. Add as many as your personalization needs.
## The template
Use `${VAR}` placeholders and let `envsubst` interpolate them per row:
```text
Hi ${name},
It's been ${days_since_contact} days since we last connected on
"${last_subject}". I'd love to do a 20-minute check-in with you and
the ${company} team.
How does ${suggested_time} ${timezone} look?
— Sam
```
## The send loop (bash)
```bash
#!/usr/bin/env bash
set -euo pipefail
DRY_RUN="${DRY_RUN:-true}" # safe default
TEMPLATE="$(cat ./template.txt)"
tail -n +2 ./outbound_list.csv | while IFS=, read -r email name company title last_subject days_since_contact timezone; do
export name company title last_subject days_since_contact timezone
body="$(envsubst <<< "$TEMPLATE")"
schedule="tomorrow 9am ${timezone}"
if [[ "$DRY_RUN" == "true" ]]; then
echo "→ would send to $email at $schedule"
continue
fi
nylas email send \
--to "$email" \
--subject "Catching up on ${last_subject}" \
--body "$body" \
--schedule "$schedule" \
--yes \
--json >> sent.log
sleep 5 # baseline throttle
done
```
Run it dry first:
```bash
bash personalized-send.sh
```
When the output looks right, ship it for real:
```bash
DRY_RUN=false bash personalized-send.sh
```
## Why these defaults exist
- **`--schedule "tomorrow 9am ${timezone}"`** — Mailchimp's analysis (Feb 2024 update) shows mail delivered between 9–11 AM in the recipient's local timezone gets ~14% better open rates than uniform send times. The flag does the math for you.
- **`sleep 5`** — providers detect "volume spikes" and drop your reputation when you blast at machine speed. Five seconds between sends is a reasonable floor for warmed-up accounts. New accounts should crawl: 10/day, then double weekly.
- **`DRY_RUN=true` by default** — sends are irreversible. Make them require an explicit opt-out.
## Distribute the load
Even with `sleep`, scheduling all sends at the same minute across hundreds of recipients still creates a spike at the provider. Stagger with `--schedule` over a 2–3 hour window:
```bash
HOUR=$((9 + RANDOM % 3)) # 9, 10, or 11
schedule="tomorrow ${HOUR}:$((RANDOM % 60)) ${timezone}"
```
This produces a smooth distribution per timezone instead of a thundering herd at exactly 9:00.
## Test before you trust
Before the real run, retarget the first three rows to your own address:
```bash
head -n 4 outbound_list.csv |
awk -F, 'NR==1 || $1="you@example.com"' OFS=, > preview.csv
DRY_RUN=false ./personalized-send.sh ./preview.csv
```
Confirm the rendered bodies, then run the full list.
## Things to know
- **`envsubst` is shell-safe but variable-naïve.** Quote your template carefully and avoid characters that look like shell substitutions.
- **`--track-label "spring-outreach-2026"`** lets you slice opens and clicks per campaign without inferring from subject lines later.
- **Hard cap.** Microsoft 365 is ~10,000/day per mailbox; Gmail is ~2,000/day; Exchange depends on tenant policy. The CLI surfaces 429s as exit code 1 — back off with `sleep 60` and retry.
## Next steps
- [Run the merge through an MCP-connected agent](/docs/cookbook/cli/llm-agent-with-tools/)
- [Nylas CLI](https://cli.nylas.com/) — installation and command reference
────────────────────────────────────────────────────────────────────────────────
title: "Build a Manus skill for Nylas email & calendar"
description: "Package the Nylas CLI as a Manus Skill so any sandbox can install it on demand. SKILL.md frontmatter, install script that detects architecture, plus the safety rules every skill should enforce."
source: "https://developer.nylas.com/docs/cookbook/cli/manus-skill/"
────────────────────────────────────────────────────────────────────────────────
A Manus Skill is a self-contained bundle that teaches an agent how to do something — instructions, scripts, reference material. This recipe packages the Nylas CLI as a skill so any Manus sandbox can install it, authenticate, and start using 70+ email and calendar commands without you wiring it up by hand each time.
The end result: a user types `/nylas-cli` in chat, Manus loads the skill, and they can say "schedule a 30-minute meeting with Ada next Tuesday at 2pm" or "show me unread emails from this week".
## Directory layout
```
nylas-cli/
├── SKILL.md
└── scripts/
└── setup.sh
```
That's it. Two files.
## SKILL.md
The frontmatter follows the Agent Skills specification. The body is Markdown documentation that teaches the agent which CLI commands map to which user intents.
```markdown
---
name: nylas-cli
description: >
Read, send, and search email. Create, update, and list calendar events.
Check availability and find meeting times across Gmail, Microsoft 365,
Exchange, Yahoo, iCloud, and IMAP.
compatibility: Requires internet access and a Nylas API key.
metadata:
author: nylas
version: "1.0"
---
# Nylas CLI
## Email
- `nylas email list --limit 20 --json` — recent messages (use --json to parse)
- `nylas email search "quarterly report" --limit 10 --json` — keyword search
- `nylas email send --to "" --subject "" --body "" --yes`
- `nylas email read --json` — single message
## Calendar
- `nylas calendar events list --days 7 --json` — upcoming
- `nylas calendar events create --title "..." --start "..." --end "..." --participant "..."`
- `nylas calendar schedule ai ""` — let the AI pick a time
- `nylas calendar find-time --participants "..." --duration 30m` — find a slot
## Rules
- Always use `--json` when parsing output.
- Always use `--yes` on send to avoid hanging on a prompt.
- Confirm recipient, subject, and body with the user before sending.
```
The "Rules" section is what protects users from agents that send mail before they should. Manus pays attention to it.
## scripts/setup.sh
The install script gets the CLI into the sandbox, authenticates against the user's API key, and verifies it works. The sandbox has no Go toolchain, so we fetch a pre-built binary instead of `go install`:
```bash
#!/usr/bin/env bash
set -euo pipefail
ARCH="$(uname -m)"
case "$ARCH" in
x86_64) TAG="linux_amd64" ;;
aarch64|arm64) TAG="linux_arm64" ;;
*) echo "Unsupported arch: $ARCH"; exit 1 ;;
esac
# Fetch latest release
curl -fsSL "https://github.com/nylas/cli/releases/latest/download/nylas_${TAG}.tar.gz" \
| tar -xz -C /tmp
# Install on PATH
if [ -w /usr/local/bin ]; then
mv /tmp/nylas /usr/local/bin/nylas
else
mkdir -p "$HOME/.local/bin"
mv /tmp/nylas "$HOME/.local/bin/nylas"
export PATH="$HOME/.local/bin:$PATH"
fi
# Configure auth
if [ -z "${NYLAS_API_KEY:-}" ]; then
read -rp "Nylas API key: " NYLAS_API_KEY
fi
nylas auth config --api-key "$NYLAS_API_KEY"
# Verify
nylas auth whoami
```
Drop it in `scripts/setup.sh` and make it executable.
## Deploy the skill
Three options:
- Upload the folder directly to your Manus **Skills** tab.
- Push to a GitHub repo and import from URL.
- Have Manus package an existing workflow into a skill (it'll prompt you for the structure).
Once installed, users invoke it with `/nylas-cli` in chat, then drive it conversationally.
## Things to know
- **API key as a sandbox secret.** Store `NYLAS_API_KEY` as a Manus secret rather than baking it into the skill — the skill is shareable, the secret isn't.
- **Architecture detection.** The install script handles `x86_64` and `aarch64`. Manus sandboxes are usually Linux; if a future variant ships, extend the `case` block.
- **Versioning.** The frontmatter `version` lets Manus warn when the installed skill is older than what's published.
- **The "Rules" section is load-bearing.** If you remove "confirm before sending", agents will start sending without confirming. Keep it.
## Next steps
- [Build an LLM agent with email & calendar tools](/docs/cookbook/cli/llm-agent-with-tools/)
- [Connect voice agents to email & calendar](/docs/cookbook/cli/connect-voice-agents/)
- [Nylas CLI](https://cli.nylas.com/) — installation and command reference
────────────────────────────────────────────────────────────────────────────────
title: "Record Zoom, Google Meet, and Teams from the CLI"
description: "Send a Notetaker bot to any meeting URL, schedule recordings in advance, and pull transcripts and recordings — Zoom, Google Meet, and Microsoft Teams from a single command."
source: "https://developer.nylas.com/docs/cookbook/cli/record-meetings/"
────────────────────────────────────────────────────────────────────────────────
Recording a meeting usually means setting up a per-platform integration — Zoom's OAuth app, Meet's Workspace add-on, Teams' admin policy. Nylas Notetaker collapses that into one command: drop a meeting URL on `nylas notetaker create` and a bot joins the call, records the audio, and produces a transcript when the meeting ends.
This recipe walks through joining an in-progress call, scheduling future recordings, retrieving the artifacts, and tearing down.
## Join a call now
```bash
nylas notetaker create --meeting-link "https://zoom.us/j/123456789"
```
The same shape works for every supported platform:
| Provider | Example URL |
| --- | --- |
| Zoom | `https://zoom.us/j/123456789` |
| Google Meet | `https://meet.google.com/abc-defg-hij` |
| Microsoft Teams | `https://teams.microsoft.com/l/meetup-join/...` |
You get back a Notetaker ID like `ntk_abc123def456`. Optionally name the bot so participants know what they're seeing in the attendee list:
```bash
nylas notetaker create \
--meeting-link "https://meet.google.com/abc-defg-hij" \
--bot-name "Notetaker"
```
## Schedule a recording in advance
Pass `--join-time` to queue the bot to join at a future moment:
```bash
nylas notetaker create \
--meeting-link "https://teams.microsoft.com/l/..." \
--join-time "2026-04-01 14:00"
```
The flag accepts ISO timestamps, natural-language strings (`"tomorrow 9am"`), or relative offsets (`"30m"`). The bot stays dormant and joins on schedule.
## Track what's happening
```bash
nylas notetaker list
nylas notetaker show ntk_abc123def456
```
Status moves through these states:
```
scheduled → joining → recording → processing → completed
```
`show` prints metadata — join time, meeting URL, current state — so you can poll programmatically while the bot does its job.
## Pull recordings and transcripts
Once the state hits `completed`:
```bash
nylas notetaker media ntk_abc123def456
```
Add `--json` to get URLs you can pipe into something else:
```bash
nylas notetaker media ntk_abc123def456 --json |
jq -r '.transcript_url' |
xargs curl -o transcript.json
```
The recording is delivered as MP4 and the transcript as structured JSON. Both URLs expire — fetch them within the displayed window or re-request.
## Skip the polling — use a webhook
If you don't want to poll for `completed`:
```bash
nylas webhook create \
--url "https://api.example.com/notetaker-done" \
--triggers notetaker.media
```
Your endpoint receives a POST the moment processing finishes, with the media URLs in the payload.
## Cancel or tear down
```bash
nylas notetaker delete ntk_abc123def456
```
Deleting before the bot joins prevents any recording. Deleting mid-recording stops the bot and discards the captured audio. Already-completed recordings stay available for the standard retention window — `delete` removes the metadata but doesn't claw back transcripts you've already fetched.
## Things to know
- **One API surface, three platforms.** The CLI absorbs Zoom's paid-account requirement, Meet's Workspace licensing, and Teams' admin dependencies. You provide a URL; Nylas handles the auth dance.
- **Bot visibility.** Most platforms surface the bot as a participant. Use `--bot-name` to give it a human-readable label.
- **Concurrent recordings.** Multiple Notetakers can run in parallel — the API doesn't gate on a single in-flight bot per grant.
## Next steps
- [Notetaker quickstart](/docs/v3/getting-started/notetaker/)
- [Add scheduling with automatic notetaking](/docs/cookbook/use-cases/build/scheduling-with-notetaking/)
- [Build an LLM agent with email & calendar tools](/docs/cookbook/cli/llm-agent-with-tools/)
────────────────────────────────────────────────────────────────────────────────
title: "How to list Exchange email messages"
description: "Retrieve email messages from Exchange on-premises servers using the Nylas Email API. Covers EWS vs. Microsoft Graph, autodiscovery, message ID behavior, AQS search, and on-prem networking."
source: "https://developer.nylas.com/docs/cookbook/email/messages/list-messages-ews/"
────────────────────────────────────────────────────────────────────────────────
Exchange on-premises servers are still common in enterprise environments. If your users run self-hosted Exchange (2007 or later), Nylas connects to them through Exchange Web Services (EWS), a separate protocol from the Microsoft Graph API used for Exchange Online and Microsoft 365.
The same [Messages API](/docs/reference/api/messages/) you use for Gmail and Outlook works for Exchange on-prem accounts. This guide covers the EWS-specific details: when to use EWS vs. Microsoft Graph, authentication, message ID behavior, and search capabilities.
## EWS vs. Microsoft Graph: which one?
This is the first thing to figure out. The two provider types target different Exchange deployments:
| Provider type | Connector | Use when |
| --------------------- | ----------- | ------------------------------------------------------------------------------------ |
| Microsoft Graph | `microsoft` | Exchange Online, Microsoft 365, Office 365, Outlook.com, personal Microsoft accounts |
| Exchange Web Services | `ews` | Self-hosted Exchange servers (on-premises) |
If the user's mailbox is hosted by Microsoft in the cloud, use the [Microsoft guide](/docs/cookbook/email/list-messages-microsoft/) instead. The `ews` connector is specifically for organizations that run their own Exchange servers.
:::warn
**Microsoft announced EWS retirement** and recommends migrating to Microsoft Graph. However, many organizations still run on-premises Exchange servers where EWS is the only option. Nylas continues to support EWS for these environments.
:::
## Why use Nylas instead of EWS directly?
EWS is a SOAP-based XML API. Every request requires building XML SOAP envelopes, every response needs XML parsing, and errors come back as SOAP faults with nested XML structures. You need to handle autodiscovery to find the right server endpoint (which is frequently misconfigured in enterprise environments), manage authentication with support for two-factor app passwords, and work with EWS-specific data formats that don't match any other email provider.
Nylas replaces all of that with a JSON REST API. No XML, no WSDL, no SOAP. Authentication and autodiscovery are handled automatically. Your code stays the same whether you're reading from Exchange on-prem, Exchange Online, Gmail, or any IMAP provider.
If you have deep EWS experience and only target Exchange on-prem, you can integrate directly. For multi-provider support or faster time-to-integration, Nylas is the simpler path.
## Before you begin
You'll need:
- A [Nylas application](/docs/v3/getting-started/) with a valid API key
- A [grant](/docs/v3/auth/) for an Exchange on-premises account
- An EWS connector configured with the appropriate scopes
- The Exchange server accessible from outside the corporate network (not behind a VPN or firewall that blocks external access)
:::info
**New to Nylas?** Start with the [quickstart guide](/docs/v3/getting-started/) to set up your app and connect a test account before continuing here.
:::
### Exchange authentication setup
Create an EWS connector with the scopes your app needs:
| Scope | Access |
| --------------- | ------------------------------------- |
| `ews.messages` | Email API (messages, drafts, folders) |
| `ews.calendars` | Calendar API |
| `ews.contacts` | Contacts API |
During authentication, users sign in with their Exchange credentials, typically the same username and password they use for Windows login. The username format is usually `user@example.com` or `DOMAIN\username`.
If EWS autodiscovery is configured on the server, authentication works automatically. If not, users can click "Additional settings" and manually enter the Exchange server address (e.g., `mail.company.com`).
:::info
**Users with two-factor authentication** must generate an app password instead of using their regular password. See [Microsoft's app password documentation](https://support.microsoft.com/en-us/help/12409/) for instructions.
:::
The full setup walkthrough is in the [Exchange on-premises provider guide](/docs/provider-guides/exchange-on-prem/).
### Network requirements
The Exchange server must be accessible from Nylas's infrastructure:
- **EWS must be enabled** on the server and exposed outside the corporate network
- If the server is behind a **firewall**, you'll need to allow Nylas's IP addresses (available on contract plans with [static IPs](/docs/dev-guide/platform/#static-ips))
- If EWS isn't enabled, Nylas can fall back to **IMAP** for email-only access, but you lose calendar and contacts support
Accounts in admin groups are not supported.
## List messages
Make a [List Messages request](/docs/reference/api/messages/get-messages/) with the grant ID. By default, Nylas returns the 50 most recent messages. These examples limit results to 5:
```bash [listMessages-Request]
curl --compressed --request GET \
--url "https://api.us.nylas.com/v3/grants//messages?limit=5" \
--header 'Accept: application/json' \
--header 'Authorization: Bearer ' \
--header 'Content-Type: application/json'
```
```json [listMessages-Response]
{
"request_id": "d0c951b9-61db-4daa-ab19-cd44afeeabac",
"data": [
{
"starred": false,
"unread": true,
"folders": ["UNREAD", "CATEGORY_PERSONAL", "INBOX"],
"grant_id": "1",
"date": 1706811644,
"attachments": [
{
"id": "1",
"grant_id": "1",
"filename": "invite.ics",
"size": 2504,
"content_type": "text/calendar; charset=\"UTF-8\"; method=REQUEST"
},
{
"id": "2",
"grant_id": "1",
"filename": "invite.ics",
"size": 2504,
"content_type": "application/ics; name=\"invite.ics\"",
"is_inline": false,
"content_disposition": "attachment; filename=\"invite.ics\""
}
],
"from": [
{
"name": "Nylas DevRel",
"email": "nylasdev@nylas.com"
}
],
"id": "1",
"object": "message",
"snippet": "Send Email with Nylas APIs",
"subject": "Learn how to Send Email with Nylas APIs",
"thread_id": "1",
"to": [
{
"name": "Nyla",
"email": "nyla@nylas.com"
}
],
"created_at": 1706811644,
"body": "Learn how to send emails using the Nylas APIs!"
}
],
"next_cursor": "123"
}
```
```js [listMessages-Node.js SDK]
import Nylas from "nylas";
const nylas = new Nylas({
apiKey: "",
apiUri: "",
});
async function fetchRecentEmails() {
try {
const messages = await nylas.messages.list({
identifier: "",
queryParams: {
limit: 5,
},
});
console.log("Messages:", messages);
} catch (error) {
console.error("Error fetching emails:", error);
}
}
fetchRecentEmails();
```
```python [listMessages-Python SDK]
from nylas import Client
nylas = Client(
"",
""
)
grant_id = ""
messages = nylas.messages.list(
grant_id,
query_params={
"limit": 5
}
)
print(messages)
```
```ruby [listMessages-Ruby SDK]
require 'nylas'
nylas = Nylas::Client.new(api_key: '')
query_params = { limit: 5 }
messages, _ = nylas.messages.list(identifier: '', query_params: query_params)
messages.each {|message|
puts "[#{Time.at(message[:date]).strftime("%d/%m/%Y at %H:%M:%S")}] \
#{message[:subject]}"
}
```
```java [listMessages-Java SDK]
import com.nylas.NylasClient;
import com.nylas.models.*;
import java.text.SimpleDateFormat;
public class ListMessages {
public static void main(String[] args) throws NylasSdkTimeoutError, NylasApiError {
NylasClient nylas = new NylasClient.Builder("").build();
ListMessagesQueryParams queryParams = new ListMessagesQueryParams.Builder().limit(5).build();
ListResponse message = nylas.messages().list("", queryParams);
for(Message email : message.getData()) {
String date = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").
format(new java.util.Date((email.getDate() * 1000L)));
System.out.println("[" + date + "] | " + email.getSubject());
}
}
}
```
```kt [listMessages-Kotlin SDK]
import com.nylas.NylasClient
import com.nylas.models.*
import java.text.SimpleDateFormat
import java.util.*
fun main(args: Array) {
val nylas = NylasClient(apiKey = "")
val queryParams = ListMessagesQueryParams(limit = 5)
val messages = nylas.messages().list("", queryParams).data
for (message in messages) {
val date = SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
.format(Date(message.date.toLong() * 1000))
println("[$date] | ${message.subject}")
}
}
```
## Filter messages
You can narrow results with query parameters. Here's what works with Exchange accounts:
| Parameter | What it does | Example |
| ----------------- | ----------------------------- | ----------------------------- |
| `subject` | Match on subject line | `?subject=Weekly standup` |
| `from` | Filter by sender | `?from=alex@example.com` |
| `to` | Filter by recipient | `?to=team@company.com` |
| `unread` | Unread only | `?unread=true` |
| `in` | Filter by folder or label ID | `?in=INBOX` |
| `received_after` | After a Unix timestamp | `?received_after=1706000000` |
| `received_before` | Before a Unix timestamp | `?received_before=1706100000` |
| `has_attachment` | Only results with attachments | `?has_attachment=true` |
Here's how to combine filters. This pulls unread messages from a specific sender:
```bash [filterMessages-curl]
curl --request GET \
--url "https://api.us.nylas.com/v3/grants//messages?from=alex@example.com&unread=true&limit=10" \
--header 'Accept: application/json' \
--header 'Authorization: Bearer '
```
```js [filterMessages-Node.js SDK]
const messages = await nylas.messages.list({
identifier: grantId,
queryParams: {
from: "alex@example.com",
unread: true,
limit: 10,
},
});
```
```python [filterMessages-Python SDK]
messages = nylas.messages.list(
grant_id,
query_params={
"from": "alex@example.com",
"unread": True,
"limit": 10,
}
)
```
### Search with `search_query_native`
Exchange supports the `search_query_native` parameter using Microsoft's [Advanced Query Syntax (AQS)](https://learn.microsoft.com/en-us/windows/win32/lwef/-search-2x-wds-aqsreference). You can combine `search_query_native` with any query parameter **except** `thread_id`.
```bash [nativeSearchEws-curl]
curl --request GET \
--url "https://api.us.nylas.com/v3/grants//messages?search_query_native=subject%3Ainvoice&limit=10" \
--header 'Accept: application/json' \
--header 'Authorization: Bearer '
```
```js [nativeSearchEws-Node.js SDK]
const messages = await nylas.messages.list({
identifier: grantId,
queryParams: {
searchQueryNative: "subject:invoice",
limit: 10,
},
});
```
```python [nativeSearchEws-Python SDK]
messages = nylas.messages.list(
grant_id,
query_params={
"search_query_native": "subject:invoice",
"limit": 10,
}
)
```
:::warn
**AQS queries must be URL-encoded.** For example, `subject:invoice` becomes `subject%3Ainvoice` in the URL. The SDKs handle this automatically, but you'll need to encode manually in curl requests.
:::
:::warn
**Exchange doesn't support searching by BCC field.** If you include BCC in a `search_query_native` query, results may be incomplete or return an error.
:::
The Exchange server must have the **AQS parser enabled** and **search indexing active** for `search_query_native` to work. If queries aren't returning expected results, the Exchange admin should verify these settings.
See the [search best practices guide](/docs/dev-guide/best-practices/search/) for more on `search_query_native` across providers.
## Things to know about Exchange
Exchange on-prem behaves differently from Exchange Online (Microsoft Graph) in several important ways.
### Message IDs change when messages move
This is the most important Exchange-specific behavior. When a message is moved from one folder to another, **its Nylas message ID changes**. This is expected EWS behavior because Exchange assigns new IDs when messages change folders.
If your app stores message IDs, treat them as **folder-specific pointers**, not permanent identifiers. For tracking a message across folder moves, use the `InternetMessageId` header instead. It stays stable regardless of which folder the message is in.
To get the `InternetMessageId`, include the `fields=include_headers` query parameter:
```bash [getHeaders-curl]
curl --request GET \
--url "https://api.us.nylas.com/v3/grants//messages/?fields=include_headers" \
--header 'Accept: application/json' \
--header 'Authorization: Bearer '
```
```js [getHeaders-Node.js SDK]
const message = await nylas.messages.find({
identifier: grantId,
messageId: messageId,
queryParams: {
fields: "include_headers",
},
});
```
```python [getHeaders-Python SDK]
message = nylas.messages.find(
grant_id,
message_id,
query_params={
"fields": "include_headers",
}
)
```
:::info
**Multiple messages can share the same `InternetMessageId`** in Exchange. This happens when copies of a message exist in multiple folders. Use it for correlation, not as a unique key.
:::
### Folder hierarchy with parent_id
Exchange supports nested folders. Nylas returns a flat folder list but includes a `parent_id` field on child folders so you can reconstruct the hierarchy. Use `parent_id` when creating or updating folders to place them in the right location.
Use the [List Folders endpoint](/docs/reference/api/folders/get-folder/) to discover all folders and their hierarchy for an Exchange account.
### Starred messages require Exchange 2010+
The `starred` query parameter only works on Exchange 2010 and later. If you're targeting Exchange 2007, filtering by starred status isn't available.
### Rate limits are admin-configured
Unlike Google and Microsoft's cloud services, Exchange on-prem rate limits are set by the server administrator. Nylas can't predict what they'll be. If the Exchange server throttles a request, Nylas returns a `Retry-After` header with the number of seconds to wait.
For apps that check mailboxes frequently, [webhooks](/docs/v3/notifications/) are the best way to avoid hitting rate limits. Let Nylas notify you of changes instead of polling.
### Date filtering is day-level precision
Exchange processes `received_before` and `received_after` filters at the **day level**, not second-level. Even though Nylas accepts Unix timestamps, Exchange rounds to the nearest day. Results are inclusive of the specified day.
For example, if you filter with `received_after=1706745600` (February 1, 2024 00:00:00 UTC), you'll get all messages from February 1 onward, including messages received earlier that day.
### Search indexing affects query accuracy
Exchange relies on a search index for queries using `to`, `from`, `cc`, `bcc`, and `any_email`. If a message has just arrived but the search index hasn't refreshed yet, it won't appear in filtered results. The search index refresh interval is controlled by the Exchange administrator.
If filtered queries aren't returning recently received messages, this is likely the cause. Unfiltered list requests (no query parameters) always return the latest messages.
### EWS fallback to IMAP
If EWS isn't enabled on the Exchange server, Nylas can still connect via IMAP for email-only access. This means:
- **Email works** with messages, drafts, and folders available
- **Calendar and contacts are not available** because these require EWS
- **The 90-day message cache applies** because IMAP connections use the same caching behavior as other IMAP providers, with `query_imap=true` for older messages
If your users report that calendar or contacts aren't working, verify that EWS is enabled on their Exchange server.
## Paginate through results
The Messages API returns paginated responses. When there are more results, the response includes a `next_cursor` value. Pass it back as `page_token` to get the next page:
```bash [paginateMessages-curl]
curl --request GET \
--url "https://api.us.nylas.com/v3/grants//messages?limit=10&page_token=" \
--header 'Accept: application/json' \
--header 'Authorization: Bearer '
```
```js [paginateMessages-Node.js SDK]
let pageCursor = undefined;
do {
const result = await nylas.messages.list({
identifier: grantId,
queryParams: {
limit: 10,
pageToken: pageCursor,
},
});
// Process result.data here
pageCursor = result.nextCursor;
} while (pageCursor);
```
```python [paginateMessages-Python SDK]
page_cursor = None
while True:
query = {"limit": 10}
if page_cursor:
query["page_token"] = page_cursor
result = nylas.messages.list(grant_id, query_params=query)
# Process result.data here
page_cursor = result.next_cursor
if not page_cursor:
break
```
Keep paginating until the response comes back without a `next_cursor`.
## What's next
- [Messages API reference](/docs/reference/api/messages/) for full endpoint documentation and all available parameters
- [Using the Messages API](/docs/v3/email/messages/) for search, modification, and deletion
- [Threads](/docs/v3/email/threads/) to group related messages into conversations
- [Search best practices](/docs/dev-guide/best-practices/search/) for advanced search with `search_query_native` and AQS for Exchange
- [Webhooks](/docs/v3/notifications/) for real-time notifications instead of polling
- [Exchange on-premises provider guide](/docs/provider-guides/exchange-on-prem/) for full Exchange setup including authentication and network requirements
- [Microsoft admin approval](/docs/provider-guides/microsoft/admin-approval/) to configure consent for enterprise organizations (Exchange Online)
────────────────────────────────────────────────────────────────────────────────
title: "How to list Google email messages"
description: "Retrieve email messages from Gmail and Google Workspace accounts using the Nylas Email API. Covers labels vs. folders, Gmail search operators, OAuth verification, and rate limits."
source: "https://developer.nylas.com/docs/cookbook/email/messages/list-messages-google/"
────────────────────────────────────────────────────────────────────────────────
Gmail and Google Workspace accounts are the most common provider type developers integrate with. If you've ever worked with the Gmail API directly, you know the OAuth verification process, scope restrictions, and label system add significant friction. Nylas abstracts all of that behind the same [Messages API](/docs/reference/api/messages/) you'd use for Microsoft or IMAP.
This guide walks through listing messages from Google accounts and covers the Google-specific details you need to know: labels, search operators, OAuth scopes, and rate limits.
## Why use Nylas instead of the Gmail API directly?
The Gmail API requires more setup overhead than most developers expect. You need to configure a GCP project, navigate Google's three-tier OAuth scope system (non-sensitive, sensitive, restricted), and for any scope beyond basic metadata, go through Google's OAuth verification or even a full third-party security assessment. On top of that, the Gmail data model uses labels instead of folders, message IDs are hex strings, and rate limits are enforced at both the per-user and per-project level.
Nylas normalizes all of this. Labels map to a unified folder model. Token refresh and scope management happen automatically. You don't need a GCP project or a security assessment to get started. And your code works across Gmail, Outlook, Yahoo, and IMAP without any provider-specific branches.
If you only need Gmail and want full control over the integration, the Gmail API works. If you need multi-provider support or want to skip the verification process, Nylas is the faster path.
## Before you begin
You'll need:
- A [Nylas application](/docs/v3/getting-started/) with a valid API key
- A [grant](/docs/v3/auth/) for a Gmail or Google Workspace account
- The appropriate [Google OAuth scopes](/docs/provider-guides/google/) configured in your GCP project
:::info
**New to Nylas?** Start with the [quickstart guide](/docs/v3/getting-started/) to set up your app and connect a test account before continuing here.
:::
### Google OAuth scopes and verification
Google classifies OAuth scopes into three tiers, and each one comes with different verification requirements:
| Scope tier | Example | What's required |
| ------------- | --------------------------------- | ------------------------------------------------- |
| Non-sensitive | `gmail.labels` | No verification needed |
| Sensitive | `gmail.readonly`, `gmail.compose` | OAuth consent screen verification |
| Restricted | `gmail.modify` | Full security assessment by a third-party auditor |
If your app needs to read message content (not just metadata), you'll need at least the `gmail.readonly` scope, which is classified as sensitive. For read-write access, `gmail.modify` is restricted and requires a [security assessment](/docs/provider-guides/google/google-verification-security-assessment-guide/).
Nylas handles the token management, but your GCP project still needs the right scopes configured. See the [Google provider guide](/docs/provider-guides/google/) for the full setup.
## List messages
Make a [List Messages request](/docs/reference/api/messages/get-messages/) with the grant ID. By default, Nylas returns the 50 most recent messages. These examples limit results to 5:
```bash [listMessages-Request]
curl --compressed --request GET \
--url "https://api.us.nylas.com/v3/grants//messages?limit=5" \
--header 'Accept: application/json' \
--header 'Authorization: Bearer ' \
--header 'Content-Type: application/json'
```
```json [listMessages-Response]
{
"request_id": "d0c951b9-61db-4daa-ab19-cd44afeeabac",
"data": [
{
"starred": false,
"unread": true,
"folders": ["UNREAD", "CATEGORY_PERSONAL", "INBOX"],
"grant_id": "1",
"date": 1706811644,
"attachments": [
{
"id": "1",
"grant_id": "1",
"filename": "invite.ics",
"size": 2504,
"content_type": "text/calendar; charset=\"UTF-8\"; method=REQUEST"
},
{
"id": "2",
"grant_id": "1",
"filename": "invite.ics",
"size": 2504,
"content_type": "application/ics; name=\"invite.ics\"",
"is_inline": false,
"content_disposition": "attachment; filename=\"invite.ics\""
}
],
"from": [
{
"name": "Nylas DevRel",
"email": "nylasdev@nylas.com"
}
],
"id": "1",
"object": "message",
"snippet": "Send Email with Nylas APIs",
"subject": "Learn how to Send Email with Nylas APIs",
"thread_id": "1",
"to": [
{
"name": "Nyla",
"email": "nyla@nylas.com"
}
],
"created_at": 1706811644,
"body": "Learn how to send emails using the Nylas APIs!"
}
],
"next_cursor": "123"
}
```
```js [listMessages-Node.js SDK]
import Nylas from "nylas";
const nylas = new Nylas({
apiKey: "",
apiUri: "",
});
async function fetchRecentEmails() {
try {
const messages = await nylas.messages.list({
identifier: "",
queryParams: {
limit: 5,
},
});
console.log("Messages:", messages);
} catch (error) {
console.error("Error fetching emails:", error);
}
}
fetchRecentEmails();
```
```python [listMessages-Python SDK]
from nylas import Client
nylas = Client(
"",
""
)
grant_id = ""
messages = nylas.messages.list(
grant_id,
query_params={
"limit": 5
}
)
print(messages)
```
```ruby [listMessages-Ruby SDK]
require 'nylas'
nylas = Nylas::Client.new(api_key: '')
query_params = { limit: 5 }
messages, _ = nylas.messages.list(identifier: '', query_params: query_params)
messages.each {|message|
puts "[#{Time.at(message[:date]).strftime("%d/%m/%Y at %H:%M:%S")}] \
#{message[:subject]}"
}
```
```java [listMessages-Java SDK]
import com.nylas.NylasClient;
import com.nylas.models.*;
import java.text.SimpleDateFormat;
public class ListMessages {
public static void main(String[] args) throws NylasSdkTimeoutError, NylasApiError {
NylasClient nylas = new NylasClient.Builder("").build();
ListMessagesQueryParams queryParams = new ListMessagesQueryParams.Builder().limit(5).build();
ListResponse message = nylas.messages().list("", queryParams);
for(Message email : message.getData()) {
String date = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").
format(new java.util.Date((email.getDate() * 1000L)));
System.out.println("[" + date + "] | " + email.getSubject());
}
}
}
```
```kt [listMessages-Kotlin SDK]
import com.nylas.NylasClient
import com.nylas.models.*
import java.text.SimpleDateFormat
import java.util.*
fun main(args: Array) {
val nylas = NylasClient(apiKey = "")
val queryParams = ListMessagesQueryParams(limit = 5)
val messages = nylas.messages().list("", queryParams).data
for (message in messages) {
val date = SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
.format(Date(message.date.toLong() * 1000))
println("[$date] | ${message.subject}")
}
}
```
## Filter messages
You can narrow results with query parameters. Here's what works with Google accounts:
| Parameter | What it does | Example |
| ----------------- | ----------------------------- | ----------------------------- |
| `subject` | Match on subject line | `?subject=Weekly standup` |
| `from` | Filter by sender | `?from=alex@example.com` |
| `to` | Filter by recipient | `?to=team@company.com` |
| `unread` | Unread only | `?unread=true` |
| `in` | Filter by folder or label ID | `?in=INBOX` |
| `received_after` | After a Unix timestamp | `?received_after=1706000000` |
| `received_before` | Before a Unix timestamp | `?received_before=1706100000` |
| `has_attachment` | Only results with attachments | `?has_attachment=true` |
:::warn
**When using the `in` parameter with Google accounts, you must use the folder (label) ID, not the display name.** Nylas does not support filtering by label name on Google accounts. Use the [List Folders endpoint](/docs/reference/api/folders/get-folder/) to get the correct IDs.
:::
Here's how to combine filters. This pulls unread messages from a specific sender:
```bash [filterMessages-curl]
curl --request GET \
--url "https://api.us.nylas.com/v3/grants//messages?from=alex@example.com&unread=true&limit=10" \
--header 'Accept: application/json' \
--header 'Authorization: Bearer '
```
```js [filterMessages-Node.js SDK]
const messages = await nylas.messages.list({
identifier: grantId,
queryParams: {
from: "alex@example.com",
unread: true,
limit: 10,
},
});
```
```python [filterMessages-Python SDK]
messages = nylas.messages.list(
grant_id,
query_params={
"from": "alex@example.com",
"unread": True,
"limit": 10,
}
)
```
### Use Gmail search operators
For more advanced filtering, you can use Gmail's native search syntax through the `search_query_native` parameter. This supports the same [search operators](https://support.google.com/mail/answer/7190?hl=en) you'd use in the Gmail search bar:
```bash [nativeSearchGmail-curl]
curl --request GET \
--url "https://api.us.nylas.com/v3/grants//messages?search_query_native=subject%3Ainvoice%20OR%20subject%3Areceipt" \
--header 'Accept: application/json' \
--header 'Authorization: Bearer '
```
```js [nativeSearchGmail-Node.js SDK]
const messages = await nylas.messages.list({
identifier: grantId,
queryParams: {
searchQueryNative: "subject:invoice OR subject:receipt",
limit: 10,
},
});
```
```python [nativeSearchGmail-Python SDK]
messages = nylas.messages.list(
grant_id,
query_params={
"search_query_native": "subject:invoice OR subject:receipt",
"limit": 10,
}
)
```
Some useful Gmail search operators:
| Operator | What it does | Example |
| ---------------- | ----------------------- | ------------------------- |
| `from:` | Messages from a sender | `from:alex@gmail.com` |
| `to:` | Messages to a recipient | `to:team@company.com` |
| `subject:` | Subject line contains | `subject:invoice` |
| `has:attachment` | Has file attachments | `has:attachment` |
| `filename:` | Attachment filename | `filename:report.pdf` |
| `after:` | Messages after a date | `after:2025/01/01` |
| `before:` | Messages before a date | `before:2025/02/01` |
| `is:unread` | Unread messages | `is:unread` |
| `label:` | Messages with a label | `label:important` |
| `OR` | Combine conditions | `from:alex OR from:priya` |
When using `search_query_native`, you can only combine it with the `in`, `limit`, and `page_token` parameters. Other query parameters will return an error. See the [search best practices guide](/docs/dev-guide/best-practices/search/) for more details.
## Things to know about Google
A few provider-specific details that matter when you're building against Gmail and Google Workspace accounts.
### Labels, not folders
This is the biggest conceptual difference from Microsoft. Gmail uses labels instead of folders, so a single message can have multiple labels at once. When you list messages with `?in=INBOX`, you're filtering by the `INBOX` label, not a folder.
Nylas normalizes this into a `folders` array on each message object. A Gmail message might look like:
```json
{
"folders": ["INBOX", "UNREAD", "CATEGORY_PERSONAL", "IMPORTANT"]
}
```
Common system labels you'll see:
| Gmail UI name | Label ID |
| -------------- | --------------------- |
| Inbox | `INBOX` |
| Sent | `SENT` |
| Drafts | `DRAFT` |
| Trash | `TRASH` |
| Spam | `SPAM` |
| Starred | `STARRED` |
| Important | `IMPORTANT` |
| Primary tab | `CATEGORY_PERSONAL` |
| Social tab | `CATEGORY_SOCIAL` |
| Promotions tab | `CATEGORY_PROMOTIONS` |
| Updates tab | `CATEGORY_UPDATES` |
Custom labels created by the user will have auto-generated IDs. Use the [List Folders endpoint](/docs/reference/api/folders/get-folder/) to discover them. Google also supports custom label colors via `text_color` and `background_color` parameters when [creating](/docs/reference/api/folders/post-folder/) or [updating](/docs/reference/api/folders/put-folders-id/) folders.
### Message IDs are shorter than Microsoft's
Google message IDs are shorter hex-style strings (like `18d5a4b2c3e4f567`), compared to Microsoft's long base64-encoded IDs. They're stable across syncs and safe to store in your database.
The `thread_id` field is a Google-native concept. Gmail groups related messages into threads automatically. If you're building an inbox UI, you'll probably want to use the [Threads API](/docs/v3/email/threads/) instead of listing individual messages.
### Rate limits are per-user and per-project
Google enforces quotas at two levels:
- **Per-user:** Each authenticated user has a daily quota for API calls
- **Per-project:** Your GCP project has an overall daily limit across all users
Nylas handles retries when you hit rate limits, but if your app polls aggressively for many users, you may exhaust your project quota. Two ways to avoid this:
- Use [webhooks](/docs/v3/notifications/) instead of polling so Nylas notifies your server when new messages arrive
- Set up [Google Pub/Sub](/docs/provider-guides/google/connect-google-pub-sub/) for real-time sync, which gives you faster notification delivery for Gmail accounts with `gmail.readonly` or `gmail.labels` scopes
### One-click unsubscribe headers
If your app sends marketing or subscription email through Gmail accounts, be aware that Google requires one-click unsubscribe headers for senders who send more than 5,000 messages per day to Gmail addresses. You'll need to include `List-Unsubscribe-Post` and `List-Unsubscribe` custom headers in your send requests.
This doesn't affect listing messages, but it's worth knowing if your app both reads and sends email. See the [email documentation](/docs/v3/email/) for implementation details.
### Google Workspace vs. personal Gmail
Both work with Nylas, but there are differences:
- **Workspace admins** can restrict which third-party apps have access. If a Workspace user can't authenticate, their admin may need to allow your app in the Google Admin console.
- **Delegated mailboxes** (shared mailboxes in Workspace) require special handling. See the [shared accounts guide](/docs/provider-guides/google/shared-accounts/).
- **Service accounts** are available for Google Workspace Calendar access (not email). See the [service accounts guide](/docs/provider-guides/google/google-workspace-service-accounts/).
## Paginate through results
The Messages API returns paginated responses. When there are more results, the response includes a `next_cursor` value. Pass it back as `page_token` to get the next page:
```bash [paginateMessages-curl]
curl --request GET \
--url "https://api.us.nylas.com/v3/grants//messages?limit=10&page_token=" \
--header 'Accept: application/json' \
--header 'Authorization: Bearer '
```
```js [paginateMessages-Node.js SDK]
let pageCursor = undefined;
do {
const result = await nylas.messages.list({
identifier: grantId,
queryParams: {
limit: 10,
pageToken: pageCursor,
},
});
// Process result.data here
pageCursor = result.nextCursor;
} while (pageCursor);
```
```python [paginateMessages-Python SDK]
page_cursor = None
while True:
query = {"limit": 10}
if page_cursor:
query["page_token"] = page_cursor
result = nylas.messages.list(grant_id, query_params=query)
# Process result.data here
page_cursor = result.next_cursor
if not page_cursor:
break
```
Keep paginating until the response comes back without a `next_cursor`.
## What's next
- [Messages API reference](/docs/reference/api/messages/) for full endpoint documentation and all available parameters
- [Using the Messages API](/docs/v3/email/messages/) for search, modification, and deletion
- [Threads](/docs/v3/email/threads/) to group related messages into Gmail-style conversations
- [Search best practices](/docs/dev-guide/best-practices/search/) for advanced search with `search_query_native` and provider-specific operators
- [Webhooks](/docs/v3/notifications/) for real-time notifications instead of polling
- [Google Pub/Sub](/docs/provider-guides/google/connect-google-pub-sub/) for real-time sync with Gmail accounts
- [Google provider guide](/docs/provider-guides/google/) for full Google setup including OAuth scopes and verification
- [Google verification & security assessment](/docs/provider-guides/google/google-verification-security-assessment-guide/), required for restricted scopes in production
- [List Gmail emails from the terminal](https://cli.nylas.com/guides/list-gmail-emails) -- read and search Gmail messages using the Nylas CLI
- [Send email from the terminal](https://cli.nylas.com/guides/send-email-from-terminal) -- send email from the command line without writing code
────────────────────────────────────────────────────────────────────────────────
title: "How to list iCloud email messages"
description: "Retrieve email messages from iCloud Mail accounts using the Nylas Email API. Covers app-specific passwords, the 90-day message cache, query_imap for older messages, and iCloud-specific behavior."
source: "https://developer.nylas.com/docs/cookbook/email/messages/list-messages-icloud/"
────────────────────────────────────────────────────────────────────────────────
Apple doesn't offer a public email API for iCloud Mail. There's no REST interface, no SDK, and no developer portal for mail. The only programmatic access is raw IMAP, and even that requires each user to manually create an app-specific password through their Apple ID settings. Nylas handles all of that and exposes iCloud through the same [Messages API](/docs/reference/api/messages/) you use for Gmail and Outlook.
This guide covers listing messages from iCloud accounts, including the app-specific password requirement, the 90-day message cache, and iCloud-specific behaviors you should know about.
## Why use Nylas instead of IMAP directly?
iCloud's biggest friction point for developers is the app-specific password requirement. There's no way to generate these programmatically. Every user must log in to their Apple ID, navigate to the security settings, and manually create a password. On top of that, you'd need to build the same IMAP infrastructure as any other IMAP integration: connection management, MIME parsing, sync state tracking, and a separate SMTP connection for sending.
Nylas handles the IMAP connection and guides users through the app password flow during authentication. You get a REST API with JSON responses, automatic sync and caching, and the same code works across iCloud, Gmail, Outlook, Yahoo, and every other provider.
If you're comfortable with raw IMAP and only targeting iCloud, you can connect directly. For multi-provider apps or faster development, Nylas saves you significant time.
## Before you begin
You'll need:
- A [Nylas application](/docs/v3/getting-started/) with a valid API key
- A [grant](/docs/v3/auth/) for an iCloud Mail account
- An iCloud connector configured in your Nylas application
:::info
**New to Nylas?** Start with the [quickstart guide](/docs/v3/getting-started/) to set up your app and connect a test account before continuing here.
:::
### iCloud authentication setup
iCloud requires **app-specific passwords** for third-party email access. Unlike Google or Microsoft OAuth, there's no way to generate these programmatically. Each user must create one manually in their Apple ID settings.
Nylas supports two authentication flows for iCloud:
| Method | Best for |
| ----------------------------------- | ---------------------------------------------------------------------- |
| Hosted OAuth | Production apps where Nylas guides users through the app password flow |
| Bring Your Own (BYO) Authentication | Custom auth pages where you collect credentials directly |
With either method, users need to:
1. Go to [appleid.apple.com](https://appleid.apple.com/) and sign in
2. Navigate to **Sign-In and Security** then **App-Specific Passwords**
3. Generate a new app password
4. Use that password (not their regular iCloud password) when authenticating
:::warn
**App-specific passwords can't be generated via API.** Your app's onboarding flow should include clear instructions telling users how to create one. Users who enter their regular iCloud password will fail authentication.
:::
The full setup walkthrough is in the [iCloud provider guide](/docs/provider-guides/icloud/) and the [app passwords guide](/docs/provider-guides/app-passwords/).
## List messages
Make a [List Messages request](/docs/reference/api/messages/get-messages/) with the grant ID. By default, Nylas returns the 50 most recent messages. These examples limit results to 5:
```bash [listMessages-Request]
curl --compressed --request GET \
--url "https://api.us.nylas.com/v3/grants//messages?limit=5" \
--header 'Accept: application/json' \
--header 'Authorization: Bearer ' \
--header 'Content-Type: application/json'
```
```json [listMessages-Response]
{
"request_id": "d0c951b9-61db-4daa-ab19-cd44afeeabac",
"data": [
{
"starred": false,
"unread": true,
"folders": ["UNREAD", "CATEGORY_PERSONAL", "INBOX"],
"grant_id": "1",
"date": 1706811644,
"attachments": [
{
"id": "1",
"grant_id": "1",
"filename": "invite.ics",
"size": 2504,
"content_type": "text/calendar; charset=\"UTF-8\"; method=REQUEST"
},
{
"id": "2",
"grant_id": "1",
"filename": "invite.ics",
"size": 2504,
"content_type": "application/ics; name=\"invite.ics\"",
"is_inline": false,
"content_disposition": "attachment; filename=\"invite.ics\""
}
],
"from": [
{
"name": "Nylas DevRel",
"email": "nylasdev@nylas.com"
}
],
"id": "1",
"object": "message",
"snippet": "Send Email with Nylas APIs",
"subject": "Learn how to Send Email with Nylas APIs",
"thread_id": "1",
"to": [
{
"name": "Nyla",
"email": "nyla@nylas.com"
}
],
"created_at": 1706811644,
"body": "Learn how to send emails using the Nylas APIs!"
}
],
"next_cursor": "123"
}
```
```js [listMessages-Node.js SDK]
import Nylas from "nylas";
const nylas = new Nylas({
apiKey: "",
apiUri: "",
});
async function fetchRecentEmails() {
try {
const messages = await nylas.messages.list({
identifier: "",
queryParams: {
limit: 5,
},
});
console.log("Messages:", messages);
} catch (error) {
console.error("Error fetching emails:", error);
}
}
fetchRecentEmails();
```
```python [listMessages-Python SDK]
from nylas import Client
nylas = Client(
"",
""
)
grant_id = ""
messages = nylas.messages.list(
grant_id,
query_params={
"limit": 5
}
)
print(messages)
```
```ruby [listMessages-Ruby SDK]
require 'nylas'
nylas = Nylas::Client.new(api_key: '')
query_params = { limit: 5 }
messages, _ = nylas.messages.list(identifier: '', query_params: query_params)
messages.each {|message|
puts "[#{Time.at(message[:date]).strftime("%d/%m/%Y at %H:%M:%S")}] \
#{message[:subject]}"
}
```
```java [listMessages-Java SDK]
import com.nylas.NylasClient;
import com.nylas.models.*;
import java.text.SimpleDateFormat;
public class ListMessages {
public static void main(String[] args) throws NylasSdkTimeoutError, NylasApiError {
NylasClient nylas = new NylasClient.Builder("").build();
ListMessagesQueryParams queryParams = new ListMessagesQueryParams.Builder().limit(5).build();
ListResponse message = nylas.messages().list("", queryParams);
for(Message email : message.getData()) {
String date = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").
format(new java.util.Date((email.getDate() * 1000L)));
System.out.println("[" + date + "] | " + email.getSubject());
}
}
}
```
```kt [listMessages-Kotlin SDK]
import com.nylas.NylasClient
import com.nylas.models.*
import java.text.SimpleDateFormat
import java.util.*
fun main(args: Array) {
val nylas = NylasClient(apiKey = "")
val queryParams = ListMessagesQueryParams(limit = 5)
val messages = nylas.messages().list("", queryParams).data
for (message in messages) {
val date = SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
.format(Date(message.date.toLong() * 1000))
println("[$date] | ${message.subject}")
}
}
```
## Filter messages
You can narrow results with query parameters. Here's what works with iCloud accounts:
| Parameter | What it does | Example |
| ----------------- | ----------------------------- | ----------------------------- |
| `subject` | Match on subject line | `?subject=Weekly standup` |
| `from` | Filter by sender | `?from=alex@example.com` |
| `to` | Filter by recipient | `?to=team@company.com` |
| `unread` | Unread only | `?unread=true` |
| `in` | Filter by folder or label ID | `?in=INBOX` |
| `received_after` | After a Unix timestamp | `?received_after=1706000000` |
| `received_before` | Before a Unix timestamp | `?received_before=1706100000` |
| `has_attachment` | Only results with attachments | `?has_attachment=true` |
Here's how to combine filters. This pulls unread messages from a specific sender:
```bash [filterMessages-curl]
curl --request GET \
--url "https://api.us.nylas.com/v3/grants//messages?from=alex@example.com&unread=true&limit=10" \
--header 'Accept: application/json' \
--header 'Authorization: Bearer '
```
```js [filterMessages-Node.js SDK]
const messages = await nylas.messages.list({
identifier: grantId,
queryParams: {
from: "alex@example.com",
unread: true,
limit: 10,
},
});
```
```python [filterMessages-Python SDK]
messages = nylas.messages.list(
grant_id,
query_params={
"from": "alex@example.com",
"unread": True,
"limit": 10,
}
)
```
### Search with `search_query_native`
iCloud supports the `search_query_native` parameter for IMAP-style search. Like Yahoo and other IMAP providers, iCloud lets you combine `search_query_native` with any other query parameter.
```bash [nativeSearchIcloud-curl]
curl --request GET \
--url "https://api.us.nylas.com/v3/grants//messages?search_query_native=subject:invoice&limit=10" \
--header 'Accept: application/json' \
--header 'Authorization: Bearer '
```
```js [nativeSearchIcloud-Node.js SDK]
const messages = await nylas.messages.list({
identifier: grantId,
queryParams: {
searchQueryNative: "subject:invoice",
limit: 10,
},
});
```
```python [nativeSearchIcloud-Python SDK]
messages = nylas.messages.list(
grant_id,
query_params={
"search_query_native": "subject:invoice",
"limit": 10,
}
)
```
:::warn
**Some IMAP providers don't fully support the `SEARCH` operator.** If `search_query_native` returns unexpected results or a `400` error, fall back to standard query parameters like `subject`, `from`, and `to` instead.
:::
See the [search best practices guide](/docs/dev-guide/best-practices/search/) for more on `search_query_native` across providers.
## Things to know about iCloud
iCloud is IMAP-based, which means it shares some behaviors with Yahoo and other IMAP providers. But Apple has its own quirks too.
### The 90-day message cache
Nylas maintains a rolling cache of messages from the last 90 days for all IMAP-based providers, including iCloud. Anything received or created within that window is synced and available through the API. For older messages, set `query_imap=true` to query iCloud's IMAP server directly. This is slower but gives you access to the full mailbox.
```bash [queryImap-curl]
curl --request GET \
--url "https://api.us.nylas.com/v3/grants//messages?query_imap=true&in=INBOX&limit=10" \
--header 'Accept: application/json' \
--header 'Authorization: Bearer '
```
```js [queryImap-Node.js SDK]
const messages = await nylas.messages.list({
identifier: grantId,
queryParams: {
queryImap: true,
in: "INBOX",
limit: 10,
},
});
```
```python [queryImap-Python SDK]
messages = nylas.messages.list(
grant_id,
query_params={
"query_imap": True,
"in": "INBOX",
"limit": 10,
}
)
```
When using `query_imap`, you must include the `in` parameter to specify which folder to search.
### Webhooks don't cover old messages
Nylas [webhooks](/docs/v3/notifications/) only fire for changes to messages within the 90-day cache window. If a user modifies or deletes a message older than 90 days, you won't receive a notification. Plan your sync strategy accordingly if your app needs to track changes across the full mailbox.
### iCloud folder names
iCloud uses standard IMAP folder names, but the exact names can vary. Some accounts use locale-specific names (for example, "Papierkorb" instead of "Trash" on German accounts), and the display name in Apple Mail may not always match the IMAP folder name on the server.
Common folder names on English-language accounts:
| Apple Mail UI name | Folder name |
| ------------------ | ------------------ |
| Inbox | `INBOX` |
| Sent | `Sent Messages` |
| Drafts | `Drafts` |
| Trash | `Deleted Messages` |
| Junk | `Junk` |
| Archive | `Archive` |
Don't hardcode these. Always use the [List Folders endpoint](/docs/reference/api/folders/get-folder/) to discover the exact folder names for each account.
### Sending rate limits
Apple publishes specific sending limits for iCloud Mail:
| Limit | Value |
| ---------------------- | ----- |
| Messages per day | 1,000 |
| Recipients per day | 1,000 |
| Recipients per message | 500 |
| Max message size | 20 MB |
These are sending limits, not read limits. Nylas handles retries if you hit throttling on reads, but if your app sends email through iCloud accounts, keep these limits in mind. Source: [Apple support documentation](https://support.apple.com/en-gb/102198).
### No unsubscribe headers
iCloud does not support `List-Unsubscribe-Post` or `List-Unsubscribe` custom headers when sending email. If your app sends subscription-style email through iCloud accounts, you'll need to handle unsubscribe links in the message body instead. This limitation is shared with some Microsoft Graph accounts.
### Contacts are disabled by default
The Contacts API is disabled by default for iCloud grants. If your app needs to access iCloud contacts, contact [Nylas Support](https://support.nylas.com/) to enable it. When enabled, contacts are parsed from email headers (From, To, CC, BCC, Reply-To fields) rather than synced from an address book.
### Sync and threading
iCloud accounts use IMAP for reading and SMTP for sending. This is invisible to your code, but it affects a few behaviors:
- **Sync relies on IMAP idle.** Nylas maintains low-bandwidth connections to monitor the Inbox and Sent folders. Other folders are checked periodically, so changes outside Inbox and Sent may take a few minutes to appear.
- **Message IDs** are IMAP UIDs, which are numeric values unique within a folder but not globally unique across the account. If you need a stable identifier across folders, use the `Message-ID` header.
- **Thread grouping** relies on subject-line and `In-Reply-To` header matching. This works well for most conversations but isn't as precise as Gmail's native threading.
### iCloud connector vs. generic IMAP
You can authenticate iCloud accounts two ways in Nylas:
| Method | Provider type | What you get |
| ---------------- | ------------- | --------------------------------------- |
| iCloud connector | `icloud` | Email via IMAP plus calendar via CalDAV |
| Generic IMAP | `imap` | Email only, no calendar access |
Use the dedicated iCloud connector if your app needs calendar access alongside email. The generic IMAP connector works for email-only use cases but doesn't include CalDAV support.
## Paginate through results
The Messages API returns paginated responses. When there are more results, the response includes a `next_cursor` value. Pass it back as `page_token` to get the next page:
```bash [paginateMessages-curl]
curl --request GET \
--url "https://api.us.nylas.com/v3/grants//messages?limit=10&page_token=" \
--header 'Accept: application/json' \
--header 'Authorization: Bearer '
```
```js [paginateMessages-Node.js SDK]
let pageCursor = undefined;
do {
const result = await nylas.messages.list({
identifier: grantId,
queryParams: {
limit: 10,
pageToken: pageCursor,
},
});
// Process result.data here
pageCursor = result.nextCursor;
} while (pageCursor);
```
```python [paginateMessages-Python SDK]
page_cursor = None
while True:
query = {"limit": 10}
if page_cursor:
query["page_token"] = page_cursor
result = nylas.messages.list(grant_id, query_params=query)
# Process result.data here
page_cursor = result.next_cursor
if not page_cursor:
break
```
Keep paginating until the response comes back without a `next_cursor`.
## What's next
- [Messages API reference](/docs/reference/api/messages/) for full endpoint documentation and all available parameters
- [Using the Messages API](/docs/v3/email/messages/) for search, modification, and deletion
- [Threads](/docs/v3/email/threads/) to group related messages into conversations
- [Search best practices](/docs/dev-guide/best-practices/search/) for advanced search with `search_query_native` across providers
- [Webhooks](/docs/v3/notifications/) for real-time notifications instead of polling
- [iCloud provider guide](/docs/provider-guides/icloud/) for full iCloud setup including authentication
- [App passwords guide](/docs/provider-guides/app-passwords/) for generating app-specific passwords for iCloud and other providers
- [IMAP provider guide](/docs/provider-guides/imap/) for general IMAP configuration and behavior
────────────────────────────────────────────────────────────────────────────────
title: "How to list IMAP email messages"
description: "Retrieve email messages from any IMAP provider using the Nylas Email API. Covers generic IMAP setup, the 90-day message cache, query_imap, folder handling, and IMAP-specific behaviors."
source: "https://developer.nylas.com/docs/cookbook/email/messages/list-messages-imap/"
────────────────────────────────────────────────────────────────────────────────
Not every email provider is Google, Microsoft, or Yahoo. Thousands of organizations run their own mail servers, and services like Zoho Mail, Fastmail, AOL, and GMX all support IMAP. Nylas connects to any standard IMAP server and exposes it through the same [Messages API](/docs/reference/api/messages/) you'd use for Gmail or Outlook.
This guide covers listing messages from generic IMAP accounts, the catch-all provider type for anything that isn't Google, Microsoft, Yahoo, or iCloud.
## Why use Nylas instead of IMAP directly?
Building a production-grade IMAP integration is a bigger project than most developers expect. The protocol requires persistent socket connections with heartbeat monitoring and reconnection logic. Messages come back in MIME format, which means parsing multipart content, handling character encodings, and extracting inline attachments. You need to track message UIDs per folder, handle `UIDVALIDITY` changes that can invalidate your entire cache, and deal with server-specific quirks like different hierarchy separators and inconsistent folder naming.
Nylas does all of that behind a REST API. You get clean JSON responses, automatic sync with a local cache, and a single integration that works across IMAP, Gmail, Outlook, Yahoo, and iCloud. Sending email requires a separate SMTP connection in raw IMAP, but Nylas handles both protocols behind one API.
If you're building a quick integration with a single IMAP server and want full control, you can connect directly. For production apps that need reliability across multiple providers, Nylas saves you months of infrastructure work.
## Before you begin
You'll need:
- A [Nylas application](/docs/v3/getting-started/) with a valid API key
- A [grant](/docs/v3/auth/) for an IMAP email account
- The IMAP server hostname and port for the provider (e.g., `imap.example.com:993`)
:::info
**New to Nylas?** Start with the [quickstart guide](/docs/v3/getting-started/) to set up your app and connect a test account before continuing here.
:::
### IMAP authentication setup
The generic IMAP connector works with any standard IMAP server. Nylas supports two authentication flows:
| Method | Best for |
| ----------------------------------- | ----------------------------------------------------------------------------- |
| Hosted OAuth | Production apps where Nylas collects IMAP credentials through a guided flow |
| Bring Your Own (BYO) Authentication | Custom auth pages where you collect IMAP host, port, and credentials directly |
With Hosted OAuth, users enter their email credentials and Nylas automatically connects. If the IMAP server requires specific connection settings, users can expand "Additional settings" to enter the IMAP host, port, SMTP host, and SMTP port manually.
With BYO Authentication, your app collects the credentials and sends them to Nylas directly:
| Setting | Description | Example |
| --------------- | ------------------------- | ----------------------- |
| `imap_username` | Email address or username | `user@example.com` |
| `imap_password` | Password or app password | (app-specific password) |
| `imap_host` | IMAP server hostname | `imap.example.com` |
| `imap_port` | IMAP server port | `993` |
| `smtp_host` | SMTP server hostname | `smtp.example.com` |
| `smtp_port` | SMTP server port | `465` |
:::info
**Most IMAP providers require app passwords** instead of the user's regular login password. This is especially true for providers with two-factor authentication enabled. See the [app passwords guide](/docs/provider-guides/app-passwords/) for provider-specific instructions.
:::
If your app needs to send email (not just read), add `options=smtp_required` to the Hosted OAuth URL. This ensures users enter their SMTP server details during authentication.
The full setup walkthrough is in the [IMAP authentication guide](/docs/v3/auth/imap/).
## List messages
Make a [List Messages request](/docs/reference/api/messages/get-messages/) with the grant ID. By default, Nylas returns the 50 most recent messages. These examples limit results to 5:
```bash [listMessages-Request]
curl --compressed --request GET \
--url "https://api.us.nylas.com/v3/grants//messages?limit=5" \
--header 'Accept: application/json' \
--header 'Authorization: Bearer ' \
--header 'Content-Type: application/json'
```
```json [listMessages-Response]
{
"request_id": "d0c951b9-61db-4daa-ab19-cd44afeeabac",
"data": [
{
"starred": false,
"unread": true,
"folders": ["UNREAD", "CATEGORY_PERSONAL", "INBOX"],
"grant_id": "1",
"date": 1706811644,
"attachments": [
{
"id": "1",
"grant_id": "1",
"filename": "invite.ics",
"size": 2504,
"content_type": "text/calendar; charset=\"UTF-8\"; method=REQUEST"
},
{
"id": "2",
"grant_id": "1",
"filename": "invite.ics",
"size": 2504,
"content_type": "application/ics; name=\"invite.ics\"",
"is_inline": false,
"content_disposition": "attachment; filename=\"invite.ics\""
}
],
"from": [
{
"name": "Nylas DevRel",
"email": "nylasdev@nylas.com"
}
],
"id": "1",
"object": "message",
"snippet": "Send Email with Nylas APIs",
"subject": "Learn how to Send Email with Nylas APIs",
"thread_id": "1",
"to": [
{
"name": "Nyla",
"email": "nyla@nylas.com"
}
],
"created_at": 1706811644,
"body": "Learn how to send emails using the Nylas APIs!"
}
],
"next_cursor": "123"
}
```
```js [listMessages-Node.js SDK]
import Nylas from "nylas";
const nylas = new Nylas({
apiKey: "",
apiUri: "",
});
async function fetchRecentEmails() {
try {
const messages = await nylas.messages.list({
identifier: "