API route patterns

Guidance for src/pages/api/**. See the root CLAUDE.md for the auth/permissions/multi-tenancy overview.

Route map

All routes live under src/pages/api/. Ticketing is Stripe (hosted checkout + webhooks). Shopify was retired in #243.

FamilyPathPurpose
Auth/api/auth/signin, signout, register, invite, bulk-invite, confirm, recovery, reset/set/update-password
Events/api/events/create; [id]/update; [id]/enrollments/
Sessions/api/sessions/list/create; [id]/; [id]/enroll (enrollment lives here, not /api/enrollments/)
Checkout/api/checkout/createCreate Stripe hosted-checkout session
Tickets/api/tickets/free; [id]/checkin; [id]/pdf; [id]/transfer (+ cancel); [id]/assign (to a managed participant, #418)
Managed participants/api/managed-participants/[id]Rename a managed participant (responsible participant only, #418)
Calls/api/calls/Call-for-proposals: list/create; [id]/
Proposals/api/proposals/list/create; [id]/ + accept / reject / request-revision / withdraw
Resources/api/resources/list/create; [id]/
Organizations/api/organizations/[id]/announcements, consent, join, members/[userId], stripe, update
Profile / Preferences/api/profile/update, /api/preferences/updateUser profile + RPG preferences
Avatar/api/avatar/uploadAvatar file upload
OG images/api/og/default.png; event/[orgSlug]/[eventSlug].png; org/[slug].png — generated cards, the fallback when a page has no cover (getOgCoverImageUrl: event → its cover; session → its cover, then the event’s generic session image, then the event cover, else the org card)
Redirects/api/redirects/Managed redirect rules
Webhooks/api/webhooks/stripe (the only live webhook); shopify/ (tombstone, returns 410)

Canonical permission pattern

Every protected route follows these four steps — authenticate, fetch with the admin client (to see the row regardless of RLS), check permission, then operate with the admin client:

import { getAuthenticatedUser } from '../../../lib/auth';
import { createServerSupabaseAdminClient } from '../../../lib/supabase';
import { canAccessEvent, permissionDeniedResponse, unauthorizedResponse, notFoundResponse } from '../../../lib/permissions';

export const POST: APIRoute = async ({ request, cookies, params }) => {
  // 1. Authenticate
  const auth = await getAuthenticatedUser(request, cookies);
  if (auth.redirect || !auth.user) return unauthorizedResponse();

  const supabase = createServerSupabaseAdminClient<Database>(request, cookies);

  // 2. Fetch the resource with the admin client (bypasses RLS for visibility)
  const { data: resource, error } = await supabase.from('events').select('*').eq('id', params.id).single();
  if (error || !resource) return notFoundResponse('Event');

  // 3. Check permission
  const perm = await canAccessEvent({ user: auth.user, organizationId: resource.organization_id }, 'update', resource);
  if (!perm.allowed) return permissionDeniedResponse(perm.reason);

  // 4. Operate with the admin client (permission already verified)
  // ...
};

Permission helpers live in src/lib/permissions.ts:

FunctionUse case
isPlatformAdmin(user)platform admin check
getOrgRole(userId, orgId)user’s org role
hasMinimumOrgRole(role, required)role hierarchy (owner > admin > organizer > member)
canAccessOrganization / Event / Session / Enrollment(ctx, action, row?)resource CRUD permissions
canAccessManagementPages(user, orgId)org management access (gates /dashboard/[orgSlug]/… pages and org-scoped writes)
permissionDeniedResponse(reason?)standard 403

Response shape

Always JSON with Content-Type: application/json. Success: { data, success: true } (200, or 201 on create). Error: { error: 'message' } with an appropriate status (400/401/403/404/500). Prefer the unauthorizedResponse() / notFoundResponse() / permissionDeniedResponse() helpers over hand-rolled responses.

Note: some routes back HTML forms and respond with a 302 redirect carrying ?message=/?error= instead of JSON (e.g. events/[id]/update.ts). Match the surrounding route’s convention.

Always build those with redirectResponse() from src/lib/http.ts — never Response.redirect(). The latter returns immutable headers, so the Netlify adapter’s unconditional Set-Cookie append throws TypeError: immutable outside every try/catch and the function 502s. It only fires when @supabase/ssr refreshes the access token on that request, which made it look random in production (issue #331). tests/unit/http.test.ts fails the build if Response.redirect( reappears under src/pages/api/.

Surfacing DB constraint errors

Temporal/overlap invariants are enforced by DB triggers/constraints (see supabase/CLAUDE.md). Routes string-match the raised exception message to produce a friendly error — match the exact message the trigger raises (resource trigger: Resource availability must be within event window; session trigger: Session must be scheduled within event window / ...within resource availability window).

Webhooks

Stripe (/api/webhooks/stripe) is the primary ticketing webhook: verify the stripe-signature header against the raw request body (request.text(), not .json()) via verifyStripeWebhook before processing, then read organization_id from event metadata.

/api/webhooks/shopify/ is a tombstone from the retired integration (#243): it returns 410 for every request and verifies nothing, since the table holding the per-store secret was dropped. Any new signed webhook must read request.text() so the body matches the signature.