SOP 007: Setup Webhooks
Fresh Last Updated: January 2025| Field | Value |
|---|---|
| SOP ID | SOP-007 |
| Version | 1.0 |
| Status | Active |
| Difficulty | Advanced |
Purpose
Configure Notion webhooks to receive real-time notifications when pages or databases change, enabling event-driven integrations.
Prerequisites
- [ ] Internal integration created (SOP 001)
- [ ] HTTPS endpoint accessible from the internet
- [ ] Server capable of handling POST requests
- [ ] Integration has access to monitored pages
Webhook Flow
Step-by-Step Instructions
Step 1: Create Webhook Endpoint
Set up an Express server to receive webhook events:
javascript
const express = require('express');
const crypto = require('crypto');
require('dotenv').config();
const app = express();
// Important: Use raw body for signature verification
app.use('/webhook', express.raw({ type: 'application/json' }));
app.use(express.json());
const WEBHOOK_SECRET = process.env.NOTION_WEBHOOK_SECRET;
// Verify webhook signature
function verifySignature(payload, signature, timestamp) {
const signingString = `${timestamp}.${payload}`;
const expectedSignature = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(signingString)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
}
// Webhook endpoint
app.post('/webhook', (req, res) => {
const signature = req.headers['x-notion-signature'];
const timestamp = req.headers['x-notion-timestamp'];
const payload = req.body.toString();
// Verify signature
if (!verifySignature(payload, signature, timestamp)) {
console.error('Invalid webhook signature');
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(payload);
// Handle verification challenge
if (event.type === 'verification') {
console.log('Webhook verification received');
return res.json({ challenge: event.challenge });
}
// Process event asynchronously
processEvent(event).catch(console.error);
// Respond immediately
res.status(200).send('OK');
});
async function processEvent(event) {
console.log('Received event:', event.type);
console.log('Event data:', JSON.stringify(event, null, 2));
switch (event.type) {
case 'page.created':
await handlePageCreated(event);
break;
case 'page.updated':
await handlePageUpdated(event);
break;
case 'page.deleted':
await handlePageDeleted(event);
break;
case 'database.updated':
await handleDatabaseUpdated(event);
break;
default:
console.log('Unknown event type:', event.type);
}
}
async function handlePageCreated(event) {
const { page_id, parent } = event.data;
console.log(`New page created: ${page_id}`);
// Add your logic here
}
async function handlePageUpdated(event) {
const { page_id, properties_changed } = event.data;
console.log(`Page updated: ${page_id}`);
console.log('Changed properties:', properties_changed);
// Add your logic here
}
async function handlePageDeleted(event) {
const { page_id } = event.data;
console.log(`Page deleted: ${page_id}`);
// Add your logic here
}
async function handleDatabaseUpdated(event) {
const { database_id } = event.data;
console.log(`Database updated: ${database_id}`);
// Add your logic here
}
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Webhook server running on port ${PORT}`);
});Step 2: Expose Local Server (Development)
For local development, use ngrok to expose your server:
bash
# Install ngrok
npm install -g ngrok
# Start your server
node server.js
# In another terminal, expose port 3000
ngrok http 3000Copy the HTTPS URL (e.g., https://abc123.ngrok.io) for webhook registration.
Step 3: Register Webhook with Notion API
javascript
const { Client } = require('@notionhq/client');
const notion = new Client({ auth: process.env.NOTION_API_KEY });
async function createWebhook(url) {
const response = await notion.webhooks.create({
url: url,
events: [
'page.created',
'page.updated',
'page.deleted',
'database.updated'
]
});
console.log('Webhook created:', response.id);
console.log('Status:', response.status);
// Store the webhook ID and secret
return response;
}
// Usage
createWebhook('https://your-domain.com/webhook');Step 4: Event Types Reference
| Event | Trigger | Data Included |
|---|---|---|
page.created | New page added | page_id, parent, properties |
page.updated | Page properties changed | page_id, properties_changed |
page.deleted | Page moved to trash | page_id |
database.updated | Database schema changed | database_id, changes |
Step 5: Webhook Management
javascript
// List all webhooks
async function listWebhooks() {
const response = await notion.webhooks.list({});
console.log('Active webhooks:');
for (const webhook of response.results) {
console.log(`- ${webhook.id}: ${webhook.url} (${webhook.status})`);
}
return response.results;
}
// Get webhook details
async function getWebhook(webhookId) {
const webhook = await notion.webhooks.retrieve({
webhook_id: webhookId
});
return webhook;
}
// Update webhook
async function updateWebhook(webhookId, updates) {
const response = await notion.webhooks.update({
webhook_id: webhookId,
...updates
});
return response;
}
// Delete webhook
async function deleteWebhook(webhookId) {
await notion.webhooks.delete({
webhook_id: webhookId
});
console.log('Webhook deleted:', webhookId);
}Step 6: Production Considerations
javascript
const express = require('express');
const crypto = require('crypto');
const { createClient } = require('redis');
const app = express();
app.use('/webhook', express.raw({ type: 'application/json' }));
// Redis for deduplication
const redis = createClient({ url: process.env.REDIS_URL });
redis.connect();
// Event deduplication
async function isDuplicate(eventId) {
const key = `webhook:event:${eventId}`;
const exists = await redis.get(key);
if (exists) return true;
// Store with 24h expiry
await redis.setEx(key, 86400, '1');
return false;
}
// Retry queue for failed processing
const eventQueue = [];
async function queueEvent(event) {
eventQueue.push({
event,
attempts: 0,
nextRetry: Date.now()
});
}
// Process queue with exponential backoff
setInterval(async () => {
const now = Date.now();
for (let i = eventQueue.length - 1; i >= 0; i--) {
const item = eventQueue[i];
if (item.nextRetry > now) continue;
try {
await processEvent(item.event);
eventQueue.splice(i, 1);
} catch (error) {
item.attempts++;
if (item.attempts >= 5) {
console.error('Event failed permanently:', item.event.id);
eventQueue.splice(i, 1);
// Send to dead letter queue or alert
} else {
// Exponential backoff: 1s, 2s, 4s, 8s, 16s
item.nextRetry = now + (Math.pow(2, item.attempts) * 1000);
}
}
}
}, 1000);
app.post('/webhook', async (req, res) => {
const signature = req.headers['x-notion-signature'];
const timestamp = req.headers['x-notion-timestamp'];
const payload = req.body.toString();
// Verify signature
if (!verifySignature(payload, signature, timestamp)) {
return res.status(401).send('Invalid signature');
}
// Check timestamp freshness (prevent replay attacks)
const eventTime = parseInt(timestamp);
const now = Date.now();
if (Math.abs(now - eventTime) > 300000) { // 5 minutes
return res.status(401).send('Stale timestamp');
}
const event = JSON.parse(payload);
// Handle verification
if (event.type === 'verification') {
return res.json({ challenge: event.challenge });
}
// Check for duplicate
if (await isDuplicate(event.id)) {
return res.status(200).send('Duplicate');
}
// Queue for processing
await queueEvent(event);
res.status(200).send('OK');
});Step 7: Alternative - Polling Pattern
If webhooks aren't suitable, use polling:
javascript
class NotionPoller {
constructor(notion, databaseId, interval = 60000) {
this.notion = notion;
this.databaseId = databaseId;
this.interval = interval;
this.lastChecked = new Date().toISOString();
this.handlers = {
created: [],
updated: []
};
}
on(event, handler) {
this.handlers[event].push(handler);
}
async poll() {
const response = await this.notion.databases.query({
database_id: this.databaseId,
filter: {
timestamp: 'last_edited_time',
last_edited_time: {
after: this.lastChecked
}
},
sorts: [
{ timestamp: 'last_edited_time', direction: 'descending' }
]
});
const checkTime = this.lastChecked;
this.lastChecked = new Date().toISOString();
for (const page of response.results) {
const createdTime = new Date(page.created_time);
const isNew = createdTime > new Date(checkTime);
if (isNew) {
this.handlers.created.forEach(h => h(page));
} else {
this.handlers.updated.forEach(h => h(page));
}
}
}
start() {
this.poll(); // Initial poll
this.timer = setInterval(() => this.poll(), this.interval);
console.log(`Polling every ${this.interval / 1000}s`);
}
stop() {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}
}
// Usage
const poller = new NotionPoller(notion, databaseId, 30000);
poller.on('created', (page) => {
console.log('New page:', page.id);
});
poller.on('updated', (page) => {
console.log('Updated page:', page.id);
});
poller.start();Complete Webhook Server
javascript
// server.js
const express = require('express');
const crypto = require('crypto');
const { Client } = require('@notionhq/client');
require('dotenv').config();
const app = express();
const notion = new Client({ auth: process.env.NOTION_API_KEY });
app.use('/webhook', express.raw({ type: 'application/json' }));
app.use(express.json());
function verifySignature(payload, signature, timestamp) {
const signingString = `${timestamp}.${payload}`;
const expectedSignature = crypto
.createHmac('sha256', process.env.NOTION_WEBHOOK_SECRET)
.update(signingString)
.digest('hex');
try {
return crypto.timingSafeEqual(
Buffer.from(signature || ''),
Buffer.from(expectedSignature)
);
} catch {
return false;
}
}
app.post('/webhook', async (req, res) => {
const signature = req.headers['x-notion-signature'];
const timestamp = req.headers['x-notion-timestamp'];
const payload = req.body.toString();
if (!verifySignature(payload, signature, timestamp)) {
return res.status(401).json({ error: 'Invalid signature' });
}
const event = JSON.parse(payload);
if (event.type === 'verification') {
return res.json({ challenge: event.challenge });
}
// Log event
console.log(`[${new Date().toISOString()}] ${event.type}:`, event.data);
// Process based on type
try {
switch (event.type) {
case 'page.created':
// Fetch full page details
const newPage = await notion.pages.retrieve({
page_id: event.data.page_id
});
console.log('New page details:', newPage);
break;
case 'page.updated':
// React to property changes
if (event.data.properties_changed?.includes('Status')) {
console.log('Status changed on page:', event.data.page_id);
}
break;
}
} catch (error) {
console.error('Error processing event:', error);
}
res.status(200).json({ received: true });
});
// Health check endpoint
app.get('/health', (req, res) => {
res.json({ status: 'healthy', timestamp: new Date().toISOString() });
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Webhook server running on port ${PORT}`);
});Verification Checklist
- [ ] HTTPS endpoint accessible
- [ ] Signature verification working
- [ ] Verification challenge handled
- [ ] Events received and logged
- [ ] Error handling in place
- [ ] Response within 30 seconds
Troubleshooting
| Issue | Cause | Solution |
|---|---|---|
| Verification fails | Wrong secret or missing | Check webhook secret |
| No events received | Endpoint unreachable | Verify URL is public HTTPS |
| Timeout errors | Processing too slow | Respond immediately, process async |
| Duplicate events | No deduplication | Use event ID for deduplication |
Debug Tips
- Log all headers - Check if signature headers are present
- Test locally with ngrok - Expose local server for testing
- Check webhook status - Use list API to verify webhook is active
- Monitor response times - Must respond within 30 seconds