Skip to content

How to Design a Complete Unified Schema for Calendar API Integrations

A complete reference for designing a unified calendar schema - with worked RRULE parsing, timezone/DST handling, backoff recipes, and test plans - across Google, Microsoft, and Apple.

Yuvraj Muley Yuvraj Muley · · 20 min read
How to Design a Complete Unified Schema for Calendar API Integrations

If your engineering team is tasked with building a two-way calendar sync, you face an immediate architectural decision. You can spend the next three months wiring up Microsoft Graph, the Google Calendar API, Apple CalDAV, and provider-specific webhook plumbing from scratch. Or, you can abstract the complexity behind a normalized data model.

As covered in our architecture guide to integrating multiple calendar services, building native integrations for Google Calendar, Microsoft Outlook, and a long tail of scheduling tools is a massive capital expense. The global appointment scheduling software market is projected to reach USD 471.58 billion by 2032, growing at a CAGR of 5.90%. This market growth underscores why calendar integration has moved from a "nice to have" feature to absolute table stakes for B2B SaaS products. Every CRM, sales engagement tool, ATS, and AI copilot now needs scheduling capabilities.

Yet, as noted in our unified calendar API quickstart, building a single custom SaaS integration from scratch typically costs between $15,000 and $40,000 in engineering time and resources. Deep bidirectional calendar syncs sit at the higher end of that range.

To avoid this sunk cost, engineering teams must create a complete unified schema reference for calendar resources. The goal is not a thin wrapper. The goal is a generic execution pipeline and data model that lets a single endpoint, GET /unified/calendar/events, return the exact same shape whether the underlying provider is Google Calendar, Microsoft Graph, or Calendly—while still surfacing provider quirks (recurrence overrides, room mailboxes, conference data) instead of hiding them.

This guide provides a deep technical blueprint for structuring that unified schema. We will cover the core entities required, the hardest engineering problems you will face - RRULE normalization, timezone/DST handling, and rate-limit backoff - the architecture for normalizing webhooks, and how to expose this schema to AI agents via the Model Context Protocol (MCP).

Why Schema Normalization is the Hardest Part of Calendar Integrations

At a glance, a calendar event is just a title, a start time, and an end time. In practice, schema normalization is the hardest problem in SaaS integrations because provider APIs fundamentally disagree on how to represent time, repetition, and resources.

Calendar APIs look superficially similar, but their architectures differ wildly:

  • Timezones: Google Calendar returns start.dateTime with an integrated timezone field. Microsoft Graph returns start.dateTime as an ISO string plus start.timeZone as a separate property, and the timezone string formats differ.
  • Recurrence: Google treats recurrences as discrete event instances tied to a master event ID using standard RRULEs. Microsoft Graph mixes a structured recurrence.pattern object with timezone-anchored ranges, and often requires you to query a specific calendar view to expand recurring series properly.
  • Conferencing: Conference data is nested under conferenceData in Google and onlineMeeting in Graph.
  • Responses: Attendee response statuses use entirely different enum vocabularies.

The pain compounds with provider-specific edge cases. Microsoft Graph does not support webhook subscriptions for room and resource mailboxes, which breaks any sync strategy that assumes uniform push notifications across mailbox types. You have to build a polling fallback specifically for conference rooms. Apple Calendar has no first-party REST API at all—you go through CalDAV or nothing. Calendly is read-mostly and exposes booking links, not raw events.

If your application logic attempts to handle these differences with if (provider === 'google') statements scattered throughout your codebase, you will inevitably introduce bugs. Adding a new calendar provider becomes a code change, a deploy, and a regression test cycle.

To solve this, your unified calendar schema must act as a generic execution pipeline. Integration-specific behavior should be defined entirely as data—JSON configuration blobs and JSONata expressions that map provider fields to your canonical schema. Adding a new calendar provider should be a data operation, not a code operation.

The Core Entities of a Unified Calendar Data Model

A unified calendar schema must handle at least five canonical entities: Calendars, Events, Contacts, Availability, and EventTypes. Anything less and you will paint yourself into a corner the first time a customer asks for free/busy lookups or routes meetings through Calendly.

