This article was originally published on here
TL;DR
If you're building Outlook Calendar integration with EspoCRM, recurring meetings will break your standard delta sync logic. This article presents a production-grade solution using:
-
Windowed Expansion: Only sync instances within
[Today - 7d, Today + 90d] - Series Rebuild: When seriesMaster changes, fetch all instances via the Instances API
- Bootstrap State Machine: Handle cold-start with explicit initialization scan
-
Critical Warning: Never use
iCalUIdas a unique key for recurring instances
The Problem: Why Delta Sync Fails for Recurring Meetings
Most developers assume Microsoft Graph's Delta Sync API will notify them of every calendar change. This works fine for single events. For recurring meetings, it breaks down.
Microsoft's Data Model
In Microsoft Graph API, a recurring meeting isn't a single record. It's split into three types:
| Type | Description | Stored in Outlook |
|---|---|---|
| seriesMaster | The template defining recurrence rules (e.g., "every Thursday at 2 PM") | Physical record |
| occurrence | A specific instance (e.g., "Jan 8, 2026 at 2 PM") | Virtual, computed from Master |
| exception | A modified instance (e.g., "Just this one, move to 3 PM") | Physical record |
The Delta Sync Trap
When you modify a seriesMaster (e.g., change "weekly Monday" to "weekly Tuesday"), the Delta API returns only the seriesMaster change. It does NOT return the 20+ occurrence changes for future instances.
Your CRM continues showing meetings on Mondays. Users complain. Debugging reveals no sync errors—the delta data simply never contained those changes.
Common Failure Patterns
| Symptom | Root Cause |
|---|---|
| Missing instances | Only processed seriesMaster, never expanded occurrences |
| Duplicate instances | Used iCalUId as dedupe key (same for entire series) |
| Orphaned instances | Deleted series in Outlook, only Master removed from CRM |
| Time drift | Expanded in UTC without proper timezone handling |
The Solution: Windowed Expansion + Series Rebuild
Core Principle: The Window
Never sync infinite future. Define a "window of interest" such as [Today - 7d, Today + 90d].
This makes any recurring series—even "no end date" ones—produce a constant, bounded number of instances.
Architecture Overview
Implementation Details
1. The Bootstrap State Machine
Delta Sync is "change-driven," not "presence-driven." Meetings created years ago with no recent changes won't appear in delta results. This causes silent missing data on first sync.
Solution: Track bootstrap state per user calendar:
<?php
class OutlookCalendarService
{
const STATUS_PENDING = 'Pending';
const STATUS_COMPLETED = 'Completed';
public function syncUserCalendar(string $userId): void
{
$calendar = $this->getCalendar($userId);
if ($calendar->getBootstrapStatus() === self::STATUS_PENDING) {
$this->runBootstrapScan($calendar);
$calendar->setBootstrapStatus(self::STATUS_COMPLETED);
$this->em->save($calendar);
}
$this->runDeltaSync($calendar);
}
private function runBootstrapScan(OutlookCalendar $calendar): void
{
$windowStart = new \DateTime('-7 days');
$windowEnd = new \DateTime('+90 days');
// Get ALL seriesMasters in window, not just changed ones
$masters = $this->graphClient->getCalendarView(
$calendar->getExternalAccountId(),
$windowStart,
$windowEnd,
['$filter' => "type eq 'seriesMaster'"]
);
foreach ($masters as $master) {
$this->rebuildSeries($master['id'], $windowStart, $windowEnd);
}
}
}
2. Series Rebuild Strategy
When seriesMaster changes, trigger instance rebuild:
<?php
private function rebuildSeries(string $masterId, \DateTime $start, \DateTime $end): void
{
// Fetch all instances in window
$instances = $this->graphClient->getInstances($masterId, $start, $end);
$apiInstanceIds = array_column($instances, 'id');
// Get existing CRM instances for this series
$existing = $this->em->getRepository(OutlookEvent::class)
->findBy(['seriesMasterId' => $masterId]);
$existingIds = array_map(fn($e) => $e->getEventId(), $existing);
// Diff: Create new, Update existing, Delete missing
foreach ($instances as $instance) {
$this->syncInstance($instance);
}
// Delete instances that disappeared from API (but within window)
foreach ($existing as $event) {
if (!in_array($event->getEventId(), $apiInstanceIds)) {
if ($event->getStart()->between($start, $end)) {
$this->em->remove($event);
}
}
}
}
3. The iCalUId Trap (CRITICAL!)
RFC 5545 specifies that ALL occurrences in a recurring series share the same UID.
This means iCalUId is NOT unique for recurring instances. Using it for deduplication will merge all your weekly meetings into one record.
<?php
// WRONG: This will break recurring meetings
$meeting = $this->findByEventId($eventId)
?? $this->findByICalUId($iCalUId); // ❌ DO NOT DO THIS
// CORRECT: Never use iCalUId fallback for recurring instances
if ($eventType === 'occurrence' || $eventType === 'exception') {
$meeting = $this->findByEventId($eventId); // ✅ Use eventId only
} else {
$meeting = $this->findByEventId($eventId)
?? $this->findByICalUId($iCalUId); // ✅ OK for single events
}
4. Complete Sync Sequence
5. Quota Management
Recurring series generate massive record counts. One user with 10 weekly meetings = ~120 instances per quarter.
Protect against API throttling:
<?php
class SyncLimits
{
private const MAX_INSTANCES_PER_RUN = 200;
private const MAX_SERIES_REBUILD_PER_RUN = 5;
private $instancesProcessed = 0;
private $seriesRebuilt = 0;
public function shouldProcessMoreInstances(): bool
{
return $this->instancesProcessed < self::MAX_INSTANCES_PER_RUN;
}
public function shouldRebuildMoreSeries(): bool
{
return $this->seriesRebuilt < self::MAX_SERIES_REBUILD_PER_RUN;
}
public function recordInstance(): void
{
$this->instancesProcessed++;
}
public function recordSeriesRebuild(): void
{
$this->seriesRebuilt++;
}
}
Production Checklist
Before deploying to production, verify:
- [ ] DST Handling: Create a weekly meeting spanning DST transition (March/November). Verify times don't drift
- [ ] Exception Preservation: Modify one instance's time, then change series subject. Verify the modified instance keeps its time
- [ ] Deletion Sync: Delete one future instance in Outlook. Confirm CRM removes it. Delete entire series. Confirm all instances are removed
- [ ] Long-running Series: Create a monthly meeting for 2 years. Verify only ~90 days are synced
- [ ] All-day Events: Create all-day recurring event. Verify it doesn't span two days due to timezone conversion
- [ ] Token Recovery: Simulate delta token expiration. Verify system falls back to full sync
- [ ] Logging: All sync operations log (seriesMasterId, instanceId, operation) for debugging
Comparison: Outlook vs Google Calendar
| Aspect | Microsoft Graph (Outlook) | Google Calendar API |
|---|---|---|
| Recurring Model | Master + Exception (occurrences are virtual) | Event + Recurrence (can expand with singleEvents=true) |
| Delta Behavior | Master change returns only Master | Master change can return all affected instances |
| ID Reference | seriesMasterId |
recurringEventId |
| Deletion | Physical delete or status change | Often status: cancelled
|
| Complexity | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ |
Google's singleEvents=true parameter makes expansion easier, but the windowed approach remains necessary for performance.
Key Takeaways
- Never trust Delta Sync alone for recurring meetings. Use delta as a signal, not as truth.
- Windowed expansion is essential. Infinite series must be bounded to a constant number of instances.
- iCalUId is half-truth. Same for all occurrences—dangerous as a unique key.
- Bootstrap state matters. Explicit initialization scan prevents "cold start" data gaps.
- Log everything. When debugging sync issues, you'll need the history.
Data Model Reference
Resources
- Microsoft Graph Delta Query Documentation
- Get incremental changes to events in a calendar view
- Best practices for working with Microsoft Graph
- RFC 5545: iCalendar Specification
Thanks for reading! You can find the original post on here
If you found this helpful, feel free to connect with me
Top comments (0)