--- name: onenote-webhooks-events description: 'Implement change detection for OneNote using polling and delta queries (webhooks decommissioned June 2023). Use when building real-time sync, change monitoring, or event-driven OneNote integrations. Trigger with "onenote changes", "onenote polling", "onenote sync", "onenote delta query". ' allowed-tools: Read, Write, Edit, Bash(npm:*), Bash(pip:*), Grep version: 1.0.0 license: MIT author: Jeremy Longshore tags: - saas - onenote - microsoft compatibility: Designed for Claude Code --- # OneNote — Change Detection (Polling & Delta Queries) ## Overview > **OneNote webhooks were decommissioned June 16, 2023.** The Graph subscription API (`POST /subscriptions` with `changeType: "updated"` on OneNote resources) returns `400 Bad Request`. Unlike Outlook mail, calendar, and OneDrive — which still support push notifications — OneNote has no webhook replacement. You must poll. This skill implements efficient change detection for OneNote using `lastModifiedDateTime` comparisons, delta query patterns, and rate-limit-aware polling intervals. The approach balances freshness (detecting changes within minutes) against the 600 requests/minute per-user rate limit. Key pain points addressed: - Subscription API for OneNote resources returns `400` — do not attempt it - Delta queries (`/me/onenote/pages/delta`) are not officially documented but work on some tenants - Polling must stay within rate budget (600/min per user, 10,000/10min per tenant) - Change detection requires comparing timestamps, not content diffs (output HTML is unstable) ## Prerequisites - Azure app registration with delegated permissions: `Notes.Read` or `Notes.ReadWrite` - App-only auth deprecated March 31, 2025 — use delegated auth only - Python: `pip install msgraph-sdk azure-identity` - Node/TypeScript: `npm install @microsoft/microsoft-graph-client @azure/identity @azure/msal-node` - A persistent store for tracking last-seen timestamps (Redis, SQLite, file system) ## Instructions ### Step 1 — Understand Why Webhooks Do Not Work ```typescript // DO NOT DO THIS — it will return 400 Bad Request // OneNote webhooks decommissioned June 16, 2023 const subscription = await client.api("/subscriptions").post({ changeType: "updated", notificationUrl: "https://yourapp.com/webhooks/onenote", resource: "/me/onenote/pages", // NOT SUPPORTED expirationDateTime: new Date(Date.now() + 3600000).toISOString(), }); // Error: "Subscription validation request failed. Resource not found." ``` For comparison, these Graph resources still support webhooks: Outlook messages, calendar events, OneDrive files, Teams messages, Planner tasks. OneNote is the notable exception. ### Step 2 — Implement Timestamp-Based Polling (TypeScript) The core pattern: periodically list pages ordered by `lastModifiedDateTime` and compare against your stored watermark. ```typescript import { Client } from "@microsoft/microsoft-graph-client"; interface ChangeEvent { pageId: string; title: string; sectionId: string; modifiedAt: string; changeType: "created" | "modified"; } class OneNotePoller { private watermarks: Map = new Map(); // sectionId → ISO timestamp private intervalMs: number; private timer: NodeJS.Timeout | null = null; private client: Client; private onChanges: (events: ChangeEvent[]) => void; constructor( client: Client, onChanges: (events: ChangeEvent[]) => void, intervalSeconds: number = 30 // Poll every 30s — uses ~2 req/min per section ) { this.client = client; this.onChanges = onChanges; this.intervalMs = intervalSeconds * 1000; } async start(sectionIds: string[]): Promise { // Initialize watermarks to "now" to avoid processing historical pages const now = new Date().toISOString(); for (const id of sectionIds) { this.watermarks.set(id, now); } this.timer = setInterval(() => this.poll(sectionIds), this.intervalMs); console.log(`Polling ${sectionIds.length} sections every ${this.intervalMs / 1000}s`); } stop(): void { if (this.timer) clearInterval(this.timer); } private async poll(sectionIds: string[]): Promise { const allChanges: ChangeEvent[] = []; for (const sectionId of sectionIds) { try { const watermark = this.watermarks.get(sectionId)!; const pages = await this.client.api( `/me/onenote/sections/${sectionId}/pages` ) .select("id,title,lastModifiedDateTime,createdDateTime") .filter(`lastModifiedDateTime ge ${watermark}`) .orderby("lastModifiedDateTime desc") .top(50) .get(); for (const page of pages.value ?? []) { if (!page.title) continue; // Skip deleted pages (null title) const isNew = page.createdDateTime === page.lastModifiedDateTime; allChanges.push({ pageId: page.id, title: page.title, sectionId, modifiedAt: page.lastModifiedDateTime, changeType: isNew ? "created" : "modified", }); } // Advance watermark if (pages.value?.length > 0) { this.watermarks.set(sectionId, pages.value[0].lastModifiedDateTime); } } catch (err: any) { if (err.statusCode === 429) { const retryAfter = parseInt(err.headers?.["retry-after"] ?? "60", 10); console.warn(`Rate limited on section ${sectionId}, backing off ${retryAfter}s`); await new Promise((r) => setTimeout(r, retryAfter * 1000)); } else { console.error(`Poll error for section ${sectionId}:`, err.message); } } } if (allChanges.length > 0) { this.onChanges(allChanges); } } } ``` ### Step 3 — Rate Budget Planning With a 600 requests/minute per-user limit, plan your polling capacity: | Sections Monitored | Poll Interval | Requests/Min | Budget Used | |---|---|---|---| | 5 | 30s | 10 | 1.7% | | 20 | 30s | 40 | 6.7% | | 50 | 60s | 50 | 8.3% | | 100 | 60s | 100 | 16.7% | | 200 | 120s | 100 | 16.7% | Reserve at least 50% of your rate budget for user-initiated operations (CRUD, search). If monitoring 100+ sections, increase the poll interval to 120s or use the tiered approach below. ### Step 4 — Tiered Polling (Prioritize Active Sections) Not all sections change equally. Poll recently-active sections more frequently: ```typescript interface TieredSection { id: string; tier: "hot" | "warm" | "cold"; lastChange: Date; } function assignTier(lastChange: Date): "hot" | "warm" | "cold" { const ageMs = Date.now() - lastChange.getTime(); const oneHour = 3600_000; const oneDay = 86400_000; if (ageMs < oneHour) return "hot"; // Changed in last hour if (ageMs < oneDay) return "warm"; // Changed in last day return "cold"; // Stale } const pollIntervals = { hot: 15_000, // 15 seconds warm: 120_000, // 2 minutes cold: 600_000, // 10 minutes }; // Re-evaluate tiers after each poll cycle ``` ### Step 5 — Python Async Polling ```python import asyncio from datetime import datetime, timezone from msgraph import GraphServiceClient class OneNotePoller: def __init__(self, client: GraphServiceClient, interval_seconds: int = 30): self.client = client self.interval = interval_seconds self.watermarks: dict[str, str] = {} self._running = False async def start(self, section_ids: list[str], callback): """Start polling sections for changes.""" self._running = True now = datetime.now(timezone.utc).isoformat() for sid in section_ids: self.watermarks[sid] = now while self._running: changes = [] for sid in section_ids: try: pages = await self.client.me.onenote.sections.by_onenote_section_id( sid ).pages.get() for page in (pages.value or []): if not page.title: continue modified = page.last_modified_date_time.isoformat() if modified > self.watermarks[sid]: changes.append({ "page_id": page.id, "title": page.title, "section_id": sid, "modified_at": modified, }) self.watermarks[sid] = max(self.watermarks[sid], modified) except Exception as e: print(f"Poll error for {sid}: {e}") if changes: await callback(changes) await asyncio.sleep(self.interval) def stop(self): self._running = False ``` ### Step 6 — Event Processing Pipeline Structure your change handler to decouple detection from processing: ```typescript interface ChangeProcessor { type: string; match: (event: ChangeEvent) => boolean; handle: (event: ChangeEvent, client: Client) => Promise; } const processors: ChangeProcessor[] = [ { type: "sync-to-database", match: (e) => e.changeType === "modified", handle: async (e, client) => { const content = await client.api(`/me/onenote/pages/${e.pageId}/content`).get(); // Parse HTML, extract structured data, upsert to your DB }, }, { type: "notify-team", match: (e) => e.changeType === "created", handle: async (e) => { // Send Slack/Teams notification for new pages console.log(`New page: "${e.title}" in section ${e.sectionId}`); }, }, ]; // In your poller callback: async function processChanges(events: ChangeEvent[], client: Client) { for (const event of events) { for (const proc of processors) { if (proc.match(event)) { await proc.handle(event, client); } } } } ``` ## Output The polling service produces change events with: - `pageId` — Graph resource ID for the changed page - `title` — Page title (null for deleted pages, which are filtered out) - `sectionId` — Parent section identifier - `modifiedAt` — ISO 8601 timestamp of the change - `changeType` — `"created"` if `createdDateTime === lastModifiedDateTime`, otherwise `"modified"` ## Error Handling | Status | Cause | Fix | |--------|-------|-----| | 400 | Attempted webhook subscription on OneNote resource | Use polling — webhooks decommissioned June 2023 | | 429 | Polling too aggressively | Read `Retry-After` header; increase poll interval; use tiered polling | | 404 | Section deleted between polls | Remove section from poll list; log and continue | | 502 | Token expired mid-poll | Refresh credentials; MSAL handles this automatically with `DeviceCodeCredential` | | 500 | Graph service error | Retry with exponential backoff; do not count toward change detection | ## Examples **Quick start — monitor a single section:** ```typescript const poller = new OneNotePoller(client, (changes) => { changes.forEach((c) => console.log(`[${c.changeType}] ${c.title} at ${c.modifiedAt}`)); }, 30); await poller.start(["section-id-here"]); // Output: [modified] Sprint Planning at 2026-03-23T15:30:00Z ``` **Production setup — tiered polling with error recovery:** ```typescript const sections = await client.api("/me/onenote/notebooks/{id}/sections") .select("id,displayName,lastModifiedDateTime") .get(); const tiered = sections.value.map((s) => ({ id: s.id, tier: assignTier(new Date(s.lastModifiedDateTime)), lastChange: new Date(s.lastModifiedDateTime), })); // Start separate pollers per tier const hotSections = tiered.filter((s) => s.tier === "hot").map((s) => s.id); const warmSections = tiered.filter((s) => s.tier === "warm").map((s) => s.id); ``` ## Resources - [OneNote API Overview](https://learn.microsoft.com/en-us/graph/api/resources/onenote-api-overview) - [Integration Guide](https://learn.microsoft.com/en-us/graph/integrate-with-onenote) - [Best Practices](https://learn.microsoft.com/en-us/graph/onenote-best-practices) - [Known Issues](https://learn.microsoft.com/en-us/graph/known-issues) - [Error Codes](https://learn.microsoft.com/en-us/graph/onenote-error-codes) - [Graph API Reference](https://learn.microsoft.com/en-us/graph/api/overview) ## Next Steps - See `onenote-rate-limits` for rate budget management when polling many sections - See `onenote-core-workflow-b` for cross-notebook search if polling detects changes you need to query - See `onenote-performance-tuning` for caching notebook/section structure to reduce poll overhead