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 /fulfillment session none FulfillmentStatusResponse
POST /fulfillment/requeue session requeueFulfillmentRequestSchema FulfillmentStatusResponse
GET /admin/fulfillments/attention session, admin none AdminAttentionFulfillmentsResponse
GET /admin/users session, admin adminUserQuerySchema on the query AdminUsersResponse
GET /admin/users/:id session, admin uuid path parameter AdminUserDetailResponse
POST /admin/users/:id/fulfillment/retrigger session, admin retriggerFulfillmentRequestSchema 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,
  githubHandle: githubHandleSchema.nullable(),
})

// POST /fulfillment/requeue
requeueFulfillmentRequestSchema = z.object({
  purchaseId: z.uuid(),
  githubHandle: githubHandleSchema,
})

// POST /admin/users/:id/fulfillment/retrigger
retriggerFulfillmentRequestSchema = z.object({
  purchaseId: z.uuid(),
  force: z.boolean(),
})

// 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) })

githubHandle is null for a buyer with no GitHub account: the sale completes and fulfillment parks until a username arrives. A non-null value is existence-checked against GitHub before either route does its work.

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, because /admin/users/:id/fulfillment/retrigger is a POST and /admin/users/:id is a GET, 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
github_handle_unknown 400 a username GitHub denies: no such account, or an organization
github_handle_unchanged 400 POST /fulfillment/requeue with the username that already failed
user_not_found 404 GET /admin/users/:id, the re-trigger, and the admin link
purchase_not_found 404 either requeue route, when the addressed user owns no such purchase, and the admin link
stripe_customer_missing 409 POST /user/billing-portal for an account that has never paid, or whose stored Customer Stripe no longer accepts
fulfillment_not_failed 409 POST /fulfillment/requeue on a row that is not failed
fulfillment_in_progress 409 the admin re-trigger, without force, on a row whose delivery is still in flight
fulfillment_already_delivered 409 the admin re-trigger, without force, on a row that already reached GitHub
request_too_large 413 a body over the 100kb limit, or 1mb on a webhook
rate_limited 429 the money rate limiter
requeue_cooldown 429 POST /fulfillment/requeue inside five minutes of the last attempt
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, /fulfillment
  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.

POST /fulfillment/requeue is the other route that calls a vendor on a stranger's behalf, and it is bounded per row rather than per address: a five-minute cooldown read from fulfillment_attempted_at, published to the caller as FulfillmentStatus.cooldownUntil so the button can be disabled rather than refused. A limiter on that router would also throttle the dashboard's own polling of GET /fulfillment.

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