Notion API Integration for ChatGPT Apps: Workspace Automation

Building a Notion integration for your ChatGPT app unlocks powerful workspace automation capabilities. Users can query databases, create pages, manage tasks, and collaborate—all through natural language conversations. This comprehensive guide shows you how to build production-ready Notion integrations with TypeScript, covering OAuth flows, database operations, block management, and real-time collaboration.

Notion's API provides access to workspaces, databases, pages, and blocks. When integrated with ChatGPT apps, users can say "Create a new project in Notion" or "Find all tasks due this week" and your app handles the complexity. You'll learn how to implement secure OAuth authentication, query databases with filters and sorts, create rich content with blocks, manage permissions, handle rate limits, and cache responses for optimal performance.

Whether you're building a project management assistant, a knowledge base curator, or a task automation tool, this guide provides the complete foundation. We'll cover everything from initial OAuth setup through production deployment, with 10 production-ready TypeScript code examples you can adapt for your ChatGPT app. Let's dive into building seamless Notion integrations that transform how users interact with their workspaces.

Notion OAuth & API Setup

Notion uses OAuth 2.0 for secure authentication. You'll create an integration in the Notion developer portal, configure redirect URIs, and implement the OAuth flow. Once authorized, you'll receive an access token that grants your ChatGPT app permission to read and modify workspace content based on the capabilities you request.

Key OAuth Concepts:

  • Integration Types: Internal (single workspace) vs Public (multi-workspace)
  • Capabilities: Read content, update content, insert content, read comments, insert comments, read users, read user info
  • Content Capabilities: Workspace content, specific pages only, no user information
  • OAuth Flow: Authorization URL → User consent → Authorization code → Access token exchange
  • Token Management: Access tokens don't expire but can be revoked by users

Here's a complete OAuth handler implementation:

/**
 * Notion OAuth Handler
 * Handles complete OAuth 2.0 flow for Notion integrations
 */

import { Client } from '@notionhq/client';
import fetch from 'node-fetch';

interface NotionOAuthConfig {
  clientId: string;
  clientSecret: string;
  redirectUri: string;
}

interface NotionTokenResponse {
  access_token: string;
  bot_id: string;
  duplicated_template_id: string | null;
  owner: {
    type: 'user' | 'workspace';
    user?: {
      id: string;
      name: string;
      avatar_url: string | null;
      type: 'person' | 'bot';
      person?: {
        email: string;
      };
    };
    workspace?: {
      id: string;
      name: string;
      icon: string | null;
    };
  };
  workspace_id: string;
  workspace_name: string;
  workspace_icon: string | null;
}

class NotionOAuthHandler {
  private config: NotionOAuthConfig;

  constructor(config: NotionOAuthConfig) {
    this.config = config;
  }

  /**
   * Generate authorization URL for user consent
   */
  getAuthorizationUrl(state: string): string {
    const params = new URLSearchParams({
      client_id: this.config.clientId,
      redirect_uri: this.config.redirectUri,
      response_type: 'code',
      owner: 'user',
      state,
    });

    return `https://api.notion.com/v1/oauth/authorize?${params.toString()}`;
  }

