Guidance for src/pages/api/**. See the root CLAUDE.md for the auth/permissions/multi-tenancy overview.
All routes live under src/pages/api/. Ticketing is Stripe (hosted checkout + webhooks). Shopify was retired in #243.
| Family | Path | Purpose |
|---|---|---|
| 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/create | Create 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/update | User profile + RPG preferences |
| Avatar | /api/avatar/upload | Avatar 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) |
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:
| Function | Use 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 |
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/.
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).
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.