Calendar apps send push notifications to the person who owns the event. That’s fine if you only need to remind yourself. The moment your application needs to reach other people — patients before an appointment, students before a tutoring session, prospects before a demo — built-in notifications fall short. You need branded, personalized email reminders sent on behalf of your users.
This recipe builds that. A scheduled job scans a calendar for events starting soon, filters out the noise, and sends a tailored HTML reminder to each attendee. A webhook listener catches reschedules and cancellations and pushes updates the same way. The whole thing runs across Google Calendar, Microsoft, and Exchange without provider-specific code.
The pipeline
Section titled “The pipeline”cron (every 15min) ─▶ list events for next 24h ─▶ filter (skip all-day, internal, already-sent) │ ▼ send reminder email │event.updated/deleted webhook ─▶ look up sent reminder ─▶ send update or cancellationTwo flows around one shared dedup store. The cron handles proactive sends; the webhook handles reactive updates.
Before you begin
Section titled “Before you begin”Make sure you have the following before starting this tutorial:
- A Nylas account with an active application
- A valid API key from your Nylas Dashboard
- At least one connected grant (an authenticated user account) for the provider you want to work with
- Node.js 18+ or Python 3.8+ installed (depending on which code samples you follow)
You also need:
- A 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)
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 eventsBuild 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.
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 messageHandle 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", 200Schedule 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)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.
Next steps
Section titled “Next steps”- Automate meeting follow-ups — the post-meeting counterpart to this pre-meeting recipe
- Scheduling agent with dedicated identity — send reminders from
[email protected]instead of a human’s mailbox - Calendar events API — full reference on reading, creating, and updating events
- Recurring events — RRULE handling and how recurring series expand into individual occurrences
- Send email — attachments, reply-to headers, and tracking options