Repository navigation
API routes return 500 instead of 400 when the request body is not valid JSON #7150
Description
Problem
apps/web API routes call await request.json() directly on the untrusted request body. When the body is not valid JSON, that call rejects with a SyntaxError. Where the call is not wrapped in a try/catch and has no .catch() on it, the exception escapes the route handler and Next.js responds 500 for what is a client error. The correct status is 400.
Two routes (verify-turnstile, update-email) additionally destructure the parsed value, so a body of literal null — which is valid JSON — throws an uncaught TypeError and also produces a 500.
Verified reproduction
Measured by invoking each route's exported POST with Content-Type: application/json and a body of not-json{, on main at 332f0c033:
| Route | not-json{ |
body null |
|---|---|---|
/api/auth/magic-link |
500 (uncaught SyntaxError) |
400 |
/api/auth/verify-turnstile |
500 (uncaught SyntaxError) |
500 (uncaught TypeError) |
/api/billing/update-email |
500 (uncaught SyntaxError) |
500 (uncaught TypeError) |
/api/code-indexing/search |
500 (uncaught SyntaxError) |
n/a — passes null to tRPC input validation |
/api/code-indexing/delete |
500 (uncaught SyntaxError) |
n/a — passes null to tRPC input validation |
/admin/api/abuse/bulk-block |
500 (uncaught SyntaxError) |
400 |
Why it matters
/api/auth/magic-link and /api/auth/verify-turnstile are unauthenticated. Any client that can reach the app can POST a malformed body and get a 5xx back. That is a 5xx raised by 4xx-class input, which is exactly the signal that pollutes error-rate alerting and burns an error budget, and it tells the caller "we broke" when the truth is "your request was malformed". It also means the response a well-behaved client gets for a bad request is indistinguishable from a genuine server fault, so retry logic cannot do the right thing.
Scope on main
Of the 49 await request.json() call sites in apps/web/src/app/**/route.ts:
- 12 already guard with
.catch()on the same line and are fine. - 26 already sit inside a
try/catch, so the route handles the parse failure. Whether each returns a 4xx from itscatchis a separate per-route question and is not triaged here. - 11 are genuinely unhandled, so a malformed body escapes as a 500.
The 11 unhandled sites:
| Route | Status |
|---|---|
/api/auth/magic-link |
fixed by #6780 |
/api/auth/verify-turnstile |
fixed by #6780 |
/api/billing/update-email |
fixed by #6780 |
/api/code-indexing/search |
fixed by #6780 |
/api/code-indexing/delete |
fixed by #6780 |
/admin/api/abuse/bulk-block |
fixed by #6780 |
/api/exa/[...path] |
fixed on an unmerged branch, see below |
/admin/api/enrichment-data/upsert |
open |
/admin/api/organizations/[id]/consume-credits |
open |
/admin/api/users/[id]/dev/insert-usage-record |
open |
/admin/api/users/add-credit |
open |
The four open ones are behind adminOnly auth, so they are lower severity than the two auth routes. /admin/api/enrichment-data/upsert is the notable one: it parses the body before delegating to caller.admin.enrichmentData.upsert, so the 500 is reachable ahead of the tRPC procedure's own authorization.
The exa / discord "wave 1" was never opened as a PR
#6780 refers to a "Wave 1 PR (exa, discord)" that is still open and awaiting a rebase. No such PR exists on this repository. The work exists only as a branch on a contributor fork — fix/request-body-invalid-json-400, commit 5fb4663d2 — and it covers /api/exa/[...path] and the discord route. If that branch is dropped, the exa fix is lost with it.
Suggested fix
Match the convention already used by the 12 guarded call sites: parse defensively, then map the failure to the 400 response shape the route already uses for a schema violation, and return before any side effect.
const body: unknown = await request.json().catch(() => undefined);For routes that destructure, narrow to the expected type rather than destructuring blind, so a null body takes the same 400 path instead of throwing. For routes whose 400 shape is a zod result, safeParse(undefined) already fails correctly and needs no extra handling.
Test note for anyone picking this up
The apps/web jest suite runs globalSetup + setupFilesAfterEach that create and migrate a per-worker Postgres database, so pnpm --filter web test needs a reachable POSTGRES_URL. A route-body repro does not need the database and can be run against a copy of apps/web/jest.config.ts with the two DB-dependent setup entries removed.
Node 24 is required (engines: ">=24 <25"); the suite does not run on Node 22.
Activity
maphew commentedon Oct 4, 2026
Partial fix: #6780 covers 6 of the 11 unhandled sites (magic-link, verify-turnstile, update-email, code-indexing search, code-indexing delete, admin bulk-block).
Its change was verified by executing each route's
POSTbefore and after: everynot-json{case goes from an uncaughtSyntaxError(500) to the route's existing 400 shape, and the twonull-bodyTypeErrors in verify-turnstile and update-email become 400.Still open after that PR lands:
/admin/api/enrichment-data/upsert— parses before the tRPC auth check, so the 500 is reachable pre-authorization/admin/api/organizations/[id]/consume-credits/admin/api/users/[id]/dev/insert-usage-record/admin/api/users/add-credit/api/exa/[...path]— fixed only on an unmerged fork branch, never PR'dmaphew commentedon Oct 4, 2026
Two operational findings that affect how this gets fixed.
1. Fork PRs never get CI on this repo. Every workflow run on a fork branch concludes
action_requiredwith zero jobs executed, pending maintainer approval. This affects all 8 open PRs. It means an unlinked fix like this one can sit for a week with no signal that its tests were never executed — #6780's tests had never run until they were run locally on Node 24.2. The wave 1 work is still applicable as-is. The
fix/request-body-invalid-json-400branch (5fb4663d2) has not drifted: neitherapps/web/src/app/api/exa/[...path]/route.tsnorapps/web/src/app/discord/webhook/route.tshas changed onmainsince its basef2ee0e6f2. The diff against currentmainis exactly the original change, so it can be opened as its own PR with no rebase and no conflict against #6780 (which touches neither file).Worth noting
apps/web/src/app/discord/webhook/route.tsis outside this issue's original scope: it parses viarequest.text()+JSON.parse()at lines 51 and 80 rather thanrequest.json(), so it was missed by arequest.json()sweep. Any future sweep should matchJSON.parse(as well.maphew commentedon Oct 5, 2026
Status update: the exa and discord work is no longer stranded. It has been folded into #6780 as a single commit, so there is now one landing point for this defect class instead of two.
#6780 is now 8 routes / 16 files and covers 7 of the 11 unhandled sites — the original six plus
/api/exa/[...path]./discord/webhookis covered too, though it was never in the 11 because it parses viarequest.text()+JSON.parse()rather thanrequest.json().What consolidation surfaced:
discord/webhook/route.test.tshadimport type { NextRequest }— a type-only import, erased at compile time — while callingnew NextRequest(...). All 9 tests in that suite failed withReferenceError: NextRequest is not defined. It has never been run, because the branch it lived on never got CI and no PR was opened. Fixed as part of the consolidation; the suite now passes.Combined verification on the consolidated branch, Node 24.21.0 with the repo's Postgres setup: 8 suites / 74 tests pass, and all 8 fail when only the corresponding route fix is reverted — 14 tests across the two newly added routes. Typecheck, lint and format all clean across all 16 files.
Still open after #6780 lands — 4 admin routes:
/admin/api/enrichment-data/upsert/admin/api/organizations/[id]/consume-credits/admin/api/users/[id]/dev/insert-usage-record/admin/api/users/add-creditNone of these has a test yet, so a follow-up PR would need new test files written and the environment rebuilt to run them. That is the remaining work on this issue.