Separate finding in the same file: your webhook route reads the body with await req.json() and then calls stripe.webhooks.constructEvent on a re-stringified copy. Signature verification needs the exact raw bytes, so this passes locally with the CLI and fails against live traffic the moment Stripe formats anything differently.
In an App Router route handler use await req.text() and pass that string straight to constructEvent. Parse afterwards.