This directory contains the backend logic for the Notion webhook integration application.
backend/
├── controllers/ # Business logic and orchestration
│ ├── healthController.ts
│ ├── pageController.ts
│ └── webhookController.ts (example)
├── routes/ # HTTP request/response handling
│ ├── api/ # RESTful API endpoints
│ │ ├── index.ts
│ │ └── pages.ts
│ ├── tasks/ # Webhook handlers
│ ├── views/ # User-facing pages
│ └── authCheck.ts # Authentication middleware
├── crons/ # Val Town cron trigger handlers
│ └── (scheduled tasks)
├── email/ # Val Town email handlers & utilities
│ └── (email triggers & sending)
├── services/ # External API integrations
│ └── notion/
│ ├── index.ts
│ ├── pages.ts
│ └── databases.ts
└── utils/ # Backend-only utility functions
└── (pure functions, no external deps)
Responsibility: HTTP ONLY - Extract parameters, apply middleware, call controllers, format HTTP responses
routes/api/ - RESTful API endpoints for data operations
/api/pages/:id, /api/databases/:idroutes/tasks/ - Webhook handlers from external services
/tasks/notion-webhookroutes/views/ - User-facing HTML/React views
/views/dashboardroutes/authCheck.ts - Authentication middleware
webhookAuth(c, next) - Shared secret authentication using X-API-KEY header/api/* and /tasks/* routesmain.http.tsx200 - Success400 - Validation errors, bad request401 - Authentication failures500 - Server/external service errorsimport { Hono } from "npm:hono@4";
import * as pageController from '../../controllers/pageController.ts';
const pages = new Hono();
pages.get('/:id', async (c) => {
const pageId = c.req.param('id');
const result = await pageController.getPage(pageId);
if (!result.success) {
return c.json({ error: result.error, details: result.details }, 400);
}
return c.json(result.data);
});
export default pages;
Responsibility: Execute scheduled tasks on intervals using Val Town's cron feature
Val Town Cron Documentation: https://docs.val.town/vals/cron/
Crons are Val Town's scheduled task feature. They run on a specified interval (e.g., every hour, daily, weekly) and execute code automatically.
Key Characteristics:
.cron.tsx file extensionUse crons for:
Do NOT use for:
Place cron handlers in backend/crons/:
backend/crons/
├── syncNotionData.cron.tsx # Sync Notion data every hour
├── cleanupOldRecords.cron.tsx # Delete old data daily
└── sendDailyReport.cron.tsx # Email daily summaries
File: backend/crons/syncNotionData.cron.tsx
import * as pageController from '../controllers/pageController.ts';
/**
* Sync Notion data every hour
* This cron runs automatically on Val Town's scheduler
*/
export default async function syncNotionData(interval: Interval) {
console.log(`[Cron] Starting Notion sync at ${new Date().toISOString()}`);
try {
// Call controller (same as routes do)
const result = await pageController.syncAllPages();
if (!result.success) {
console.error(`[Cron] Sync failed: ${result.error}`);
return;
}
console.log(`[Cron] Sync completed: ${result.data.synced} pages updated`);
} catch (err) {
console.error(`[Cron] Unexpected error:`, err);
}
}
Crons are configured in Val Town's UI with:
0 * * * * (every hour at minute 0)Scheduler → Cron Handler → Controller → Service → External API
Example:
Every Hour → syncNotionData.cron.tsx → pageController.syncAllPages() → notionService.queryDatabase() → Notion API
Responsibility: Handle incoming emails and send outgoing emails using Val Town's email system
Val Town Email Documentation:
Val Town provides two email capabilities:
backend/email/
├── inbound.email.tsx # Handle incoming emails (trigger)
├── processTicket.email.tsx # Process support emails (trigger)
└── notifications.ts # Send email utilities (called by controllers)
When to Use:
File Extension: .email.tsx
Handler Pattern:
import { email } from "https://esm.town/v/std/email";
import * as ticketController from '../controllers/ticketController.ts';
/**
* Handle incoming support emails
* Triggered when email is received at your-val@valtown.email
*/
export default async function processTicket(email: Email) {
console.log(`[Email] Received from: ${email.from}`);
console.log(`[Email] Subject: ${email.subject}`);
try {
// Call controller to process email
const result = await ticketController.createTicketFromEmail({
from: email.from,
subject: email.subject,
body: email.text,
});
if (!result.success) {
console.error(`[Email] Failed to create ticket: ${result.error}`);
// Send error response
return await email.send({
to: email.from,
subject: "Re: " + email.subject,
text: "Sorry, we couldn't process your email. Please try again.",
});
}
// Send success response
return await email.send({
to: email.from,
subject: "Re: " + email.subject,
text: `Ticket created: ${result.data.ticketId}`,
});
} catch (err) {
console.error(`[Email] Unexpected error:`, err);
}
}
Email Trigger Rules:
.email.tsx extensionWhen to Use:
File Extension: .ts (regular TypeScript)
Utility Pattern:
// backend/email/notifications.ts
import { email } from "https://esm.town/v/std/email";
/**
* Send a notification email about a Notion page update
* Called by controllers/services
*/
export async function sendPageUpdateNotification(
to: string,
pageTitle: string,
changes: string[]
) {
try {
await email.send({
to: to,
subject: `Notion Page Updated: ${pageTitle}`,
text: `
The following changes were made:
${changes.map(c => `- ${c}`).join('\n')}
View page: [link]
`.trim(),
});
return { success: true, data: null, error: null };
} catch (err: any) {
return {
success: false,
data: null,
error: err.message || 'Failed to send email'
};
}
}
/**
* Send a daily summary report
* Called by cron handlers
*/
export async function sendDailySummary(
to: string,
summary: { pages: number; updates: number }
) {
try {
await email.send({
to: to,
subject: "Daily Notion Summary",
text: `
Daily Summary Report
====================
Pages synced: ${summary.pages}
Updates made: ${summary.updates}
Generated at: ${new Date().toISOString()}
`.trim(),
});
return { success: true, data: null, error: null };
} catch (err: any) {
return {
success: false,
data: null,
error: err.message || 'Failed to send summary'
};
}
}
Email Utility Rules:
{success, data, error} formatstd/email libraryFrom Controllers:
// backend/controllers/pageController.ts
import * as emailNotifications from '../email/notifications.ts';
export async function updatePageAndNotify(pageId: string, changes: any) {
// Update page via service
const result = await notionService.updateNotionPage(pageId, changes);
if (!result.success) {
return { success: false, data: null, error: result.error };
}
// Send notification
await emailNotifications.sendPageUpdateNotification(
'user@example.com',
'My Page',
['Title changed', 'Status updated']
);
return { success: true, data: result.data, error: null };
}
From Crons:
// backend/crons/sendDailyReport.cron.tsx
import * as emailNotifications from '../email/notifications.ts';
export default async function sendDailyReport(interval: Interval) {
const summary = { pages: 42, updates: 15 };
await emailNotifications.sendDailySummary(
'admin@example.com',
summary
);
}
Email Trigger Flow:
Incoming Email → Email Handler → Controller → Service → External API
↓
email.send() (reply)
Email Utility Flow:
Route/Cron → Controller → Email Utility → std/email.send()
Responsibility: Business logic, validation, orchestration, data transformation
IMPORTANT: Organize controllers by purpose or context. Each controller handles a specific domain or concern.
Examples:
healthController.ts - System health checks (no services, just env checks)pageController.ts - Notion page operations (calls Notion service)webhookController.ts - Webhook processing logicPattern: Group related operations in one controller. Controllers can grow as features are added.
All controller functions MUST return this exact structure:
{
success: boolean,
data: any | null,
error: string | null,
details?: string // Additional error context
}
pageId, not task/project-specific)Pattern 1: Controller with Service Calls (pageController.ts)
import * as notionService from '../services/notion/index.ts';
export async function updatePage(pageId: string, properties: any) {
// Validate input
if (!pageId || typeof pageId !== 'string') {
return {
success: false,
data: null,
error: "Invalid pageId",
details: "pageId must be a non-empty string"
};
}
// Call service
const result = await notionService.updateNotionPage(pageId, properties);
if (!result.success) {
return {
success: false,
data: null,
error: "Failed to update page",
details: result.error
};
}
// Filter sensitive data
const filteredData = filterSensitiveProperties(result.data);
return {
success: true,
data: filteredData,
error: null
};
}
Pattern 2: Controller without Service Calls (healthController.ts)
import { getProjectInfo } from '../utils/valtown.ts';
/**
* Get system health status
* PUBLIC endpoint - no authentication required
*/
export function getHealth() {
// Check system state (no service calls needed)
// Can use utils for server-side information
return {
success: true,
data: {
timestamp: new Date().toISOString(),
project: getProjectInfo(), // Val Town project info
configuration: {
NOTION_API_KEY: !!Deno.env.get('NOTION_API_KEY'),
WEBHOOK_SECRET: !!Deno.env.get('WEBHOOK_SECRET')
}
},
error: null
};
}
Responsibility: External API calls ONLY - No business logic
For complex services (multiple concerns): Use directory structure
services/notion/
├── index.ts # Initialize client, re-export all
├── pages.ts # Page operations
└── databases.ts # Database operations
For simple services (single concern): Use single file
services/
├── firecrawlerService.ts
└── blobService.ts
// services/notion/index.ts
import { Client } from "npm:@notionhq/client@2";
export const notion = new Client({ auth: Deno.env.get("NOTION_API_KEY") });
export * from "./pages.ts";
export * from "./databases.ts";
// services/notion/pages.ts
import { notion } from "./index.ts";
export async function getNotionPage(pageId: string) {
try {
const page = await notion.pages.retrieve({ page_id: pageId });
return {
success: true,
data: page,
error: null
};
} catch (err: any) {
return {
success: false,
data: null,
error: err.message || 'Failed to fetch Notion page'
};
}
}
{success, data, error}Responsibility: Pure utility functions with NO external dependencies and NO business logic
✅ Backend-only utilities that can use Deno APIs:
cryptoUtils.ts - Hashing, encryption, JWT signing (Web Crypto API)dateUtils.ts - Date formatting, timezone conversionsvalidationUtils.ts - Input validation helpers (using Deno features)logUtils.ts - Logging formattersenvUtils.ts - Environment variable parsers❌ External API calls → use services/
❌ Business logic → use controllers/
❌ Browser-compatible code → use shared/utils.ts
❌ Database operations → use services/
// utils/cryptoUtils.ts
/**
* Generate a SHA-256 hash of a string
*/
export async function sha256(message: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(message);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}
shared/utils.ts)┌─────────────────────────────────────────────────────────────────┐
│ TRIGGERS (Layer 1) │
│ ┌────────────┐ ┌───────────┐ ┌─────────────┐ │
│ │ HTTP Route │ │ Cron │ │ Email Trigger│ │
│ │ (routes/) │ │ (crons/) │ │ (email/) │ │
│ └──────┬─────┘ └─────┬─────┘ └──────┬──────┘ │
└─────────┼────────────────┼──────────────────┼──────────────────┘
│ │ │
└────────────────┴──────────────────┘
│
v
┌────────────────┐
│ Controller │ Validate, orchestrate, transform
│ (Business) │ Can use utils/ and email/
└────────┬───────┘
│
v
┌────────────────┐
│ Service │ External API calls only
│ (External) │
└────────┬───────┘
│
v
┌────────────────┐
│ External API │ Notion, Firecrawler, etc.
└────────────────┘
HTTP Route Flow:
HTTP Request → Route (routes/) → Controller → Service → External API
↓
HTTP Response
Cron Flow:
Scheduler → Cron Handler (crons/) → Controller → Service → External API
↓
Execute & Complete
Email Trigger Flow:
Incoming Email → Email Handler (email/) → Controller → Service → External API
↓
email.send() (reply)
Email Utility Flow (called by controllers):
Controller → Email Utility (email/) → std/email.send()
NEVER skip layers!
All backend code uses npm: imports for packages:
import { Hono } from "npm:hono@4";
import { Client } from "npm:@notionhq/client@2";
For Val Town utilities, use URL imports:
import { readFile, serveFile } from "https://esm.town/v/std/utils@85-main/index.ts";
First, determine which trigger type fits your use case:
| Trigger Type | When to Use | File Extension | Example |
|---|---|---|---|
| HTTP Route | User requests, webhooks, API endpoints | .ts in routes/ | /api/pages/:id |
| Cron | Scheduled tasks, periodic sync | .cron.tsx in crons/ | Sync data every hour |
| Email Trigger | Incoming email handling | .email.tsx in email/ | Process support emails |
| Email Utility | Send emails from code | .ts in email/ | Send notifications |
✅ Create route file in appropriate directory:
routes/tasks/routes/api/routes/views/✅ Extract request data in route (params, query, body)
✅ Create controller function in controllers/[feature].ts
✅ Create/update service functions in services/[service].ts
{success, data, error} structure✅ Return standardized response from controller
✅ Format HTTP response in route based on controller result
✅ Add TypeScript types to shared/types.ts if data is shared across layers
✅ Import route into main.http.tsx and mount on Hono app
✅ Test the endpoint
Note: Authentication is applied globally in main.http.tsx for all /api/* and /tasks/* routes (except public endpoints like /api/health). No per-route authentication setup needed.
✅ Create cron file in crons/ with .cron.tsx extension
crons/syncNotionData.cron.tsx✅ Export default async function that accepts interval: Interval
✅ Add logging for start, completion, and errors
✅ Call controller functions (same as routes do)
✅ Create/update controller function if needed
✅ Create/update service functions if needed
✅ Configure schedule in Val Town UI
0 * * * * for hourly)✅ Test by running manually in Val Town
✅ Monitor logs to ensure it runs on schedule
✅ Create email trigger file in email/ with .email.tsx extension
email/processTicket.email.tsx✅ Export default async function that accepts email: Email
✅ Add logging for incoming emails
✅ Call controller functions to process email content
✅ Send response using email.send() if needed
✅ Configure email address in Val Town UI
your-val@valtown.email✅ Test by sending email to your Val Town address
✅ Create utility file in email/ with .ts extension
email/notifications.ts✅ Export functions that send emails
✅ Return standardized response {success, data, error}
✅ Import and call from controllers, services, or crons
✅ Test by calling from your code
Services: Return errors, don't throw
return { success: false, data: null, error: "API call failed" };
Controllers: Add context to service errors
if (!result.success) {
return {
success: false,
data: null,
error: "Failed to update page",
details: result.error
};
}
Routes: Map to HTTP status codes
if (!result.success) {
return c.json({ error: result.error, details: result.details }, 400);
}
Authentication is applied globally in main.http.tsx using array-based route protection.
Middleware: routes/authCheck.ts exports webhookAuth(c, next)
X-API-KEY headerWEBHOOK_SECRET environment variableGlobal Protection in main.http.tsx:
import { webhookAuth } from './backend/routes/authCheck.ts';
// Define protected route prefixes and public exceptions
const protectedPrefixes = ['/api', '/tasks'];
const publicExceptions = ['/api/health'];
// Apply middleware before mounting routes
app.use('*', async (c, next) => {
const path = c.req.path;
// Check if path is a public exception (exact match)
if (publicExceptions.includes(path)) {
await next();
return;
}
// Check if path starts with any protected prefix
const isProtected = protectedPrefixes.some(prefix => path.startsWith(prefix));
if (isProtected) {
await webhookAuth(c, next);
} else {
await next();
}
});
In webhook configuration (Notion buttons, external services):
X-API-KEYWEBHOOK_SECRET environment variableFrom API clients (curl, fetch):
curl -H "X-API-KEY: your-secret" https://your-val.town/api/pages/recent
Protected routes:
/api/* routes (except /api/health)/tasks/* routesPublic routes:
/api/health - No authentication required/ - Frontend HTML/frontend/* - Static files/shared/* - Static filesAccess environment variables using:
const apiKey = Deno.env.get('NOTION_API_KEY');
NEVER hardcode secrets - always use environment variables.
pageId generically, not task/project-specificdetails fieldshared/types.tsCLAUDE.md - Comprehensive project architecture guidelinesshared/types.ts - TypeScript interfacesshared/utils.ts - Browser-compatible utility functions