Hono API Overview
All backend APIs are implemented using Hono and mounted at /api/* via SvelteKit's hooks.server.ts.
Architecture
┌─────────────────────────────────────────────────────────────────┐
│ Browser / Client │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ hooks.server.ts Pipeline │
│ ┌────────────┐ ┌──────────┐ ┌───────────┐ ┌─────────────┐ │
│ │ subdomain │→│ Hono │→│ Supabase │→│ Auth Guard │ │
│ │ Handle │ │ Handle │ │ Handle │ │ Handle │ │
│ └────────────┘ └──────────┘ └───────────┘ └─────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ /api/* routes │ (early return) │
│ │ handled by │ │
│ │ Hono app │ │
│ └─────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Hono API Layer │
│ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ src/api/index.ts │ │
│ │ .basePath('/api') + CORS + Logger │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌────────────────────────┼────────────────────────┐ │
│ ▼ ▼ ▼ │
│ ┌─────────┐ ┌────────────────────────────────┐ ┌─────────┐ │
│ │ Public │ │ Authenticated Routes │ │ Webhook │ │
│ │ Routes │ │ │ │ Routes │ │
│ │ health │ │ bookings, booking-links, │ │ stripe │ │
│ │ avail. │ │ calendars, automations, │ │ │ │
│ │ oauth │ │ prep-questions, funding, │ │ │ │
│ │ │ │ media, rss, team, user/avatar │ │ │ │
│ │ │ │ guests, guest-network, │ │ │ │
│ │ │ │ podcast-guests, │ │ │ │
│ │ │ │ episode-people (mixed) │ │ │ │
│ └─────────┘ └────────────────────────────────┘ └─────────┘ │
└─────────────────────────────────────────────────────────────────┘Route Summary
| Route Group | Path | Auth | Purpose |
|---|---|---|---|
| Health | /api/health | No | Service health checks |
| Analytics | /api/analytics | Yes | Download analytics, exports, engagement imports, client report links |
| Bookings | /api/bookings | Mixed | Booking CRUD and lifecycle |
| Booking Links | /api/booking-links | Yes | Event type management |
| Availability | /api/availability | No | Time slot availability |
| Calendars | /api/calendars | Yes | Calendar connections |
| Google OAuth | /api/auth/google | No | OAuth callback |
| Prep Questions | /api/prep-questions | Yes | Interview prep config |
| Automations | /api/automations | Yes | Automation engine |
| Media | /api/media | Mixed | File upload and audio |
| User Avatar (docs pending) | /api/user/avatar | Yes | Profile avatar uploads |
| Guests (docs pending) | /api/guests | Mixed | Guest management + verify |
| Podcast Guests | /api/podcast-guests | Yes | Canonical guest identities |
| Team | /api/team | Yes | Team member management |
| Guest Network | /api/guest-network | Mixed | Guest discovery and invites |
| RSS | /api/rss | Yes | Feed cache invalidation |
| Imports | /api/imports | Yes | RSS back catalogue import |
| Stripe | /api/stripe | Yes | Checkout and portal |
| Webhooks | /api/webhooks | No* | External service webhooks |
| Funding | /api/funding | Yes | Podcast funding |
| Episode People | /api/episodes | Yes | Episode roster & credits |
| Episodes (docs pending) | /api/episodes | Yes | Episode CRUD |
| External Link | /api/episodes/:id/external-link/*, /api/podcasts/:id/external-feed/*, /api/podcasts/:id/upgrade-hosting/* | Yes | Per-episode link repair, unmatched-feed-item listing, hosting-migration commit/status (Epic 11) |
| Notifications | /api/notifications | Yes | Notification inbox + preferences |
| Podcast Lifecycle | /api/podcast-lifecycle | Yes | Pause and deletion lifecycle |
| Booking Sessions | /api/booking-sessions | Yes | Bookings hub (board data, outcomes, reschedule) |
| User Onboarding | /api/user/onboarding | Yes | Get Started checklist state |
| Account | /api/account | Yes | Account usage (storage vs cap) |
| Admin | /api/admin | Yes** | Platform admin + CRM |
| AI | /api/ai | Yes | Transcription + guest research jobs |
| Distribution | /api/distribution | Yes | Directory listing submissions |
| Signup Complete (docs pending) | /api/auth/signup-complete | Yes | Post-OTP signup side effects |
*Webhooks use secret verification instead of auth tokens. Webhook mounts: /api/webhooks/stripe, /api/webhooks/freescout, /api/webhooks/moosend.
**Admin routes additionally require requirePlatformAdmin() behind the admin. Cloudflare Access perimeter.
Routes marked "docs pending" are mounted in src/api/index.ts but do not yet have dedicated per-route docs pages.
Critical Pattern: RPC Client
The Hono app uses .basePath('/api'), which means TypeScript types include the /api prefix.
Client Setup
// src/api/client.ts
import { hc } from 'hono/client';
import type { AppType } from './index';
export function createApiClient(customFetch: typeof fetch = fetch) {
// EMPTY base - types already include /api prefix
return hc<AppType>('', { fetch: customFetch });
}Correct Usage
import { createApiClient } from '$api/client';
import { createClient } from '$lib/supabase/client';
// Get auth token from Supabase
const supabase = createClient();
const {
data: { session }
} = await supabase.auth.getSession();
const token = session?.access_token;
// Create type-safe client
const client = createApiClient(fetch);
// CORRECT - always include .api prefix
const res = await client.api.health.$get();
const res = await client.api.bookings.$post({ json: data });
const res = await client.api.automations.rules[':id'].toggle.$post(
{
param: { id: ruleId }
},
{
headers: { Authorization: `Bearer ${token}` }
}
);
// WRONG - missing .api prefix (TypeScript error)
const res = await client.bookings.$post({ json: data }); // ❌Why Empty Base?
If the client base were /api instead of empty, calling client.api.bookings would result in /api/api/bookings (double-prefixed, 404 error).
Authentication and Authorization
Bearer Token Pattern
All authenticated endpoints require:
Authorization: Bearer <supabase_access_token>Middleware-First Pattern
import {
requireAuth,
optionalAuth,
requirePodcastRole,
requirePodcastRoleByResolver
} from '../middleware';
routes
.get('/public', optionalAuth(), handler)
.post('/secured', requireAuth(), zValidator('json', schema), handler)
.get('/podcast/:podcastId', requireAuth(), requirePodcastRole('member'), handler)
.put(
'/resource/:id',
requireAuth(),
zValidator('json', schema),
requirePodcastRoleByResolver('admin', resolvePodcastId),
handler
);requireAuth() sets user and supabase on context (c.get('user'), c.get('supabase')).
Use shared middleware only. Do not add route-local helpers like getAuthenticatedUser, checkPodcastMemberAccess, or checkPodcastAdminAccess.
Validation
All inputs validated using zValidator from @hono/zod-validator:
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
const schema = z.object({
name: z.string().min(1),
email: z.string().email(),
podcast_id: z.string().uuid()
});
export const exampleRoute = new Hono<{ Bindings: Bindings }>().post(
'/',
zValidator('json', schema),
async (c) => {
const body = c.req.valid('json'); // Fully typed and validated
return c.json({ success: true, data: body });
}
);Error Handling
Standard Error Response
return c.json({ error: 'Error message' }, statusCode);HTTP Exceptions
import { HTTPException } from 'hono/http-exception';
if (!authorized) {
throw new HTTPException(403, { message: 'Not authorized' });
}Status Codes
| Code | Meaning | When to Use |
|---|---|---|
200 | OK | Successful GET, PUT, DELETE |
201 | Created | Successful POST creating resource |
400 | Bad Request | Invalid input, validation failed |
401 | Unauthorized | Missing or invalid auth token |
403 | Forbidden | User lacks permission |
404 | Not Found | Resource doesn't exist |
409 | Conflict | Duplicate resource, slot taken |
500 | Internal Error | Server-side error |
Environment Bindings
Access via c.env in route handlers:
| Binding | Type | Purpose |
|---|---|---|
PUBLIC_APP_URL | string | Base URL for email links |
PUBLIC_SUPABASE_URL | string | Supabase project URL |
SUPABASE_SECRET_KEY | string | Server-side auth validation |
GOOGLE_CLIENT_ID | string | Google OAuth client ID |
GOOGLE_CLIENT_SECRET | string | Google OAuth secret |
STRIPE_SECRET_KEY | string | Stripe API key |
STRIPE_WEBHOOK_SECRET | string | Stripe webhook verification (primary endpoint) |
STRIPE_WEBHOOK_SECRET_SHOWFM | string | Optional second signing secret for the my.show.fm endpoint (Epic 16 dual-host transition) |
STRIPE_STARTER_PRICE_ID | string | Starter plan price ID |
STRIPE_PROFESSIONAL_PRICE_ID | string | Pro plan price ID |
R2_PUBLIC_URL | string | Media CDN base URL |
RSS_INVALIDATION_SECRET | string | RSS worker secret |
RESEND_API_KEY | string | Email service API key |
RESEND_FROM_EMAIL | string | Email sender address |
Cloudflare Bindings (Production)
| Binding | Type | Purpose |
|---|---|---|
MEDIA_BUCKET | R2Bucket | Audio/image storage |
RSS_INVALIDATION_QUEUE | Queue | Cache invalidation |
AUTOMATION_EXECUTION_QUEUE | Queue | Automation jobs |
File Structure
src/api/
├── index.ts # Main Hono app entry point
├── client.ts # RPC client factory
├── CLAUDE.md # Developer quick reference
└── routes/
├── health.ts # Health check endpoints
├── analytics/
│ └── index.ts # Download analytics + imports + report links
├── bookings/
│ └── index.ts # Booking CRUD + lifecycle
├── booking-links.ts # Event type management
├── availability/
│ └── index.ts # Time slot availability
├── calendars/
│ └── index.ts # Calendar connections
├── guest-network/
│ └── index.ts # Guest discovery + invitations
├── episode-people/
│ └── index.ts # Episode roster + credits
├── guests/
│ └── index.ts # Guest access + verification
├── podcast-guests/
│ └── index.ts # Canonical guest identities
├── auth/
│ └── google.ts # Google OAuth callback
├── prep-questions/
│ └── index.ts # Interview prep config
├── automations/
│ ├── index.ts # Route aggregator
│ ├── templates.ts # Notification templates
│ ├── rules.ts # Automation rules
│ ├── executions.ts # Execution history
│ └── scheduled-jobs.ts # Scheduled jobs
├── media/
│ ├── presigned.ts # Upload URL generation
│ └── audio.ts # Audio metadata/delete
├── rss.ts # Feed cache invalidation + feed parse
├── imports/
│ └── index.ts # RSS back catalogue import workflow
├── stripe/
│ ├── checkout.ts # Checkout sessions
│ └── portal.ts # Customer portal
├── team/
│ └── index.ts # Team member + invitation management
├── user/
│ └── avatar.ts # User profile avatar uploads
├── webhooks/
│ └── stripe.ts # Stripe webhook handler
└── funding.ts # Podcast fundingCreating New Routes
For a full walkthrough, see How to Add a New API Route.
1. Create Route File
// src/api/routes/example.ts
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { requireAuth, requirePodcastRoleByResolver, type PermissionEnv } from '../middleware';
type ExampleEnv = {
Bindings: PermissionEnv['Bindings'];
Variables: PermissionEnv['Variables'];
};
const createSchema = z.object({
name: z.string().min(1),
podcast_id: z.string().uuid()
});
const resolvePodcastId = async ({
c,
supabase
}: {
c: { req: { param: (key: string) => string } };
supabase: PermissionEnv['Variables']['supabase'];
}) => {
const id = c.req.param('id');
const { data, error } = await supabase
.from('resources')
.select('podcast_id')
.eq('id', id)
.single();
if (error || !data) {
throw new HTTPException(404, { message: 'Resource not found' });
}
return data.podcast_id;
};
export const exampleRoutes = new Hono<ExampleEnv>().put(
'/:id',
requireAuth(),
zValidator('json', createSchema),
requirePodcastRoleByResolver('admin', resolvePodcastId),
async (c) => {
const { name } = c.req.valid('json');
const supabase = c.get('supabase');
const id = c.req.param('id');
const { error } = await supabase.from('resources').update({ name }).eq('id', id);
if (error) {
return c.json({ error: 'Failed to update resource' }, 500);
}
return c.json({ success: true });
}
);2. Mount in index.ts
import { exampleRoutes } from './routes/example';
const app = baseApp
// ... existing routes
.route('/example', exampleRoutes);3. Types Auto-Update
The AppType export automatically includes the new routes for RPC client type safety.
Best Practices
DO
- Use
zValidatorfor all inputs - Use shared middleware from
src/api/middleware/for auth/authorization - Use
c.get('supabase')andc.get('user')afterrequireAuth() - Return consistent
{ error: string }for errors - Return consistent
{ success: true, data: {} }for success - Use query params for filtering (
?podcast_id=) - Use path params for resource IDs (
/:id) - Check permissions before operations
DON'T
- Create SvelteKit
+server.tsfiles for API endpoints - Create endpoints to return auth tokens (use browser Supabase client)
- Access
c.envvalues without null checks in development - Add route-local auth/permission helpers (use middleware)
- Add
SUPABASE_SERVICE_ROLE_KEYfallback logic in new routes - Use
getSession()for auth validation (usegetUser()) - Skip permission checks based on RLS alone