Skip to Content
StandardsAPI Standards

API Standards

All API routes follow a three-tier security model enforced by directory structure. This page covers route patterns, security classification, error handling, and middleware.

The Three Tiers

/api/internal/*

Server Only

Service-to-service communication only. Requires the SUPABASE_SERVICE_ROLE_KEY. Never exposed to browsers. Used for webhooks, cron jobs, and admin operations.

// Access control
Header: x-service-key = process.env.SUPABASE_SERVICE_ROLE_KEY

// Rate limit: None (trusted internal traffic)
// Auth: Service role key validation

/api/v1/*

Authenticated

Standard authenticated endpoints. Requires a valid user session via Supabase Auth. Per-user rate limiting prevents abuse.

// Access control
Auth: Supabase session (JWT in cookie)

// Rate limit: 100 requests/minute per user
// RLS: Row-Level Security enforced via user context

/api/public/*

Public

Unauthenticated endpoints for public data. Aggressive rate limiting and careful data exposure. Think: public profile pages, health checks, webhooks from external services.

// Access control
Auth: None required

// Rate limit: 10 requests/minute per IP
// Data: Only explicitly public fields — no PII

Directory Structure

The tier is enforced by where the route file lives:

src/app/api/ ├── internal/ # 🔴 Service role key required │ ├── webhooks/ │ └── cron/ ├── v1/ # 🔵 Authenticated user session required │ ├── users/ │ ├── invoices/ │ └── settings/ └── public/ # 🟢 No auth required, aggressive rate limiting ├── health/ └── profiles/

Route Handler Pattern

// src/app/api/v1/invoices/route.ts import { NextRequest } from 'next/server'; import { createClient } from '@frequencyads/auth/server'; import { apiSuccess, apiError } from '@/lib/api'; import { invoiceSchema } from '@/features/invoices'; export async function GET(req: NextRequest) { try { const supabase = await createClient(); const { data, error } = await supabase .from('invoices') .select('*') .order('created_at', { ascending: false }); if (error) return apiError('FETCH_FAILED', error.message, 500); return apiSuccess(data); } catch (err) { return apiError('INTERNAL_ERROR', 'An unexpected error occurred', 500); } } export async function POST(req: NextRequest) { try { const body = await req.json(); const parsed = invoiceSchema.safeParse(body); if (!parsed.success) { return apiError('VALIDATION_ERROR', parsed.error.message, 400); } const supabase = await createClient(); const { data, error } = await supabase .from('invoices') .insert(parsed.data) .select() .single(); if (error) return apiError('CREATE_FAILED', error.message, 500); return apiSuccess(data, 201); } catch (err) { return apiError('INTERNAL_ERROR', 'An unexpected error occurred', 500); } }

Error Handling

The Result Type

For expected failures, use the Result type instead of throwing:

import { AppError, Result, ok, err } from '@/lib/errors'; async function fetchUser(id: string): Promise<Result<User>> { try { const supabase = await createClient(); const { data, error } = await supabase .from('users') .select('*') .eq('id', id) .single(); if (error) return err(new AppError('NOT_FOUND', 'User not found')); return ok(data); } catch (e) { return err(new AppError('INTERNAL_ERROR', 'Unexpected failure')); } } // Usage const result = await fetchUser(id); if (!result.ok) { return apiError(result.error.code, result.error.message); } return apiSuccess(result.data);

Custom Error Classes

Extend AppError for domain-specific errors:

type BillingErrorCode = 'PAYMENT_FAILED' | 'CARD_EXPIRED' | 'INSUFFICIENT_FUNDS'; class BillingError extends AppError<BillingErrorCode> { constructor(code: BillingErrorCode, message: string) { super(code, message); } }

API Response Format

All API responses follow a consistent envelope:

// Success { "ok": true, "data": { ... } } // Error { "ok": false, "error": { "code": "VALIDATION_ERROR", "message": "Email is required" } }

OpenAPI Documentation

All APIs are documented with OpenAPI/Swagger, viewable at /docs/api:

  1. Define your schema using Zod
  2. Register the endpoint with the OpenAPI registry
  3. Implement the handler with proper types
  4. Documentation auto-generates from the registry

Middleware Stack

Every request passes through these middleware layers in order:

OrderMiddlewarePurpose
1checkMaintenance()Return 503 if maintenance mode enabled
2addRequestId()Attach UUID for request tracing
3Session refreshRefresh Supabase auth tokens
4Route protectionRedirect unauthenticated users
5addSecurityHeaders()CSP, HSTS, X-Frame-Options
6rateLimit()Per-tier rate limiting

Available Middleware Utilities

import { rateLimit, addSecurityHeaders, addRequestId, checkMaintenance, cors, logRequest, } from '@/lib/middleware';

Best Practices

  1. Validate all input with Zod before processing
  2. Use apiSuccess() and apiError() — never construct raw responses
  3. Log errors with context — include request ID, user ID, and operation name
  4. Never expose internal errors to clients — wrap in generic messages
  5. Use the Result type for expected failures — reserve throw for truly unexpected errors
  6. Keep routes thin — business logic lives in feature modules, not route handlers
Last updated on