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