  /**
   * Exchange authorization code for access token
   */
  async exchangeCodeForToken(code: string): Promise<NotionTokenResponse> {
    const auth = Buffer.from(
      `${this.config.clientId}:${this.config.clientSecret}`
    ).toString('base64');

    const response = await fetch('https://api.notion.com/v1/oauth/token', {
      method: 'POST',
      headers: {
        'Authorization': `Basic ${auth}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        grant_type: 'authorization_code',
        code,
        redirect_uri: this.config.redirectUri,
      }),
    });

    if (!response.ok) {
      const error = await response.text();
      throw new Error(`Token exchange failed: ${error}`);
    }

    return response.json() as Promise<NotionTokenResponse>;
  }

  /**
   * Create authenticated Notion client
   */
  createClient(accessToken: string): Client {
    return new Client({
      auth: accessToken,
    });
  }

  /**
   * Verify workspace access
   */
  async verifyAccess(client: Client): Promise<boolean> {
    try {
      await client.users.me({});
      return true;
    } catch (error) {
      console.error('Access verification failed:', error);
      return false;
    }
  }
}

/**
 * Example OAuth flow implementation
 */
async function exampleOAuthFlow() {
  const handler = new NotionOAuthHandler({
    clientId: process.env.NOTION_CLIENT_ID!,
    clientSecret: process.env.NOTION_CLIENT_SECRET!,
    redirectUri: 'https://api.yourapp.com/oauth/notion/callback',
  });

  // Step 1: Generate authorization URL
  const state = generateRandomState(); // Implement CSRF protection
  const authUrl = handler.getAuthorizationUrl(state);
  console.log('Authorization URL:', authUrl);

  // Step 2: User authorizes and is redirected back with code
  // In your callback handler:
  const code = 'received_from_callback';

  try {
    // Step 3: Exchange code for token
    const tokenResponse = await handler.exchangeCodeForToken(code);

    console.log('Access Token:', tokenResponse.access_token);
    console.log('Bot ID:', tokenResponse.bot_id);
    console.log('Workspace:', tokenResponse.workspace_name);

    // Step 4: Create client and verify access
    const client = handler.createClient(tokenResponse.access_token);
    const hasAccess = await handler.verifyAccess(client);

    if (hasAccess) {
      console.log('OAuth flow completed successfully');
      // Store token securely for future use
    }
  } catch (error) {
    console.error('OAuth flow failed:', error);
  }
}

function generateRandomState(): string {
  return Math.random().toString(36).substring(2, 15);
}

This OAuth handler manages the complete authentication flow. The getAuthorizationUrl method generates the consent URL where users authorize your app. After authorization, Notion redirects back with a code that you exchange for an access token using exchangeCodeForToken. The client creation and verification methods ensure you have working access to the workspace.

For more details on Notion OAuth, see the official Notion OAuth documentation.

Database Operations

Notion databases are powerful structured content containers. You can query databases with complex filters, create new pages (database rows), update properties, and retrieve database schemas. Each database has properties (columns) with specific types like title, rich text, number, select, multi-select, date, people, files, checkbox, URL, email, phone, formula, relation, and rollup.

Database Query Capabilities:

  • Filters: Property-based filtering with operators (equals, contains, starts_with, etc.)
  • Sorts: Multi-level sorting by properties or timestamps
  • Pagination: Cursor-based pagination for large datasets
  • Property Types: 20+ property types with type-specific values
  • Database Schema: Retrieve property definitions and database metadata

Here's a comprehensive database query builder:

/**
 * Notion Database Query Builder
 * Type-safe database querying with filters, sorts, and pagination
 */

import { Client } from '@notionhq/client';
import {
  QueryDatabaseParameters,
  QueryDatabaseResponse,
} from '@notionhq/client/build/src/api-endpoints';

type FilterOperator =
  | 'equals' | 'does_not_equal'
  | 'contains' | 'does_not_contain'
  | 'starts_with' | 'ends_with'
  | 'is_empty' | 'is_not_empty'
  | 'greater_than' | 'less_than'
  | 'on_or_after' | 'on_or_before';

interface FilterCondition {
  property: string;
  operator: FilterOperator;
  value?: string | number | boolean;
  type?: 'title' | 'rich_text' | 'number' | 'select' | 'multi_select' |
         'date' | 'checkbox' | 'status' | 'people';
}

interface SortCondition {
  property?: string;
  timestamp?: 'created_time' | 'last_edited_time';
  direction: 'ascending' | 'descending';
}

class NotionDatabaseQueryBuilder {
  private client: Client;
  private databaseId: string;
  private filters: FilterCondition[] = [];
  private sorts: SortCondition[] = [];
  private pageSize: number = 100;

  constructor(client: Client, databaseId: string) {
    this.client = client;
    this.databaseId = databaseId;
  }

  /**
   * Add filter condition
   */
  filter(condition: FilterCondition): this {
    this.filters.push(condition);
    return this;
  }

  /**
   * Add sort condition
   */
  sort(condition: SortCondition): this {
    this.sorts.push(condition);
    return this;
  }

  /**
   * Set page size for pagination
   */
  limit(size: number): this {
    this.pageSize = Math.min(size, 100);
    return this;
  }

  /**
   * Build filter object from conditions
   */
  private buildFilter(): any {
    if (this.filters.length === 0) return undefined;

    const filterObjects = this.filters.map(f => {
      const propertyType = f.type || 'rich_text';
      const filter: any = {
        property: f.property,
        [propertyType]: {},
      };

      if (f.operator === 'is_empty' || f.operator === 'is_not_empty') {
        filter[propertyType][f.operator] = true;
      } else if (f.value !== undefined) {
        filter[propertyType][f.operator] = f.value;
      }

      return filter;
    });

    if (filterObjects.length === 1) {
      return filterObjects[0];
    }

    return {
      and: filterObjects,
    };
  }

  /**
   * Build sorts array from conditions
   */
  private buildSorts(): any[] {
    return this.sorts.map(s => {
      if (s.property) {
        return {
          property: s.property,
          direction: s.direction,
        };
      } else if (s.timestamp) {
        return {
          timestamp: s.timestamp,
          direction: s.direction,
        };
      }
      return null;
    }).filter(Boolean);
  }

  /**
   * Execute query and return all results
   */
  async execute(): Promise<QueryDatabaseResponse['results']> {
    const filter = this.buildFilter();
    const sorts = this.buildSorts();

    const params: QueryDatabaseParameters = {
      database_id: this.databaseId,
      page_size: this.pageSize,
    };

    if (filter) params.filter = filter;
    if (sorts.length > 0) params.sorts = sorts;

    const results: QueryDatabaseResponse['results'] = [];
    let hasMore = true;
    let startCursor: string | undefined;

    while (hasMore) {
      const response: QueryDatabaseResponse = await this.client.databases.query({
        ...params,
        start_cursor: startCursor,
      });

      results.push(...response.results);
      hasMore = response.has_more;
      startCursor = response.next_cursor || undefined;
    }

    return results;
  }

  /**
   * Execute query with manual pagination
   */
  async executePage(startCursor?: string): Promise<QueryDatabaseResponse> {
    const filter = this.buildFilter();
    const sorts = this.buildSorts();

    const params: QueryDatabaseParameters = {
      database_id: this.databaseId,
      page_size: this.pageSize,
    };

    if (filter) params.filter = filter;
    if (sorts.length > 0) params.sorts = sorts;
    if (startCursor) params.start_cursor = startCursor;

    return this.client.databases.query(params);
  }
}

/**
 * Example database queries
 */
async function exampleDatabaseQueries(client: Client) {
  const databaseId = 'your_database_id';

  // Query 1: Find all incomplete tasks
  const incompleteTasks = await new NotionDatabaseQueryBuilder(client, databaseId)
    .filter({
      property: 'Status',
      type: 'status',
      operator: 'equals',
      value: 'In Progress',
    })
    .sort({
      property: 'Due Date',
      direction: 'ascending',
    })
    .execute();

  console.log(`Found ${incompleteTasks.length} incomplete tasks`);

  // Query 2: Find high-priority items created this week
  const highPriorityItems = await new NotionDatabaseQueryBuilder(client, databaseId)
    .filter({
      property: 'Priority',
      type: 'select',
      operator: 'equals',
      value: 'High',
    })
    .filter({
      property: 'Created',
      type: 'date',
      operator: 'on_or_after',
      value: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
    })
    .sort({
      timestamp: 'created_time',
      direction: 'descending',
    })
    .limit(50)
    .execute();

  console.log(`Found ${highPriorityItems.length} high-priority items`);

  // Query 3: Paginated query
  let cursor: string | undefined;
  let pageNum = 1;

  do {
    const response = await new NotionDatabaseQueryBuilder(client, databaseId)
      .limit(10)
      .executePage(cursor);

    console.log(`Page ${pageNum}: ${response.results.length} items`);
    cursor = response.next_cursor || undefined;
    pageNum++;
  } while (cursor);
}

The query builder provides a fluent API for constructing complex database queries. You can chain multiple filters (AND logic), add multiple sort conditions, and control pagination. The execute method automatically handles pagination to retrieve all results, while executePage gives you manual control for streaming large datasets.

Learn more about Notion's powerful database query capabilities in the official documentation.

Block Management

Notion's block-based content model enables rich, structured documents. Blocks can be paragraphs, headings, lists, code blocks, images, embeds, and more. Blocks can have children (nested blocks), creating hierarchical content structures. Understanding block types and rich text formatting is essential for creating sophisticated content programmatically.

Block Types:

  • Text Blocks: paragraph, heading_1, heading_2, heading_3, bulleted_list_item, numbered_list_item, to_do, toggle, quote, callout, code
  • Media Blocks: image, video, file, pdf, bookmark, embed
  • Advanced Blocks: table, table_row, column_list, column, synced_block, template, link_preview
  • Database Blocks: child_database, child_page

Here's a comprehensive block manager:

/**
 * Notion Block Manager
 * Create, update, and manage Notion blocks with rich text
 */

import { Client } from '@notionhq/client';
import {
  BlockObjectRequest,
  RichTextItemRequest,
} from '@notionhq/client/build/src/api-endpoints';

interface RichTextOptions {
  bold?: boolean;
  italic?: boolean;
  strikethrough?: boolean;
  underline?: boolean;
  code?: boolean;
  color?: string;
  link?: string;
}

class NotionBlockManager {
  private client: Client;

  constructor(client: Client) {
    this.client = client;
  }

  /**
   * Create rich text object with formatting
   */
  createRichText(
    text: string,
    options: RichTextOptions = {}
  ): RichTextItemRequest {
    const richText: RichTextItemRequest = {
      type: 'text',
      text: {
        content: text,
        link: options.link ? { url: options.link } : null,
      },
      annotations: {
        bold: options.bold || false,
        italic: options.italic || false,
        strikethrough: options.strikethrough || false,
        underline: options.underline || false,
        code: options.code || false,
        color: (options.color as any) || 'default',
      },
    };

    return richText;
  }

  /**
   * Create paragraph block
   */
  createParagraph(
    text: string | RichTextItemRequest[],
    options: RichTextOptions = {}
  ): BlockObjectRequest {
    const richText = typeof text === 'string'
      ? [this.createRichText(text, options)]
      : text;

    return {
      object: 'block',
      type: 'paragraph',
      paragraph: {
        rich_text: richText,
      },
    };
  }

  /**
   * Create heading block
   */
  createHeading(
    level: 1 | 2 | 3,
    text: string,
    options: RichTextOptions = {}
  ): BlockObjectRequest {
    const type = `heading_${level}` as 'heading_1' | 'heading_2' | 'heading_3';
    return {
      object: 'block',
      type,
      [type]: {
        rich_text: [this.createRichText(text, options)],
      },
    };
  }

  /**
   * Create bulleted list item
   */
  createBulletedList(
    text: string,
    options: RichTextOptions = {},
    children?: BlockObjectRequest[]
  ): BlockObjectRequest {
    const block: BlockObjectRequest = {
      object: 'block',
      type: 'bulleted_list_item',
      bulleted_list_item: {
        rich_text: [this.createRichText(text, options)],
      },
    };

    if (children && children.length > 0) {
      (block.bulleted_list_item as any).children = children;
    }

    return block;
  }

  /**
   * Create numbered list item
   */
  createNumberedList(
    text: string,
    options: RichTextOptions = {},
    children?: BlockObjectRequest[]
  ): BlockObjectRequest {
    const block: BlockObjectRequest = {
      object: 'block',
      type: 'numbered_list_item',
      numbered_list_item: {
        rich_text: [this.createRichText(text, options)],
      },
    };

    if (children && children.length > 0) {
      (block.numbered_list_item as any).children = children;
    }

    return block;
  }

  /**
   * Create to-do item
   */
  createToDo(
    text: string,
    checked: boolean = false,
    options: RichTextOptions = {}
  ): BlockObjectRequest {
    return {
      object: 'block',
      type: 'to_do',
      to_do: {
        rich_text: [this.createRichText(text, options)],
        checked,
      },
    };
  }

  /**
   * Create code block
   */
  createCode(
    code: string,
    language: string = 'javascript'
  ): BlockObjectRequest {
    return {
      object: 'block',
      type: 'code',
      code: {
        rich_text: [{
          type: 'text',
          text: { content: code },
        }],
        language: language as any,
      },
    };
  }

  /**
   * Create callout block
   */
  createCallout(
    text: string,
    icon: string = '💡',
    color: string = 'gray_background'
  ): BlockObjectRequest {
    return {
      object: 'block',
      type: 'callout',
      callout: {
        rich_text: [this.createRichText(text)],
        icon: {
          type: 'emoji',
          emoji: icon,
        },
        color: color as any,
      },
    };
  }

  /**
   * Append blocks to a page or block
   */
  async appendBlocks(
    parentId: string,
    blocks: BlockObjectRequest[]
  ): Promise<void> {
    await this.client.blocks.children.append({
      block_id: parentId,
      children: blocks,
    });
  }

  /**
   * Update block content
   */
  async updateBlock(
    blockId: string,
    block: Partial<BlockObjectRequest>
  ): Promise<void> {
    await this.client.blocks.update({
      block_id: blockId,
      ...block as any,
    });
  }

  /**
   * Delete block
   */
  async deleteBlock(blockId: string): Promise<void> {
    await this.client.blocks.delete({
      block_id: blockId,
    });
  }
}

/**
 * Example block operations
 */
async function exampleBlockOperations(client: Client) {
  const manager = new NotionBlockManager(client);
  const pageId = 'your_page_id';

  // Create structured content
  const blocks: BlockObjectRequest[] = [
    manager.createHeading(1, 'Project Overview'),
    manager.createParagraph('This is a comprehensive project plan.'),

    manager.createHeading(2, 'Key Objectives'),
    manager.createBulletedList('Complete initial research', { bold: true }),
    manager.createBulletedList('Design system architecture'),
    manager.createBulletedList('Implement core features', {}, [
      manager.createBulletedList('User authentication'),
      manager.createBulletedList('Database integration'),
      manager.createBulletedList('API endpoints'),
    ]),

    manager.createHeading(2, 'Tasks'),
    manager.createToDo('Set up development environment', true),
    manager.createToDo('Write technical specification', false),
    manager.createToDo('Begin implementation', false),

    manager.createCallout(
      'Remember to test thoroughly before deployment',
      '⚠️',
      'yellow_background'
    ),

    manager.createCode(`
function deployApplication() {
  console.log('Deploying to production...');
  return buildAndDeploy();
}
    `.trim(), 'typescript'),
  ];

  await manager.appendBlocks(pageId, blocks);
  console.log('Content created successfully');
}

The block manager provides methods for creating all common block types with rich text formatting. You can create nested structures by passing children to list items, update existing blocks, and delete blocks. Rich text supports bold, italic, strikethrough, underline, code formatting, colors, and links.

Explore all Notion block types in the API reference.

Page & Workspace Operations

Beyond databases and blocks, Notion's API provides operations for creating standalone pages, searching across the workspace, managing comments, retrieving users, and controlling sharing permissions. These operations enable comprehensive workspace automation.

Workspace Operations:

  • Page Creation: Create new pages with properties and content
  • Search: Full-text search across pages and databases
  • Users: Retrieve workspace members and bot information
  • Comments: Add and retrieve comments on pages and blocks
  • Sharing: Manage page sharing and permissions (limited in API)

Here's a complete page creator:

/**
 * Notion Page Creator
 * Create pages with properties, content, and metadata
 */

import { Client } from '@notionhq/client';
import {
  CreatePageParameters,
  PageObjectResponse,
} from '@notionhq/client/build/src/api-endpoints';
import { NotionBlockManager, RichTextOptions } from './block-manager';

interface PageProperty {
  type: 'title' | 'rich_text' | 'number' | 'select' | 'multi_select' |
        'date' | 'people' | 'files' | 'checkbox' | 'url' | 'email' | 'phone_number';
  value: any;
}

class NotionPageCreator {
  private client: Client;
  private blockManager: NotionBlockManager;

  constructor(client: Client) {
    this.client = client;
    this.blockManager = new NotionBlockManager(client);
  }

  /**
   * Create property value based on type
   */
  private createPropertyValue(property: PageProperty): any {
    switch (property.type) {
      case 'title':
        return {
          title: [{
            type: 'text',
            text: { content: property.value },
          }],
        };

      case 'rich_text':
        return {
          rich_text: [{
            type: 'text',
            text: { content: property.value },
          }],
        };

      case 'number':
        return {
          number: property.value,
        };

      case 'select':
        return {
          select: { name: property.value },
        };

      case 'multi_select':
        return {
          multi_select: property.value.map((v: string) => ({ name: v })),
        };

      case 'date':
        return {
          date: typeof property.value === 'string'
            ? { start: property.value }
            : property.value,
        };

      case 'people':
        return {
          people: property.value.map((id: string) => ({ id })),
        };

      case 'checkbox':
        return {
          checkbox: property.value,
        };

      case 'url':
        return {
          url: property.value,
        };

      case 'email':
        return {
          email: property.value,
        };

      case 'phone_number':
        return {
          phone_number: property.value,
        };

      default:
        throw new Error(`Unsupported property type: ${property.type}`);
    }
  }

  /**
   * Create page in database
   */
  async createDatabasePage(
    databaseId: string,
    properties: Record<string, PageProperty>,
    content?: any[]
  ): Promise<PageObjectResponse> {
    const params: CreatePageParameters = {
      parent: {
        type: 'database_id',
        database_id: databaseId,
      },
      properties: {},
    };

    // Build properties object
    for (const [key, property] of Object.entries(properties)) {
      params.properties[key] = this.createPropertyValue(property);
    }

    // Add content if provided
    if (content && content.length > 0) {
      params.children = content;
    }

    const response = await this.client.pages.create(params);
    return response as PageObjectResponse;
  }

  /**
   * Create standalone page
   */
  async createPage(
    parentPageId: string,
    title: string,
    content?: any[]
  ): Promise<PageObjectResponse> {
    const params: CreatePageParameters = {
      parent: {
        type: 'page_id',
        page_id: parentPageId,
      },
      properties: {
        title: {
          title: [{
            type: 'text',
            text: { content: title },
          }],
        },
      },
    };

    if (content && content.length > 0) {
      params.children = content;
    }

    const response = await this.client.pages.create(params);
    return response as PageObjectResponse;
  }

  /**
   * Update page properties
   */
  async updatePage(
    pageId: string,
    properties: Record<string, PageProperty>
  ): Promise<PageObjectResponse> {
    const params: any = {
      page_id: pageId,
      properties: {},
    };

    for (const [key, property] of Object.entries(properties)) {
      params.properties[key] = this.createPropertyValue(property);
    }

    const response = await this.client.pages.update(params);
    return response as PageObjectResponse;
  }

  /**
   * Archive/delete page
   */
  async archivePage(pageId: string): Promise<void> {
    await this.client.pages.update({
      page_id: pageId,
      archived: true,
    });
  }

  /**
   * Retrieve page
   */
  async getPage(pageId: string): Promise<PageObjectResponse> {
    const response = await this.client.pages.retrieve({
      page_id: pageId,
    });
    return response as PageObjectResponse;
  }
}

/**
 * Example page operations
 */
async function examplePageOperations(client: Client) {
  const creator = new NotionPageCreator(client);
  const blockManager = new NotionBlockManager(client);
  const databaseId = 'your_database_id';

  // Create task in database
  const task = await creator.createDatabasePage(
    databaseId,
    {
      'Name': {
        type: 'title',
        value: 'Implement Notion integration',
      },
      'Status': {
        type: 'select',
        value: 'In Progress',
      },
      'Priority': {
        type: 'select',
        value: 'High',
      },
      'Due Date': {
        type: 'date',
        value: '2026-01-31',
      },
      'Assignee': {
        type: 'people',
        value: ['user_id_here'],
      },
      'Tags': {
        type: 'multi_select',
        value: ['Backend', 'Integration', 'API'],
      },
      'Estimated Hours': {
        type: 'number',
        value: 16,
      },
    },
    [
      blockManager.createHeading(2, 'Requirements'),
      blockManager.createBulletedList('OAuth authentication'),
      blockManager.createBulletedList('Database queries'),
      blockManager.createBulletedList('Block creation'),

      blockManager.createHeading(2, 'Notes'),
      blockManager.createParagraph('Review Notion API docs thoroughly.'),
      blockManager.createCallout('Test with multiple workspaces', '📝'),
    ]
  );

  console.log('Created task:', task.id);

  // Update task status
  await creator.updatePage(task.id, {
    'Status': {
      type: 'select',
      value: 'Completed',
    },
  });

  console.log('Task marked as completed');
}

The page creator handles all property types and automatically formats values correctly. You can create database pages (with properties) or standalone pages (simpler structure), update properties, archive pages, and retrieve page data.

Rich Text Formatter

Rich text is fundamental to Notion content. Understanding how to format text with multiple styles, colors, links, and mentions enables sophisticated content creation. Here's a specialized rich text formatter:

/**
 * Rich Text Formatter
 * Advanced rich text creation with multiple styles
 */

import { RichTextItemRequest } from '@notionhq/client/build/src/api-endpoints';

type Color =
  | 'default' | 'gray' | 'brown' | 'orange' | 'yellow' | 'green'
  | 'blue' | 'purple' | 'pink' | 'red'
  | 'gray_background' | 'brown_background' | 'orange_background'
  | 'yellow_background' | 'green_background' | 'blue_background'
  | 'purple_background' | 'pink_background' | 'red_background';

class RichTextFormatter {
  /**
   * Create formatted text segment
   */
  static text(
    content: string,
    options: {
      bold?: boolean;
      italic?: boolean;
      strikethrough?: boolean;
      underline?: boolean;
      code?: boolean;
      color?: Color;
      link?: string;
    } = {}
  ): RichTextItemRequest {
    return {
      type: 'text',
      text: {
        content,
        link: options.link ? { url: options.link } : null,
      },
      annotations: {
        bold: options.bold || false,
        italic: options.italic || false,
        strikethrough: options.strikethrough || false,
        underline: options.underline || false,
        code: options.code || false,
        color: options.color || 'default',
      },
    };
  }

  /**
   * Create mention (user, page, or date)
   */
  static mention(
    type: 'user' | 'page' | 'date',
    id: string
  ): RichTextItemRequest {
    if (type === 'user') {
      return {
        type: 'mention',
        mention: {
          type: 'user',
          user: { id },
        },
      };
    } else if (type === 'page') {
      return {
        type: 'mention',
        mention: {
          type: 'page',
          page: { id },
        },
      };
    } else {
      return {
        type: 'mention',
        mention: {
          type: 'date',
          date: { start: id }, // ISO date string
        },
      };
    }
  }

  /**
   * Create equation
   */
  static equation(expression: string): RichTextItemRequest {
    return {
      type: 'equation',
      equation: {
        expression,
      },
    };
  }

  /**
   * Combine multiple text segments
   */
  static combine(...segments: RichTextItemRequest[]): RichTextItemRequest[] {
    return segments;
  }

  /**
   * Parse markdown-like syntax to rich text
   */
  static fromMarkdown(markdown: string): RichTextItemRequest[] {
    const segments: RichTextItemRequest[] = [];
    let current = '';
    let bold = false;
    let italic = false;
    let code = false;

    for (let i = 0; i < markdown.length; i++) {
      const char = markdown[i];
      const next = markdown[i + 1];

      if (char === '*' && next === '*') {
        if (current) {
          segments.push(this.text(current, { bold, italic, code }));
          current = '';
        }
        bold = !bold;
        i++; // Skip next asterisk
      } else if (char === '*') {
        if (current) {
          segments.push(this.text(current, { bold, italic, code }));
          current = '';
        }
        italic = !italic;
      } else if (char === '`') {
        if (current) {
          segments.push(this.text(current, { bold, italic, code }));
          current = '';
        }
        code = !code;
      } else {
        current += char;
      }
    }

    if (current) {
      segments.push(this.text(current, { bold, italic, code }));
    }

    return segments;
  }
}

/**
 * Example rich text formatting
 */
function exampleRichTextFormatting() {
  // Simple formatted text
  const bold = RichTextFormatter.text('Important:', { bold: true });
  const normal = RichTextFormatter.text(' This is crucial information.');

  // Multi-style text
  const multiStyle = RichTextFormatter.combine(
    RichTextFormatter.text('Status: ', { bold: true }),
    RichTextFormatter.text('Completed', {
      bold: true,
      color: 'green',
    }),
    RichTextFormatter.text(' on '),
    RichTextFormatter.mention('date', '2026-01-15'),
  );

  // Code and links
  const codeAndLink = RichTextFormatter.combine(
    RichTextFormatter.text('Run '),
    RichTextFormatter.text('npm install', { code: true }),
    RichTextFormatter.text(' or see '),
    RichTextFormatter.text('documentation', {
      link: 'https://docs.example.com',
      color: 'blue',
      underline: true,
    }),
  );

  // From markdown
  const fromMarkdown = RichTextFormatter.fromMarkdown(
    'This is **bold** and this is *italic* and this is `code`.'
  );

  // User mention
  const withMention = RichTextFormatter.combine(
    RichTextFormatter.text('Assigned to '),
    RichTextFormatter.mention('user', 'user_id_here'),
  );

  return {
    bold,
    normal,
    multiStyle,
    codeAndLink,
    fromMarkdown,
    withMention,
  };
}

The rich text formatter provides utilities for creating complex formatted text. You can combine multiple segments with different styles, create mentions, parse markdown-like syntax, and apply colors. This is essential for creating professional-looking Notion content.

Search Engine

Notion's search API enables finding content across the entire workspace. Here's a search implementation:

/**
 * Notion Search Engine
 * Search across workspace content
 */

import { Client } from '@notionhq/client';
import {
  SearchParameters,
  SearchResponse,
} from '@notionhq/client/build/src/api-endpoints';

type SearchFilter = 'page' | 'database';
type SearchSort = 'last_edited_time';

class NotionSearchEngine {
  private client: Client;

