'Build change detection and event handling for Notion workspaces using
Scanned 5/30/2026
Install to Claude Code
npx -y skills add jeremylongshore/claude-code-plugins-plus-skills --skill notion-webhooks-events --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Notion Webhooks Events?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/jeremylongshore-notion-webhooks-events)More formats (shields.io, HTML) on the badges page.
---
name: notion-webhooks-events
description: 'Build change detection and event handling for Notion workspaces using
polling, native webhooks, and third-party connectors.
Use when implementing real-time sync, change feeds, incremental backup,
or event-driven workflows with Notion data.
Trigger with phrases like "notion webhook", "notion events",
"notion change detection", "notion polling", "notion sync changes",
"notion real-time", "notion watch for changes".
'
allowed-tools: Read, Write, Edit, Bash(node:*), Bash(npx:*), Bash(npm:*), Bash(curl:*)
version: 1.0.0
license: MIT
author: Jeremy Longshore <jeremy@intentsolutions.io>
tags:
- saas
- productivity
- notion
compatibility: Designed for Claude Code
---
# Notion Webhooks & Event Handling
## Overview
Notion offers three approaches to change detection, each with different trade-offs:
| Approach | Latency | Complexity | Reliability |
|----------|---------|------------|-------------|
| **Polling with `search` / `databases.query`** | 30s-5min (your poll interval) | Low | High — you control timing |
| **Native webhooks** (API 2025-02+) | Near real-time | Medium | Good — requires HTTPS endpoint, retry handling |
| **Third-party connectors** (Zapier, Make) | 1-15 min | Low (no-code) | Vendor-dependent |
**Honest assessment:** Notion's native webhook support arrived in mid-2025 and covers page, database, comment, and data source events. It works well for event notification but does not deliver full payloads — you still need API calls to fetch the changed data. For many use cases, especially incremental sync and backup, polling with `last_edited_time` filters remains the most battle-tested pattern.
## Prerequisites
- `@notionhq/client` v2.3+ installed (`npm install @notionhq/client`)
- Notion integration created at https://www.notion.so/my-integrations
- Integration shared with target pages/databases (Connections menu in Notion)
- `NOTION_TOKEN` environment variable set to the integration's Internal Integration Secret
- For native webhooks: HTTPS endpoint accessible from the internet
## Instructions
### Step 1: Polling-Based Change Detection
Polling is the most reliable approach and works with every Notion API version. Use `notion.search()` to discover recently edited content across the entire workspace, or `notion.databases.query()` with timestamp filters for targeted change detection on a specific database.
#### Workspace-Wide Change Feed
```typescript
import { Client } from '@notionhq/client';
import type {
PageObjectResponse,
DatabaseObjectResponse,
} from '@notionhq/client/build/src/api-endpoints';
const notion = new Client({ auth: process.env.NOTION_TOKEN });
interface ChangeRecord {
id: string;
object: 'page' | 'database';
lastEdited: string;
title: string;
}
// Track the high-water mark for incremental polling
let lastPollTimestamp: string | null = null;
async function pollWorkspaceChanges(): Promise<ChangeRecord[]> {
const changes: ChangeRecord[] = [];
let cursor: string | undefined;
do {
const response = await notion.search({
sort: {
direction: 'descending',
timestamp: 'last_edited_time',
},
start_cursor: cursor,
page_size: 100,
});
for (const result of response.results) {
if (!('last_edited_time' in result)) continue;
const item = result as PageObjectResponse | DatabaseObjectResponse;
// Stop when we reach items older than our last poll
if (lastPollTimestamp && item.last_edited_time <= lastPollTimestamp) {
// Found our boundary — everything after this is old
return changes;
}
const title = extractTitle(item);
changes.push({
id: item.id,
object: item.object,
lastEdited: item.last_edited_time,
title,
});
}
cursor = response.has_more ? (response.next_cursor ?? undefined) : undefined;
} while (cursor);
return changes;
}
function extractTitle(
item: PageObjectResponse | DatabaseObjectResponse
): string {
if (item.object === 'database') {
return (item as DatabaseObjectResponse).title
.map(t => t.plain_text).join('');
}
const page = item as PageObjectResponse;
for (const prop of Object.values(page.properties)) {
if (prop.type === 'title') {
return prop.title.map(t => t.plain_text).join('');
}
}
return 'Untitled';
}
// Run the poller on an interval
async function startPolling(intervalMs: number = 60_000) {
console.log(`Polling Notion every ${intervalMs / 1000}s`);
async function tick() {
try {
const changes = await pollWorkspaceChanges();
if (changes.length > 0) {
console.log(`Detected ${changes.length} change(s):`);
for (const c of changes) {
console.log(` [${c.object}] "${c.title}" edited at ${c.lastEdited}`);
}
lastPollTimestamp = changes[0].lastEdited;
await processChanges(changes);
} else {
console.log('No new changes');
}
} catch (err) {
console.error('Poll error:', err);
}
}
// Initial poll
await tick();
setInterval(tick, intervalMs);
}
```
#### Database-Specific Incremental Sync
When you only care about one database, `databases.query` with a `last_edited_time` filter is more efficient and stays within rate limits:
```typescript
async function pollDatabaseChanges(
databaseId: string,
since: string // ISO 8601 timestamp
): Promise<PageObjectResponse[]> {
const changed: PageObjectResponse[] = [];
let cursor: string | undefined;
do {
const response = await notion.databases.query({
database_id: databaseId,
filter: {
timestamp: 'last_edited_time',
last_edited_time: { after: since },
},
sorts: [
{ timestamp: 'last_edited_time', direction: 'descending' },
],
start_cursor: cursor,
page_size: 100,
});
changed.push(...response.results as PageObjectResponse[]);
cursor = response.has_more ? (response.next_cursor ?? undefined) : undefined;
} while (cursor);
return changed;
}
// Compare with cached state for fine-grained diff
interface CachedPage {
id: string;
lastEdited: string;
properties: Record<string, unknown>;
}
function diffChanges(
current: PageObjectResponse[],
cache: Map<string, CachedPage>
): { added: string[]; modified: string[]; propertyChanges: Map<string, string[]> } {
const added: string[] = [];
const modified: string[] = [];
const propertyChanges = new Map<string, string[]>();
for (const page of current) {
const cached = cache.get(page.id);
if (!cached) {
added.push(page.id);
continue;
}
if (cached.lastEdited !== page.last_edited_time) {
modified.push(page.id);
// Detect which properties changed
const changedProps: string[] = [];
for (const [key, value] of Object.entries(page.properties)) {
if (JSON.stringify(value) !== JSON.stringify(cached.properties[key])) {
changedProps.push(key);
}
}
if (changedProps.length > 0) {
propertyChanges.set(page.id, changedProps);
}
}
}
return { added, modified, propertyChanges };
}
```
#### Content-Level Change Tracking
To detect changes inside page content (not just property edits), fetch and compare block children:
```typescript
async function getBlockFingerprint(pageId: string): Promise<string> {
const blocks: string[] = [];
let cursor: string | undefined;
do {
const response = await notion.blocks.children.list({
block_id: pageId,
start_cursor: cursor,
page_size: 100,
});
for (const block of response.results) {
if ('type' in block && 'last_edited_time' in block) {
// Use block ID + edit time as a lightweight fingerprint
blocks.push(`${block.id}:${block.last_edited_time}`);
}
}
cursor = response.has_more ? (response.next_cursor ?? undefined) : undefined;
} while (cursor);
// Simple hash: join and compare as a string
return blocks.join('|');
}
// Cache fingerprints and compare on each poll
const contentCache = new Map<string, string>();
async function detectContentChanges(pageId: string): Promise<boolean> {
const current = await getBlockFingerprint(pageId);
const previous = contentCache.get(pageId);
contentCache.set(pageId, current);
if (previous === undefined) return false; // First seen
return current !== previous;
}
```
### Step 2: Native Webhooks (API 2025-02+)
Notion's native webhooks deliver HTTP POST notifications when pages, databases, comments, or data sources change. They notify you that something changed — you then call the API to fetch the actual data.
#### Register and Verify a Webhook Endpoint
```typescript
import express from 'express';
import crypto from 'crypto';
const app = express();
app.use(express.json());
// Step A: Handle the verification challenge during webhook registration
// Notion sends { type: "url_verification", challenge: "..." }
app.post('/webhooks/notion', (req, res) => {
if (req.body.type === 'url_verification') {
console.log('Webhook verification received');
return res.status(200).json({ challenge: req.body.challenge });
}
// Step B: Process real events
handleWebhookEvent(req.body);
// Always respond 200 quickly — do heavy processing async
res.status(200).json({ ok: true });
});
app.listen(3000, () => console.log('Webhook receiver on :3000'));
```
#### Handle Webhook Events
```typescript
interface NotionWebhookEvent {
type: string;
data: {
id: string;
object: 'page' | 'database' | 'data_source' | 'comment';
parent?: { type: string; page_id?: string; database_id?: string };
};
integration_id: string;
authors: Array<{ id: string; type: 'person' | 'bot' }>;
attempt_number: number;
timestamp: string;
}
// Supported event types:
// page.created, page.deleted, page.moved, page.undeleted
// page.content_updated, page.properties_updated
// page.locked, page.unlocked
// database.created, database.deleted, database.moved
// database.schema_updated, database.content_updated
// comment.created, comment.updated, comment.deleted
// data_source.schema_updated
async function handleWebhookEvent(event: NotionWebhookEvent) {
console.log(`[webhook] ${event.type} → ${event.data.object} ${event.data.id}`);
switch (event.type) {
case 'page.content_updated':
case 'page.properties_updated':
// Fetch the page to see what actually changed
const page = await notion.pages.retrieve({ page_id: event.data.id });
await processPageUpdate(page, event.type);
break;
case 'page.created':
const newPage = await notion.pages.retrieve({ page_id: event.data.id });
await processNewPage(newPage);
break;
case 'page.deleted':
await handlePageDeletion(event.data.id);
break;
case 'database.schema_updated':
const db = await notion.databases.retrieve({ database_id: event.data.id });
console.log('Schema changed. Properties:', Object.keys(db.properties));
break;
case 'comment.created':
await handleNewComment(event.data.id);
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
}
```
#### Debouncing and Deduplication
Notion may deliver events more than once (retries on non-200 responses). Rapid edits can also produce a burst of events for the same resource. Handle both:
```typescript
// Deduplication: skip events we have already processed
const processedEvents = new Map<string, number>(); // key → timestamp
function isDuplicate(event: NotionWebhookEvent): boolean {
const key = `${event.type}:${event.data.id}:${event.timestamp}`;
if (processedEvents.has(key)) return true;
processedEvents.set(key, Date.now());
// Prune entries older than 10 minutes
const cutoff = Date.now() - 10 * 60 * 1000;
for (const [k, ts] of processedEvents) {
if (ts < cutoff) processedEvents.delete(k);
}
return false;
}
// Debouncing: collapse rapid edits into one processing call
const pendingDebounce = new Map<string, NodeJS.Timeout>();
const DEBOUNCE_MS = 2000;
function debounceEvent(
event: NotionWebhookEvent,
handler: (event: NotionWebhookEvent) => Promise<void>
) {
const resourceKey = `${event.data.object}:${event.data.id}`;
const existing = pendingDebounce.get(resourceKey);
if (existing) clearTimeout(existing);
pendingDebounce.set(
resourceKey,
setTimeout(async () => {
pendingDebounce.delete(resourceKey);
await handler(event);
}, DEBOUNCE_MS)
);
}
// Combined entry point
async function onWebhookReceived(event: NotionWebhookEvent) {
if (isDuplicate(event)) {
console.log(`Skipping duplicate: ${event.type} ${event.data.id}`);
return;
}
debounceEvent(event, handleWebhookEvent);
}
```
### Step 3: Scheduled Polling with Cron / GitHub Actions
See [scheduled polling and examples](references/scheduled-polling-and-examples.md) for Node.js cron scripts, GitHub Actions scheduled sync with persistent state, third-party connector patterns, and additional examples including minimal change watchers and hybrid webhook+polling fallback.
## Output
- Polling-based change detection for workspace-wide or per-database monitoring
- Property-level diff detection comparing current state against cached snapshots
- Block-level content fingerprinting for page body change tracking
- Native webhook receiver with verification handshake and event routing
- Deduplication and debouncing for reliable event processing
- Cron/GitHub Actions scheduled sync with persistent state
## Error Handling
| Issue | Cause | Solution |
|-------|-------|----------|
| `search` returns stale results | Notion indexing delay (up to 30s) | Accept eventual consistency; do not poll faster than 30s |
| Rate limited during polling | Too many API calls per second | Add 350ms delay between paginated requests; use `databases.query` over `search` when possible |
| Webhook verification fails | Endpoint not returning `{ challenge }` | Respond with `res.json({ challenge: req.body.challenge })` for `url_verification` type |
| Duplicate webhook events | Network retries from Notion | Deduplicate on `type + data.id + timestamp` composite key |
| Missed changes between polls | Poll interval too wide | Persist `lastPollTimestamp` to disk; use overlapping windows (poll since `lastSync - 60s`) |
| Content changes not detected | `last_edited_time` only covers properties | Use `blocks.children.list` fingerprinting for page body changes |
| `databases.query` filter ignored | Wrong filter structure | Use `{ timestamp: 'last_edited_time', last_edited_time: { after: isoString } }` — not a property filter |
## Resources
- [Notion API Search endpoint](https://developers.notion.com/reference/post-search)
- [Database query filters](https://developers.notion.com/reference/post-database-query-filter)
- [Notion Webhooks Reference](https://developers.notion.com/reference/webhooks)
- [Webhook Actions (database automations)](https://www.notion.com/help/webhook-actions)
- [@notionhq/client on npm](https://www.npmjs.com/package/@notionhq/client)
- [Rate limits](https://developers.notion.com/reference/request-limits)
## Next Steps
For tuning poll intervals and managing API usage, see `notion-rate-limits` and `notion-performance-tuning`.
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!