Here is the minimum viable entity map:

Entity Purpose Equivalent in Google Equivalent in Microsoft Graph
Calendar Container for time-based entries calendarList calendar
Event Individual meeting or time block events event
Contact Attendee, organizer, external guest attendees [] attendees []
Availability Computed free/busy window freebusy.query getSchedule
EventType Booking template (duration, routing) n/a (use App Script) n/a (use Bookings)
Attachment Files attached to an event attachments [] attachments endpoint

The relationships matter as much as the entities. A Calendar contains a chronological list of Events. An Event is the central node—it is populated by Contacts (people attending) and enriched by Attachments. Availability is a derived state computed from existing Events on one or more Calendars. EventTypes sit in front of Availability and produce new Events when an external user books a slot.

1. The Calendar Object (The Container)

The Calendar acts as the root ledger for time-based entries. A user might have multiple calendars (e.g., "Work", "Personal", "Team Holidays"). Your schema must normalize the metadata associated with these containers.

{
  "id": "cal_01H9X...",
  "remote_id": "user@example.com",
  "name": "Primary Work Calendar",
  "description": "Main calendar for external meetings",
  "timezone": "America/New_York",
  "is_primary": true,
  "read_only": false
}

2. The Event Object (The Time Block)

The Event resource is where most teams overfit to one provider and regret it later. A defensible canonical shape looks like this:

{
  "id": "evt_01H9Y...",
  "calendar_id": "cal_01H9X...",
  "title": "Q3 Architecture Review",
  "description": "Reviewing the new unified API mappings.",
  "start": { "datetime": "2026-10-15T14:00:00Z", "timezone": "America/New_York" },
  "end":   { "datetime": "2026-10-15T15:00:00Z", "timezone": "America/New_York" },
  "all_day": false,
  "status": "confirmed",
  "visibility": "default",
  "location": { "text": "Zoom", "url": "https://zoom.us/j/..." },
  "conferencing": {
    "provider": "zoom",
    "join_url": "https://zoom.us/j/...",
    "meeting_id": "123 456 7890"
  },
  "organizer": { "email": "jane@example.com", "name": "Jane Doe" },
  "attendees": [
    { "email": "sam@example.com", "response_status": "accepted", "optional": false }
  ],
  "recurrence": { "rrule": "FREQ=WEEKLY;BYDAY=TH", "timezone": "America/New_York" },
  "created_at": "2026-09-01T10:00:00Z",
  "updated_at": "2026-09-04T11:32:00Z",
  "remote_data": { "...": "raw provider payload" }
}

Three non-obvious design choices are doing a lot of work here:

  • start/end as objects, not strings: Timezones are a first-class field because Graph and Google disagree on representation, and all_day events have no time component at all. Normalizing into UTC ISO 8601 strings is mandatory, but retaining the original timezone string handles daylight saving time shifts correctly.
  • response_status as a normalized enum: (needs_action, accepted, declined, tentative). Providers use different vocabularies. Normalize at the edge so consumers never branch on provider.
  • remote_data passthrough: You will always need an escape hatch for fields the canonical schema does not cover. Strip it on demand with a query flag, but never throw it away.

Parsing and Normalizing RFC 5545 Recurrence Rules

Recurrence is the single hardest data modeling problem in calendar integrations. A weekly standup is one record in the database but fifty-two instances on screen. Every provider handles this gap differently, and the differences are not cosmetic - they will break your sync if you are not prepared.

The Provider Divergence Problem

Google Calendar stores recurrence as standard RFC 5545 RRULE strings directly on the event resource. You get a string like RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR;COUNT=52 and it is your job to expand it.

Microsoft Graph takes a fundamentally different approach. Instead of an RRULE string, Graph returns a structured recurrence object with nested pattern and range sub-objects:

{
  "recurrence": {
    "pattern": {
      "type": "weekly",
      "interval": 1,
      "daysOfWeek": ["monday", "wednesday", "friday"],
      "firstDayOfWeek": "sunday"
    },
    "range": {
      "type": "numbered",
      "startDate": "2026-10-01",
      "numberOfOccurrences": 52
    }
  }
}