  constructor(client: Client) {
    this.client = client;
  }

  /**
   * Search workspace
   */
  async search(
    query: string,
    options: {
      filter?: SearchFilter;
      sort?: SearchSort;
      direction?: 'ascending' | 'descending';
      pageSize?: number;
    } = {}
  ): Promise<SearchResponse['results']> {
    const params: SearchParameters = {
      query,
      page_size: options.pageSize || 100,
    };

    if (options.filter) {
      params.filter = {
        property: 'object',
        value: options.filter,
      };
    }

    if (options.sort) {
      params.sort = {
        direction: options.direction || 'descending',
        timestamp: options.sort,
      };
    }

    const results: SearchResponse['results'] = [];
    let hasMore = true;
    let startCursor: string | undefined;

    while (hasMore) {
      const response: SearchResponse = await this.client.search({
        ...params,
        start_cursor: startCursor,
      });

      results.push(...response.results);
      hasMore = response.has_more;
      startCursor = response.next_cursor || undefined;
    }

    return results;
  }

  /**
   * Search pages only
   */
  async searchPages(query: string): Promise<SearchResponse['results']> {
    return this.search(query, { filter: 'page' });
  }

  /**
   * Search databases only
   */
  async searchDatabases(query: string): Promise<SearchResponse['results']> {
    return this.search(query, { filter: 'database' });
  }

