DEV Community

Nikolas Dimitroulakis
Nikolas Dimitroulakis

Posted on

Create calendar invites in Node.js that update and cancel cleanly


Create calendar invites in Node.js that update and cancel cleanly

Send .ics calendar invites from Node.js with Nodemailer, then move and cancel them without leaving duplicate events in anyone's calendar.

Sending one calendar invite is easy. The bugs show up on the second email.

You move the meeting by an hour and every attendee now has two events. You cancel it and the event stays on everyone's calendar. You send it from Gmail and the Accept and Decline buttons never appear.

All three come from the same place: the rules for how calendar clients match an update to the original event. Those rules live in RFC 5546 (iTIP), which sits on top of the file format in RFC 5545. This post builds a small Node.js module that creates, updates and cancels an invite and gets those rules right.

The three rules that prevent duplicate events

  1. Keep the UID. Every event has a UID. An update or a cancellation must reuse it. A new UID means a new event.
  2. Increment SEQUENCE. RFC 5546 says the organizer MUST increment SEQUENCE when the start, end, recurrence or status changes. Clients ignore an update whose sequence is not higher than what they already have.
  3. Match METHOD. METHOD:REQUEST is an invitation, METHOD:CANCEL is a cancellation. The email's calendar part must carry the same method as the file, or clients treat it as a plain attachment.
create  ─► UID=abc  SEQUENCE=0  METHOD:REQUEST
update  ─► UID=abc  SEQUENCE=1  METHOD:REQUEST   (replaces, no duplicate)
cancel  ─► UID=abc  SEQUENCE=2  METHOD:CANCEL    (removes the event)
Enter fullscreen mode Exit fullscreen mode

Three ways to generate the .ics

You need the .ics text before you can send it. There are three common routes.

Approach Pros Trade-offs
Hand-written string No dependencies, full control You own line folding, escaping, DTSTAMP and time zone blocks
ics npm package Open source (ISC), runs locally, supports uid, sequence, method, attendees, alarms and recurrence Times go in as local or UTC (startInputType), so converting from a named zone is your job
ICS generator API JSON in, .ics out; accepts IANA zones like Europe/Athens; recurrence, reminders and attendees as fields; cancellation with one query parameter Good to know: it is a network call and needs an ApyHub API key

Hand-written strings are fine for a single static event. The format has sharp edges, though. RFC 5545 requires lines to be folded at 75 octets and specific characters to be escaped, and a missing detail often fails silently in one client only.

The ics package is a solid choice when you want everything local and your times are already in UTC.

The API route fits when events come from users in many time zones, or when an AI agent is the one creating them. It is what the code below uses.

  • Pros: you send the meeting date, start time and a zone name, and it returns the finished file. The same request body with ?event_type=cancel produces the cancellation. There are two endpoints: one returns the .ics file for email attachments, and one returns a signed download link for "Add to calendar" buttons.
  • Best for: apps that send invites on behalf of users, booking flows, and agents.

Setup

mkdir invites && cd invites
npm init -y
npm i nodemailer
export APY_TOKEN=your_token_here
# optional, to send real email:
export SMTP_URL="smtps://user:pass@smtp.example.com"
Enter fullscreen mode Exit fullscreen mode

Without SMTP_URL, the script uses Nodemailer's JSON transport and prints each message instead of sending it. That is useful for a dry run. You need Node 18 or later for the built-in fetch.

The code

Save this as invite.mjs:

// invite.mjs
// Create, update and cancel a calendar invite from Node.js.
// Usage: APY_TOKEN=your_token node invite.mjs
// Set SMTP_URL (for example smtps://user:pass@smtp.example.com) to send real email.
// Without it, Nodemailer prints the message as JSON instead of sending it.
import nodemailer from "nodemailer";

const API = "https://api.eu.apyhub.com/apyhub/generate-ical-event/download";
const TOKEN = process.env.APY_TOKEN;

const transport = process.env.SMTP_URL
  ? nodemailer.createTransport(process.env.SMTP_URL)
  : nodemailer.createTransport({ jsonTransport: true });

// Turn event details into .ics text
async function buildIcs(event, { cancel = false } = {}) {
  const params = new URLSearchParams({ output: "invite" });
  if (cancel) params.set("event_type", "cancel");

  const res = await fetch(`${API}?${params}`, {
    method: "POST",
    headers: { "apy-token": TOKEN, "Content-Type": "application/json" },
    body: JSON.stringify(event),
  });
  if (!res.ok) throw new Error(`ICS generation failed: ${res.status}`);
  return res.text();
}

// Nodemailer's method must match the METHOD line inside the file,
// so read it from the file instead of hardcoding it
const methodOf = (ics) => ics.match(/^METHOD:(\w+)/m)?.[1] ?? "PUBLISH";

async function send(event, ics, subject) {
  const method = methodOf(ics);
  const info = await transport.sendMail({
    from: event.organizer_email,
    to: event.attendees_emails,
    subject,
    text: `${event.summary} on ${event.meeting_date} at ${event.start_time} (${event.time_zone})`,
    icalEvent: { method, filename: "invite.ics", content: ics },
  });
  return { method, messageId: info.messageId };
}

export async function createInvite(event) {
  const ics = await buildIcs(event);
  return send(event, ics, `Invitation: ${event.summary}`);
}

