Skip to content

Notion Connector Example

An OAuth connector for Notion demonstrating custom provider configuration.

Overview

  • Type: Connector
  • OAuth Provider: Custom (Notion)
  • Capabilities: Search pages, Read pages, List databases

Key Difference: Custom Provider

Unlike Google (built-in), Notion requires custom OAuth URLs:

json
{
  "oauth": {
    "provider": "custom",
    "providerName": "Notion",
    "authorizationUrl": "https://api.notion.com/v1/oauth/authorize",
    "tokenUrl": "https://api.notion.com/v1/oauth/token",
    "scopes": [],
    "clientIdSecret": "NOTION_CLIENT_ID",
    "clientSecretSecret": "NOTION_CLIENT_SECRET"
  }
}

Full Code

js
/**
 * Notion Connector - Search and read Notion pages
 * 
 * Demonstrates:
 * - Custom OAuth provider configuration
 * - Notion API v2022-06-28
 * - Handling Notion's unique data structures
 */

const NOTION_API = 'https://api.notion.com/v1';
const NOTION_VERSION = '2022-06-28';

async function handler(input, context) {
  const { action, ...params } = input;
  
  // Check OAuth connection
  if (!await context.oauth.isConnected()) {
    return { 
      error: true, 
      message: 'Notion not connected. Please connect your Notion account.',
      requiresAuth: true 
    };
  }
  
  try {
    switch (action) {
      case 'search':
        return await search(params, context);
      case 'getPage':
        return await getPage(params, context);
      case 'listDatabases':
        return await listDatabases(context);
      case 'queryDatabase':
        return await queryDatabase(params, context);
      default:
        return { 
          error: true, 
          message: `Unknown action: ${action}. Valid: search, getPage, listDatabases, queryDatabase` 
        };
    }
  } catch (error) {
    return { error: true, message: error.message || 'Notion operation failed' };
  }
}

// Helper: Make Notion API request with required headers
async function notionFetch(context, endpoint, options = {}) {
  const url = endpoint.startsWith('http') ? endpoint : `${NOTION_API}${endpoint}`;
  
  return context.oauth.fetch(url, {
    ...options,
    headers: {
      'Content-Type': 'application/json',
      'Notion-Version': NOTION_VERSION,
      ...options.headers,
    },
  });
}

// Helper: Extract title from Notion page/database
function extractTitle(obj) {
  if (obj.properties?.title?.title?.[0]?.plain_text) {
    return obj.properties.title.title[0].plain_text;
  }
  if (obj.properties?.Name?.title?.[0]?.plain_text) {
    return obj.properties.Name.title[0].plain_text;
  }
  if (obj.title?.[0]?.plain_text) {
    return obj.title[0].plain_text;
  }
  return 'Untitled';
}

async function search({ query, filter }, context) {
  if (!query) {
    return { error: true, message: 'Search query is required' };
  }
  
  const body = {
    query,
    page_size: 20,
  };
  
  // Optional: filter by type
  if (filter === 'page' || filter === 'database') {
    body.filter = { property: 'object', value: filter };
  }
  
  const resp = await notionFetch(context, '/search', {
    method: 'POST',
    body: JSON.stringify(body),
  });
  
  if (!resp.ok) {
    throw new Error(`Notion search failed: ${resp.status}`);
  }
  
  const data = JSON.parse(resp.body);
  
  const results = data.results.map(item => ({
    id: item.id,
    type: item.object,
    title: extractTitle(item),
    url: item.url,
    lastEdited: item.last_edited_time,
  }));
  
  return {
    success: true,
    action: 'search',
    query,
    count: results.length,
    results,
  };
}

async function getPage({ pageId }, context) {
  if (!pageId) {
    return { error: true, message: 'Page ID is required' };
  }
  
  // Get page metadata
  const pageResp = await notionFetch(context, `/pages/${pageId}`);
  
  if (!pageResp.ok) {
    if (pageResp.status === 404) {
      return { error: true, message: 'Page not found or not shared with integration' };
    }
    throw new Error(`Failed to get page: ${pageResp.status}`);
  }
  
  const page = JSON.parse(pageResp.body);
  
  // Get page content (blocks)
  const blocksResp = await notionFetch(context, `/blocks/${pageId}/children`);
  
  let content = [];
  if (blocksResp.ok) {
    const blocksData = JSON.parse(blocksResp.body);
    content = blocksData.results.map(block => {
      const type = block.type;
      const data = block[type];
      
      // Extract text from rich_text arrays
      if (data?.rich_text) {
        return {
          type,
          text: data.rich_text.map(t => t.plain_text).join(''),
        };
      }
      
      return { type, data };
    });
  }
  
  return {
    success: true,
    action: 'getPage',
    page: {
      id: page.id,
      title: extractTitle(page),
      url: page.url,
      created: page.created_time,
      lastEdited: page.last_edited_time,
      properties: page.properties,
    },
    content,
  };
}

