Claude
Skills
Sign in
Back

onenote-webhooks-events

Included with Lifetime
$97 forever

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".

Generalsaasonenotemicrosoft

What this skill does

# 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<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:

```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,
                        

Related in General