1. Features
  2. Calendar
Calendar

Every calendar, with recurring series done right.

workwae syncs every calendar an account can see on Google Calendar, Microsoft 365 and CalDAV into one event model. Read a rolling window, write events and series back, answer invitations, find free time across attendees and attach Teams, Meet, Zoom or Webex meetings.

this-week.ts
import { paginate } from "@workwae/sdk";

// Singles, occurrences and exceptions overlapping the range
const events = paginate(workwae.calendarEvents.list, {
  accountId,
  start: new Date("2026-11-02T00:00:00Z"),
  end: new Date("2026-11-09T00:00:00Z"),
});

for await (const event of events) {
  // "single" | "occurrence" | "exception"
  console.log(event.kind, event.subject, event.start.dateTime);
  if (event.seriesId) {
    // The series holds the rule, stored once
    const series = await workwae.calendarEvents.get({
      accountId,
      eventId: event.seriesId,
    });
    console.log(series.recurrence, series.recurrenceTimeZone);
  }
}
What you get

A full calendar API, not a read-only feed

The same calendars, events and answers on every provider, with the differences handled where they belong and reported where they cannot be.

Every calendar the account sees

The account's own calendars and those shared with it that the provider lists, with owner, access level (owner, writer, reader or free/busy), canEdit, color, time zone, the primary calendar, and calendars hidden from the user's list, which still sync.

A rolling window

Events are indexed from 90 days back to 365 days ahead, and the window moves forward on its own. List any range of up to 400 days inside it, by start.

Series stored once

A recurring event is one series with its RFC 5545 rule, its exceptions and its cancelled starts. Occurrences are expanded inside the window, so a change to the series is one update, not hundreds.

Writes at every level

Create, update and delete single events, whole series or one occurrence. Changing an occurrence makes it an exception; giving a single a rule makes it a series.

RSVP

Accept, tentatively accept or decline one occurrence or the whole series, with an optional comment, and choose whether the organizer is told.

Free/busy and suggestions

Busy blocks for up to 20 attendees and common free slots, with your slot length, step, working days and hours, and time zone, from one provider request.

Time zones kept

Write wall-clock times with an IANA zone; read back the instant, the zone it was scheduled in, and exclusive end dates for all-day events. Windows zone names are mapped to IANA.

Online meetings

A Teams or Meet link from the provider, a Zoom or Webex meeting workwae creates and keeps in step, or any https link you attach, read back as onlineMeetingUrl.

Changes as events

One quiet first pass, then calendar_event.* and calendar.* events, each update naming the fields that changed. Calendar sync never holds up mail.

How it works

What each provider does underneath

workwae uses push where the provider has it and polls where it does not, with a backstop poll behind every push channel so a dropped notification only delays a change. Intervals below are defaults.