CalDAV (Apple, FastMail, etc.) ships the raw VEVENT with its RRULE verbatim and leaves all expansion math to the client. Your unified schema must normalize both the structured Graph object and the raw RRULE string into a single canonical format. The best target is the RFC 5545 RRULE string itself, since it is the most widely supported format across libraries.

Here is a minimal normalizer that converts a Microsoft Graph recurrence pattern into an RRULE string:

function graphPatternToRRule(pattern: GraphRecurrencePattern, range: GraphRecurrenceRange): string {
  const parts: string[] = [];
 
  // Map Graph's type to RFC 5545 FREQ
  const freqMap: Record<string, string> = {
    daily: 'DAILY', weekly: 'WEEKLY',
    absoluteMonthly: 'MONTHLY', relativeMonthly: 'MONTHLY',
    absoluteYearly: 'YEARLY', relativeYearly: 'YEARLY',
  };
  parts.push(`FREQ=${freqMap[pattern.type]}`);
 
  if (pattern.interval > 1) parts.push(`INTERVAL=${pattern.interval}`);
 
  // Map daysOfWeek to BYDAY
  if (pattern.daysOfWeek?.length) {
    const dayMap: Record<string, string> = {
      sunday: 'SU', monday: 'MO', tuesday: 'TU',
      wednesday: 'WE', thursday: 'TH', friday: 'FR', saturday: 'SA',
    };
    const byDay = pattern.daysOfWeek.map(d => dayMap[d]).join(',');
    parts.push(`BYDAY=${byDay}`);
  }
 
  // Map range termination
  if (range.type === 'numbered') {
    parts.push(`COUNT=${range.numberOfOccurrences}`);
  } else if (range.type === 'endDate' && range.endDate) {
    // UNTIL must be UTC
    parts.push(`UNTIL=${range.endDate.replace(/-/g, '')}T235959Z`);
  }
  // range.type === 'noEnd' means no COUNT or UNTIL
 
  return parts.join(';');
}
Warning

Microsoft Graph's relativeMonthly type (e.g., "second Thursday of every month") maps to an RRULE with both BYDAY and BYSETPOS. If you skip the BYSETPOS translation, your expanded instances will be wrong for every relative pattern. Test this conversion explicitly.

EXDATE, RDATE, and Recurrence Overrides

The RRULE defines the pattern, but real calendars are messy. Users cancel the third instance, reschedule the seventh, and add a one-off occurrence on a holiday. RFC 5545 handles this with three properties that together form the complete recurrence set:

  • RRULE - the repeating pattern
  • RDATE - additional dates included in the set that the rule alone would not produce
  • EXDATE - dates excluded from the set (cancelled instances)

The recurrence set is computed by gathering all dates from the RRULE and RDATE, then subtracting any EXDATE values. EXDATE takes precedence - if a date appears in both RRULE output and EXDATE, it is excluded.

Providers encode overrides differently. Google creates a separate event resource with an originalStartTime field pointing back to the master event. Microsoft Graph models it as an exception with an occurrence type. CalDAV adds a second VEVENT with a RECURRENCE-ID matching the original instance date.

Your unified schema should normalize all three into a consistent structure on the Event object:

{
  "recurrence": {
    "rrule": "FREQ=WEEKLY;BYDAY=TH",
    "timezone": "America/New_York",
    "exdates": ["2026-11-26T14:00:00Z"],
    "rdates": ["2026-11-25T14:00:00Z"]
  },
  "recurrence_master_id": null,
  "original_start": null
}

For override instances (the rescheduled seventh occurrence), the child event should carry:

{
  "recurrence": null,
  "recurrence_master_id": "evt_01H9Y...",
  "original_start": { "datetime": "2026-12-05T14:00:00Z", "timezone": "America/New_York" }
}

