| 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.
accessLog, first, so a request that helmet or CORS rejects is still loggedhelmet, with CSP off, because this app returns no HTMLcors, with an allowlist fromCORS_ORIGINSandcredentials: true/health, mounted above everything auth-shaped so it answers while the answer is "Clerk is misconfigured"express.urlencoded()clerkMiddleware(), global, so the webhook routers below need no re-mountingexpress.raw()plus/stripeand/clerkexpress.json()/checkout,/user,/admin- a 404 handler, so a typo'd URL answers in the
{ code, message }shape 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.
Related
docs/adr/0013-api-hardening-baseline.mddocs/adr/0015-handlers-throw-apperror.mddocs/adr/0016-positional-middleware-that-only-tightens.mddocs/adr/0031-identity-precedes-payment.md, why checkout has a session guard
