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- Example:
/api/pages/:id,/api/databases/:id - Returns JSON responses
- Example:
-
routes/tasks/- Webhook handlers from external services- Example:
/tasks/notion-webhook - Handle incoming webhook payloads
- Example:
-
routes/views/- User-facing HTML/React views- Example:
/views/dashboard - Serve HTML pages
- Example:
-
routes/authCheck.ts- Authentication middlewarewebhookAuth(c, next)- Shared secret authentication using X-API-KEY header- Protects
/api/*and/tasks/*routes - Applied globally in
main.http.tsx
- ❌ NO business logic in routes
- ❌ NEVER call services directly from routes
- ✅ Extract request parameters (query, body, headers)
- ✅ Apply authentication middleware
- ✅ Call controller functions
- ✅ Map controller responses to HTTP status codes:
200- Success400- Validation errors, bad request401- Authentication failures500- Server/external service errors
import { 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:
- Use
.cron.tsxfile extension - Run on Val Town's scheduler (not HTTP requests)
- Execute and complete (no return value needed)
- Follow same architecture: Cron handler → Controller → Service
Use crons for:
- ✅ Periodic data synchronization (sync Notion data hourly)
- ✅ Scheduled cleanup tasks (delete old records daily)
- ✅ Regular status checks (check API health every 5 minutes)
- ✅ Batch processing (process queued items every 10 minutes)
- ✅ Scheduled reports (send daily summary emails)
- ✅ Cache warming (refresh cached data periodically)
Do NOT use for:
- ❌ Real-time processing (use routes/webhooks instead)
- ❌ User-initiated actions (use API routes instead)
- ❌ Immediate responses (use HTTP endpoints instead)
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:
- Schedule: Every hour, daily, weekly, custom cron expression
- Example:
0 * * * *(every hour at minute 0)
- ✅ Call controllers (same as routes)
- ✅ Use try/catch for error handling
- ✅ Log start and completion
- ✅ Keep handlers simple (logic in controllers)
- ❌ NO return value required (execute and complete)
- ❌ NO HTTP response formatting
- ❌ NO authentication needed (runs as scheduled)
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:
- Email triggers: https://docs.val.town/vals/email/
- Email utilities: https://docs.val.town/reference/std/email/
Val Town provides two email capabilities:
- Email triggers - Receive emails at a Val Town email address
- Email utilities - Send emails from your vals
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:
- ✅ Process incoming support emails
- ✅ Create Notion pages from email
- ✅ Auto-reply to specific email patterns
- ✅ Parse and route inbound messages
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:
- ✅ Use
.email.tsxextension - ✅ Call controllers for business logic
- ✅ Can send reply emails
- ✅ Return email response (unlike crons)
- ✅ Log incoming email details
- ❌ NO HTTP responses (use email.send instead)
When to Use:
- ✅ Send notifications from controllers
- ✅ Email reports generated by crons
- ✅ Send alerts from services
- ✅ Confirmation emails from API routes
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:
- ✅ Export functions that send emails
- ✅ Return
{success, data, error}format - ✅ Can be called by controllers, services, or crons
- ✅ Handle errors gracefully
- ✅ Use Val Town's
std/emaillibrary - ❌ NO business logic (that's in controllers)
From 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 logic
Pattern: Group related operations in one controller. Controllers can grow as features are added.
- Validate request data
- Orchestrate multiple service calls (or just check system state)
- Transform and filter data
- Remove sensitive information
- Add business logic context to errors
All controller functions MUST return this exact structure:
{
success: boolean,
data: any | null,
error: string | null,
details?: string // Additional error context
}
- ❌ NEVER return HTTP responses (controllers are HTTP-agnostic)
- ❌ NEVER call external APIs directly
- ✅ Call service layer functions for external API operations
- ✅ May check system state without services (e.g., health checks)
- ✅ Return standardized response structure
- ✅ Validate all input data
- ✅ Filter sensitive data (e.g., button properties)
- ✅ Work with generic Notion pages (use
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: {
status: 'ok',
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'
};
}
}
- ✅ Return structured results with
{success, data, error} - ✅ Handle all API errors gracefully (don't throw)
- ✅ Use environment variables for API keys
- ✅ Export typed functions
- ❌ NO business logic
- ❌ NO data validation (that's controller's job)
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('');
}
- ✅ Pure functions with predictable outputs
- ✅ No side effects (no global state)
- ✅ Single responsibility
- ✅ Can use Deno APIs (unlike
shared/utils.ts) - ✅ Type safety with TypeScript
┌─────────────────────────────────────────────────────────────────┐
│ 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!
- HTTP routes, crons, and email triggers all call controllers
- Controllers call services (not external APIs directly)
- Services handle external API calls
- Utils are called by controllers/services for helper functions
- Email utilities can be called by controllers, services, or crons
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:
- Webhook handlers →
routes/tasks/ - API endpoints →
routes/api/ - Views →
routes/views/
- Webhook handlers →
-
✅ Extract request data in route (params, query, body)
-
✅ Create controller function in
controllers/[feature].ts- Implement validation
- Implement business logic
- Orchestrate service calls
-
✅ Create/update service functions in
services/[service].ts- Make external API calls
- Return
{success, data, error}structure
-
✅ Return standardized response from controller
-
✅ Format HTTP response in route based on controller result
-
✅ Add TypeScript types to
shared/types.tsif data is shared across layers -
✅ Import route into
main.http.tsxand 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.tsxextension- Example:
crons/syncNotionData.cron.tsx
- Example:
-
✅ Export default async function that accepts
interval: Interval -
✅ Add logging for start, completion, and errors
-
✅ Call controller functions (same as routes do)
- Validate controller response
- Log results
-
✅ Create/update controller function if needed
-
✅ Create/update service functions if needed
-
✅ Configure schedule in Val Town UI
- Set cron expression (e.g.,
0 * * * *for hourly)
- Set cron expression (e.g.,
-
✅ Test by running manually in Val Town
-
✅ Monitor logs to ensure it runs on schedule
-
✅ Create email trigger file in
email/with.email.tsxextension- Example:
email/processTicket.email.tsx
- Example:
-
✅ 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
- Set up
your-val@valtown.email
- Set up
-
✅ Test by sending email to your Val Town address
-
✅ Create utility file in
email/with.tsextension- Example:
email/notifications.ts
- Example:
-
✅ 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)
- Uses shared secret approach with
X-API-KEYheader - Validates against
WEBHOOK_SECRETenvironment variable - Gracefully degrades in development mode (logs warning if not configured)
Global 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):
- Header name:
X-API-KEY - Header value: Value of
WEBHOOK_SECRETenvironment variable
From API clients (curl, fetch):
curl -H "X-API-KEY: your-secret" https://your-val.town/api/pages/recent
Protected routes:
- All
/api/*routes (except/api/health) - All
/tasks/*routes
Public routes:
/api/health- No authentication required/- Frontend HTML/frontend/*- Static files/shared/*- Static files
Access environment variables using:
const apiKey = Deno.env.get('NOTION_API_KEY');
NEVER hardcode secrets - always use environment variables.
- Layer Separation: Never skip layers in the architecture
- Generic Controllers: Work with
pageIdgenerically, not task/project-specific - Error Context: Add helpful error messages with
detailsfield - Type Safety: Use TypeScript types from
shared/types.ts - No Throwing: Services return error objects, don't throw
- Filter Sensitive Data: Remove button properties and other sensitive data in controllers
- Pure Utils: Keep utility functions pure with no side effects
- Single Responsibility: Each function should do one thing well
CLAUDE.md- Comprehensive project architecture guidelinesshared/types.ts- TypeScript interfacesshared/utils.ts- Browser-compatible utility functions