This pattern lets API consumers reconstruct the full recurrence set: expand the master's RRULE, subtract EXDATEs, add RDATEs, and overlay any child events matched by recurrence_master_id + original_start.

Do not write your own RRULE expander. The RFC 5545 recurrence section is dozens of pages long, and edge cases around BYSETPOS, WKST (week start day), and cross-timezone expansion will burn months of effort.

Language Library Notes
JavaScript/TypeScript rrule (rrule.js) Mature, widely used. Note: dtstart is not automatically the first instance unless it matches the rule - this is a known deviation from RFC 5545. Use RRuleSet and add dtstart as an rdate if you need strict compliance.
JavaScript/TypeScript rrule-temporal Built on the Temporal API. Returns Temporal.ZonedDateTime instances for native timezone-aware expansion. Best choice for new projects targeting Node.js 26+.
Python python-dateutil (dateutil.rrule) The standard. Use rruleset for EXDATE/RDATE support - the base rrule class does not handle exclusions. exdate() takes datetime objects, not EXDATE strings, so you must parse those yourself.
PHP rlanvin/php-rrule Port of python-dateutil. Stricter RFC compliance than the Python original.
Tip

Pitfall with rrule.js: The popular rrule library does not treat DTSTART as the first instance of the series by default - unlike what RFC 5545 specifies. This means if your DTSTART falls on a Tuesday but your rule says BYDAY=TH, rrule.js will skip Tuesday entirely. To get compliant behavior, use an RRuleSet and explicitly add the dtstart as an rdate.

Timezone and DST Conversion Patterns

Timezone handling is the second major source of calendar integration bugs, right behind recurrence. The core principle is simple: store all timestamps in UTC, retain the original IANA timezone identifier, and convert to local time only at the display layer.

But the devil is in the edge cases.

All-Day Events Are Not UTC

All-day events are the most common timezone trap. An all-day event on "October 15" must remain October 15 regardless of the viewer's timezone. If you store it as 2026-10-15T00:00:00Z, a user in America/Los_Angeles (UTC-7) will see it on October 14.

The fix: all-day events must be stored as "floating" date values with no time component and no timezone. Your schema should represent them as:

{
  "start": { "date": "2026-10-15", "datetime": null, "timezone": null },
  "end":   { "date": "2026-10-16", "datetime": null, "timezone": null },
  "all_day": true
}

Google Calendar returns all-day events with start.date (no time) and Microsoft Graph returns them with isAllDay: true and a midnight-to-midnight range in an unspecified timezone. Your normalization layer must detect both patterns and output the floating date form.

DST Transition Edge Cases

Daylight saving transitions create two failure modes that your conversion code must handle:

  1. Spring-forward gaps: When clocks jump from 2:00 AM to 3:00 AM, the time 2:30 AM does not exist. If a recurring event is scheduled at 2:30 AM on the transition day, your expander must decide what to do. The correct behavior per RFC 5545 is to use the IANA timezone's rules and shift to the next valid time (3:00 AM).

  2. Fall-back duplicates: When clocks fall back from 2:00 AM to 1:00 AM, the time 1:30 AM occurs twice. Your system must disambiguate which 1:30 AM is intended. Most calendar providers anchor to the timezone offset before the transition.

The Temporal API - now part of ECMAScript 2026 and shipped unflagged in Node.js 26 - handles both cases natively with its disambiguation option:

import { Temporal } from '@js-temporal/polyfill';
 
// Spring-forward: 2:30 AM doesn't exist on March 8, 2026 in US Eastern
const springForward = Temporal.ZonedDateTime.from({
  year: 2026, month: 3, day: 8,
  hour: 2, minute: 30,
  timeZone: 'America/New_York'
}, { disambiguation: 'compatible' });
// Result: 3:00 AM EDT (pushed forward to next valid time)
 
// Fall-back: 1:30 AM happens twice on November 1, 2026 in US Eastern
const fallBack = Temporal.ZonedDateTime.from({
  year: 2026, month: 11, day: 1,
  hour: 1, minute: 30,
  timeZone: 'America/New_York'
}, { disambiguation: 'earlier' });
// Result: 1:30 AM EDT (the first occurrence, before clocks fall back)