  /**
   * Search recently edited
   */
  async searchRecent(limit: number = 10): Promise<SearchResponse['results']> {
    return this.search('', {
      sort: 'last_edited_time',
      direction: 'descending',
      pageSize: limit,
    });
  }
}

/**
 * Example search operations
 */
async function exampleSearchOperations(client: Client) {
  const search = new NotionSearchEngine(client);

  // Search for specific content
  const projectPages = await search.searchPages('project plan');
  console.log(`Found ${projectPages.length} project pages`);

  // Find databases
  const databases = await search.searchDatabases('tasks');
  console.log(`Found ${databases.length} task databases`);

  // Get recently edited
  const recent = await search.searchRecent(5);
  console.log('Recently edited:', recent.map(r => (r as any).id));
}

Comment Manager

Comments enable collaboration. Here's a comment manager:

/**
 * Comment Manager
 * Add and retrieve comments on pages and blocks
 */

import { Client } from '@notionhq/client';
import { RichTextItemRequest } from '@notionhq/client/build/src/api-endpoints';

class NotionCommentManager {
  private client: Client;

  constructor(client: Client) {
    this.client = client;
  }

  /**
   * Add comment to page or block
   */
  async addComment(
    discussionId: string,
    richText: RichTextItemRequest[]
  ): Promise<void> {
    await this.client.comments.create({
      parent: {
        page_id: discussionId,
      },
      rich_text: richText,
    });
  }

