Notion MCP Developer
Overview
Transform escape room designs into fully functional Notion templates using Notion MCP tools integrated with Claude Code. This skill orchestrates specialized design skills and implements their output through Notion's API while managing rate limits, session persistence, and iterative development workflows.
Table of Contents
Quick Start
Best Practices & Patterns
Workflows & Guides
Reference Documentation
Detailed guides for escape room development:
- Escape Room Database Architecture - Complete 5-database structure (Scenes, Puzzles, Items, PlayerState, Endings) with property schemas and relationships
- Game Mechanics Formulas - 20+ formula patterns across 5 categories (Unlocking Logic, Progress Tracking, Hint Systems, Timer Systems, Ending Triggers)
- Rollup Patterns - All 21 Notion rollup functions with escape room examples
- Relation Design Patterns - 4 relation types (Single Property, Dual Property, Self-Referencing, Many-to-Many) with implementation guides
- Phase-Based Implementation - 5-phase creation strategy optimized from 23 to 11 API calls
- Antipatterns & Gotchas - 6 antipatterns + 6 gotchas with solutions
Core Capabilities
1. Template Creation from Scratch
Build complete Notion templates for escape rooms, time tracking, project management, or custom workflows.
2. Skill Orchestration
Coordinate existing skills (narrative-architect-korean-style, language-agnostic-puzzle-designer, notion-formula-master, escape-room-localizer, playtesting-orchestrator) and implement their outputs in Notion.
3. Database Architecture
Design and implement complex database structures with proper relationships, formulas, rollups, and views using notion-create-database and notion-update-database.
Related References:
- Escape Room Database Architecture - Complete database structure patterns
- Relation Design Patterns - How to connect databases
- Rollup Patterns - Aggregate data across relations
- Phase-Based Implementation - Optimal creation order
4. Content Management
Create, fetch, update, and search Notion content using all Notion MCP tools: notion-create-pages, notion-fetch, notion-update-page, notion-search, notion-move-pages, notion-duplicate-page.
5. Rate Limit Management
Intelligently batch operations to respect Notion API limits (180 requests/min general, 30 searches/min).
6. Session Persistence
Use Serena MCP to save/load development context for complex multi-step builds spanning multiple sessions.
Notion MCP Tool Reference
Essential Tools
notion-search (Rate limit: 30/min)
- Search across Notion workspace and connected tools (Slack, Google Drive, Jira)
- Use for finding existing content, templates, or research documents
- Syntax:
query, optionalteamspace_id,page_url,data_source_url,filters
notion-fetch (Rate limit: 180/min)
- Retrieve complete page or database content by URL/ID
- Returns Notion-flavored Markdown for pages, schema for databases
- Use before modifying content to understand current structure
notion-create-database (Rate limit: 180/min)
- Create new database with property schema
- Returns database ID and initial view
- Follow with
notion-update-databasefor advanced configuration
notion-create-pages (Rate limit: 180/min)
- Bulk create up to 100 pages in single call
- Specify parent (page_id, database_id, or data_source_id)
- Use Notion-flavored Markdown for content
notion-update-page (Rate limit: 180/min)
- Update page properties or content
- Commands:
update_properties,replace_content,replace_content_range,insert_content_after - Always fetch page first to get current content
notion-update-database (Rate limit: 180/min)
- Modify database schema, add/remove/rename properties
- Update title, description, inline display mode
- Cannot delete title properties or create multiple unique_id properties
See references/notion-mcp-tools.md for complete API reference with examples.
Security Best Practices
1. Prompt Injection Defense
Threat: User-provided content in formulas or rich text could inject malicious logic into Notion pages.
Validation Rules
Formula Input Sanitization:
// ❌ Dangerous: Direct formula from user input
const userFormula = userInput; // Could be: if(true, deleteAllPages(), "safe")
notion_create_database({
properties: {
"Calculated": {formula: {expression: userFormula}} // INJECTION RISK
}
});
// ✅ Safe: Whitelist validation
function validateFormula(formula: string): boolean {
const allowedFunctions = ['if', 'prop', 'and', 'or', 'format', 'round'];
const forbiddenPatterns = [
/delete/i, // No destructive operations
/fetch/i, // No external calls
/eval/i, // No code execution
/<script>/i // No HTML injection
];
// Check allowed functions only
const functions = formula.match(/\w+\(/g) || [];
const allAllowed = functions.every(f =>
allowedFunctions.includes(f.replace('(', ''))
);
// Check no forbidden patterns
const noForbidden = !forbiddenPatterns.some(p => p.test(formula));
return allAllowed && noForbidden;
}
// Usage
if (validateFormula(userFormula)) {
notion_create_database({...});
} else {
throw new Error("Invalid formula: contains disallowed operations");
}
Rich Text Content Sanitization:
// ❌ Dangerous: Direct HTML/Markdown from user
const userContent = userInput; // Could contain: <script>alert('xss')</script>
// ✅ Safe: Strip dangerous tags
function sanitizeContent(content: string): string {
return content
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '') // Remove <script>
.replace(/<iframe\b[^<]*(?:(?!<\/iframe>)<[^<]*)*<\/iframe>/gi, '') // Remove <iframe>
.replace(/javascript:/gi, '') // Remove javascript: URLs
.replace(/on\w+\s*=/gi, ''); // Remove event handlers (onclick, onerror, etc.)
}
// Usage
notion_create_pages({
pages: [{
properties: {title: "User Content"},
content: sanitizeContent(userContent)
}]
});
Formula Validation Checklist
Before creating database with formulas:
- [ ] Formulas use only whitelisted functions?
- [ ] No external data fetching (fetch, http)?
- [ ] No user-controllable function names?
- [ ] Validated against forbidden patterns?
- [ ] Tested with malicious inputs?
2. Permission Validation
Threat: Operations performed without verifying user has necessary permissions.
Pre-Operation Checks
Verify Bot Permissions:
// Always check bot permissions before operations
async function verifyBotPermissions(): Promise<void> {
const bot = await notion_get_self();
if (bot.bot.owner.type !== "workspace") {
throw new Error("❌ Bot must be workspace-level integration for this operation");
}
console.log(`✅ Operating as: ${bot.name} (workspace-level)`);
}
// Usage before critical operations
await verifyBotPermissions();
await notion_create_database({...}); // Now safe
Verify Team Access:
// Check user can access target teamspace
async function verifyTeamAccess(teamName: string): Promise<string> {
const teams = await notion_get_teams({query: teamName});
const team = teams.user_teams.find(t => t.name === teamName);
if (!team) {
throw new Error(`❌ No access to team: ${teamName}`);
}
if (team.role === "guest") {
throw new Error(`❌ Insufficient permissions (guest only) for: ${teamName}`);
}
console.log(`✅ Access confirmed: ${teamName} (role: ${team.role})`);
return team.id;
}
// Usage
const teamId = await verifyTeamAccess("Game Design Team");
await notion_create_database({
parent: {teamspace_id: teamId},
// ...
});
Database Permission Check:
// Verify can modify database before updates
async function verifyDatabaseAccess(databaseId: string): Promise<void> {
try {
const db = await notion_fetch({id: databaseId});
// If fetch succeeds, we have read access
// Try a harmless update to verify write access
await notion_update_database({
database_id: databaseId,
// No actual changes (empty properties update)
properties: {}
});
console.log(`✅ Write access confirmed for database: ${databaseId}`);
} catch (error) {
throw new Error(`❌ Cannot modify database: ${error.message}`);
}
}
Permission Checklist
Before operations:
- [ ] Verified bot is workspace-level integration?
- [ ] Checked team access if team-specific?
- [ ] Confirmed user has write permissions?
- [ ] Validated operation scope (read vs. write)?
3. Endpoint Validation
Threat: Operating on invalid or malicious page/database IDs.
ID Validation
UUID Format Validation:
// Validate Notion UUID format
function isValidNotionId(id: string): boolean {
// Notion IDs are UUIDv4 (with or without dashes)
const uuidRegex = /^[0-9a-f]{8}-?[0-9a-f]{4}-?4[0-9a-f]{3}-?[89ab][0-9a-f]{3}-?[0-9a-f]{12}$/i;
const noDashRegex = /^[0-9a-f]{32}$/i;
return uuidRegex.test(id) || noDashRegex.test(id);
}
// Usage
if (!isValidNotionId(pageId)) {
throw new Error(`❌ Invalid Notion ID format: ${pageId}`);
}
await notion_fetch({id: pageId}); // Now safe
Existence Verification:
// Verify page/database exists before operations
async function verifyExists(id: string, type: "page" | "database"): Promise<void> {
try {
const entity = await notion_fetch({id});
if (type === "database" && !entity.properties) {
throw new Error(`Entity ${id} is not a database`);
}
console.log(`✅ ${type} exists: ${id}`);
} catch (error) {
throw new Error(`❌ ${type} not found or no access: ${id}`);
}
}
// Usage
await verifyExists(databaseId, "database");
await notion_update_database({database_id: databaseId, ...}); // Now safe
URL Parsing Validation:
// Extract and validate ID from Notion URL
function extractNotionId(urlOrId: string): string {
// If already an ID, validate and return
if (isValidNotionId(urlOrId)) {
return urlOrId;
}
// If URL, extract ID
const urlMatch = urlOrId.match(/([0-9a-f]{32}|[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})/i);
if (!urlMatch) {
throw new Error(`❌ Cannot extract valid ID from: ${urlOrId}`);
}
return urlMatch[1];
}
// Usage
const cleanId = extractNotionId(userInput); // Handles URLs or IDs
await notion_fetch({id: cleanId});
Endpoint Checklist
Before operations:
- [ ] Validated ID format (UUID)?
- [ ] Verified entity exists?
- [ ] Confirmed entity type (page vs. database)?
- [ ] Extracted ID from URL correctly?
4. Human Confirmation for Destructive Actions
Threat: Accidental data loss from bulk deletes, moves, or irreversible changes.
Confirmation Patterns
Batch Operations (>10 items):
// Require confirmation for batch operations
async function batchMovePagesWithConfirmation(
pageIds: string[],
targetParentId: string
): Promise<void> {
if (pageIds.length > 10) {
console.log(`⚠️ About to move ${pageIds.length} pages to ${targetParentId}`);
console.log(`Pages: ${pageIds.slice(0, 5).join(', ')}...`);
// ASK USER: "Proceed with moving 50 pages? (yes/no)"
const userConfirmed = await askUser(`Proceed with moving ${pageIds.length} pages?`);
if (!userConfirmed) {
throw new Error("❌ Operation cancelled by user");
}
}
await notion_move_pages({
page_or_database_ids: pageIds,
new_parent: {page_id: targetParentId}
});
console.log(`✅ Moved ${pageIds.length} pages successfully`);
}
Trash Operations:
// Require explicit confirmation for moving to trash
async function moveDatabaseToTrashWithConfirmation(databaseId: string): Promise<void> {
const db = await notion_fetch({id: databaseId});
console.log(`⚠️ WARNING: Moving database to trash`);
console.log(`Database: ${db.title}`);
console.log(`This action CANNOT be undone via API`);
// ASK USER: "Are you SURE? Type database name to confirm:"
const userInput = await askUser("Type database name to confirm:");
if (userInput !== db.title) {
throw new Error("❌ Confirmation failed: name mismatch");
}
await notion_update_database({
database_id: databaseId,
in_trash: true
});
console.log(`✅ Database moved to trash: ${databaseId}`);
}
Destructive Updates:
// Warn before removing database properties
async function removePropertyWithWarning(
databaseId: string,
propertyName: string
): Promise<void> {
console.log(`⚠️ About to remove property: "${propertyName}"`);
console.log(`This will DELETE all data in this column`);
const confirmed = await askUser("Confirm removal? (yes/no)");
if (!confirmed) {
throw new Error("❌ Operation cancelled");
}
await notion_update_database({
database_id: databaseId,
properties: {
[propertyName]: null // Remove property
}
});
console.log(`✅ Property removed: ${propertyName}`);
}
Confirmation Checklist
Require human confirmation for:
- [ ] Batch operations (>10 items)
- [ ] Moving to trash
- [ ] Removing database properties
- [ ] Bulk content replacement
- [ ] Irreversible changes
Error Handling Patterns
Comprehensive error handling strategies for robust Notion MCP operations. Covers retry logic, rate limits, timeouts, partial failures, and recovery patterns.
1. Retry Logic with Exponential Backoff
Use Case: Handle transient network errors, temporary Notion API unavailability, or 5xx server errors.
Pattern: Exponential backoff with jitter to avoid thundering herd problem.
async function retryWithBackoff<T>(
operation: () => Promise<T>,
options: {
maxRetries?: number;
initialDelayMs?: number;
maxDelayMs?: number;
backoffMultiplier?: number;
} = {}
): Promise<T> {
const {
maxRetries = 3,
initialDelayMs = 1000,
maxDelayMs = 10000,
backoffMultiplier = 2
} = options;
let lastError: Error;
let delayMs = initialDelayMs;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await operation();
} catch (error) {
lastError = error;
// Don't retry on 4xx errors (client errors)
if (error.status >= 400 && error.status < 500) {
throw error;
}
// Last attempt, throw error
if (attempt === maxRetries) {
throw new Error(
`❌ Operation failed after ${maxRetries + 1} attempts: ${error.message}`
);
}
// Calculate delay with jitter (±25%)
const jitter = delayMs * 0.25 * (Math.random() * 2 - 1);
const actualDelay = Math.min(delayMs + jitter, maxDelayMs);
console.log(
`⚠️ Attempt ${attempt + 1}/${maxRetries + 1} failed. Retrying in ${Math.round(actualDelay)}ms...`
);
await new Promise(resolve => setTimeout(resolve, actualDelay));
// Exponential backoff
delayMs = Math.min(delayMs * backoffMultiplier, maxDelayMs);
}
}
throw lastError!;
}
Usage Example:
// Retry notion-fetch with exponential backoff
const page = await retryWithBackoff(
() => notion_fetch({id: "page-id"}),
{maxRetries: 3, initialDelayMs: 1000}
);
Retry Decision Matrix:
Error Type | Retry? | Max Attempts | Backoff
---------------------------|--------|--------------|----------
5xx (Server Error) | ✅ Yes | 3 | Exponential
429 (Rate Limit) | ✅ Yes | 5 | Use Retry-After header
Network Timeout | ✅ Yes | 3 | Exponential
Connection Refused | ✅ Yes | 2 | Linear
400 (Bad Request) | ❌ No | 0 | N/A
401 (Unauthorized) | ❌ No | 0 | N/A
404 (Not Found) | ❌ No | 0 | N/A
409 (Conflict) | ⚠️ Maybe | 1 | Short delay
2. Timeout Handling
Use Case: Prevent hanging operations, especially for long-running API calls like notion-duplicate-page or bulk operations.
Pattern: Implement configurable timeouts with graceful degradation.
async function withTimeout<T>(
operation: Promise<T>,
timeoutMs: number,
operationName: string
): Promise<T> {
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => {
reject(new Error(`❌ ${operationName} timed out after ${timeoutMs}ms`));
}, timeoutMs);
});
try {
return await Promise.race([operation, timeoutPromise]);
} catch (error) {
if (error.message.includes('timed out')) {
console.log(`⚠️ Timeout occurred for: ${operationName}`);
// Log for monitoring/alerting
// Could also attempt cleanup here
}
throw error;
}
}
Usage Example:
// Set 30-second timeout for duplicate operation
const duplicated = await withTimeout(
notion_duplicate_page({page_id: "template-id"}),
30000,
"notion-duplicate-page"
);
Recommended Timeouts by Operation:
const TIMEOUTS = {
'notion-search': 10000, // 10s (search can be slow)
'notion-fetch': 5000, // 5s (single page)
'notion-create-database': 15000, // 15s (schema creation)
'notion-create-pages': 20000, // 20s (batch creation)
'notion-update-page': 10000, // 10s (content update)
'notion-update-database': 15000, // 15s (schema update)
'notion-duplicate-page': 30000, // 30s (async operation)
'notion-move-pages': 20000, // 20s (bulk operation)
'bulk-operations': 60000 // 60s (large batches)
};
Timeout Pattern with Partial Success:
async function batchCreateWithTimeout(
pages: any[],
batchSize: number = 100,
timeoutPerBatchMs: number = 20000
): Promise<{successful: any[], failed: any[]}> {
const successful: any[] = [];
const failed: any[] = [];
for (let i = 0; i < pages.length; i += batchSize) {
const batch = pages.slice(i, i + batchSize);
try {
const result = await withTimeout(
notion_create_pages({pages: batch}),
timeoutPerBatchMs,
`Batch ${Math.floor(i / batchSize) + 1}`
);
successful.push(...result.results);
} catch (error) {
console.log(`❌ Batch ${Math.floor(i / batchSize) + 1} failed: ${error.message}`);
failed.push(...batch);
}
}
return {successful, failed};
}
3. Rate Limit Error Recovery
Use Case: Handle Notion API rate limits (180 requests/min general, 30 requests/min for searches).
Pattern: Respect Retry-After header and implement adaptive rate limiting.
class RateLimitHandler {
private requestCount: number = 0;
private windowStartMs: number = Date.now();
private readonly windowMs: number = 60000; // 1 minute
private readonly maxRequests: number = 170; // Leave 10 request buffer
async throttle(): Promise<void> {
const now = Date.now();
const elapsed = now - this.windowStartMs;
// Reset window if expired
if (elapsed >= this.windowMs) {
this.requestCount = 0;
this.windowStartMs = now;
return;
}
// Check if approaching limit
if (this.requestCount >= this.maxRequests) {
const waitMs = this.windowMs - elapsed;
console.log(`⚠️ Rate limit approaching. Waiting ${waitMs}ms...`);
await new Promise(resolve => setTimeout(resolve, waitMs));
// Reset after waiting
this.requestCount = 0;
this.windowStartMs = Date.now();
}
this.requestCount++;
}
async handleRateLimitError(error: any): Promise<void> {
if (error.status === 429) {
// Use Retry-After header if available
const retryAfterSeconds = error.headers?.['retry-after'];
const waitMs = retryAfterSeconds
? parseInt(retryAfterSeconds) * 1000
: 60000; // Default 60s
console.log(`⚠️ Rate limit hit (429). Waiting ${waitMs}ms before retry...`);
await new Promise(resolve => setTimeout(resolve, waitMs));
// Reset counter after waiting
this.requestCount = 0;
this.windowStartMs = Date.now();
}
}
}
// Usage
const rateLimiter = new RateLimitHandler();
async function notionApiCall(operation: () => Promise<any>): Promise<any> {
await rateLimiter.throttle();
try {
return await operation();
} catch (error) {
if (error.status === 429) {
await rateLimiter.handleRateLimitError(error);
// Retry once after rate limit wait
return await operation();
}
throw error;
}
}
Adaptive Rate Limiting for Search Operations (30/min limit):
class SearchRateLimiter {
private searchCount: number = 0;
private searchWindowStart: number = Date.now();
private readonly searchLimit: number = 25; // Buffer of 5
async throttleSearch(): Promise<void> {
const now = Date.now();
const elapsed = now - this.searchWindowStart;
if (elapsed >= 60000) {
this.searchCount = 0;
this.searchWindowStart = now;
return;
}
if (this.searchCount >= this.searchLimit) {
const waitMs = 60000 - elapsed;
console.log(`⚠️ Search rate limit approaching. Waiting ${waitMs}ms...`);
await new Promise(resolve => setTimeout(resolve, waitMs));
this.searchCount = 0;
this.searchWindowStart = Date.now();
}
this.searchCount++;
}
}
4. Partial Failure Handling in Bulk Operations
Use Case: Handle scenarios where only some items in a batch operation fail (e.g., creating 100 pages but 5 fail).
Pattern: Continue-on-error with detailed failure reporting.
interface BatchResult<T> {
successful: T[];
failed: Array<{item: any; error: Error}>;
summary: {
total: number;
succeeded: number;
failed: number;
successRate: number;
};
}
async function batchCreatePagesWithPartialFailure(
pages: any[],
options: {
continueOnError?: boolean;
batchSize?: number;
} = {}
): Promise<BatchResult<any>> {
const {continueOnError = true, batchSize = 100} = options;
const successful: any[] = [];
const failed: Array<{item: any; error: Error}> = [];
for (let i = 0; i < pages.length; i += batchSize) {
const batch = pages.slice(i, i + batchSize);
try {
const result = await notion_create_pages({pages: batch});
successful.push(...result.results);
console.log(`✅ Batch ${Math.floor(i / batchSize) + 1}: ${result.results.length} pages created`);
} catch (error) {
console.log(`❌ Batch ${Math.floor(i / batchSize) + 1} failed: ${error.message}`);
if (continueOnError) {
// Record failures and continue
batch.forEach(item => {
failed.push({item, error});
});
} else {
// Stop on first error
throw new Error(
`Batch operation failed at batch ${Math.floor(i / batchSize) + 1}. ` +
`Completed: ${successful.length}/${pages.length}. ` +
`Error: ${error.message}`
);
}
}
}
const summary = {
total: pages.length,
succeeded: successful.length,
failed: failed.length,
successRate: (successful.length / pages.length) * 100
};
return {successful, failed, summary};
}
Usage with Retry for Failed Items:
async function createPagesWithRetry(
pages: any[],
maxRetries: number = 2
): Promise<BatchResult<any>> {
let result = await batchCreatePagesWithPartialFailure(pages);
// Retry failed items
for (let retry = 0; retry < maxRetries && result.failed.length > 0; retry++) {
console.log(
`\n⚠️ Retry ${retry + 1}/${maxRetries}: Retrying ${result.failed.length} failed items...`
);
const failedPages = result.failed.map(f => f.item);
const retryResult = await batchCreatePagesWithPartialFailure(failedPages);
// Update results
result.successful.push(...retryResult.successful);
result.failed = retryResult.failed;
// Update summary
result.summary = {
total: pages.length,
succeeded: result.successful.length,
failed: result.failed.length,
successRate: (result.successful.length / pages.length) * 100
};
}
// Final report
console.log(`\n📊 Final Results:`);
console.log(` Total: ${result.summary.total}`);
console.log(` ✅ Succeeded: ${result.summary.succeeded}`);
console.log(` ❌ Failed: ${result.summary.failed}`);
console.log(` 📈 Success Rate: ${result.summary.successRate.toFixed(1)}%`);
return result;
}
Granular Failure Handling (Item-by-item):
async function createPagesIndividually(
pages: any[]
): Promise<BatchResult<any>> {
const successful: any[] = [];
const failed: Array<{item: any; error: Error}> = [];
for (let i = 0; i < pages.length; i++) {
const page = pages[i];
try {
const result = await notion_create_pages({pages: [page]});
successful.push(result.results[0]);
if ((i + 1) % 10 === 0) {
console.log(`✅ Progress: ${i + 1}/${pages.length} pages processed`);
}
} catch (error) {
console.log(`❌ Failed to create page ${i + 1}: ${error.message}`);
failed.push({item: page, error});
}
}
const summary = {
total: pages.length,
succeeded: successful.length,
failed: failed.length,
successRate: (successful.length / pages.length) * 100
};
return {successful, failed, summary};
}
5. Network Error Patterns
Use Case: Handle network connectivity issues, DNS failures, connection timeouts, and SSL errors.
Pattern: Differentiate between retryable and non-retryable network errors.
interface NetworkError extends Error {
code?: string;
errno?: number;
}
function isRetryableNetworkError(error: NetworkError): boolean {
// Retryable network error codes
const retryableCodes = [
'ETIMEDOUT', // Connection timeout
'ECONNRESET', // Connection reset by peer
'ECONNREFUSED', // Connection refused (server down)
'EPIPE', // Broken pipe
'ENOTFOUND', // DNS lookup failed (temporary)
'EAI_AGAIN', // DNS lookup timeout
'ENETUNREACH', // Network unreachable
'EHOSTUNREACH' // Host unreachable
];
return retryableCodes.includes(error.code || '');
}
function isNonRetryableNetworkError(error: NetworkError): boolean {
// Non-retryable errors (configuration/permanent issues)
const nonRetryableCodes = [
'ENOTFOUND', // DNS lookup failed (if persistent)
'EPROTO', // Protocol error (SSL/TLS)
'CERT_HAS_EXPIRED', // SSL certificate expired
'UNABLE_TO_VERIFY_LEAF_SIGNATURE' // SSL verification failed
];
// If error persists for >3 attempts, consider ENOTFOUND non-retryable
return nonRetryableCodes.includes(error.code || '');
}
async function handleNetworkError(
operation: () => Promise<any>,
maxRetries: number = 3
): Promise<any> {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await operation();
} catch (error) {
const networkError = error as NetworkError;
// Non-retryable errors
if (isNonRetryableNetworkError(networkError)) {
console.log(`❌ Non-retryable network error: ${networkError.code}`);
throw new Error(
`Network configuration error: ${networkError.code}. ` +
`Check DNS, SSL certificates, and network connectivity.`
);
}
// Retryable errors
if (isRetryableNetworkError(networkError)) {
if (attempt === maxRetries) {
throw new Error(
`❌ Network error persisted after ${maxRetries + 1} attempts: ${networkError.code}`
);
}
const delayMs = Math.min(1000 * Math.pow(2, attempt), 10000);
console.log(
`⚠️ Network error (${networkError.code}). Retry ${attempt + 1}/${maxRetries} in ${delayMs}ms...`
);
await new Promise(resolve => setTimeout(resolve, delayMs));
continue;
}
// Unknown error, rethrow
throw error;
}
}
}
Network Health Check:
async function checkNetworkHealth(): Promise<{
healthy: boolean;
latencyMs: number;
error?: string;
}> {
const startMs = Date.now();
try {
// Ping Notion API with lightweight request
await notion_get_self();
const latencyMs = Date.now() - startMs;
return {
healthy: true,
latencyMs
};
} catch (error) {
return {
healthy: false,
latencyMs: Date.now() - startMs,
error: error.message
};
}
}
// Usage: Pre-flight check before bulk operations
async function performBulkOperationWithHealthCheck(
operation: () => Promise<any>
): Promise<any> {
const health = await checkNetworkHealth();
if (!health.healthy) {
throw new Error(
`❌ Network health check failed: ${health.error}. ` +
`Cannot proceed with bulk operation.`
);
}
if (health.latencyMs > 5000) {
console.log(
`⚠️ High network latency detected (${health.latencyMs}ms). ` +
`Operation may be slow.`
);
}
return await operation();
}
6. 4xx vs 5xx Error Handling Strategies
Use Case: Differentiate between client errors (4xx, user's fault) and server errors (5xx, Notion's fault).
Pattern: Log and fix for 4xx, retry for 5xx.
interface ApiError extends Error {
status: number;
code?: string;
details?: any;
}
function handleApiError(error: ApiError, context: string): never {
// 4xx: Client Errors (User/Configuration Issue)
if (error.status >= 400 && error.status < 500) {
switch (error.status) {
case 400:
throw new Error(
`❌ Bad Request in ${context}: ${error.message}\n` +
`Fix: Verify request parameters, property names, and data types.\n` +
`Details: ${JSON.stringify(error.details, null, 2)}`
);
case 401:
throw new Error(
`❌ Unauthorized in ${context}: ${error.message}\n` +
`Fix: Check Notion integration token and permissions.\n` +
`Run: await verifyBotPermissions()`
);
case 403:
throw new Error(
`❌ Forbidden in ${context}: ${error.message}\n` +
`Fix: Bot lacks permission to access this resource.\n` +
`Check: Team membership and database sharing settings.`
);
case 404:
throw new Error(
`❌ Not Found in ${context}: ${error.message}\n` +
`Fix: Verify page/database ID exists and bot has access.\n` +
`Try: await notion_search({query: "resource name"})`
);
case 409:
throw new Error(
`❌ Conflict in ${context}: ${error.message}\n` +
`Fix: Resource was modified concurrently. Retry with latest version.\n` +
`Strategy: Fetch latest → Merge changes → Retry update`
);
case 429:
throw new Error(
`❌ Rate Limit Exceeded in ${context}\n` +
`Fix: Reduce request frequency or wait before retry.\n` +
`Current Limits: 180 req/min (general), 30 req/min (search)`
);
default:
throw new Error(
`❌ Client Error ${error.status} in ${context}: ${error.message}`
);
}
}
// 5xx: Server Errors (Notion API Issue - Retry)
if (error.status >= 500 && error.status < 600) {
switch (error.status) {
case 500:
console.log(`⚠️ Internal Server Error in ${context}. Retryable.`);
break;
case 502:
console.log(`⚠️ Bad Gateway in ${context}. Retryable.`);
break;
case 503:
console.log(`⚠️ Service Unavailable in ${context}. Retryable.`);
break;
case 504:
console.log(`⚠️ Gateway Timeout in ${context}. Retryable.`);
break;
}
throw error; // Let retry handler catch it
}
// Unknown error
throw error;
}
Comprehensive API Call Wrapper:
async function safeNotionApiCall<T>(
operation: () => Promise<T>,
context: string,
options: {
maxRetries?: number;
timeoutMs?: number;
} = {}
): Promise<T> {
const {maxRetries = 3, timeoutMs = 10000} = options;
try {
// Add timeout
const result = await withTimeout(operation(), timeoutMs, context);
return result;
} catch (error) {
const apiError = error as ApiError;
// Handle 4xx (don't retry)
if (apiError.status >= 400 && apiError.status < 500) {
handleApiError(apiError, context);
}
// Handle 5xx (retry)
if (apiError.status >= 500 && apiError.status < 600) {
console.log(`⚠️ Server error in ${context}. Will retry...`);
return await retryWithBackoff(
operation,
{maxRetries, initialDelayMs: 2000}
);
}
// Handle network errors
if (apiError.code && isRetryableNetworkError(apiError as NetworkError)) {
console.log(`⚠️ Network error in ${context}. Will retry...`);
return await handleNetworkError(operation, maxRetries);
}
// Unknown error, rethrow
throw error;
}
}
Usage Example:
// Wrap all Notion API calls
const scenes = await safeNotionApiCall(
() => notion_create_database({
title: [{text: {content: "Scenes"}}],
properties: {...}
}),
"create-scenes-database",
{maxRetries: 3, timeoutMs: 15000}
);
7. Error Recovery Checklist
Use this checklist before deploying bulk operations or critical workflows:
Error Handling Verification:
- [ ] Retry logic implemented for 5xx errors
- [ ] Exponential backoff with jitter configured
- [ ] Timeout configured for each operation type
- [ ] Rate limit handling implemented (180/min general, 30/min search)
- [ ] Partial failure handling for batch operations
- [ ] Network error detection and retry
- [ ] 4xx errors logged with fix instructions
- [ ] Human confirmation for destructive operations
- [ ] Pre-flight network health check
- [ ] Detailed error logging with context
- [ ] Rollback plan for failed operations
- [ ] Monitoring/alerting for repeated failures
Error Handling Test Plan:
// Test error scenarios
async function testErrorHandling() {
console.log("🧪 Testing Error Handling...\n");
// Test 1: Invalid page ID (404)
try {
await notion_fetch({id: "invalid-id"});
} catch (error) {
console.log("✅ Test 1 (404 handling): Passed");
}
// Test 2: Rate limit simulation
// (Make 200 requests rapidly to trigger 429)
// Test 3: Timeout handling
try {
await withTimeout(
new Promise(resolve => setTimeout(resolve, 10000)),
1000,
"test-operation"
);
} catch (error) {
console.log("✅ Test 3 (timeout): Passed");
}
// Test 4: Partial batch failure
const result = await batchCreatePagesWithPartialFailure([
{properties: {Title: "Valid"}},
{properties: {}}, // Invalid: missing title
{properties: {Title: "Valid 2"}}
]);
console.log(`✅ Test 4 (partial failure): ${result.summary.succeeded}/3 succeeded`);
}
Summary: Error handling is critical for production Notion integrations. Always implement:
- Retry with exponential backoff for transient errors
- Timeouts to prevent hanging operations
- Rate limit awareness and adaptive throttling
- Partial failure handling with detailed reporting
- Network error differentiation and recovery
- 4xx logging (fix) vs 5xx retry strategy
- Pre-deployment error handling verification
Primary Workflows
Workflow 1: Create Escape Room Template from Design
Use Case: "Design and implement a murder mystery escape room in Notion"
Process:
Copy this checklist:
Notion Escape Room Build:
- [ ] Step 1: Design narrative (narrative-architect-korean-style)
- [ ] Step 2: Create puzzle inventory (language-agnostic-puzzle-designer)
- [ ] Step 3: Plan database architecture (notion-formula-master)
- [ ] Step 4: Save session context (Serena MCP)
- [ ] Step 5: Create databases via Notion MCP
- [ ] Step 6: Implement formulas and relationships
- [ ] Step 7: Create initial content pages
- [ ] Step 8: Add localization (escape-room-localizer)
- [ ] Step 9: Self-test and iterate
- [ ] Step 10: Save final session context
Step 1: Design Narrative
Invoke narrative-architect-korean-style skill to create:
- 3-act story structure
- Character profiles
- Multiple endings
- Emotional beat mapping
Step 2: Create Puzzle Inventory
Invoke language-agnostic-puzzle-designer skill to create:
- 12-18 puzzles (visual/logic mix)
- Difficulty progression
- Hint system (3 levels per puzzle)
Step 3: Plan Database Architecture
Invoke notion-formula-master skill to design:
- Scenes database (story progression)
- Items database (inventory)
- Puzzles database (challenges)
- PlayerState database (progress tracking)
- Endings database (multiple conclusions)
Reference Guides: Before implementation, review:
- Escape Room Database Architecture - Complete 5-database structure with all properties
- Relation Design Patterns - How to connect databases (Scenes ↔ Puzzles, etc.)
- Game Mechanics Formulas - Unlocking logic, progress tracking, hint systems
- Phase-Based Implementation - Optimal API call order (11 calls vs 23)
- Antipatterns & Gotchas - Common mistakes to avoid
Step 4: Save Session Context
# Use Serena MCP to save design outputs
# Invoke: /sc:save --context "escape-room-design-phase"
Step 5: Create Databases
Use mcp__notion__notion-create-database for each database:
// Example: Create Scenes database
{
"title": [{"text": {"content": "Scenes"}}],
"properties": {
"Scene ID": {"title": {}},
"Description": {"rich_text": {}},
"Unlocked": {"checkbox": {}},
"Order": {"number": {"format": "number"}},
"Act": {"select": {"options": [
{"name": "Act 1", "color": "blue"},
{"name": "Act 2", "color": "yellow"},
{"name": "Act 3", "color": "green"}
]}},
"Previous Scene": {"relation": {"data_source_id": "<scenes-db-id>", "type": "single_property"}}
}
}
Rate Limit Strategy: Create databases sequentially (5 databases = 5 requests, well under 180/min limit).
Step 6: Implement Formulas and Rollups
Use mcp__notion__notion-update-database to add formula properties and rollups:
// Example 1: Scene unlock condition formula
{
"database_id": "<scenes-db-id>",
"properties": {
"Unlock Condition": {
"formula": {
"expression": "if(prop(\"Scene ID\") == \"S001\", true, prop(\"Previous Scene\").prop(\"Unlocked\"))"
}
}
}
}
// Example 2: Rollup to count solved puzzles
{
"database_id": "<scenes-db-id>",
"properties": {
"Solved Count": {
"rollup": {
"relation_property_name": "Required Puzzles",
"rollup_property_name": "Solved",
"function": "checked"
}
}
}
}
…(truncated)