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.6.0 license: MIT author: Jeremy Longshore jeremy@intentsolutions.io 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 /subscriptionswithchangeType: "updated"on OneNote resources) returns400 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.ReadorNotes.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:
pageId— Graph resource ID for the changed pagetitle— Page title (null for deleted pages, which are filtered out)sectionId— Parent section identifiermodifiedAt— ISO 8601 timestamp of the changechangeType—"created"ifcreatedDateTime === 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:
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
onenote-rate-limitsfor rate budget management when polling many sections - See
onenote-core-workflow-bfor cross-notebook search if polling detects changes you need to query - See
onenote-performance-tuningfor caching notebook/section structure to reduce poll overhead
Expert Next.js App Router
Developpement
Un skill qui transforme Claude en expert Next.js App Router.
Générateur de README
Developpement
Crée des README.md professionnels et complets pour vos projets.
Rédacteur de Documentation API
Developpement
Génère de la documentation API complète au format OpenAPI/Swagger.