Notion Workspace Integration for ChatGPT Apps

Notion has become the go-to workspace for millions of teams and individuals to organize knowledge, manage projects, and collaborate. Integrating Notion with ChatGPT apps unlocks powerful capabilities: conversational knowledge base search, automated content extraction, intelligent database queries, and seamless workflow automation.

This comprehensive guide walks you through building a production-ready Notion workspace integration for ChatGPT apps, covering the Pages API, database operations, block manipulation, semantic search, embeddings synchronization, OAuth authentication, and webhook handling.

Table of Contents

  1. Why Integrate Notion with ChatGPT Apps
  2. Notion API Overview
  3. Setting Up OAuth Authentication
  4. Building the Notion Client
  5. Database Query Handler
  6. Semantic Search Engine
  7. Embeddings Synchronization
  8. Content Extraction Pipeline
  9. Webhook Integration
  10. Production Deployment Considerations

Why Integrate Notion with ChatGPT Apps

Notion workspace integrations transform how users interact with their knowledge bases:

  • Conversational Search: Users can ask natural language questions and get relevant Notion content instantly
  • Contextual Retrieval: ChatGPT understands context and surfaces the most relevant pages, databases, and blocks
  • Automated Knowledge Management: Sync Notion content to vector databases for semantic search capabilities
  • Workflow Automation: Create, update, and manage Notion content directly from ChatGPT conversations
  • Real-time Updates: Webhooks ensure your ChatGPT app always has the latest Notion data
  • Team Collaboration: Enable teams to interact with shared workspaces through conversational interfaces

According to Notion's API documentation, the platform offers comprehensive APIs for pages, databases, blocks, search, and user management, making it an ideal integration target for ChatGPT apps.

Notion API Overview

The Notion API provides several key capabilities:

  • Pages API: Create, retrieve, update, and archive pages
  • Databases API: Query databases, create entries, and manage properties
  • Blocks API: Read and manipulate block content (paragraphs, headings, lists, etc.)
  • Search API: Full-text search across workspace content
  • Users API: Retrieve user information and workspace members
  • Comments API: Read and create comments on pages and blocks

For ChatGPT app integrations, the most valuable endpoints are search, database queries, and block content retrieval for knowledge extraction.

Setting Up OAuth Authentication

Notion uses OAuth 2.0 for secure authentication. Here's a complete OAuth implementation:

// notion-oauth-handler.js (95 lines)
import axios from 'axios';

class NotionOAuthHandler {
  constructor(clientId, clientSecret, redirectUri) {
    this.clientId = clientId;
    this.clientSecret = clientSecret;
    this.redirectUri = redirectUri;
    this.authorizationUrl = 'https://api.notion.com/v1/oauth/authorize';
    this.tokenUrl = 'https://api.notion.com/v1/oauth/token';
  }

  /**
   * Generate OAuth authorization URL
   * @param {string} state - CSRF protection state parameter
   * @returns {string} Authorization URL
   */
  getAuthorizationUrl(state) {
    const params = new URLSearchParams({
      client_id: this.clientId,
      redirect_uri: this.redirectUri,
      response_type: 'code',
      owner: 'user',
      state: state
    });

    return `${this.authorizationUrl}?${params.toString()}`;
  }

  /**
   * Exchange authorization code for access token
   * @param {string} code - Authorization code from Notion
   * @returns {Promise<Object>} Token response with access_token
   */
  async exchangeCodeForToken(code) {
    try {
      const auth = Buffer.from(`${this.clientId}:${this.clientSecret}`).toString('base64');

      const response = await axios.post(
        this.tokenUrl,
        {
          grant_type: 'authorization_code',
          code: code,
          redirect_uri: this.redirectUri
        },
        {
          headers: {
            'Authorization': `Basic ${auth}`,
            'Content-Type': 'application/json'
          }
        }
      );

      return {
        accessToken: response.data.access_token,
        botId: response.data.bot_id,
        workspaceId: response.data.workspace_id,
        workspaceName: response.data.workspace_name,
        workspaceIcon: response.data.workspace_icon,
        owner: response.data.owner
      };
    } catch (error) {
      throw new Error(`OAuth token exchange failed: ${error.response?.data?.error || error.message}`);
    }
  }