For Python, use pytz or zoneinfo (standard library in Python 3.9+) with explicit fold handling for ambiguous times.

The most important architectural rule: always keep the IANA timezone database updated. Governments change DST rules with minimal notice. Pin your IANA tzdata version in your deployment and update it on a regular cadence.

3. Contacts and EventTypes

Attendees, organizers, and external guests should be normalized into a Contact sub-entity. This allows your application to cross-reference meeting participants with records in your CRM or HRIS unified APIs.

For scheduling and routing platforms, EventTypes represent pre-configured meeting templates (e.g., "30-Minute Discovery Call"). This entity defines the parameters of a meeting before it is formally booked.

For a deeper architectural treatment on abstracting integration logic via declarative mappings, see Zero Integration-Specific Code.

Handling Availability and Free/Busy Computation

Querying availability is the most critical operation in any scheduling application. Availability should be a computed, real-time query—not a cached projection. This is the single most consequential design decision in a scheduling product.

Many engineering teams attempt to solve availability by syncing all calendar events into a local database and computing free/busy times locally. That model breaks in three predictable ways:

  1. Staleness: A user accepts a meeting on their phone. Until your sync catches up, your scheduler offers a slot that is already booked. Double-booking is the cardinal sin of scheduling software.
  2. Compliance drag: Caching calendar data is a massive liability. Calendar bodies contain confidential strategy, candidate names, and M&A code names. Storage expands your SOC 2 scope, triggers GDPR data residency requirements, and violates the strict security policies of enterprise customers.
  3. Reconciliation cost: Recurrence overrides, attendee changes, and cancellations create a permanent backlog of edge cases between your cache and the source of truth.

The correct architectural approach is a real-time pass-through model. The best unified calendar APIs do not store calendar data. Instead, they provide an Availability endpoint that dynamically translates your unified query into a real-time provider query (e.g., calling freebusy.query on Google or getSchedule on Microsoft) on every check and merges the results in memory.

Your unified schema for querying availability should look like this:

// POST /unified/calendar/availability
{
  "calendars": ["cal_01H9X..."],
  "start_time": "2026-10-16T09:00:00Z",
  "end_time": "2026-10-16T17:00:00Z",
  "timezone": "America/New_York"
}

The proxy layer takes this request, resolves the integrated account credentials, formats the native provider request, and maps the response back into a clean array of available time slots:

{
  "user": "jane@example.com",
  "timezone": "America/New_York",
  "busy": [
    { "start": "2026-10-16T09:00:00Z", "end": "2026-10-16T10:30:00Z" },
    { "start": "2026-10-16T13:00:00Z", "end": "2026-10-16T17:00:00Z" }
  ]
}

Do not return event titles or attendees in the availability payload. The whole point of free/busy is that consumers learn when without learning what. For the broader real-time vs cached debate, see Tradeoffs Between Real-time and Cached Unified APIs.

Normalizing Webhooks and Real-Time Event Sync

When a user declines a meeting directly inside Outlook, your application needs to know immediately. Polling the calendar API every five minutes is highly inefficient and will result in rate limits. You must implement unified webhooks to receive delta updates.

Providers use wildly different push models. Google issues channel-based notifications with a watch expiry. Microsoft Graph uses subscription objects with renewal cycles and a separate lifecycle event payload. Apple has none of the above. Calendly emits its own event taxonomy.

A unified webhook layer should normalize all of these into a small set of canonical events: record:created, record:updated, and record:deleted.

There are two primary ingestion paths you must architect:

  1. Integrated-Account Webhooks: The provider sends events to a unique URL generated for a specific connected account (common for explicitly subscribed user calendars).
  2. Environment-Integration Webhooks (Fan-Out): The provider sends all events for an entire OAuth application to a single shared URL. The ingestion layer must parse the payload, identify the specific tenant, and fan the event out.
