Skip to content

SOP 007: Setup Webhooks

Fresh Last Updated: January 2025
FieldValue
SOP IDSOP-007
Version1.0
StatusActive
DifficultyAdvanced

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 3000

Copy 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

EventTriggerData Included
page.createdNew page addedpage_id, parent, properties
page.updatedPage properties changedpage_id, properties_changed
page.deletedPage moved to trashpage_id
database.updatedDatabase schema changeddatabase_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

IssueCauseSolution
Verification failsWrong secret or missingCheck webhook secret
No events receivedEndpoint unreachableVerify URL is public HTTPS
Timeout errorsProcessing too slowRespond immediately, process async
Duplicate eventsNo deduplicationUse event ID for deduplication

Debug Tips

  1. Log all headers - Check if signature headers are present
  2. Test locally with ngrok - Expose local server for testing
  3. Check webhook status - Use list API to verify webhook is active
  4. Monitor response times - Must respond within 30 seconds

See Also

Built with VitePress | Powered by Claude AI