  /**
   * Validate access token by fetching bot user
   * @param {string} accessToken - Notion access token
   * @returns {Promise<boolean>} Token validity
   */
  async validateToken(accessToken) {
    try {
      const response = await axios.get('https://api.notion.com/v1/users/me', {
        headers: {
          'Authorization': `Bearer ${accessToken}`,
          'Notion-Version': '2022-06-28'
        }
      });

      return response.status === 200;
    } catch (error) {
      return false;
    }
  }

  /**
   * Revoke access token (logout)
   * Note: Notion doesn't have a revoke endpoint; delete token from your database
   * @param {string} accessToken - Token to revoke
   */
  async revokeToken(accessToken) {
    // Notion API doesn't provide a revoke endpoint
    // Implement token deletion in your database
    console.log('Token revocation: Remove from database');
    return true;
  }
}

export default NotionOAuthHandler;

Key OAuth Features:

  • CSRF protection with state parameter
  • Secure token exchange using Basic authentication
  • Token validation before use
  • Workspace metadata retrieval

Learn more about building ChatGPT apps with OAuth integrations.

Building the Notion Client

The Notion client handles all API interactions with proper error handling and rate limiting:

// notion-client.js (120 lines)
import axios from 'axios';

class NotionClient {
  constructor(accessToken, options = {}) {
    this.accessToken = accessToken;
    this.baseUrl = 'https://api.notion.com/v1';
    this.version = options.version || '2022-06-28';
    this.rateLimit = {
      maxRequests: 3,
      perMilliseconds: 1000,
      queue: []
    };
  }

  /**
   * Make authenticated request to Notion API with rate limiting
   * @private
   */
  async _makeRequest(method, endpoint, data = null) {
    await this._waitForRateLimit();

    try {
      const config = {
        method: method,
        url: `${this.baseUrl}${endpoint}`,
        headers: {
          'Authorization': `Bearer ${this.accessToken}`,
          'Notion-Version': this.version,
          'Content-Type': 'application/json'
        }
      };

      if (data) {
        config.data = data;
      }

      const response = await axios(config);
      return response.data;
    } catch (error) {
      this._handleError(error);
    }
  }

  /**
   * Rate limiting implementation
   * @private
   */
  async _waitForRateLimit() {
    const now = Date.now();
    this.rateLimit.queue = this.rateLimit.queue.filter(
      timestamp => now - timestamp < this.rateLimit.perMilliseconds
    );

    if (this.rateLimit.queue.length >= this.rateLimit.maxRequests) {
      const oldestRequest = this.rateLimit.queue[0];
      const waitTime = this.rateLimit.perMilliseconds - (now - oldestRequest);
      await new Promise(resolve => setTimeout(resolve, waitTime));
    }

    this.rateLimit.queue.push(now);
  }

  /**
   * Error handling with retry logic
   * @private
   */
  _handleError(error) {
    const status = error.response?.status;
    const message = error.response?.data?.message || error.message;

    if (status === 429) {
      throw new Error('Rate limit exceeded. Please try again later.');
    } else if (status === 401) {
      throw new Error('Invalid or expired access token.');
    } else if (status === 404) {
      throw new Error('Resource not found.');
    } else if (status === 400) {
      throw new Error(`Bad request: ${message}`);
    } else {
      throw new Error(`Notion API error: ${message}`);
    }
  }

  /**
   * Retrieve a page by ID
   */
  async getPage(pageId) {
    return await this._makeRequest('GET', `/pages/${pageId}`);
  }

  /**
   * Retrieve page blocks (content)
   */
  async getPageBlocks(pageId, cursor = null) {
    const endpoint = cursor
      ? `/blocks/${pageId}/children?start_cursor=${cursor}`
      : `/blocks/${pageId}/children`;
    return await this._makeRequest('GET', endpoint);
  }

  /**
   * Search workspace content
   */
  async search(query, options = {}) {
    const payload = {
      query: query,
      filter: options.filter || {},
      sort: options.sort || {}
    };

    return await this._makeRequest('POST', '/search', payload);
  }

