Calendar apps send push notifications to the person who owns the event. That works fine if you only need to remind yourself. But the moment your application needs to reach other people, built-in notifications fall short. A healthcare platform needs to remind patients about upcoming appointments. A tutoring service needs to ping students before a session starts. A sales tool needs to nudge prospects so they actually show up to demos.
These are email reminders, not push notifications, and they need to be personalized, branded, and sent on behalf of your users. This tutorial builds exactly that: a system that periodically scans a calendar for upcoming events and sends tailored reminder emails to each attendee using the Nylas Calendar and Email APIs.
What you’ll build
Section titled “What you’ll build”The reminder system runs on a simple loop:
- A scheduled job runs every 15 minutes and queries the Nylas Calendar API for events starting in the next 24 hours.
- The job filters events to skip all-day events, events without external participants, and events that already received a reminder.
- For each qualifying event, the job composes a personalized HTML email with the meeting time, location, conferencing link, and agenda.
- The job sends the reminder to each attendee using the Nylas Email API and records the event ID to prevent duplicates.
- A webhook listener watches for
event.updatedandevent.deletedtriggers so your system can send updated or cancellation notices when meetings change.
The result is a hands-free reminder pipeline that works across Google Calendar, Outlook, and Exchange without any provider-specific code.
Before you begin
Section titled “Before you begin”Make sure you have the following before starting this tutorial:
- A Nylas account with an active application
- A valid API key from your Nylas Dashboard
- At least one connected grant (an authenticated user account) for the provider you want to work with
- Node.js 18+ or Python 3.8+ installed (depending on which code samples you follow)
New to Nylas? Start with the quickstart guide to set up your app and connect a test account before continuing here.
You also need:
- A connected grant with calendar read access and email send access for the account you want to send reminders from
- A calendar ID for the calendar you want to scan (use the List Calendars endpoint to find it)
- A persistent store for tracking which events have already received reminders (a database table, Redis set, or even a local JSON file for prototyping)
One grant handles both read and send. The same connected account that reads calendar events can also send reminder emails. You do not need separate grants for calendar and email access.
Fetch upcoming events
Section titled “Fetch upcoming events”Query the Nylas Calendar API for events in a time window. The Events API accepts start and end query parameters as Unix timestamps, so you can request exactly the window you care about. A 24-hour lookahead works well for most reminder systems.
curl --request GET \ --url 'https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/events?calendar_id=<CALENDAR_ID>&start=1700000000&end=1700086400&limit=50' \ --header 'Accept: application/json' \ --header 'Authorization: Bearer <NYLAS_API_KEY>'{ "request_id": "abc-123", "data": [ { "id": "event_abc123", "title": "Product demo with Acme Corp", "status": "confirmed", "when": { "start_time": 1700053200, "end_time": 1700056800, "start_timezone": "America/New_York", "end_timezone": "America/New_York", "object": "timespan" }, "participants": [ ], "conferencing": { "provider": "Zoom Meeting", "details": { "url": "https://zoom.us/j/123456789" } }, "description": "Walk through the Q1 roadmap and pricing options." } ]}import Nylas from "nylas";
const nylas = new Nylas({ apiKey: process.env.NYLAS_API_KEY, apiUri: process.env.NYLAS_API_URI,});
async function fetchUpcomingEvents(grantId, calendarId) { const now = Math.floor(Date.now() / 1000); const twentyFourHoursLater = now + 86400;
const response = await nylas.events.list({ identifier: grantId, queryParams: { calendarId: calendarId, start: now, end: twentyFourHoursLater, limit: 50, }, });
return response.data;}import osimport timefrom nylas import Client
nylas = Client( api_key=os.environ["NYLAS_API_KEY"], api_uri="https://api.us.nylas.com",)
def fetch_upcoming_events(grant_id, calendar_id): now = int(time.time()) twenty_four_hours_later = now + 86400
events, _, _ = nylas.events.list( identifier=grant_id, query_params={ "calendar_id": calendar_id, "start": now, "end": twenty_four_hours_later, "limit": 50, }, )
return eventsThe start and end parameters are Unix timestamps in seconds. They filter events whose time range overlaps with the window you specify. Use the next_cursor value from the response to paginate through large result sets.
Build the reminder logic
Section titled “Build the reminder logic”Not every event deserves a reminder. All-day events (like holidays or out-of-office blocks) rarely need one. Events where you are the only participant are just calendar holds. And you definitely do not want to send the same reminder twice.
// In-memory tracking for prototyping. Use a database in production.const sentReminders = new Set();
function shouldSendReminder(event) { if (sentReminders.has(event.id)) return false;
// Skip all-day events (they use "date" instead of "timespan") if (event.when?.object === "date" || event.when?.object === "datespan") { return false; }
if (event.status === "cancelled") return false;
// Skip events with no external participants const external = getExternalParticipants(event); return external.length > 0;}
function getExternalParticipants(event) { return (event.participants || []).filter( (p) => p.email !== process.env.ORGANIZER_EMAIL );}import os
sent_reminders = set()ORGANIZER_EMAIL = os.environ.get("ORGANIZER_EMAIL", "")
def should_send_reminder(event): if event.id in sent_reminders: return False
when_obj = getattr(event.when, "object", None) or event.when.get("object") if when_obj in ("date", "datespan"): return False
if getattr(event, "status", None) == "cancelled": return False
return len(get_external_participants(event)) > 0
def get_external_participants(event): participants = getattr(event, "participants", []) or [] return [p for p in participants if p.email != ORGANIZER_EMAIL]The key design decisions: track by event ID for deduplication, filter by when.object to distinguish timed events ("timespan") from all-day events ("date" or "datespan"), and exclude the organizer’s email so they do not receive their own reminder.
An in-memory set resets when your process restarts. For production, use a database keyed by event ID with a timestamp so you can distinguish “reminder sent” from “update needed” if the event changes later.
Compose and send reminder emails
Section titled “Compose and send reminder emails”For each qualifying event, build a personalized email and send it through the Nylas Email API. Include the meeting title, time, location or conferencing link, and any agenda from the event description.
Build the email body
Section titled “Build the email body”function formatEventTime(event) { const startTime = event.when?.start_time; const timezone = event.when?.start_timezone || "UTC"; if (!startTime) return "Time not specified";
return new Date(startTime * 1000).toLocaleString("en-US", { weekday: "long", year: "numeric", month: "long", day: "numeric", hour: "numeric", minute: "2-digit", timeZone: timezone, timeZoneName: "short", });}
function buildReminderEmail(event, formattedTime) { const title = event.title || "Upcoming meeting"; const location = event.location || ""; const conferenceUrl = event.conferencing?.details?.url || ""; const description = event.description || "";
const locationHtml = location ? `<p><strong>Location:</strong> ${location}</p>` : ""; const conferenceHtml = conferenceUrl ? `<p><strong>Join link:</strong> <a href="${conferenceUrl}">${conferenceUrl}</a></p>` : ""; const descriptionHtml = description ? `<h3>Agenda</h3><p>${description}</p>` : "";
return `<html> <body style="font-family: sans-serif; line-height: 1.6; color: #333;"> <p>Hi,</p> <p>This is a reminder about your upcoming meeting:</p> <div style="background: #f5f5f5; padding: 16px; border-radius: 8px;"> <h2 style="margin-top: 0; color: #1a73e8;">${title}</h2> <p><strong>When:</strong> ${formattedTime}</p> ${locationHtml} ${conferenceHtml} </div> ${descriptionHtml} </body> </html>`;}from datetime import datetimefrom zoneinfo import ZoneInfo
def format_event_time(event): when = event.when if isinstance(event.when, dict) else vars(event.when) start_time = when.get("start_time") tz_str = when.get("start_timezone", "UTC") if not start_time: return "Time not specified" dt = datetime.fromtimestamp(start_time, tz=ZoneInfo(tz_str)) return dt.strftime("%A, %B %d, %Y at %I:%M %p %Z")
def build_reminder_email(event, formatted_time): title = getattr(event, "title", None) or "Upcoming meeting" location = getattr(event, "location", None) or "" description = getattr(event, "description", None) or "" conferencing = getattr(event, "conferencing", None) conf_url = "" if conferencing: details = getattr(conferencing, "details", None) or {} conf_url = details.get("url", "") if isinstance(details, dict) else ""
loc = f"<p><strong>Location:</strong> {location}</p>" if location else "" conf = (f'<p><strong>Join link:</strong> <a href="{conf_url}">{conf_url}</a></p>' if conf_url else "") desc = f"<h3>Agenda</h3><p>{description}</p>" if description else ""
return f"""<html> <body style="font-family: sans-serif; line-height: 1.6; color: #333;"> <p>Hi,</p> <p>This is a reminder about your upcoming meeting:</p> <div style="background: #f5f5f5; padding: 16px; border-radius: 8px;"> <h2 style="margin-top: 0; color: #1a73e8;">{title}</h2> <p><strong>When:</strong> {formatted_time}</p> {loc}{conf} </div> {desc} </body> </html>"""Send the reminder
Section titled “Send the reminder”Use the POST /v3/grants/<NYLAS_GRANT_ID>/messages/send endpoint to deliver the reminder to each attendee.
curl --request POST \ --url 'https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/messages/send' \ --header 'Authorization: Bearer <NYLAS_API_KEY>' \ --header 'Content-Type: application/json' \ --data '{ "subject": "Reminder: Product demo with Acme Corp - Tomorrow at 2:00 PM EST", "body": "<html><body><p>Hi,</p><p>Reminder about your upcoming meeting.</p><h2>Product demo with Acme Corp</h2><p><strong>When:</strong> Wednesday, November 15, 2023 at 2:00 PM EST</p></body></html>", "to": [ { "name": "Jordan Lee", "email": "[email protected]" } ] }'async function sendReminder(grantId, event) { const formattedTime = formatEventTime(event); const emailBody = buildReminderEmail(event, formattedTime); const recipients = getExternalParticipants(event); const title = event.title || "Upcoming meeting";
const response = await nylas.messages.send({ identifier: grantId, requestBody: { subject: `Reminder: ${title} - ${formattedTime}`, body: emailBody, to: recipients.map((p) => ({ name: p.name || p.email, email: p.email })), }, });
sentReminders.add(event.id); console.log(`Sent reminder for "${title}" to ${recipients.length} recipient(s).`); return response;}def send_reminder(grant_id, event): formatted_time = format_event_time(event) email_body = build_reminder_email(event, formatted_time) recipients = get_external_participants(event) title = getattr(event, "title", None) or "Upcoming meeting"
to_list = [ {"name": getattr(p, "name", None) or p.email, "email": p.email} for p in recipients ]
message, _ = nylas.messages.send( identifier=grant_id, request_body={ "subject": f"Reminder: {title} - {formatted_time}", "body": email_body, "to": to_list, }, )
sent_reminders.add(event.id) print(f'Sent reminder for "{title}" to {len(to_list)} recipient(s).') return messageEach reminder email is sent from the account associated with your grant. If the grant belongs to a service account, all reminders come from that address. If it belongs to an individual user, reminders come from their personal email. Choose the grant that matches your desired sender identity.
Handle event changes with webhooks
Section titled “Handle event changes with webhooks”Reminders are only useful if they reflect the current state of the event. When someone reschedules or cancels a meeting after you already sent a reminder, you need to notify attendees.
Subscribe to event.updated and event.deleted webhooks:
curl --request POST \ --url 'https://api.us.nylas.com/v3/webhooks/' \ --header 'Content-Type: application/json' \ --header 'Authorization: Bearer <NYLAS_API_KEY>' \ --data '{ "trigger_types": ["event.updated", "event.deleted"], "description": "Event change notifications for reminder system", "webhook_url": "https://your-server.com/webhooks/nylas", "notification_email_addresses": ["[email protected]"] }'Then handle updates and cancellations. Only act on events you have already sent reminders for:
import express from "express";const app = express();app.use(express.json());
app.get("/webhooks/nylas", (req, res) => { return res.status(200).send(req.query.challenge);});
app.post("/webhooks/nylas", async (req, res) => { res.status(200).send("ok"); // Respond immediately
const { type, data } = req.body; const eventData = data.object; const grantId = eventData.grant_id;
if (!sentReminders.has(eventData.id)) return;
const title = eventData.title || "Meeting"; const recipients = getExternalParticipants(eventData); const to = recipients.map((p) => ({ name: p.name || p.email, email: p.email }));
if (type === "event.deleted" || eventData.status === "cancelled") { await nylas.messages.send({ identifier: grantId, requestBody: { subject: `Cancelled: ${title}`, body: `<html><body><p>The following meeting has been cancelled: <strong>${title}</strong>.</p></body></html>`, to, }, }); sentReminders.delete(eventData.id); } else if (type === "event.updated") { const formattedTime = formatEventTime(eventData); await nylas.messages.send({ identifier: grantId, requestBody: { subject: `Updated: ${title} - ${formattedTime}`, body: `<html><body><p><strong>${title}</strong> has been rescheduled to ${formattedTime}. Check your calendar for details.</p></body></html>`, to, }, }); }});
app.listen(3000, () => console.log("Webhook server running on port 3000"));from flask import Flask, request
app = Flask(__name__)
@app.route("/webhooks/nylas", methods=["GET"])def webhook_challenge(): return request.args.get("challenge", ""), 200
@app.route("/webhooks/nylas", methods=["POST"])def handle_webhook(): notification = request.get_json() event_data = notification["data"]["object"] grant_id = event_data["grant_id"] event_id = event_data.get("id")
if event_id not in sent_reminders: return "ok", 200
trigger_type = notification["type"] if trigger_type == "event.deleted" or event_data.get("status") == "cancelled": send_cancellation_notice(grant_id, event_data) sent_reminders.discard(event_id) elif trigger_type == "event.updated": send_updated_reminder(grant_id, event_data)
return "ok", 200The event.deleted payload is minimal. It only includes the event ID, grant ID, and calendar ID. Store event metadata (title, participants) alongside the reminder record in your database so you can compose a useful cancellation email even after the event is gone.
Schedule the reminder job
Section titled “Schedule the reminder job”Tie the pieces together with a scheduled job. Use node-cron in Node.js or schedule in Python to run the scan every 15 minutes.
import cron from "node-cron";
const GRANT_ID = process.env.NYLAS_GRANT_ID;const CALENDAR_ID = process.env.NYLAS_CALENDAR_ID;
async function runReminderScan() { try { const events = await fetchUpcomingEvents(GRANT_ID, CALENDAR_ID); const eventsToRemind = events.filter(shouldSendReminder); console.log(`${eventsToRemind.length} of ${events.length} events need reminders.`);
for (const event of eventsToRemind) { try { await sendReminder(GRANT_ID, event); } catch (err) { console.error(`Reminder failed for ${event.id}:`, err.message); } } } catch (error) { console.error("Reminder scan failed:", error.message); }}
cron.schedule("*/15 * * * *", runReminderScan);runReminderScan(); // Run once on startupimport scheduleimport time as time_module
GRANT_ID = os.environ["NYLAS_GRANT_ID"]CALENDAR_ID = os.environ["NYLAS_CALENDAR_ID"]
def run_reminder_scan(): try: events = fetch_upcoming_events(GRANT_ID, CALENDAR_ID) events_to_remind = [e for e in events if should_send_reminder(e)] print(f"{len(events_to_remind)} of {len(events)} events need reminders.")
for event in events_to_remind: try: send_reminder(GRANT_ID, event) except Exception as e: print(f"Reminder failed for {event.id}: {e}") except Exception as e: print(f"Reminder scan failed: {e}")
schedule.every(15).minutes.do(run_reminder_scan)run_reminder_scan()
while True: schedule.run_pending() time_module.sleep(1)Combine the cron job and webhook handler in one process. Run the Express or Flask server for webhooks alongside the scheduled scanner. The cron job handles proactive reminders, while the webhook listener reacts to changes. Together, they cover both sides of the problem.
Things to know
Section titled “Things to know”A few practical details that will save you time in production:
-
Timezone handling is the hardest part. Events include
start_timezoneandend_timezonefields, but not all providers populate them consistently. Always fall back to UTC if the timezone is missing, and format times using the event’s timezone rather than your server’s local time. An attendee in Tokyo does not want to see a time formatted for Chicago. -
All-day events use a different
whenobject. Timed events havewhen.objectset to"timespan"with Unix timestamps. All-day events use"date"(single day) or"datespan"(multi-day) with date strings like"2024-03-15". Check thewhen.objectvalue rather than looking for missing timestamps. -
Recurring events produce separate instances. When you query the Events API with a time range, each occurrence of a recurring series appears as its own event with its own ID. You do not need to expand recurrence rules yourself. Track reminders by the individual occurrence ID, not the
master_event_id. -
Rate limits apply to both APIs. Nylas enforces rate limits per grant. If you are sending reminders for a calendar with dozens of events, add a small delay between email sends. For high-volume use cases, queue your sends and process them with a rate-limited worker.
-
Deduplication prevents double reminders. Your scan runs on a schedule, so the same event appears in multiple scans. Without deduplication, an attendee could receive the same reminder every 15 minutes. In production, store the event ID alongside a hash of key fields (time, title, participants) so you can detect whether the event changed enough to warrant a new notification.
-
Some providers sync with a delay. Google Calendar events typically appear in Nylas within seconds. Microsoft and Exchange accounts may take slightly longer. If your reminder window is narrow (for example, 30 minutes before the event), factor in sync latency to avoid missing recently created events.
-
Do not hardcode calendar IDs. The primary calendar ID varies by provider. Google uses the user’s email address. Microsoft uses a long opaque string. Use the List Calendars endpoint to discover calendars dynamically.
What’s next
Section titled “What’s next”- Calendar events API for the full reference on reading, creating, and updating events
- Recurring events for details on RRULE handling and how recurring series expand into individual occurrences
- Send email for advanced send options including attachments, reply-to headers, and tracking
- Webhook notification schemas for the complete payload reference for
event.created,event.updated, andevent.deletedtriggers - Webhooks overview for webhook verification, retry behavior, and status management
- Check availability and free/busy to layer availability checks into your reminder logic