---
title: "Troubleshooting"
description: "Symptom-indexed fixes — pnpm can't resolve components, the OTP never arrives, builds that never exit, start_push 500, JWKS after rotating AUTH_SECRET, and more."
canonical_url: "https://nuxt-backend.dev/docs/ship/operate/troubleshooting"
---
# Troubleshooting

> Symptom-indexed fixes — pnpm can't resolve components, the OTP never arrives, builds that never exit, start_push 500, JWKS after rotating AUTH_SECRET, and more.

Start with `npx nuxt-backend doctor` — most of what follows is a finding with a fix hint. Then find your symptom.

## `convex dev` can't resolve `@convex-dev/…/convex.config`

**Symptom:** the push fails resolving a component definition imported by `backend/convex.config.ts`.

**Cause:** pnpm's default isolated linker hides transitive packages, and Convex's bundler resolves the `@convex-dev/*` component definitions from your project root.

**Fix:** let them hoist, from `pnpm-workspace.yaml` (pnpm 11+ no longer reads such settings from `.npmrc`):

```yaml [pnpm-workspace.yaml]
publicHoistPattern:
  - '@convex-dev/*'
```

(or `nodeLinker: hoisted`), then reinstall.

## The OTP email never arrives

**Symptom:** "request code" answers *"This app is in test mode and only emails test addresses, so this address can't receive mail here"* or *"This app can't send email yet"*, or succeeds but no email comes.

