Claude-code-plugins-plus onenote-webhooks-events
git clone https://github.com/jeremylongshore/claude-code-plugins-plus-skills
T=$(mktemp -d) && git clone --depth=1 https://github.com/jeremylongshore/claude-code-plugins-plus-skills "$T" && mkdir -p ~/.claude/skills && cp -r "$T/plugins/saas-packs/onenote-pack/skills/onenote-webhooks-events" ~/.claude/skills/jeremylongshore-claude-code-plugins-plus-onenote-webhooks-events && rm -rf "$T"
plugins/saas-packs/onenote-pack/skills/onenote-webhooks-events/SKILL.mdOneNote — Change Detection (Polling & Delta Queries)
Overview
OneNote webhooks were decommissioned June 16, 2023. The Graph subscription API (
withPOST /subscriptionson OneNote resources) returnschangeType: "updated". Unlike Outlook mail, calendar, and OneDrive — which still support push notifications — OneNote has no webhook replacement. You must poll.400 Bad Request
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
— do not attempt it400 - Delta queries (
) are not officially documented but work on some tenants/me/onenote/pages/delta - 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:
orNotes.ReadNotes.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
// 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.
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<string, string> = 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<void> { // 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<void> { 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:
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
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:
interface ChangeProcessor { type: string; match: (event: ChangeEvent) => boolean; handle: (event: ChangeEvent, client: Client) => Promise<void>; } 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:
— Graph resource ID for the changed pagepageId
— Page title (null for deleted pages, which are filtered out)title
— Parent section identifiersectionId
— ISO 8601 timestamp of the changemodifiedAt
—changeType
if"created"
, otherwisecreatedDateTime === lastModifiedDateTime"modified"
Error Handling
| Status | Cause | Fix |
|---|---|---|
| 400 | Attempted webhook subscription on OneNote resource | Use polling — webhooks decommissioned June 2023 |
| 429 | Polling too aggressively | Read 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 |
| 500 | Graph service error | Retry with exponential backoff; do not count toward change detection |
Examples
Quick start — monitor a single section:
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:
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
Next Steps
- See
for rate budget management when polling many sectionsonenote-rate-limits - See
for cross-notebook search if polling detects changes you need to queryonenote-core-workflow-b - See
for caching notebook/section structure to reduce poll overheadonenote-performance-tuning