// Same id, higher sequence: calendars replace the event instead of adding a copy
export async function updateInvite(event, changes) {
  const next = { ...event, ...changes, sequence: event.sequence + 1 };
  const ics = await buildIcs(next);
  return { event: next, ...(await send(next, ics, `Updated: ${next.summary}`)) };
}

export async function cancelInvite(event) {
  const next = { ...event, sequence: event.sequence + 1 };
  const ics = await buildIcs(next, { cancel: true });
  return { event: next, ...(await send(next, ics, `Canceled: ${next.summary}`)) };
}

// Demo: create, move by an hour, then cancel
if (process.argv[1].endsWith("invite.mjs")) {
  let event = {
    id: "team-sync-2026@example.com", // store this with your meeting record
    sequence: 0,
    summary: "Weekly team sync",
    meeting_date: "2026-10-06",
    start_time: "09:00",
    end_time: "09:30",
    time_zone: "Europe/Athens",
    location: "https://meet.example.com/team-sync",
    organizer_email: "organizer@example.com",
    attendees_emails: ["dev1@example.com", "dev2@example.com"],
    recurring: true,
    recurrence: { frequency: "WEEKLY", interval: 1, count: 10 },
    reminders: [{ action: "display", minutes_before: 10 }],
  };

  console.log("create", await createInvite(event));

  const updated = await updateInvite(event, { start_time: "10:00", end_time: "10:30" });
  event = updated.event;
  console.log("update", { sequence: event.sequence, method: updated.method });

  const canceled = await cancelInvite(event);
  console.log("cancel", { sequence: canceled.event.sequence, method: canceled.method });
}
Enter fullscreen mode Exit fullscreen mode

Run it:

node invite.mjs
Enter fullscreen mode Exit fullscreen mode
create { method: 'REQUEST', messageId: '<fc8efa38-...@example.com>' }
update { sequence: 1, method: 'REQUEST' }
cancel { sequence: 2, method: 'CANCEL' }
Enter fullscreen mode Exit fullscreen mode

One UID, three emails, sequence 0 to 2. Each attendee ends up with one event that moves and then disappears.

Design decisions worth copying

Store id and sequence with your meeting record. They are the only link between the email you sent last week and the one you send today. Put them in the same table as the meeting, and increment sequence in the same transaction as the change.

Read METHOD from the file. Nodemailer's calendar docs say the method option should match the METHOD inside the .ics. Parsing it from the generated file means the two stay in sync. If the file has no METHOD line, the code falls back to PUBLISH, and clients will show a plain "add to calendar" file with no RSVP buttons. Log that case.

Keep the email simple. The same Nodemailer page recommends only text, html and a single icalEvent for the best client compatibility. Extra attachments are a common reason RSVP buttons disappear.

Use a zone name, never an offset. Europe/Athens handles daylight saving on its own. +03:00 is wrong for half the year, and a weekly recurring meeting will cross that line.

Common problems and fixes

Symptom Cause Fix
Update creates a second event New UID on the update Reuse the stored id
Update is ignored SEQUENCE not incremented sequence + 1 on every change
Cancel does nothing Missing METHOD:CANCEL, or old sequence ?event_type=cancel with the same id and a higher sequence
No Accept/Decline buttons Method mismatch, or extra attachments Match icalEvent.method to the file; send only text, html and the invite
Meeting one hour off after a DST change Fixed UTC offset Send an IANA zone name

Letting an AI agent send invites

Scheduling is one of the first jobs people hand to agents: "book 30 minutes with the team next Tuesday." The agent needs a reliable way to turn that into a real invite.

The calendar invite generator is available through ApyHub MCP, like every endpoint in the catalog. An agent can discover it, read its parameters, and call it directly without a hand-written wrapper or tool definition. Pair it with your own tool that stores id and sequence, and the agent can reschedule and cancel without creating duplicates.

Going further

FAQ

How do I create a calendar invite in Node.js?
Generate an .ics file with a stable UID, then send it with Nodemailer's icalEvent option and method: "REQUEST". The code above does both, using an ICS file generator API for the file.

How do I update a calendar invite without creating a duplicate?
Send a new .ics with the same UID and a higher SEQUENCE. Clients replace the existing event.

How do I cancel a calendar invite?
Send METHOD:CANCEL with the same UID and an incremented SEQUENCE. With the API, add ?event_type=cancel to the same request.

How can I test these APIs before writing code?
Run the request from the API's page in the catalog, send the curl command from your terminal, or open it in Voiden, the open-source API client, add your API key and run it. The free plan covers testing: 5 calls a day and 3,000 atoms a month, with no credit card. The demo above uses 3 calls.

Does this work with Google Calendar, Outlook and Apple Calendar?
They all read RFC 5545 files and follow the UID and SEQUENCE rules. Send one test invite to each before launch, since each client renders the email a little differently.

About ApyHub

ApyHub is a curated API catalog and trusted operational layer for developers and AI agents, with over 1,500 endpoints and capabilities, and it keeps growing. Every API is verified before listing and carries machine-readable certification for GDPR, SOC 2 and ISO 27001. One subscription covers the catalog, every endpoint is MCP-ready by default, and the free plan needs no credit card.

Top comments (0)