**Cause:** no email transport — `EMAIL_API_KEY` is unset — test mode, or no `EMAIL_FROM` out of test mode. The package refuses a code it knows would not arrive (see [Get email into inboxes](https://nuxt-backend.dev/docs/email/deliverability)), and logs `email.undeliverable` with the setting to change.

**Fix:**

- In dev, you don't need email: with `NUXT_BACKEND_LOG_OTP=1` on the deployment the code prints where Convex's function logs go: the **nuxt-backend dev terminal** (or the `convex dev` one, with two terminals). `npm run dev` / `env push` set it automatically on dev deployments while no `EMAIL_API_KEY` exists; set it yourself with `npx convex env set NUXT_BACKEND_LOG_OTP 1`.
- With a key set, `EMAIL_TEST_MODE` defaults to **on**: only the provider's test addresses (`delivered@resend.dev`, `+label` aliases) receive mail. Set it to `false` after verifying your sending domain.
- `doctor` finding `email-transport` / `deployment-email-api-key` tells you which case you are in.

## `nuxt build` or `generate` never exits

**Symptom:** the build finishes compiling, the prerender crawl starts, and the process hangs.

**Cause:** the crawler rendered a page that opens a live Convex WebSocket (`useQuery` subscriptions), which keeps the event loop alive. The module's own pages are never prerendered; this is one of the app's.

**Fix:** exclude the app's live pages from prerendering:

```ts [nuxt.config.ts]
export default defineNuxtConfig({
  routeRules: {
    '/app/**': { prerender: false },
  },
})
```

## `start_push 500` on `convex dev` / `deploy`

**Symptom:** the push fails with an opaque `start_push 500` while evaluating the definition bundle.

**Cause:** Convex discovers components by intercepting every `convex.config` import while bundling `convex.config.ts`, and current backends crash when such an import is reached through an **intermediate module** (a helper that imports component definitions for you) or when `app.use` runs in a loop.

**Fix:** keep the scaffolded shape — every component imported and mounted **in the root convex.config.ts**, one `app.use()` per component. Never wrap the definitions in a helper module. `nuxt-backend/app` deliberately exports only the env contract (`backendEnv`) for that reason.

## Sessions or agent tokens fail after rotating `AUTH_SECRET`

**Symptom:** after changing `AUTH_SECRET`, sign-in loops, the `/api/auth` proxy answers 5xx on token requests, or `/mcp/exchange` returns `token_mint_failed`.

**Cause:** `AUTH_SECRET` signs sessions **and** encrypts the RS256 private keys stored in the backend component's `jwks` table. With a new secret the stored keys can no longer be decrypted, so no JWT can be signed; existing sessions are invalid regardless.

**Fix:** clear the stored keys so fresh ones are generated on the next request — in the Convex dashboard open the `backend` component's data and delete the rows of the `jwks` table (or run a one-off mutation on `components.backend.adapter.deleteMany` with `{ model: 'jwks' }`). Every user signs in again; agents re-run their OAuth flow. Plan rotations as an incident-response step, not routine.

## Pricing is empty although the catalog synced

**Symptom:** `useBilling().products` is `{}` / the pricing page shows nothing, but `billing sync` created the products.

**Cause:** `getConfiguredProducts` reads the reactive products table, which fills from `product.*` webhooks — and the webhook endpoint is created by the same sync run, *after* the products.

**Fix:** `billing sync` runs `billing:syncProducts` itself. If it reported the products as not loaded, the deployment lacked `BILLING_ACCESS_TOKEN` or had another `BILLING_ENVIRONMENT` than the one synced: set those, then run `npx convex run billing:syncProducts` (add `--prod` for production). Also check `BILLING_ENVIRONMENT` matches the environment you synced (`billing.generated.ts` holds `sandbox` and `production` separately) and that `appConfig.backend.billing.plans[].key` matches the catalog keys.

## Credits don't update after a top-up / "Credit balance not synced yet"

**Cause:** `/billing/events` isn't receiving or verifying events (`BILLING_WEBHOOK_SECRET` unset → `503`, wrong secret → `403`, dashboard pointed at the wrong URL → nothing).

**Fix:** `doctor` (`billing-webhook-route`), then the delivery log in the DevTools Backend tab — the outcome column names the failure. As a fallback, `useCredits().refresh()` runs `syncEntitlements` and pulls the balance directly.

## Invitation links 404

**Cause:** `organization.invitationPath` (Convex, what emails link to) and `backend.pages.acceptInvitation` (Nuxt, where the page mounts) disagree.

**Fix:** align them; `doctor`'s `invitation-path` finding cross-checks both (`agent-login-path` and `agent-consent-path` do the same for the agent sign-in and consent pages). Also make sure `SITE_URL` is the app origin, never the `.convex.site` URL — links are built from it.

## A composable returns `undefined` forever

**Symptom:** `useBilling` / `useCredits` / `useGifts` / `useFeatures` / `useEmailStatus` never load; or `useAuth` has no user.

**Cause:** the deployment lacks a function the composable binds to by name — a scaffolded file was renamed or its exports trimmed.

**Fix:** `doctor`'s `function-contract` finding lists the missing `module:export` names. Restore with `npx nuxt-backend init` (existing files untouched) and let `convex dev` push. If you renamed on purpose, pass `{ api }` to the composable — or for the agent tools, `backend.mcp.functions`.

## `/login` shows the Nuxt welcome page

**Cause:** `app/app.vue` is still the `create nuxt` starter — `<NuxtWelcome />` and no `<NuxtPage />` — so the route resolves (the `auth` middleware even redirects there) but nothing renders it. The dev console says so: "app.vue still renders <nuxt-welcome>

and no <nuxt-page>

".

</nuxt-page>
</nuxt-welcome>



**Fix:** replace `<NuxtWelcome />` with `<NuxtPage />`, or run `npx nuxt-backend init` while the file is untouched.

## `npm run dev` stops at "Preparing Convex functions" and Nuxt never starts

**Cause:** the `dev` script is `convex dev --start "nuxt dev"` (`nuxt-convex-module` writes it on `nuxt prepare` when `convex.devScript` is on). On a new deployment its first push fails on the missing `AUTH_SECRET` and `SITE_URL`, Convex starts Nuxt only after a successful push, and those values are provisioned from inside Nuxt.

**Fix:** `npx nuxt-backend doctor --fix` points the script at [`nuxt-backend dev`](https://nuxt-backend.dev/docs/reference/cli#dev), which provisions the env first. `doctor` reports the script as `dev-script`.

## `npx convex dev` says "add `convex` to your package.json dependencies"

**Cause:** `convex` is this package's peer dependency. A package manager installs it, but never writes it to your own `package.json` — and the Convex CLI reads that file before it runs.

**Fix:** `npm install convex` (or run `npx nuxt-backend init`, which adds the line when it is missing).

## `convex dev` says "A local backend is still running on port 3210"

**Cause:** an earlier run of `convex dev` itself (not through `nuxt-backend dev`) was stopped with SIGTERM — a plain `kill`, which is how many coding agents and process managers stop a process — or any run was ended with `kill -9`. The Convex CLI exits on SIGTERM but leaves the local backend and the Nuxt server running, so the next run finds the port taken. `nuxt-backend dev`, the `dev` script `init` sets, passes SIGINT and SIGTERM on to Convex as SIGINT, so it shuts all three down either way.

**Fix:** stop what that run left behind, then run `dev` again. On macOS and Linux, list what listens on the default ports with `lsof -nP -iTCP:3210 -iTCP:3000 -sTCP:LISTEN`, then `kill <pid>` the `convex-local-backend` and the `node` process from that run; port 3000 may belong to another app. On Windows, end `convex-local-backend.exe` and the old `node` process in Task Manager. Stop a bare `convex dev` with Ctrl-C (SIGINT) or `kill -INT <pid>` next time: that shuts down all three.

## The built server answers 500: `Cannot find module '…/convex/dist/esm/index.js'`

**Cause:** Nuxt 4.6 changed how a production build resolves a module runtime's imports, and `nuxt-convex-module` before 0.11 let it inline the Convex client with an import that points outside `.output`. `nuxt build` succeeds and `nuxt dev` works; every request to the built server fails, on a Node server or in a Vercel function.

**Fix:** this package depends on `nuxt-convex-module` 0.11 or later, which avoids it — if a lockfile still holds 0.10, update it (`npm update nuxt-convex-module`). Adding `vite: { resolve: { dedupe: ['convex'] } }` to `nuxt.config.ts` fixes it too.

## Gated pages return `500 NUXT_PUBLIC_CONVEX_SITE_URL is not set` on the host

**Cause:** the production build has no backend URL. The `auth` middleware verifies the session on the server through the backend's HTTP-actions origin, and the underlying Convex module raises this (its own name for the variable) when that origin is unknown. Pages without the middleware still render, which is why the home page works and the account pages don't.

**Fix:** set `NUXT_PUBLIC_BACKEND_URL` on the host — the `.convex.site` origin is derived from a `*.convex.cloud` URL — or `NUXT_PUBLIC_BACKEND_SITE_URL` for a custom domain. See [the env split](https://nuxt-backend.dev/docs/ship/deploy#the-env-split). The platform's `NUXT_PUBLIC_CONVEX_SITE_URL` also works.

## Sign-in fails from `http://localhost:<port>`

**Cause:** `SITE_URL` is the only trusted browser origin, and it defaults to `http://localhost:3000` on dev deployments — another port (or `127.0.0.1`) is a different origin.

**Fix:** set `AUTH_TRUST_LOCAL_ORIGINS=1` on the **dev** deployment (`env push` forwards it to dev deployments only), or set `SITE_URL` to the exact origin.

## Agents get `401` on `/mcp` forever

- The gate answers `401` with `WWW-Authenticate: Bearer resource_metadata="…"` until the client completes OAuth — that is the discovery handshake, not an error. A client that never proceeds may not support the authorization-code flow with dynamic registration.
- `503 Agent endpoint unavailable`: no backend site URL configured on the Nuxt side (`NUXT_PUBLIC_BACKEND_SITE_URL`).
- `502 Agent token exchange failed`: `/mcp/exchange` isn't mounted (`registerBackendRoutes({ mcp })`) or the deployment is unreachable.
- `429`: the `mcp` rate limit (per client + user) — see [Rate limiting](https://nuxt-backend.dev/docs/ship/production/rate-limits).

## An agent call fails with `Validator error: Missing required field`

**Cause:** the function reads the caller's Better Auth session by `identity.sessionId`, and an agent token from the exchange carries none — it names the user (`subject`) and the OAuth client (`clientId`) only.

**Fix:** read the account through `authComponent.getAuthUser`, `safeGetAuthUser` or the query `authComponent.clientApi()` registers, which resolve an agent by `subject`, or key your own data by `identity.subject`. `sessionId` only tells the two token shapes apart: it is not proof of a live session, since a revoked session's token still carries it until it expires. Session-only work (listing or revoking devices) must look the session up and refuse when it is gone, as `listSessions` and `revokeSession` do.

## Type errors on `#backend/*` before the first `convex dev`

The module ships fallback ambient types for `#backend/*` until `_generated/api` exists, and re-renders them the moment codegen appears. If types stay stale, restart the dev server after the first `convex dev` run.

## Log codes

Every line the package writes to the deployment's logs reads `[nuxt-backend] <code>: <message>`, so a search for the code in the Convex dashboard's logs finds each occurrence. No line names a person: addresses, names and subjects stay out, because Convex keeps logs. The one exception is `auth.otp_echo`, which a dev deployment opts into.

<table>
<thead>
  <tr>
    <th>
      Code
    </th>
    
    <th>
      Level
    </th>
    
    <th>
      What happened
    </th>
    
    <th>
      What to do
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        auth.otp_echo
      </code>
    </td>
    
    <td>
      warn
    </td>
    
    <td>
      A sign-in code was printed because <code>
        NUXT_BACKEND_LOG_OTP
      </code>
      
       is set and no email transport is configured.
    </td>
    
    <td>
      Nothing in development. Never set <code>
        NUXT_BACKEND_LOG_OTP
      </code>
      
       on production.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        email.skipped_no_api_key
      </code>
    </td>
    
    <td>
      warn
    </td>
    
    <td>
      An email was not sent: <code>
        EMAIL_API_KEY
      </code>
      
       is not set.
    </td>
    
    <td>
      Set it, or ignore it on a dev deployment that echoes codes.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        auth.email_change_notice_unsent
      </code>
    </td>
    
    <td>
      warn
    </td>
    
    <td>
      The notice telling the old address of an email change was not sent: the email instance threw (test mode refusing that address, an override with no body, a provider error). The change went through.
    </td>
    
    <td>
      Fix the cause in the error after the line; the notice is not retried.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        auth.welcome_unsent
      </code>
    </td>
    
    <td>
      warn
    </td>
    
    <td>
      A new account's welcome email was not sent: the email instance threw (test mode refusing a real address, an override with no body, a provider error). The account exists and <code>
        onUserCreated
      </code>
      
       ran.
    </td>
    
    <td>
      Fix the cause in the error after the line; the welcome is not retried.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        email.undeliverable
      </code>
    </td>
    
    <td>
      warn
    </td>
    
    <td>
      A one-time code (to sign in, or for an email change) or an invitation was refused on the request, because its email would not arrive. The line names the setting: <code>
        EMAIL_API_KEY
      </code>
      
      , <code>
        EMAIL_TEST_MODE
      </code>
      
       or <code>
        EMAIL_FROM
      </code>
      
      .
    </td>
    
    <td>
      Change that setting, as <a href="https://nuxt-backend.dev/docs/email/deliverability">
        Get email into inboxes
      </a>
      
       shows.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        webhook.secret_missing
      </code>
    </td>
    
    <td>
      warn
    </td>
    
    <td>
      A webhook route is mounted without its secret, so every delivery is refused with <code>
        503
      </code>
      
      .
    </td>
    
    <td>
      Set <code>
        BILLING_WEBHOOK_SECRET
      </code>
      
       or <code>
        EMAIL_WEBHOOK_SECRET
      </code>
      
      ; <code>
        doctor
      </code>
      
       names the route.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        webhook.log_write_failed
      </code>
    </td>
    
    <td>
      error
    </td>
    
    <td>
      A delivery was handled, but its row in the delivery log was not written.
    </td>
    
    <td>
      The event itself was processed. Check the error that follows the line.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        webhook.rejected
      </code>
    </td>
    
    <td>
      warn
    </td>
    
    <td>
      <code>
        /billing/events
      </code>
      
       or <code>
        /email/events
      </code>
      
       refused a delivery before running anything. The line names the route, the outcome and the status: <code>
        oversized
      </code>
      
       (413), <code>
        invalid_signature
      </code>
      
       (403), or <code>
        missing_secret
      </code>
      
       (503) when the backend component has no <code>
        EMAIL_WEBHOOK_SECRET
      </code>
      
       of its own. It is written once per isolate for each route and outcome; the delivery log counts every refusal.
    </td>
    
    <td>
      For <code>
        invalid_signature
      </code>
      
      , check that <code>
        BILLING_WEBHOOK_SECRET
      </code>
      
       or <code>
        EMAIL_WEBHOOK_SECRET
      </code>
      
       holds the endpoint's current secret (a comma-separated list during a rotation). For <code>
        missing_secret
      </code>
      
      , keep the line that forwards <code>
        EMAIL_WEBHOOK_SECRET
      </code>
      
       to the component in <code>
        convex.config.ts
      </code>
      
      . A steady trickle from unknown senders needs nothing.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        billing.provider_failed
      </code>
    </td>
    
    <td>
      error
    </td>
    
    <td>
      The billing provider refused a request; the caller saw <code>
        unavailable
      </code>
      
       with reason <code>
        provider_refused
      </code>
      
      , never the provider's own words.
    </td>
    
    <td>
      Read the provider's error that follows the line: a token scope, a product id or the provider's status.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        billing.trusted_origin_invalid
      </code>
    </td>
    
    <td>
      warn
    </td>
    
    <td>
      When billing is set up: a <code>
        setupBilling({ trustedOrigins })
      </code>
      
       entry has no scheme or is no URL (<code>
        app.example.com
      </code>
      
      ), so it trusts nothing and a checkout back to it is refused.
    </td>
    
    <td>
      Write it as an origin with its scheme: <code>
        https://app.example.com
      </code>
      
      .
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        billing.gift_unverified
      </code>
    </td>
    
    <td>
      warn
    </td>
    
    <td>
      A paid order named a gift in its metadata but was not that gift's purchase (another product, customer, a discount or no charge) or claim (another discount or workspace). The gift was left as it was.
    </td>
    
    <td>
      Look the order up in the provider's dashboard. A purchase order that should have delivered the gift points at a gift product <code>
        billing sync
      </code>
      
       did not create; refund it and have the purchaser send the gift again.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        billing.gift_claim_not_free
      </code>
    </td>
    
    <td>
      error
    </td>
    
    <td>
      A gift's claim checkout came back with a price, so the package did not confirm it and the recipient saw <code>
        unavailable
      </code>
      
      .
    </td>
    
    <td>
      Check the gift's discount in the provider's dashboard: it must be 100% on the pack, unexpired and unredeemed.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        billing.gift_claim_hosted
      </code>
    </td>
    
    <td>
      warn
    </td>
    
    <td>
      A gift claim opened the provider's $0 checkout instead of confirming on the server, because the pack asks for checkout fields.
    </td>
    
    <td>
      Nothing: the recipient finishes it there. Drop the pack's required custom fields for one-click claims.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        billing.gift_refunded_after_claim
      </code>
    </td>
    
    <td>
      warn
    </td>
    
    <td>
      A gift's purchase was refunded, or <code>
        refundOrder
      </code>
      
       was about to refund it, after its recipient claimed it or while the claim was under way (its discount was redeemed). The credits stay with them: the provider can't take back a $0 order's benefits.
    </td>
    
    <td>
      Decide with the recipient; revoke credits by hand if you must.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        billing.gift_claimed_after_refund
      </code>
    </td>
    
    <td>
      error
    </td>
    
    <td>
      A gift's claim order was paid after its purchase was refunded and voided: the recipient holds credits nobody paid for.
    </td>
    
    <td>
      Look at the two orders in the provider's dashboard; revoke the credits by hand or let them stand.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        billing.gift_over_limit
      </code>
    </td>
    
    <td>
      error
    </td>
    
    <td>
      A gift was paid past its purchaser's gift limit (several checkouts opened at once, all paid), so the package refunded it in full instead of delivering it.
    </td>
    
    <td>
      Nothing, unless the limit is too low for your app: raise <code>
        setupBilling({ giftLimit })
      </code>
      
      .
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        billing.customer_mapping_shared
      </code>
    </td>
    
    <td>
      error
    </td>
    
    <td>
      A billing entity is mapped onto a provider customer that is keyed to another entity: two workspaces shared one customer before 0.2. Its checkouts are refused as <code>
        forbidden
      </code>
      
       (reason <code>
        customer_taken
      </code>
      
      ).
    </td>
    
    <td>
      Reset the deployment's billing data (the billing provider component's <code>
        customers
      </code>
      
       table and the <code>
        backend
      </code>
      
       component's <code>
        billingEntitlements
      </code>
      
       cache), or start from a fresh provider organization.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        ai.cost_over_reservation
      </code>
    </td>
    
    <td>
      warn, error
    </td>
    
    <td>
      A metered action cost more than the credits it reserved, or its finalized cost was not an amount (NaN, infinite, negative); the reservation was charged.
    </td>
    
    <td>
      Raise that action's estimate, or fix its <code>
        finalize
      </code>
      
      .
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        ai.balance_hook_failed
      </code>
    </td>
    
    <td>
      error
    </td>
    
    <td>
      An <code>
        onCreditsLow
      </code>
      
       or <code>
        onCreditsExhausted
      </code>
      
       hook threw. The spend itself settled.
    </td>
    
    <td>
      Fix the hook; the error follows the line.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        ai.reservation_close_failed
      </code>
    </td>
    
    <td>
      error
    </td>
    
    <td>
      A spend reached the provider, but its reservation was not closed.
    </td>
    
    <td>
      Nothing: the balance re-syncs on the next refresh. Repeated lines point at the provider.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        ai.stream_release_unscheduled
      </code>
    </td>
    
    <td>
      warn
    </td>
    
    <td>
      The release timer for a stream's reservation was not scheduled.
    </td>
    
    <td>
      Nothing: the pending-spend TTL releases it.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        ai.stream_spend_lost
      </code>
    </td>
    
    <td>
      error
    </td>
    
    <td>
      A stream was delivered, but its spend never reached the provider; the reservation was released.
    </td>
    
    <td>
      The user was not charged for that stream. Repeated lines point at the provider.
    </td>
  </tr>
</tbody>
</table>


## Sitemap

See the full [sitemap](https://nuxt-backend.dev/sitemap.md) for all pages.