  /**
   * Query a database
   */
  async queryDatabase(databaseId, filter = {}, sorts = []) {
    const payload = { filter, sorts };
    return await this._makeRequest('POST', `/databases/${databaseId}/query`, payload);
  }

  /**
   * Create a page in a database
   */
  async createPage(parent, properties, children = []) {
    const payload = { parent, properties };
    if (children.length > 0) {
      payload.children = children;
    }
    return await this._makeRequest('POST', '/pages', payload);
  }

  /**
   * Update page properties
   */
  async updatePage(pageId, properties) {
    return await this._makeRequest('PATCH', `/pages/${pageId}`, { properties });
  }
}

export default NotionClient;

This client provides:

  • Automatic rate limiting (3 requests per second)
  • Comprehensive error handling
  • Support for pagination with cursors
  • All essential CRUD operations

Explore more about API integration patterns for ChatGPT apps.

Database Query Handler

Database queries are central to most Notion integrations. Here's a sophisticated handler:

// notion-database-handler.js (130 lines)
class NotionDatabaseHandler {
  constructor(notionClient) {
    this.client = notionClient;
  }

  /**
   * Build Notion filter from natural language query
   * @param {string} query - Natural language query
   * @param {Object} schema - Database schema metadata
   * @returns {Object} Notion filter object
   */
  buildFilter(query, schema) {
    const filters = [];
    const lowerQuery = query.toLowerCase();

    // Detect property filters from query
    for (const [propertyName, propertyConfig] of Object.entries(schema)) {
      const propertyType = propertyConfig.type;

      if (propertyType === 'title' || propertyType === 'rich_text') {
        // Text search
        if (lowerQuery.includes(propertyName.toLowerCase())) {
          filters.push({
            property: propertyName,
            rich_text: { contains: query }
          });
        }
      } else if (propertyType === 'select' || propertyType === 'status') {
        // Select option matching
        const options = propertyConfig.options || [];
        for (const option of options) {
          if (lowerQuery.includes(option.name.toLowerCase())) {
            filters.push({
              property: propertyName,
              select: { equals: option.name }
            });
          }
        }
      } else if (propertyType === 'checkbox') {
        // Boolean matching
        if (lowerQuery.includes('completed') || lowerQuery.includes('done')) {
          filters.push({
            property: propertyName,
            checkbox: { equals: true }
          });
        }
      } else if (propertyType === 'date') {
        // Date range detection
        if (lowerQuery.includes('this week')) {
          const startOfWeek = new Date();
          startOfWeek.setDate(startOfWeek.getDate() - startOfWeek.getDay());
          filters.push({
            property: propertyName,
            date: { on_or_after: startOfWeek.toISOString().split('T')[0] }
          });
        }
      }
    }

    // Combine filters with OR logic if multiple matches
    if (filters.length === 0) {
      return {};
    } else if (filters.length === 1) {
      return filters[0];
    } else {
      return { or: filters };
    }
  }

  /**
   * Build sort order from natural language
   * @param {string} sortQuery - Sort instruction (e.g., "newest first")
   * @param {Object} schema - Database schema
   * @returns {Array} Notion sorts array
   */
  buildSorts(sortQuery, schema) {
    const sorts = [];
    const lowerQuery = sortQuery.toLowerCase();

    // Detect date sorting
    if (lowerQuery.includes('newest') || lowerQuery.includes('recent')) {
      const dateProperty = Object.entries(schema).find(([_, config]) =>
        config.type === 'created_time' || config.type === 'last_edited_time'
      );
      if (dateProperty) {
        sorts.push({
          property: dateProperty[0],
          direction: 'descending'
        });
      }
    } else if (lowerQuery.includes('oldest')) {
      const dateProperty = Object.entries(schema).find(([_, config]) =>
        config.type === 'created_time'
      );
      if (dateProperty) {
        sorts.push({
          property: dateProperty[0],
          direction: 'ascending'
        });
      }
    }

    // Alphabetical sorting
    if (lowerQuery.includes('alphabetical') || lowerQuery.includes('a-z')) {
      const titleProperty = Object.entries(schema).find(([_, config]) =>
        config.type === 'title'
      );
      if (titleProperty) {
        sorts.push({
          property: titleProperty[0],
          direction: 'ascending'
        });
      }
    }

    return sorts;
  }