async function listDatabases(context) {
  const resp = await notionFetch(context, '/search', {
    method: 'POST',
    body: JSON.stringify({
      filter: { property: 'object', value: 'database' },
      page_size: 50,
    }),
  });
  
  if (!resp.ok) {
    throw new Error(`Failed to list databases: ${resp.status}`);
  }
  
  const data = JSON.parse(resp.body);
  
  const databases = data.results.map(db => ({
    id: db.id,
    title: extractTitle(db),
    url: db.url,
    description: db.description?.[0]?.plain_text,
  }));
  
  return {
    success: true,
    action: 'listDatabases',
    count: databases.length,
    databases,
  };
}

async function queryDatabase({ databaseId, filter, sorts }, context) {
  if (!databaseId) {
    return { error: true, message: 'Database ID is required' };
  }
  
  const body = { page_size: 50 };
  
  if (filter) {
    body.filter = typeof filter === 'string' ? JSON.parse(filter) : filter;
  }
  
  if (sorts) {
    body.sorts = typeof sorts === 'string' ? JSON.parse(sorts) : sorts;
  }
  
  const resp = await notionFetch(context, `/databases/${databaseId}/query`, {
    method: 'POST',
    body: JSON.stringify(body),
  });
  
  if (!resp.ok) {
    throw new Error(`Failed to query database: ${resp.status}`);
  }
  
  const data = JSON.parse(resp.body);
  
  const items = data.results.map(item => ({
    id: item.id,
    url: item.url,
    properties: item.properties,
  }));
  
  return {
    success: true,
    action: 'queryDatabase',
    databaseId,
    count: items.length,
    items,
  };
}

module.exports = { handler };

Manifest

json
{
  "trigger": "auto",
  "oauth": {
    "provider": "custom",
    "providerName": "Notion",
    "authorizationUrl": "https://api.notion.com/v1/oauth/authorize",
    "tokenUrl": "https://api.notion.com/v1/oauth/token",
    "scopes": [],
    "clientIdSecret": "NOTION_CLIENT_ID",
    "clientSecretSecret": "NOTION_CLIENT_SECRET"
  },
  "developerSecrets": {
    "NOTION_CLIENT_ID": {
      "description": "Notion OAuth Client ID from your integration",
      "required": true
    },
    "NOTION_CLIENT_SECRET": {
      "description": "Notion OAuth Client Secret",
      "required": true
    }
  },
  "tool": {
    "name": "notion",
    "description": "Search and read Notion pages and databases. Use when users ask about their Notion workspace.",
    "parameters": {
      "type": "object",
      "properties": {
        "action": {
          "type": "string",
          "enum": ["search", "getPage", "listDatabases", "queryDatabase"],
          "description": "The Notion action to perform"
        },
        "query": {
          "type": "string",
          "description": "For search: text to search for"
        },
        "filter": {
          "type": "string",
          "description": "For search: 'page' or 'database'. For queryDatabase: JSON filter object"
        },
        "pageId": {
          "type": "string",
          "description": "For getPage: the page ID"
        },
        "databaseId": {
          "type": "string",
          "description": "For queryDatabase: the database ID"
        }
      },
      "required": ["action"]
    }
  }
}

Setup

  1. Create Notion Integration

    • Go to Notion Integrations
    • Click "New Integration"
    • Name: "PIE Connector" (or your choice)
    • Select workspace
    • Enable capabilities: Read content, Read user info
  2. Enable OAuth

    • In integration settings, go to "Distribution"
    • Enable "Public integration"
    • Add redirect URI: https://your-pie-domain.com/api/oauth/plugin/{pluginId}/callback
    • Copy Client ID and Secret
  3. Create Agent in PIE

    • Type: Connector
    • Paste code and manifest
    • Add credentials as developer secrets
  4. User Connection

    • When users connect, they select which pages to share
    • Integration only sees pages explicitly shared

Key Patterns

Custom OAuth Provider

Notion doesn't have built-in support, so we specify URLs:

json
{
  "provider": "custom",
  "authorizationUrl": "https://api.notion.com/v1/oauth/authorize",
  "tokenUrl": "https://api.notion.com/v1/oauth/token"
}

Required Headers

Notion requires Notion-Version header:

js
async function notionFetch(context, endpoint, options = {}) {
  return context.oauth.fetch(url, {
    ...options,
    headers: {
      'Content-Type': 'application/json',
      'Notion-Version': '2022-06-28',
      ...options.headers,
    },
  });
}

Permission Model

Unlike Google, Notion uses a page-based permission model:

  • Users choose which pages to share during OAuth
  • Integrations only see explicitly shared content
  • No global access to workspace

Built with VitePress