From onenote-pack
Implements OneNote change detection via polling, timestamps, and delta queries on Microsoft Graph since webhooks are decommissioned. For real-time sync and monitoring integrations.
npx claudepluginhub jeremylongshore/claude-code-plugins-plus-skills --plugin onenote-packThis skill is limited to using the following tools:
> **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.
Optimizes OneNote Graph API usage to avoid rate limits using metadata caching, batch requests, delta sync, $select/$expand, and deduplication. For high-volume integrations and capacity planning.
Implements Notion change detection using polling, native webhooks, and third-party connectors for real-time sync, change feeds, backups, and event workflows.
Implements Evernote webhooks for real-time note/notebook change notifications using Express endpoints, sync API for updates, event handlers, and polling fallback.
Share bugs, ideas, or general feedback.
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:
400 — do not attempt it/me/onenote/pages/delta) are not officially documented but work on some tenantsNotes.Read or Notes.ReadWritepip install msgraph-sdk azure-identitynpm install @microsoft/microsoft-graph-client @azure/identity @azure/msal-node// 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.
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);
}
}
}
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.
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
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
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);
}
}
}
}
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" if createdDateTime === lastModifiedDateTime, otherwise "modified"| 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 |
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);
onenote-rate-limits for rate budget management when polling many sectionsonenote-core-workflow-b for cross-notebook search if polling detects changes you need to queryonenote-performance-tuning for caching notebook/section structure to reduce poll overhead