  /**
   * Query database with natural language
   * @param {string} databaseId - Database ID
   * @param {string} query - Natural language query
   * @param {Object} schema - Database schema
   * @returns {Promise<Array>} Query results
   */
  async queryWithNaturalLanguage(databaseId, query, schema) {
    const filter = this.buildFilter(query, schema);
    const sorts = this.buildSorts(query, schema);

    const results = await this.client.queryDatabase(databaseId, filter, sorts);
    return this.formatResults(results.results, schema);
  }

  /**
   * Format database results for ChatGPT consumption
   * @param {Array} results - Raw Notion results
   * @param {Object} schema - Database schema
   * @returns {Array} Formatted results
   */
  formatResults(results, schema) {
    return results.map(page => {
      const formatted = {
        id: page.id,
        url: page.url,
        properties: {}
      };

      for (const [propertyName, propertyData] of Object.entries(page.properties)) {
        formatted.properties[propertyName] = this.extractPropertyValue(propertyData);
      }

      return formatted;
    });
  }

  /**
   * Extract property value based on type
   * @private
   */
  extractPropertyValue(propertyData) {
    const type = propertyData.type;

    switch (type) {
      case 'title':
        return propertyData.title.map(t => t.plain_text).join('');
      case 'rich_text':
        return propertyData.rich_text.map(t => t.plain_text).join('');
      case 'number':
        return propertyData.number;
      case 'select':
        return propertyData.select?.name || null;
      case 'multi_select':
        return propertyData.multi_select.map(s => s.name);
      case 'date':
        return propertyData.date?.start || null;
      case 'checkbox':
        return propertyData.checkbox;
      case 'url':
        return propertyData.url;
      case 'email':
        return propertyData.email;
      default:
        return null;
    }
  }
}

export default NotionDatabaseHandler;

Database Handler Features:

  • Natural language to Notion filter conversion
  • Intelligent sort detection
  • Property value extraction for all Notion types
  • Clean data formatting for ChatGPT responses

For more on database integrations, see Building Data-Driven ChatGPT Apps.

Semantic Search Engine

Semantic search enables users to find content by meaning, not just keywords:

// notion-search-engine.js (110 lines)
import { OpenAI } from 'openai';

class NotionSearchEngine {
  constructor(notionClient, openaiApiKey) {
    this.client = notionClient;
    this.openai = new OpenAI({ apiKey: openaiApiKey });
    this.embeddingModel = 'text-embedding-3-small';
  }

  /**
   * Perform semantic search across workspace
   * @param {string} query - User's search query
   * @param {Object} options - Search options
   * @returns {Promise<Array>} Ranked search results
   */
  async semanticSearch(query, options = {}) {
    const limit = options.limit || 10;
    const filter = options.filter || { property: 'object', value: 'page' };

    // Step 1: Get query embedding
    const queryEmbedding = await this.getEmbedding(query);

    // Step 2: Perform Notion search to get candidate pages
    const searchResults = await this.client.search(query, { filter });

    if (searchResults.results.length === 0) {
      return [];
    }

    // Step 3: Get content and embeddings for each result
    const rankedResults = [];

    for (const page of searchResults.results) {
      try {
        const content = await this.extractPageContent(page.id);
        const contentEmbedding = await this.getEmbedding(content);

        // Calculate cosine similarity
        const similarity = this.cosineSimilarity(queryEmbedding, contentEmbedding);

        rankedResults.push({
          page: page,
          content: content.substring(0, 500), // First 500 chars
          similarity: similarity,
          url: page.url
        });
      } catch (error) {
        console.error(`Failed to process page ${page.id}:`, error.message);
      }
    }

    // Step 4: Sort by similarity and return top results
    rankedResults.sort((a, b) => b.similarity - a.similarity);
    return rankedResults.slice(0, limit);
  }

  /**
   * Extract plain text content from page
   * @param {string} pageId - Page ID
   * @returns {Promise<string>} Page content
   */
  async extractPageContent(pageId) {
    const blocks = await this.client.getPageBlocks(pageId);
    let content = '';

    for (const block of blocks.results) {
      content += this.extractBlockText(block) + '\n';
    }

    return content.trim();
  }