flowchart TD
    G[Google Calendar watch channel] --> B[Ingestion Router]
    M[Microsoft Graph subscription] --> B
    C[Calendly webhook] --> B
    B --> D{Webhook Type}
    D -->|Account Specific| E[Integrated Account Pipeline]
    D -->|Shared App URL| F[Environment Fan-Out Pipeline]
    E --> N[JSONata Transformation Engine]
    F --> N
    N -->|Idempotency Deduplication| U[Canonical 'record:updated' Event]
    U --> S[Customer Webhook Endpoint with HMAC signature]

Once the payload reaches the transformation engine, JSONata expressions map the provider-specific webhook shape into your unified schema. The payload should include resource_type: "event", the canonical event object, the integrated_account_id, and an idempotency key derived from the provider's event ID plus revision. Deduplicate aggressively—Graph in particular re-sends notifications under network reorderings.

Two operational realities to plan for:

  • Subscription renewals: Graph subscriptions expire in days, Google watches in hours-to-days. The platform needs a scheduler that renews ahead of expiry. Treat any missed renewal as a sync gap and reconcile via delta query.
  • Resource mailboxes: Because Microsoft Graph does not support webhook subscriptions for room and resource mailboxes, you must run a polling loop with getSchedule or delta query for rooms, while regular user mailboxes use push. The unified schema hides this from the caller; the runtime quietly does both.

Managing Rate Limits and API Errors

Calendar APIs are notoriously aggressive with rate limiting. Google Calendar enforces strict per-user quotas plus a global project quota. Microsoft Graph throws 429 errors with a Retry-After header if you query too many mailboxes concurrently.

A common mistake when building a unified API is attempting to absorb and automatically retry these rate limits on behalf of the client. This creates opaque latency, flattens requests across customers, burns shared quota, and can lead to cascading failures across your infrastructure.

The correct approach is radical transparency. The unified API should act as a fast, deterministic proxy. It should not retry, throttle, or apply backoff on rate limit errors. When an upstream API returns an HTTP 429 (Too Many Requests), the unified API must pass that error directly to the caller, who owns the retry policy.

To make this actionable for developers, the unified API must normalize the upstream rate limit information into standardized HTTP headers per the IETF specification:

  • ratelimit-limit: The maximum number of requests permitted in the current window.
  • ratelimit-remaining: The number of requests remaining in the current window.
  • ratelimit-reset: The time at which the current rate limit window resets.

Why this matters: A caller that wanted to fail fast (an AI agent making a tool call, for instance) shouldn't be forced to wait. Pass the error up, expose the standardized headers, and let the caller's retry strategy reflect its actual SLO. For implementation patterns, see Best Practices for Handling API Rate Limits and Retries.

A defensible error contract looks like this:

HTTP status Meaning Caller action
429 Provider rate limit Read ratelimit-reset, apply exponential backoff with jitter
401 / 403 Auth or scope issue Re-auth or surface to end user
404 Resource missing Treat as deleted; reconcile state
5xx Provider outage Retry with backoff, then circuit-break

Exponential Backoff with Jitter: A Concrete Recipe

The error table above says "apply exponential backoff with jitter," but most teams implement this wrong. Here is a tested pattern that respects the ratelimit-reset header when present and falls back to exponential backoff when it is not:

interface RetryConfig {
  maxRetries: number;
  baseDelayMs: number;
  maxDelayMs: number;
}
 
async function fetchWithBackoff(
  url: string,
  options: RequestInit,
  config: RetryConfig = { maxRetries: 5, baseDelayMs: 1000, maxDelayMs: 60000 }
): Promise<Response> {
  for (let attempt = 0; attempt <= config.maxRetries; attempt++) {
    const response = await fetch(url, options);
 
    if (response.status !== 429 && response.status < 500) {
      return response;
    }
 
    if (attempt === config.maxRetries) {
      return response; // Caller decides what to do with the final failure
    }
 
    let delayMs: number;
 
    // Prefer the server's reset hint over computed backoff
    const resetHeader = response.headers.get('ratelimit-reset');
    const retryAfter = response.headers.get('retry-after');
 
    if (resetHeader) {
      // ratelimit-reset is seconds until window resets
      delayMs = parseInt(resetHeader, 10) * 1000;
    } else if (retryAfter) {
      delayMs = parseInt(retryAfter, 10) * 1000;
    } else {
      // Exponential backoff: 1s, 2s, 4s, 8s, 16s...
      delayMs = Math.min(
        config.baseDelayMs * Math.pow(2, attempt),
        config.maxDelayMs
      );
    }
 
    // Add jitter: randomize between 50%-100% of the delay
    // This prevents thundering-herd when multiple workers retry simultaneously
    const jitter = delayMs * (0.5 + Math.random() * 0.5);
    await new Promise(resolve => setTimeout(resolve, jitter));
  }
 
  throw new Error('Unreachable');
}

