Booking Links API
Manages booking links (event types) that define how guests can schedule recordings.
Base Path: /api/booking-links
Authentication: All endpoints require Bearer token.
Endpoints
| Method | Path | Auth | Description |
|---|---|---|---|
GET | / | Yes | List booking links for podcast |
GET | /:id | Yes | Get booking link details |
POST | / | Yes | Create booking link |
PUT | /:id | Yes | Update booking link |
DELETE | /:id | Yes | Delete booking link |
POST | /:id/toggle | Yes | Toggle active status |
GET | /meet-availability | Yes | Whether Google Meet may be chosen for a NEW link |
List Booking Links
Retrieves all booking links for a podcast.
GET /api/booking-links?podcast_id={id}Query Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
podcast_id | UUID | Yes | Podcast to list links for |
is_active | boolean | No | Filter by active status |
Response:
{
"bookingLinks": [
{
"id": "uuid",
"podcast_id": "uuid",
"name": "Guest Interview",
"slug": "interview",
"description": "Schedule a 60-minute interview session",
"duration_minutes": 60,
"buffer_before": 15,
"buffer_after": 15,
"min_notice_hours": 24,
"max_advance_days": 60,
"timezone": "America/New_York",
"is_active": true,
"confirmation_message": "Thanks for booking! We'll confirm shortly.",
"availability_rules": [
{
"day": 1,
"start_time": "09:00",
"end_time": "17:00"
}
],
"custom_fields": [
{
"id": "topic",
"label": "What topic would you like to discuss?",
"type": "textarea",
"required": true
}
],
"collect_phone": false,
"created_at": "2025-01-07T10:00:00Z",
"updated_at": "2025-01-07T10:00:00Z"
}
],
"total": 3
}Get Booking Link
Retrieves a single booking link with full details.
GET /api/booking-links/:idResponse:
{
"bookingLink": {
"id": "uuid",
"podcast_id": "uuid",
"name": "Guest Interview",
"slug": "interview",
"description": "Schedule a 60-minute interview session",
"duration_minutes": 60,
"buffer_before": 15,
"buffer_after": 15,
"min_notice_hours": 24,
"max_advance_days": 60,
"timezone": "America/New_York",
"is_active": true,
"confirmation_message": "Thanks for booking!",
"availability_rules": [...],
"custom_fields": [...],
"collect_phone": false,
"created_at": "2025-01-07T10:00:00Z",
"updated_at": "2025-01-07T10:00:00Z"
}
}Create Booking Link
Creates a new booking link for a podcast. Requires owner or admin role.
POST /api/booking-linksRequest Body:
{
"podcast_id": "uuid",
"name": "Guest Interview",
"slug": "interview",
"description": "Schedule a 60-minute interview session",
"duration_minutes": 60,
"buffer_before": 15,
"buffer_after": 15,
"min_notice_hours": 24,
"max_advance_days": 60,
"timezone": "America/New_York",
"is_active": true,
"creates_episode": true,
"confirmation_message": "Thanks for booking! We'll confirm shortly.",
"availability_rules": [
{
"day": 1,
"start_time": "09:00",
"end_time": "17:00"
},
{
"day": 2,
"start_time": "09:00",
"end_time": "17:00"
}
],
"custom_fields": [
{
"id": "topic",
"label": "What topic would you like to discuss?",
"type": "textarea",
"required": true
},
{
"id": "experience",
"label": "Podcast experience level",
"type": "dropdown",
"required": false,
"options": ["First time", "Some experience", "Veteran"]
}
],
"collect_phone": false
}Response 201 Created:
{
"bookingLink": {
"id": "uuid",
"podcast_id": "uuid",
"name": "Guest Interview",
"slug": "interview",
...
}
}Validation:
slugmust be unique within the podcastduration_minutesmust be 15-480buffer_before/aftermust be 0-120min_notice_hoursmust be 0-168 (1 week)max_advance_daysmust be 1-365availability_rulesday must be 0-6 (Sunday=0)custom_fieldstype must be: text, textarea, dropdown, checkbox
Show Notes Overrides
A booking link can override the show-notes template for any of the three sections — shared, host_private, guest — independently. Each section has a mode + _template_id pair:
| Field | Type | Default | Notes |
|---|---|---|---|
show_notes_shared_mode | 'inherit' | 'template' | 'blank' | 'inherit' | inherit falls through to podcasts.default_shared_template_id |
show_notes_shared_template_id | UUID | null | null | Required iff mode is 'template' |
show_notes_host_private_mode | 'inherit' | 'template' | 'blank' | 'inherit' | |
show_notes_host_private_template_id | UUID | null | null | Required iff mode is 'template' |
show_notes_guest_mode | 'inherit' | 'template' | 'blank' | 'inherit' | |
show_notes_guest_template_id | UUID | null | null | Required iff mode is 'template' |
Pair consistency — the API enforces (via Zod superRefine) and the DB enforces (via CHECK constraints) that:
mode === 'template'requires a_template_id.mode === 'inherit'ormode === 'blank'rejects a non-null_template_id.
A request that violates this returns 400 Bad Request from validation, before hitting the DB constraint.
Cross-tenant safety — the referenced _template_id must point to a notification_templates row with the matching target_section and the same podcast_id as the booking link. This is enforced by the enforce_show_notes_template_section_match trigger; a violation surfaces as a 500 from the DB. The host UI prevents this by listing only this-podcast templates filtered by section.
For the resolution model and how these fields feed applyShowNotesTemplate, see Show Notes Auto-Create & Per-Section Templates.
Update Booking Link
Updates an existing booking link. Requires owner or admin role.
PUT /api/booking-links/:idRequest Body: Same as create (all fields optional).
Response:
{
"bookingLink": {
"id": "uuid",
...updated fields...
}
}Delete Booking Link
Deletes a booking link. Requires owner role.
DELETE /api/booking-links/:idResponse:
{
"success": true
}Validation:
- Cannot delete if there are active (pending/confirmed) bookings
- Returns
409 Conflictif bookings exist
Toggle Active Status
Enables or disables a booking link. Requires owner or admin role.
POST /api/booking-links/:id/toggleRequest Body (optional):
{
"is_active": false
}If no body provided, toggles the current state.
Response:
{
"bookingLink": {
"id": "uuid",
"is_active": false,
...
}
}Data Model
Booking Link
interface BookingLink {
id: string;
podcast_id: string;
name: string;
slug: string;
description: string | null;
duration_minutes: number;
buffer_before: number;
buffer_after: number;
min_notice_hours: number;
max_advance_days: number;
timezone: string;
is_active: boolean;
confirmation_message: string | null;
availability_rules: AvailabilityRule[];
custom_fields: CustomField[];
collect_phone: boolean;
/**
* Booking mode (#295 item 1). TRUE = Episode booking (default), FALSE =
* General booking. Snapshotted onto `booking_sessions.creates_episode` at
* session creation, so a change here applies to NEW bookings only.
*/
creates_episode: boolean;
created_at: string;
updated_at: string;
}creates_episode validation
ai_research.enabled and creates_episode = false are mutually exclusive: guest research has no episode to attach answers to. Both write paths reject the pair with 400.
- Create — enforced by the
researchModeConsistencyOnCreatesuperRefine oncreateBookingLinkSchema; after defaults both halves are always present. - Update — enforced in the route against the merged state (payload over the stored row), because either half may be absent from the payload. Catches both directions: enabling research on a general link, and flipping a research-enabled link to general.
The edit UI sends both fields in one request when switching to General, so the merged state the server validates is never invalid.
The mode lock
creates_episode may only CHANGE while the link has no pending or confirmed bookings. A change against a live queue returns 409 with code booking_mode_locked; a read failure on the count returns 500, never a permissive pass (same fail-closed posture as the delete guard directly below it).
The guard keys on an actual change, so a payload restating the current mode (any full-form save) is never blocked by the link's own queue.
This is a CONSISTENCY rule, not an entitlement one. booking_links_per_account caps how many links run concurrently, and flipping one link's mode never yields two live booking pages, so the cap is not being circumvented. The reason is that a booking freezes its mode on booking_sessions.creates_episode at creation, while the public availability gate reads the LINK. Requiring an empty queue makes those two unable to disagree about any slot that can still be offered: completed and no-show bookings sit in the past, and declined or canceled ones collapse their session outright.
The edit UI disables the selector and explains the lock, using the activeSessionCount the page already loads. It passes a boolean rather than that number, because the page counts SESSIONS while this guard counts BOOKINGS: the two agree on "any?" but not on "how many".
Every column on
booking_linksis publicly readable. The policyPublic can view active booking linksisFOR SELECT USING (is_active = TRUE)with noTOclause, so it coversanonand every authenticated stranger, and no application-layer column selection changes that. A booking mode is not secret, socreates_episodeis fine here. Any future PRIVATE link configuration belongs in a member-scoped side table, never on this table.
Calendar owner (event_calendar_user_id)
The single answer to "whose diary is this link selling". It drives three mechanisms:
- The only calendar polled for busy times:
src/lib/services/availability.tsqueriescalendar_connectionsfor exactly this user, deliberately not every admin (an unrelated colleague's meeting would produce a false conflict). - Which other booking links cross-block this one:
fetchCrossLinkBusyPeriodsmatches on this column, so two links protect each other only when they name the same person. - First choice for where the confirmed booking's calendar event is created:
resolve_session_calendar_owner, tier 1.
API behaviour (both schemas carry the field):
- Create: an omitted value falls back to the link creator (route logic AND a DB trigger). An explicit value naming someone who is not an active
podcast_membersrow returns400 event_calendar_user_id must be an active member of this podcast. - Update: only a CHANGED value is validated, with the same 400.
nullis accepted (defensive; the editor never sends it).
Page loaders resolve the roster the picker renders through resolveBookingLinkTeam (src/lib/server/booking-link-team.ts): every active member with a derived calendar-health verdict. It runs on an elevated client behind the pages' own admin gate, because calendar_connections has no podcast_id and is RLS-protected per user: the same precedent as meet-availability.ts, and the same exposure ceiling as the team settings page (name, email, avatar, role) plus only derived calendar facts.
Health states (deriveCalendarFacts in src/lib/booking/calendar-owner.ts, aggregated across the person's valid Google connections):
| State | Meaning | Band |
|---|---|---|
ready | Calendars ticked for conflicts AND an event calendar chosen | green |
no_conflicts | Connected, calendars_for_availability empty: nothing is checked | amber |
no_event_calendar | Conflicts checked, calendar_for_events null: invites fall through | amber |
not_configured | Connected, neither mechanism set up | amber |
invalid | Every connection has is_valid = false: behaves like none, but the copy says "expired", never "never connected" | red |
none | No Google connection at all | red |
A named owner who is no longer in podcast_members renders as a distinct "left the team" state (the loader resolves their display name from user_profiles, which outlives membership), whose only action is reassignment.
The editor saves explicitly. Both booking-link pages moved off per-card autosave in 2026-08: the edit page runs one SettingsForm draft over the whole page and issues a single PUT of the changed fields (buildBookingLinkUpdatePayload in src/lib/booking/booking-link-editor-form.ts), with paired fields (meeting platform + URL, each show-notes mode + template id, the multi-guest trio) always travelling together and the General-mode/AI-research invariant enforced in the builder. Validation that would previously silently hold an autosave is now a named issue that disables the save bar (collectBookingLinkIssues).
Recording platform default (#200)
meeting_platform and meeting_url are accepted by the create and update schemas but are NOT columns on booking_links and must never become columns on it — see the note above. A reusable Riverside or Zoom room URL is effectively a join credential, so they are written to the member-only booking_link_meeting_defaults table, on which anon holds no privilege at all and whose rows are member-scoped by RLS. The PUT handler strips them from the body before the link update for exactly that reason.
The host-facing GET / and GET /:id join the row back in, so the editor sees one object. The public booking page reads booking_links directly and cannot see the table by privilege.
Values are a PAIR, mirroring the DB shape check with friendlier errors:
| Platform | meeting_url |
|---|---|
google_meet, in_person | must be absent — Meet rooms come from the calendar event, in person has none |
zoom, riverside, squadcast, descript_rooms, zencastr, custom | required, https:// only, no embedded credentials |
null (No meeting link) | must be absent; DELETES the defaults row |
An absent row means no meeting is configured, never a silent Google Meet. The migration seeds an explicit google_meet row for every pre-existing link so live behaviour is unchanged.
The create FORM pre-selects google_meet when a calendar is connected, and "No meeting link" when one is not. The API itself still has no default: a platform reaches it only because the form put it in the body. The pre-selection exists because the migration seeded every pre-existing link to google_meet, so starting new links at "no meeting link" would have made them behave differently from every link already in the account. The distinction that matters is that the choice is explicit and visible before the host saves, which is what the feature set out to fix, rather than silent.
GET /:id also returns meta.meet_selectable and meta.meet_blocked_message from booking_link_has_calendar_owner, and GET /meet-availability?podcastId= answers the same question for a link that does not exist yet. Both exist because the browser cannot resolve it: the answer depends on other members' calendar connections, which RLS hides.
The /meet-availability rule lives in src/lib/server/meet-availability.ts because the create page's server load asks the same question to pick that default. It resolves there rather than from the browser so the picker renders its real value on first paint: fetching after mount showed "No meeting link" and then flipped, which is both a flicker and a window in which a fast host's own choice could be overwritten. The load needs an elevated client for the same RLS reason, and only a boolean crosses the boundary.
Two fail-soft directions, deliberately different: an unresolved check leaves Meet selectable (the API and DB accept the choice regardless, exactly as an existing link with no calendar behaves) but picks no default (defaulting to Meet without a confirmed calendar would offer a room that can never be minted).
Availability Rule
interface AvailabilityRule {
day: number; // 0=Sunday, 1=Monday, ..., 6=Saturday
start_time: string; // HH:mm format (24-hour)
end_time: string; // HH:mm format (24-hour)
}Custom Field
interface CustomField {
id: string;
label: string;
type: 'text' | 'textarea' | 'dropdown' | 'checkbox';
required: boolean;
options?: string[]; // For dropdown type only
placeholder?: string;
}Public Booking URL
Booking links are accessed publicly at:
https://book.podcasterplus.com/{podcast_slug}/{booking_link_slug}For example:
https://book.podcasterplus.com/the-tech-show/interviewTypeScript Client Usage
import { createApiClient } from '$api/client';
const client = createApiClient(fetch);
// List booking links
const listRes = await client.api['booking-links'].$get(
{ query: { podcast_id: 'uuid' } },
{ headers: { Authorization: `Bearer ${token}` } }
);
// Create booking link
const createRes = await client.api['booking-links'].$post(
{
json: {
podcast_id: 'uuid',
name: 'Guest Interview',
slug: 'interview',
duration_minutes: 60,
timezone: 'America/New_York',
availability_rules: [{ day: 1, start_time: '09:00', end_time: '17:00' }],
custom_fields: []
}
},
{
headers: { Authorization: `Bearer ${token}` }
}
);
// Toggle status
const toggleRes = await client.api['booking-links'][':id'].toggle.$post(
{ param: { id: 'link-uuid' } },
{ headers: { Authorization: `Bearer ${token}` } }
);Related
- Bookings API - Booking creation and management
- Availability API - Time slot calculation
- Google Calendar Integration - Calendar sync