  /**
   * Extract text from block based on type
   * @private
   */
  extractBlockText(block) {
    const type = block.type;
    const blockData = block[type];

    if (!blockData || !blockData.rich_text) {
      return '';
    }

    return blockData.rich_text.map(t => t.plain_text).join('');
  }

  /**
   * Get embedding vector for text
   * @param {string} text - Text to embed
   * @returns {Promise<Array<number>>} Embedding vector
   */
  async getEmbedding(text) {
    const response = await this.openai.embeddings.create({
      model: this.embeddingModel,
      input: text.substring(0, 8000) // Token limit
    });

    return response.data[0].embedding;
  }

  /**
   * Calculate cosine similarity between two vectors
   * @param {Array<number>} vecA - First vector
   * @param {Array<number>} vecB - Second vector
   * @returns {number} Similarity score (0-1)
   */
  cosineSimilarity(vecA, vecB) {
    let dotProduct = 0;
    let normA = 0;
    let normB = 0;

    for (let i = 0; i < vecA.length; i++) {
      dotProduct += vecA[i] * vecB[i];
      normA += vecA[i] * vecA[i];
      normB += vecB[i] * vecB[i];
    }

    return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
  }

  /**
   * Hybrid search (keyword + semantic)
   * @param {string} query - Search query
   * @param {Object} options - Search options
   * @returns {Promise<Array>} Combined results
   */
  async hybridSearch(query, options = {}) {
    const semanticWeight = options.semanticWeight || 0.7;
    const keywordWeight = 1 - semanticWeight;

    // Get both semantic and keyword results
    const [semanticResults, keywordResults] = await Promise.all([
      this.semanticSearch(query, options),
      this.client.search(query, { filter: options.filter })
    ]);

    // Merge and re-rank results
    const mergedScores = new Map();

    semanticResults.forEach((result, index) => {
      const score = (1 - index / semanticResults.length) * semanticWeight;
      mergedScores.set(result.page.id, { ...result, score });
    });

    keywordResults.results.forEach((page, index) => {
      const keywordScore = (1 - index / keywordResults.results.length) * keywordWeight;
      const existing = mergedScores.get(page.id);

      if (existing) {
        existing.score += keywordScore;
      } else {
        mergedScores.set(page.id, { page, score: keywordScore, content: '' });
      }
    });

    // Sort by combined score
    return Array.from(mergedScores.values())
      .sort((a, b) => b.score - a.score)
      .slice(0, options.limit || 10);
  }
}

export default NotionSearchEngine;

Search Engine Capabilities:

  • Semantic search using OpenAI embeddings
  • Hybrid search combining keyword and semantic matching
  • Cosine similarity ranking
  • Content extraction from all block types

Learn more about semantic search in ChatGPT apps.

Embeddings Synchronization

Keep your vector database in sync with Notion content for fast semantic search:

// notion-embeddings-syncer.js (100 lines)
import { Pinecone } from '@pinecone-database/pinecone';

class NotionEmbeddingsSyncer {
  constructor(notionClient, searchEngine, pineconeApiKey, indexName) {
    this.notionClient = notionClient;
    this.searchEngine = searchEngine;
    this.pinecone = new Pinecone({ apiKey: pineconeApiKey });
    this.indexName = indexName;
    this.batchSize = 100;
  }

  /**
   * Initialize Pinecone index
   */
  async initialize() {
    this.index = this.pinecone.index(this.indexName);
  }