  /**
   * Retrieve comments
   */
  async getComments(blockId: string): Promise<any[]> {
    const response = await this.client.comments.list({
      block_id: blockId,
    });

    return response.results;
  }
}

Rate Limiter

Notion enforces rate limits (3 requests per second). Here's a rate limiter:

/**
 * Rate Limiter
 * Handle Notion API rate limits
 */

class RateLimiter {
  private queue: Array<() => Promise<any>> = [];
  private processing = false;
  private lastRequest = 0;
  private minInterval = 334; // ~3 requests per second

  async execute<T>(fn: () => Promise<T>): Promise<T> {
    return new Promise((resolve, reject) => {
      this.queue.push(async () => {
        try {
          const result = await fn();
          resolve(result);
        } catch (error) {
          reject(error);
        }
      });

      this.process();
    });
  }

  private async process(): Promise<void> {
    if (this.processing || this.queue.length === 0) return;

    this.processing = true;

    while (this.queue.length > 0) {
      const now = Date.now();
      const timeSinceLastRequest = now - this.lastRequest;

      if (timeSinceLastRequest < this.minInterval) {
        await this.sleep(this.minInterval - timeSinceLastRequest);
      }

      const task = this.queue.shift();
      if (task) {
        this.lastRequest = Date.now();
        await task();
      }
    }

    this.processing = false;
  }

