Skip to content

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 GroupPathAuthPurpose
Health/api/healthNoService health checks
Analytics/api/analyticsYesDownload analytics, exports, engagement imports, client report links
Bookings/api/bookingsMixedBooking CRUD and lifecycle
Booking Links/api/booking-linksYesEvent type management
Availability/api/availabilityNoTime slot availability
Calendars/api/calendarsYesCalendar connections
Google OAuth/api/auth/googleNoOAuth callback
Prep Questions/api/prep-questionsYesInterview prep config
Automations/api/automationsYesAutomation engine
Media/api/mediaMixedFile upload and audio
User Avatar (docs pending)/api/user/avatarYesProfile avatar uploads
Guests (docs pending)/api/guestsMixedGuest management + verify
Podcast Guests/api/podcast-guestsYesCanonical guest identities
Team/api/teamYesTeam member management
Guest Network/api/guest-networkMixedGuest discovery and invites
RSS/api/rssYesFeed cache invalidation
Imports/api/importsYesRSS back catalogue import
Stripe/api/stripeYesCheckout and portal
Webhooks/api/webhooksNo*External service webhooks
Funding/api/fundingYesPodcast funding
Episode People/api/episodesYesEpisode roster & credits
Episodes (docs pending)/api/episodesYesEpisode CRUD
External Link/api/episodes/:id/external-link/*, /api/podcasts/:id/external-feed/*, /api/podcasts/:id/upgrade-hosting/*YesPer-episode link repair, unmatched-feed-item listing, hosting-migration commit/status (Epic 11)
Notifications/api/notificationsYesNotification inbox + preferences
Podcast Lifecycle/api/podcast-lifecycleYesPause and deletion lifecycle
Booking Sessions/api/booking-sessionsYesBookings hub (board data, outcomes, reschedule)
User Onboarding/api/user/onboardingYesGet Started checklist state
Account/api/accountYesAccount usage (storage vs cap)
Admin/api/adminYes**Platform admin + CRM
AI/api/aiYesTranscription + guest research jobs
Distribution/api/distributionYesDirectory listing submissions
Signup Complete (docs pending)/api/auth/signup-completeYesPost-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

typescript
// 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

typescript
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

typescript
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:

typescript
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

typescript
return c.json({ error: 'Error message' }, statusCode);

HTTP Exceptions

typescript
import { HTTPException } from 'hono/http-exception';

if (!authorized) {
	throw new HTTPException(403, { message: 'Not authorized' });
}

Status Codes

CodeMeaningWhen to Use
200OKSuccessful GET, PUT, DELETE
201CreatedSuccessful POST creating resource
400Bad RequestInvalid input, validation failed
401UnauthorizedMissing or invalid auth token
403ForbiddenUser lacks permission
404Not FoundResource doesn't exist
409ConflictDuplicate resource, slot taken
500Internal ErrorServer-side error

Environment Bindings

Access via c.env in route handlers:

BindingTypePurpose
PUBLIC_APP_URLstringBase URL for email links
PUBLIC_SUPABASE_URLstringSupabase project URL
SUPABASE_SECRET_KEYstringServer-side auth validation
GOOGLE_CLIENT_IDstringGoogle OAuth client ID
GOOGLE_CLIENT_SECRETstringGoogle OAuth secret
STRIPE_SECRET_KEYstringStripe API key
STRIPE_WEBHOOK_SECRETstringStripe webhook verification (primary endpoint)
STRIPE_WEBHOOK_SECRET_SHOWFMstringOptional second signing secret for the my.show.fm endpoint (Epic 16 dual-host transition)
STRIPE_STARTER_PRICE_IDstringStarter plan price ID
STRIPE_PROFESSIONAL_PRICE_IDstringPro plan price ID
R2_PUBLIC_URLstringMedia CDN base URL
RSS_INVALIDATION_SECRETstringRSS worker secret
RESEND_API_KEYstringEmail service API key
RESEND_FROM_EMAILstringEmail sender address

Cloudflare Bindings (Production)

BindingTypePurpose
MEDIA_BUCKETR2BucketAudio/image storage
RSS_INVALIDATION_QUEUEQueueCache invalidation
AUTOMATION_EXECUTION_QUEUEQueueAutomation 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 funding

Creating New Routes

For a full walkthrough, see How to Add a New API Route.

1. Create Route File

typescript
// 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

typescript
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 zValidator for all inputs
  • Use shared middleware from src/api/middleware/ for auth/authorization
  • Use c.get('supabase') and c.get('user') after requireAuth()
  • 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.ts files for API endpoints
  • Create endpoints to return auth tokens (use browser Supabase client)
  • Access c.env values without null checks in development
  • Add route-local auth/permission helpers (use middleware)
  • Add SUPABASE_SERVICE_ROLE_KEY fallback logic in new routes
  • Use getSession() for auth validation (use getUser())
  • Skip permission checks based on RLS alone

Internal documentation - Not for public distribution