  /**
   * Sync all workspace pages to vector database
   * @param {Object} options - Sync options
   * @returns {Promise<Object>} Sync statistics
   */
  async syncWorkspace(options = {}) {
    const startTime = Date.now();
    let processedPages = 0;
    let errorCount = 0;
    let hasMore = true;
    let cursor = null;

    while (hasMore) {
      try {
        // Fetch pages in batches
        const response = await this.notionClient.search('', {
          filter: { property: 'object', value: 'page' },
          page_size: this.batchSize,
          start_cursor: cursor
        });

        const vectors = [];

        // Process each page
        for (const page of response.results) {
          try {
            const vector = await this.createPageVector(page);
            vectors.push(vector);
            processedPages++;
          } catch (error) {
            console.error(`Failed to process page ${page.id}:`, error.message);
            errorCount++;
          }
        }

        // Upsert vectors to Pinecone
        if (vectors.length > 0) {
          await this.index.upsert(vectors);
        }

        // Check for more pages
        hasMore = response.has_more;
        cursor = response.next_cursor;

      } catch (error) {
        console.error('Batch processing error:', error.message);
        errorCount++;
        break;
      }
    }

    const duration = Date.now() - startTime;

    return {
      processedPages,
      errorCount,
      duration,
      pagesPerSecond: (processedPages / (duration / 1000)).toFixed(2)
    };
  }

  /**
   * Create vector representation of a page
   * @private
   */
  async createPageVector(page) {
    // Extract page content
    const content = await this.searchEngine.extractPageContent(page.id);

    // Get embedding
    const embedding = await this.searchEngine.getEmbedding(content);

    // Extract metadata
    const metadata = {
      pageId: page.id,
      url: page.url,
      title: this.extractTitle(page),
      createdTime: page.created_time,
      lastEditedTime: page.last_edited_time,
      contentPreview: content.substring(0, 500)
    };

    return {
      id: page.id,
      values: embedding,
      metadata: metadata
    };
  }

  /**
   * Extract page title
   * @private
   */
  extractTitle(page) {
    const titleProperty = Object.values(page.properties).find(
      prop => prop.type === 'title'
    );

    if (titleProperty && titleProperty.title.length > 0) {
      return titleProperty.title.map(t => t.plain_text).join('');
    }

    return 'Untitled';
  }

  /**
   * Sync single page (for webhook updates)
   * @param {string} pageId - Page ID to sync
   */
  async syncPage(pageId) {
    try {
      const page = await this.notionClient.getPage(pageId);
      const vector = await this.createPageVector(page);
      await this.index.upsert([vector]);
      return true;
    } catch (error) {
      console.error(`Failed to sync page ${pageId}:`, error.message);
      return false;
    }
  }

  /**
   * Delete page from vector database
   * @param {string} pageId - Page ID to delete
   */
  async deletePage(pageId) {
    try {
      await this.index.deleteOne(pageId);
      return true;
    } catch (error) {
      console.error(`Failed to delete page ${pageId}:`, error.message);
      return false;
    }
  }

  /**
   * Query vector database for similar content
   * @param {string} query - Search query
   * @param {number} topK - Number of results
   * @returns {Promise<Array>} Similar pages
   */
  async querySimilar(query, topK = 10) {
    const queryEmbedding = await this.searchEngine.getEmbedding(query);

    const results = await this.index.query({
      vector: queryEmbedding,
      topK: topK,
      includeMetadata: true
    });

    return results.matches.map(match => ({
      pageId: match.id,
      score: match.score,
      ...match.metadata
    }));
  }
}

export default NotionEmbeddingsSyncer;

Embeddings Syncer Features:

  • Full workspace synchronization to Pinecone
  • Incremental page updates via webhooks
  • Batch processing for performance
  • Metadata extraction for rich results

Discover vector database integration strategies.

Content Extraction Pipeline

Extract and structure Notion content for ChatGPT consumption:

// notion-content-extractor.js (80 lines)
class NotionContentExtractor {
  constructor(notionClient) {
    this.client = notionClient;
  }

  /**
   * Extract structured content from page
   * @param {string} pageId - Page ID
   * @returns {Promise<Object>} Structured content
   */
  async extractPageContent(pageId) {
    const [page, blocks] = await Promise.all([
      this.client.getPage(pageId),
      this.getAllBlocks(pageId)
    ]);

    return {
      metadata: this.extractMetadata(page),
      content: this.structureBlocks(blocks),
      rawText: this.extractPlainText(blocks)
    };
  }

  /**
   * Get all blocks with pagination
   * @private
   */
  async getAllBlocks(blockId) {
    let allBlocks = [];
    let hasMore = true;
    let cursor = null;

    while (hasMore) {
      const response = await this.client.getPageBlocks(blockId, cursor);
      allBlocks = allBlocks.concat(response.results);
      hasMore = response.has_more;
      cursor = response.next_cursor;
    }

    return allBlocks;
  }