  private sleep(ms: number): Promise<void> {
    return new Promise(resolve => setTimeout(resolve, ms));
  }
}

Pagination Handler

Handle cursor-based pagination:

/**
 * Pagination Handler
 * Generic pagination for Notion API responses
 */

type PaginatedResponse = {
  has_more: boolean;
  next_cursor: string | null;
  results: any[];
};

class PaginationHandler {
  static async *paginate<T>(
    fetchPage: (cursor?: string) => Promise<PaginatedResponse>
  ): AsyncGenerator<T[], void, unknown> {
    let cursor: string | undefined;
    let hasMore = true;

    while (hasMore) {
      const response = await fetchPage(cursor);
      yield response.results as T[];
      hasMore = response.has_more;
      cursor = response.next_cursor || undefined;
    }
  }

  static async all<T>(
    fetchPage: (cursor?: string) => Promise<PaginatedResponse>
  ): Promise<T[]> {
    const results: T[] = [];

    for await (const page of this.paginate<T>(fetchPage)) {
      results.push(...page);
    }

    return results;
  }
}

Cache Manager

Cache responses for performance:

/**
 * Cache Manager
 * Simple in-memory cache for Notion responses
 */

class CacheManager<T> {
  private cache = new Map<string, { data: T; timestamp: number }>();
  private ttl: number;

