| 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.
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,/fulfillment- 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.
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.
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