Key thresholds to calibrate for calendar APIs specifically:

  • Google Calendar: Per-user quota is roughly 40 requests per 10 seconds for read operations. Batch related reads into a single GET with field masks when possible.
  • Microsoft Graph: Returns Retry-After in seconds on 429 responses. Respect it exactly - Graph penalizes clients that ignore it by extending the throttle window.
  • Calendly: Rate limits are per-OAuth-application, not per-user. A single aggressive tenant can exhaust your quota for all tenants if you share a client ID.
Tip

Handling 429s in your client code: Always read the ratelimit-reset header when you receive a 429 status code. Pause your worker queue until that exact timestamp before reattempting the request. Do not rely on static sleep() intervals.

Integration Test Plan for Recurrence and Timezone Correctness

Recurrence and timezone bugs are the most common production failures in calendar integrations. They are also the easiest to prevent with targeted test coverage. Here are the specific test cases your CI pipeline should run.

Recurrence Expansion Tests

Test case Input Expected behavior
Basic weekly expansion FREQ=WEEKLY;BYDAY=MO;COUNT=4 starting 2026-10-05 Four Mondays: Oct 5, 12, 19, 26
EXDATE cancellation Same rule + EXDATE:20261012T140000Z Three instances, Oct 12 missing
RDATE addition Same rule + RDATE:20261008T140000Z Five instances, Oct 8 (Wednesday) added
Override instance Master + child event with original_start: Oct 12, new start: Oct 13 Oct 12 excluded, Oct 13 included with modified data
UNTIL boundary FREQ=DAILY;UNTIL=20261010T235959Z starting Oct 5 Six instances, Oct 5-10 inclusive
BYSETPOS relative monthly FREQ=MONTHLY;BYDAY=TH;BYSETPOS=2 Second Thursday of each month
WKST sensitivity FREQ=WEEKLY;INTERVAL=2;BYDAY=SU;WKST=MO vs WKST=SU Different expansion because week boundaries shift

Timezone and DST Tests

Test case Input Expected behavior
Spring-forward gap Event at 2:30 AM America/New_York on March 8, 2026 Shifted to 3:00 AM EDT or rejected
Fall-back duplicate Event at 1:30 AM America/New_York on Nov 1, 2026 Resolved to pre-transition (EDT) occurrence
All-day cross-timezone All-day event on Oct 15, viewed from UTC-12 Still shows as Oct 15
Recurring event across DST FREQ=WEEKLY;BYDAY=MO at 9:00 AM America/New_York spanning March 9:00 AM local time both before and after DST transition (UTC offset changes)
Non-DST timezone Event in Asia/Kolkata (no DST) Offset remains +05:30 year-round
IANA tzdata update Event in a timezone that recently changed rules Correct offset after tzdata update

Run these tests against every provider-specific mapping you support. Parse a real Google Calendar API response and a real Microsoft Graph response for the same logical event and assert that your unified output is byte-identical (minus remote_data).

Exposing the Unified Schema to AI Agents via MCP

The rise of agentic workflows has entirely changed how we interact with scheduling data. AI agents require strict, predictable data models to function without hallucinating. If you expose raw Google Calendar and Microsoft Graph APIs directly to an LLM, the agent will struggle to reconcile the different pagination styles, query parameters, and payload structures.