Calendar sync behavior by provider
Aspect Google Calendar Microsoft 365 CalDAV (IMAP accounts)
Calendars The calendar list, read in full at most once per backstop interval, since some of its changes never show in incremental reads Every calendar plus every calendar group's calendars, listed on the first pass and on polls; a new calendar arrives with the next listing; calendars of another mailbox are not synced Discovered from a provider table (iCloud, Fastmail, Yahoo, GMX, Zoho, mailbox.org, Posteo), .well-known or DNS SRV records
Push One watch channel per calendar the account owns or can write, plus one on the calendar list, renewed about a day before it expires One Graph change notification subscription per account on its events None; every API write syncs at once
Backstop poll Once a day with push channels, hourly without; reader and free/busy calendars rely on it Every 10 minutes with push, every minute without Every 15 minutes
Change detection Incremental lists with a sync token, series included; requests are frugal because Google counts them against a daily quota One calendarView delta per calendar; series rules read from the series master sync-collection where offered, else ctag and etag comparison; changed objects read 50 at a time
Window Series expanded locally; the window rolls daily without a request Anchored to the first of the current UTC month and reaching about a month past the future window; re-read in full when the month turns, announcing only real changes Series expanded locally in the start's zone, keeping wall-clock times across daylight saving changes
Recurrence writes RFC 5545 lines; one occurrence is resolved right before a write to it One RRULE that Outlook has a pattern for; RDATE, EXDATE, EXRULE, several rules and finer frequencies are refused RRULE, RDATE and EXDATE lines on the series' iCalendar object; overrides are its exceptions
Invitations Sent by Google; notify chooses whether attendees hear Always sent for a meeting the account organizes; a delete can carry a comment Sent by the server where it schedules (RFC 6638); otherwise mailed from the account's SMTP server for meetings it organizes
Free/busy One freeBusy.query for every attendee One getSchedule request for every attendee One VFREEBUSY request where the server schedules; otherwise the account's own calendars only
Online meetings Meet; another link becomes an add-on conference Teams; another link becomes the location's link, with a join line in the body No provider links; another link is the CONFERENCE property
  • The first pass ends with one calendar.initialized; nothing before it is announced event by event.
  • Calendar sync runs next to mail with its own backoff, so a grant without calendar access reports a calendar sync error while mail keeps syncing.
  • Google needs the full calendar scope; calendar.events alone cannot read the calendar list.
  • On Microsoft, all-day events are anchored in the mailbox time zone, which needs MailboxSettings.Read. Without it they still sync, anchored in the zone each event was created in.
  • CalDAV writes are conditional on the version read; a concurrent change from another client is reapplied up to three times before the write is refused as a conflict.
Build it: series

Create a series, then change one occurrence

Times are wall-clock with an IANA zone, and the rule is evaluated in the start's zone, so a 10:00 meeting stays at 10:00 across daylight saving changes.

  • Four kinds. single, series, occurrence and exception; occurrences and exceptions carry seriesId and originalStart.
  • Address the level you mean. Patch an occurrence to change only it, or the series to change every occurrence. A rule on an occurrence is refused.
  • Removals are explicit. An occurrence removed from a series is listed in the series' cancelledStarts.
  • Attendees are told by default. notify: false keeps a change quiet where the provider allows it.
series.ts
const timeZone = "Europe/Berlin";

const series = await workwae.calendarEvents.create({
  accountId,
  calendarId,
  subject: "Design review",
  start: { dateTime: "2026-11-05T10:00:00", timeZone },
  end: { dateTime: "2026-11-05T11:00:00", timeZone },
  attendees: [{ address: "lena@example.com" }],
  recurrence: ["RRULE:FREQ=WEEKLY;BYDAY=TH;COUNT=10"],
});

// Find the third occurrence in the index
const { items } = await workwae.calendarEvents.list({
  accountId,
  calendarId,
  start: new Date("2026-11-19T00:00:00Z"),
  end: new Date("2026-11-20T00:00:00Z"),
});
const third = items.find((e) => e.seriesId === series.id)!;

// Move just that one: it becomes an exception
await workwae.calendarEvents.update({
  accountId,
  eventId: third.id,
  start: { dateTime: "2026-11-19T14:00:00", timeZone },
  end: { dateTime: "2026-11-19T15:00:00", timeZone },
});

// Cancel the whole series, quietly
await workwae.calendarEvents.delete({
  params: { accountId, eventId: series.id },
  query: { notify: false },
});
Build it: availability

Find a time that works for everyone

One provider request answers for every attendee, and workwae computes the common free slots. The answer is cached for 60 seconds, so a booking page can ask again without spending provider quota.

  • Up to 20 attendees over a range of up to 62 days; the account's own calendar counts unless includeAccount is false.
  • Slots you control. Length and step from 5 minutes to a day (30 by default), working days and hours, and the zone they are read in.
  • Honest gaps. An attendee the provider cannot see carries an error and does not constrain the suggestions.
  • Busy status as busy, tentative, oof, working_elsewhere (which does not block a slot) or unknown. Google reports every block as busy.
