All pages

API routes

Method and path Auth Request Response type
GET /health none none HealthResponse
POST /checkout/session session, rate limited checkoutSessionRequestSchema CheckoutSessionResponse
POST /stripe/webhook Stripe signature raw body { received: true }
POST /clerk/webhook svix signature raw body { received: true }
GET /user/entitlements session none EntitlementsResponse
GET /user/billing-history session none BillingHistoryResponse
POST /user/billing-portal session none BillingPortalSessionResponse
GET /admin/users session, admin adminUserQuerySchema on the query AdminUsersResponse
GET /admin/users/:id session, admin uuid path parameter AdminUserDetailResponse
POST /admin/purchases/:id/link session, admin linkPurchaseRequestSchema AdminUserDetailResponse

Every response type is declared in packages/shared/src/responses.ts. Every request schema is a Zod schema in packages/shared/src/requests.ts, parsed at the top of its handler.

/user is the caller's own account. /admin contains operations reserved for administrators, including access to other users.

Request schemas

// POST /checkout/session
checkoutSessionRequestSchema = z.object({
  slug: z.enum(SLUGS),
  locale: localeSchema,
})

// POST /admin/purchases/:id/link
linkPurchaseRequestSchema = z.object({ userId: z.uuid() })

// GET /admin/users?q=
adminUserQuerySchema = z.object({ q: z.string().trim().min(1).max(320) })

q is required, so a bare GET /admin/users is a 400 rather than the whole table. It matches a Clerk id exactly and case-sensitively, because that string was copied rather than typed, and an email case-insensitively on any part of the address. The result set is capped at 50: an admin arriving from a support email wants one row, and a query matching five hundred was mistyped.

:id is always one of this app's own uuids, never a Clerk id: a users.id under /admin/users, and a purchases.id on /admin/purchases/:id/link. It is parsed with z.uuid() before it reaches a query, because comparing a Postgres uuid against not-a-uuid is a database error surfacing as a 500 from a query that was never going to match.

Auth, in three layers

Middleware Asserts Failure
authenticateToken the Clerk session verifies 401 unauthenticated
requireUser it resolves to a users row, and writes res.locals.user 403 no_user_context
requireAdmin the Clerk id is in ADMIN_CLERK_IDS 403 forbidden

The baseline guard is a router's first statement, and later .use() calls may only add guards. A route appended to the bottom of a guarded file lands inside the guard, so a misplaced line is a 403 rather than a leak.

Static path segments precede /:param ones on the same router, or the parameter route shadows them into a silent 404. Nothing collides today, but GET /admin/users/search written below GET /admin/users/:id is wrong every time.

requireAdmin runs no query. It compares the id getAuth(req) already verified against the environment allowlist.

Error responses

Every failure answers with the same flat shape:

type ErrorResponse = {
  code: string
  message: string
  details?: unknown
}

message is authored copy and safe to display. It is never a serialized caught error, and the caught error's own message goes to the log line through describeError() instead, because that string is the one most likely to name a table, a file path or a key prefix.

The web client sends its explicit locale as Accept-Language. Error middleware uses that request language for authored messages while code remains stable across languages.

Branch on code, never on message.

Code Status Raised by
invalid_request 400 any Zod parse, with details carrying the flattened issues
invalid_json 400 a body express.json() could not read
invalid_signature 400 any webhook verification failure, of any kind
unknown_slug 400 a slug with no catalog entry
unauthenticated 401 authenticateToken
no_user_context 403 requireUser
forbidden 403 requireAdmin
not_found 404 an unmatched path
user_not_found 404 GET /admin/users/:id and the admin link
purchase_not_found 404 the admin link, when no purchase has that id
stripe_customer_missing 409 POST /user/billing-portal for an account that has never paid, or whose stored Customer Stripe no longer accepts
request_too_large 413 a body over the 100kb limit, or 1mb on a webhook
rate_limited 429 the money rate limiter
internal_error 500 anything unhandled
user_not_provisioned 500 the Clerk upsert returning no row
not_configured 501 an optional environment value read at its point of use
stripe_customer_not_stored 500 the lazy Stripe Customer write returning no reference
stripe_customer_unavailable 502 the contact refresh before checkout failing for a reason other than the stored id
checkout_url_missing 502 Stripe accepted the session and returned no URL
stripe_env_mismatch 503 the slug exists but has no price in this api's Stripe account

apps/web/src/lib/api/client.ts adds three codes of its own for failures that produce no api body at all: api_url_missing, network_error and unexpected_response. They stay distinct because a missing VITE_API_URL is a deployment that was never finished, and a network error is a deployment that is finished and unreachable.

The middleware chain

The order in apps/api/src/index.ts is load-bearing.

  1. accessLog, first, so a request that helmet or CORS rejects is still logged
  2. helmet, with CSP off, because this app returns no HTML
  3. cors, with an allowlist from CORS_ORIGINS and credentials: true
  4. /health, mounted above everything auth-shaped so it answers while the answer is "Clerk is misconfigured"
  5. express.urlencoded()
  6. clerkMiddleware(), global, so the webhook routers below need no re-mounting
  7. express.raw() plus /stripe and /clerk
  8. express.json()
  9. /checkout, /user, /admin
  10. a 404 handler, so a typo'd URL answers in the { code, message } shape
  11. errorMiddleware, strictly last

Nothing in this file runs migrations, and nothing in it holds a rate limiter. Migrations are an explicit pnpm db:migrate. The limiter is per-router and lives on /checkout only, at 20 requests per 15 minutes per IP, because a global limiter on a JSON api mostly punishes your own frontend. It runs before authenticateToken on that router so an unauthenticated burst is cheap.

app.set('trust proxy', 1) is exactly one hop, not app.enable('trust proxy'). The unbounded form trusts the whole X-Forwarded-For chain and lets a client spoof its own IP, which matters precisely because the rate limiter keys on IP.

  • docs/adr/0013-api-hardening-baseline.md
  • docs/adr/0015-handlers-throw-apperror.md
  • docs/adr/0016-positional-middleware-that-only-tightens.md
  • docs/adr/0031-identity-precedes-payment.md, why checkout has a session guard

Updated