  /**
   * Extract page metadata
   * @private
   */
  extractMetadata(page) {
    const metadata = {
      id: page.id,
      url: page.url,
      createdTime: page.created_time,
      lastEditedTime: page.last_edited_time,
      properties: {}
    };

    for (const [key, value] of Object.entries(page.properties)) {
      metadata.properties[key] = this.extractPropertyValue(value);
    }

    return metadata;
  }

  /**
   * Extract property value (reuse from database handler)
   * @private
   */
  extractPropertyValue(propertyData) {
    const type = propertyData.type;

    switch (type) {
      case 'title':
        return propertyData.title.map(t => t.plain_text).join('');
      case 'rich_text':
        return propertyData.rich_text.map(t => t.plain_text).join('');
      case 'number':
        return propertyData.number;
      case 'select':
        return propertyData.select?.name || null;
      case 'date':
        return propertyData.date?.start || null;
      case 'checkbox':
        return propertyData.checkbox;
      default:
        return null;
    }
  }

  /**
   * Structure blocks into hierarchical content
   * @private
   */
  structureBlocks(blocks) {
    return blocks.map(block => ({
      id: block.id,
      type: block.type,
      text: this.extractBlockText(block),
      hasChildren: block.has_children
    }));
  }

  /**
   * Extract text from block
   * @private
   */
  extractBlockText(block) {
    const type = block.type;
    const blockData = block[type];

    if (!blockData || !blockData.rich_text) {
      return '';
    }

    return blockData.rich_text.map(t => t.plain_text).join('');
  }

  /**
   * Extract plain text for embeddings
   * @private
   */
  extractPlainText(blocks) {
    return blocks.map(block => this.extractBlockText(block)).join('\n');
  }
}

export default NotionContentExtractor;

Webhook Integration

Real-time synchronization using Notion webhooks (requires Notion API v2023-10-31+):

// notion-webhook-handler.js (65 lines)
import crypto from 'crypto';

class NotionWebhookHandler {
  constructor(webhookSecret, embeddingsSyncer) {
    this.webhookSecret = webhookSecret;
    this.syncer = embeddingsSyncer;
  }

  /**
   * Verify webhook signature
   * @param {string} signature - X-Notion-Signature header
   * @param {string} body - Raw request body
   * @returns {boolean} Signature valid
   */
  verifySignature(signature, body) {
    const hmac = crypto.createHmac('sha256', this.webhookSecret);
    hmac.update(body);
    const expectedSignature = hmac.digest('hex');

    return crypto.timingSafeEqual(
      Buffer.from(signature),
      Buffer.from(expectedSignature)
    );
  }

  /**
   * Handle webhook event
   * @param {Object} event - Webhook event
   */
  async handleEvent(event) {
    const { type, page } = event;

    switch (type) {
      case 'page.created':
      case 'page.updated':
        await this.syncer.syncPage(page.id);
        console.log(`Synced page ${page.id} after ${type}`);
        break;

      case 'page.deleted':
        await this.syncer.deletePage(page.id);
        console.log(`Deleted page ${page.id} from vector database`);
        break;

      case 'database.created':
      case 'database.updated':
        // Handle database schema changes
        console.log(`Database ${event.database.id} ${type}`);
        break;

      default:
        console.log(`Unhandled event type: ${type}`);
    }
  }

  /**
   * Express middleware for webhook endpoint
   */
  middleware() {
    return async (req, res) => {
      const signature = req.headers['x-notion-signature'];
      const rawBody = JSON.stringify(req.body);

      if (!this.verifySignature(signature, rawBody)) {
        return res.status(401).json({ error: 'Invalid signature' });
      }

      try {
        await this.handleEvent(req.body);
        res.status(200).json({ success: true });
      } catch (error) {
        console.error('Webhook processing error:', error.message);
        res.status(500).json({ error: 'Processing failed' });
      }
    };
  }
}

export default NotionWebhookHandler;

For comprehensive webhook patterns, see Real-time Data Sync for ChatGPT Apps.