  constructor(ttlMinutes: number = 5) {
    this.ttl = ttlMinutes * 60 * 1000;
  }

  get(key: string): T | null {
    const entry = this.cache.get(key);

    if (!entry) return null;

    if (Date.now() - entry.timestamp > this.ttl) {
      this.cache.delete(key);
      return null;
    }

    return entry.data;
  }

  set(key: string, data: T): void {
    this.cache.set(key, {
      data,
      timestamp: Date.now(),
    });
  }

  clear(): void {
    this.cache.clear();
  }
}

Production Integration

Combining all components into a production-ready integration requires error handling, retry logic, rate limiting, and caching:

/**
 * Production Notion Integration
 * Complete integration with all best practices
 */

class ProductionNotionClient {
  private client: Client;
  private rateLimiter: RateLimiter;
  private cache: CacheManager<any>;

  constructor(accessToken: string) {
    this.client = new Client({ auth: accessToken });
    this.rateLimiter = new RateLimiter();
    this.cache = new CacheManager(5);
  }

  async queryDatabase(databaseId: string, filters?: any) {
    const cacheKey = `db:${databaseId}:${JSON.stringify(filters)}`;
    const cached = this.cache.get(cacheKey);
    if (cached) return cached;

    const result = await this.rateLimiter.execute(() =>
      this.client.databases.query({
        database_id: databaseId,
        filter: filters,
      })
    );

    this.cache.set(cacheKey, result);
    return result;
  }

  async retryOnError<T>(
    fn: () => Promise<T>,
    maxRetries: number = 3
  ): Promise<T> {
    let lastError: Error;

    for (let i = 0; i < maxRetries; i++) {
      try {
        return await fn();
      } catch (error: any) {
        lastError = error;

        if (error.code === 'rate_limited') {
          await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1)));
          continue;
        }

        throw error;
      }
    }

    throw lastError!;
  }
}

This production integration combines rate limiting, caching, and retry logic for reliable, performant Notion integrations.

Conclusion

You now have a complete foundation for building Notion integrations in ChatGPT apps. From OAuth authentication through database queries, block creation, search, comments, and production optimization, you can create sophisticated workspace automation tools. The 10 TypeScript code examples provide production-ready implementations you can adapt to your specific needs.

For more integration examples and advanced patterns, explore our guides on Google Drive API integration, Slack API integration, and calendar integration patterns. Ready to build your ChatGPT app with powerful integrations? Start your free trial today, or explore our template marketplace for pre-built integration templates.

Build smarter workspace automation—one Notion integration at a time.