A normalized calendar schema solves this by providing a single, canonical interface. Each unified resource (events, availability, contacts, event_types) becomes a tool exposed via the Model Context Protocol (MCP). Because the unified API relies on standardized JSON Schema definitions, the runtime resolves the right provider behind the scenes automatically.

The high-value agent workflows fall out naturally:

  1. Autonomous meeting orchestration: An AI agent receives an email request for a meeting. The agent calls the calendar.availability.get tool to find open slots for the internal team, drafts an email reply proposing the times, and upon confirmation, calls calendar.events.create to secure the time block and invite the external Contacts.
  2. Pre-meeting context briefings (RAG): A chron-job triggers an agent 15 minutes before an Event. The agent fetches the event details, downloads any Attachments, cross-references the attendees in a Unified CRM API, and pushes a synthesized briefing via a unified messaging API.
  3. Smart time-blocking: An agent reads a task list from a ticketing system, queries availability, and writes "Deep Work" events to the user's calendar.

Because the schema is unified, the exact same agent logic works whether the internal team uses Google Workspace and the external client uses Microsoft 365. The agent does not need to know which provider it is talking to.

Tip

If you are designing your schema with AI agents in mind, keep every field name human-readable and every enum value lowercased and underscore-delimited. LLMs hallucinate camelCase variants of your fields constantly. The closer your schema is to plain English, the lower your tool-calling error rate.

Strategic Next Steps for Engineering Leads

If you are evaluating unified calendar APIs, you know that a complete unified calendar schema is not a 500-line YAML file. It is an architectural commitment: declarative mappings instead of provider branches, pass-through availability instead of cached events, normalized rate-limit headers instead of opaque retries, and canonical webhook events instead of provider-specific push payloads.

Get these four right, and adding Outlook, Calendly, or Apple Calendar later is a configuration change, not a sprint. The trade-offs are real—pass-through architecture pays a latency tax, and JSONata mappings have a learning curve—but pretending they do not exist is how unified API projects fail.

Building a calendar integration should not dictate your engineering roadmap for the quarter. Standardize your core entities, rely on real-time architecture, and perfectly position your product for the next generation of AI agent capabilities.

FAQ

How do I normalize Microsoft Graph recurrence patterns into RFC 5545 RRULEs?
Microsoft Graph returns structured recurrence objects with pattern and range sub-objects instead of RRULE strings. You need to map Graph's type field (daily, weekly, absoluteMonthly, relativeMonthly) to RFC 5545 FREQ values, convert daysOfWeek arrays to BYDAY, and translate the range termination into COUNT or UNTIL. Pay special attention to relativeMonthly patterns, which require BYSETPOS in the RRULE.
How should I handle all-day events in a unified calendar schema?
All-day events must be stored as floating date values with no time component and no timezone. If you store an all-day event as a UTC midnight timestamp, users in western timezones will see it on the wrong day. Use a date-only field (e.g., start.date = '2026-10-15') and set datetime and timezone to null.
What libraries should I use for RRULE parsing in JavaScript and Python?
For JavaScript/TypeScript, use rrule.js (mature, widely adopted) or rrule-temporal (built on the Temporal API for native timezone-aware expansion). For Python, use python-dateutil's rruleset class, which supports EXDATE and RDATE handling. Note that rrule.js does not treat DTSTART as the first instance by default, unlike RFC 5545.
Should I retry 429 rate-limit errors automatically in a unified calendar API?
No. A unified API should pass 429 errors through to the caller with normalized ratelimit-reset headers. Absorbing retries creates opaque latency, burns shared quota, and removes the caller's ability to choose a retry strategy that matches their SLO. The caller should read the reset header and apply exponential backoff with jitter.
How do I test calendar integrations for DST correctness?
Test spring-forward gaps (e.g., 2:30 AM on March 8 in US Eastern does not exist), fall-back duplicates (1:30 AM on November 1 occurs twice), recurring events that span a DST transition (should maintain local time), and all-day events viewed across timezone boundaries. Run these tests against real provider API responses to catch mapping-layer bugs.

More from our Blog