Production Deployment Considerations

Security Best Practices

  1. Token Storage: Store Notion access tokens encrypted in your database, never in client-side code
  2. Webhook Verification: Always verify webhook signatures to prevent spoofing
  3. Rate Limiting: Implement backoff strategies for 429 responses
  4. Scope Limitation: Request minimal OAuth scopes needed for your integration

Performance Optimization

  1. Caching: Cache frequently accessed pages and database schemas
  2. Batch Processing: Group API calls to minimize network overhead
  3. Lazy Loading: Load block content only when needed
  4. Vector Database: Use Pinecone or similar for fast semantic search vs. real-time API calls

Error Handling

  1. Retry Logic: Implement exponential backoff for transient errors
  2. Graceful Degradation: Fall back to keyword search if semantic search fails
  3. User Feedback: Provide clear error messages when Notion is unavailable
  4. Monitoring: Track API usage, error rates, and sync latency

Compliance

According to Notion's API Terms, ensure you:

  • Display Notion's logo and branding when showing Notion content
  • Respect user permissions and workspace access controls
  • Implement proper data retention and deletion policies
  • Provide clear privacy disclosures about Notion data usage

Complete Integration Example

Here's how to combine all components into a production ChatGPT app:

// app.js - Complete Notion integration
import NotionOAuthHandler from './notion-oauth-handler.js';
import NotionClient from './notion-client.js';
import NotionDatabaseHandler from './notion-database-handler.js';
import NotionSearchEngine from './notion-search-engine.js';
import NotionEmbeddingsSyncer from './notion-embeddings-syncer.js';
import NotionWebhookHandler from './notion-webhook-handler.js';

class NotionChatGPTIntegration {
  constructor(config) {
    // Initialize OAuth
    this.oauth = new NotionOAuthHandler(
      config.notion.clientId,
      config.notion.clientSecret,
      config.notion.redirectUri
    );

    // Store config
    this.config = config;
  }

  /**
   * Initialize user session after OAuth
   */
  async initializeUserSession(accessToken) {
    // Create Notion client
    const client = new NotionClient(accessToken);

    // Initialize components
    const dbHandler = new NotionDatabaseHandler(client);
    const searchEngine = new NotionSearchEngine(client, this.config.openai.apiKey);
    const syncer = new NotionEmbeddingsSyncer(
      client,
      searchEngine,
      this.config.pinecone.apiKey,
      this.config.pinecone.indexName
    );

    await syncer.initialize();

    return {
      client,
      dbHandler,
      searchEngine,
      syncer
    };
  }

  /**
   * MCP tool: Search Notion workspace
   */
  async searchWorkspace(accessToken, query, limit = 10) {
    const session = await this.initializeUserSession(accessToken);
    const results = await session.searchEngine.semanticSearch(query, { limit });

    return results.map(r => ({
      title: r.page.properties.title?.title[0]?.plain_text || 'Untitled',
      url: r.page.url,
      snippet: r.content,
      relevance: r.similarity
    }));
  }

  /**
   * MCP tool: Query Notion database
   */
  async queryDatabase(accessToken, databaseId, query, schema) {
    const session = await this.initializeUserSession(accessToken);
    return await session.dbHandler.queryWithNaturalLanguage(databaseId, query, schema);
  }
}

export default NotionChatGPTIntegration;

Next Steps

Now that you have a complete Notion workspace integration, explore these advanced topics:

Build Your Notion ChatGPT App Today

Ready to bring conversational AI to your Notion workspace? MakeAIHQ provides a no-code platform to build, deploy, and manage ChatGPT apps with pre-built Notion integration templates.

Start your free trial and deploy your first Notion-powered ChatGPT app in under 48 hours—no coding required.

Get Started Free | View Notion Templates | Read Documentation


About the Author: The MakeAIHQ team specializes in ChatGPT app development and enterprise integrations. We help businesses leverage conversational AI to transform knowledge management and workflow automation.

Published: December 25, 2026 | Updated: December 25, 2026 | Reading Time: 12 minutes

Related Topics: Notion API, Knowledge Base ChatGPT Apps, Workspace Automation, Semantic Search, OAuth Authentication