availability.ts
const { attendees, suggestions } = await workwae.availability.find({
  accountId,
  attendees: ["lena@example.com", "omar@example.com"],
  start: new Date("2026-11-09T00:00:00Z"),
  end: new Date("2026-11-14T00:00:00Z"),
  durationMinutes: 60,
  stepMinutes: 30,
  timeZone: "Europe/Berlin",
  workingHours: {
    start: "09:00",
    end: "17:00",
    days: ["mon", "tue", "wed", "thu"],
  },
  maxSuggestions: 5,
});

// Attendees the provider could not see
for (const { address, error } of attendees) {
  if (error) console.warn(address, error);
}

// Earliest first
const [slot] = suggestions;
Build it: meetings and RSVP

Attach a meeting, answer an invitation

Pick one way to add a meeting per event: the provider's own Teams or Meet link, a link you already have, or a Zoom or Webex meeting workwae creates and moves with the event.

  • One option per event. onlineMeeting, onlineMeetingUrl and onlineMeetingService are mutually exclusive.
  • Remove a link with onlineMeetingUrl: null: on Google any conference, Meet included; on Microsoft the location's link, leaving Teams alone.
  • Answer per occurrence or series. Google changes only the account's own attendee entry; an organizer cannot answer its own meeting.
  • Zoom and Webex meetings follow the event's subject, times and rule. How meetings work.
meeting-and-rsvp.ts
// Teams on Microsoft 365, Meet on Google
await workwae.calendarEvents.create({
  accountId,
  calendarId,
  subject: "Kickoff",
  start: { dateTime: "2026-11-10T16:00:00", timeZone: "UTC" },
  end: { dateTime: "2026-11-10T16:30:00", timeZone: "UTC" },
  attendees: [{ address: "omar@example.com" }],
  onlineMeeting: true,
});

// Or a link you already have, on any provider
await workwae.calendarEvents.update({
  accountId,
  eventId,
  onlineMeetingUrl: "https://meet.example.com/r/standup",
});

// Decline one occurrence with a note
await workwae.calendarEvents.respond({
  accountId,
  eventId: occurrence.id,
  response: "declined",
  comment: "Out that day, notes please",
});
Events

Calendar changes as webhooks and feed items

Singles, series and exceptions are announced; plain occurrences never are, so a change to a weekly series is one calendar_event.updated. See webhooks and the change feed.

Calendar events
Event When Data
calendar.initialized The account's first calendar sync finished calendars, events (counts)
calendar.created A calendar appeared calendar
calendar.updated A calendar changed: name, color, access, time zone, hidden state calendar, changes
calendar.deleted A calendar went away, deleted or unshared calendar
calendar_event.created A single event, series or exception appeared; an occurrence changed on its own arrives as a new exception event
calendar_event.updated A single event, series or exception changed event, changes (for example start, attendees, selfResponse)
calendar_event.deleted A single event, series or exception went away eventId, calendarId, kind, seriesId
FAQ

Calendar questions

Why does listing events not return the series itself?

A range listing returns what happens in that range: singles, occurrences and exceptions overlapping it. The series is the definition behind them; get it by the seriesId on any occurrence to read its rule, its time zone and its cancelled starts.

Can I read events outside the window?

No. Only the sync window is indexed, 90 days back to 365 days ahead, and it moves forward on its own. A series that started before the window still has its occurrences inside it.

What if the account can only read a calendar?

It still syncs, and each calendar says whether events can be created and changed (canEdit). A write to a calendar the account can only read is refused with calendar_read_only.

What happens when the provider cannot express a request?

workwae refuses it rather than writing something different, for example a recurrence rule Outlook has no pattern for, or notify: false on a Microsoft meeting the account organizes. The error names the reason, and nothing is written.

Can calendar be turned off for some accounts?

Yes. Calendar sync follows a tenant default and a per-account setting. While it is off, calendar routes answer calendar_disabled, and connect asks for no calendar scopes when the plan leaves calendar out.

Early access

Build on every calendar your users already have

workwae is in early access. Tell us what you are building, and read the docs in the meantime.