Back to skills
SKILL.md
Notion Webhooks Events
ASecurity'Build change detection and event handling for Notion workspaces using
- 2 stars
- 0 votes
- 0 copies
- 0 views
- Added November 11, 2026
Works with
Security analysis
92/100- Installs packages at runtime which could introduce malicious dependencies
Pro scans all 2 files and shows the line behind each finding
npx -y skills add pauldx/claudifying --skill notion-webhooks-events --agent claude-codeAre you the author of Notion Webhooks Events?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/pauldx-notion-webhooks-events)---
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
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`.
Files in this skill
- SKILL.md
- references/scheduled-polling-and-examples.md
Attribution
Related skills
View all in tools →Comments
Loading comments…
Newsletter
Get the weekly list of safe new skills, and the ones we pulled.