Errors & troubleshooting

Every Pluralize failure surfaces the same way, whether you use the SDK or call the REST API directly. This page is the one-stop reference: the error shape, the codes grouped by area, and the handful of issues that trip up almost every new integration.

How errors surface

Every SDK method rejects with a PluralizeError when the API returns a non-2xx response:

import { PluralizeError } from '@pluralize/sdk';
 
try {
  await app.auth.login(email, password);
} catch (err) {
  if (err instanceof PluralizeError) {
    err.code;    // stable string, e.g. "invalid_credentials" — branch on this
    err.status;  // HTTP status, e.g. 401
    err.message; // human-readable; for your logs, not your users
  }
  throw err; // anything else (network down, bug) is a normal Error
}
  • code — a stable, machine-readable string. Branch on this, never on message.
  • status — the HTTP status code.
  • message — a developer-facing description. Map it to your own user-facing copy.

Calling the REST API directly (no SDK)? The same information is in the JSON body:

{ "error": "invalid_credentials", "message": "Invalid email or password" }

with the matching HTTP status. The error field equals the SDK's code.

A reusable handler

function explain(err: unknown): string {
  if (!(err instanceof PluralizeError)) return 'Something went wrong. Please try again.';
  switch (err.code) {
    case 'invalid_credentials': return 'Wrong email or password.';
    case 'email_taken':         return 'That email already has an account.';
    case 'rate_limited':        return 'Too many attempts. Try again in a minute.';
    case 'session_expired':     return 'Your session ended. Please sign in again.';
    default:                    return 'Something went wrong. Please try again.';
  }
}

Error codes by area

Authentication & sessions

| Status | code | Meaning | | --- | --- | --- | | 409 | email_taken | Signup: an account already exists for this email. | | 401 | invalid_credentials | Login: wrong email or password (never says which). | | 400 | weak_password | Password is outside the 10–128 character range. | | 400 | invite_required | App is invite_only and the request omitted inviteToken. | | 403 | invite_invalid | Invite token unknown, revoked, expired, or used up. | | 403 | signup_closed | App's signup mode is closed. | | 400 | invalid_token | A verify-email / password-reset token is missing or malformed. | | 400 | token_expired | The token is past its TTL (1 hour for reset, longer for verify). | | 400 | token_consumed | The token was already used. | | 409 | already_verified | Resend-verification when the email is already verified. | | 401 | session_expired | The SDK's silent refresh failed — show the login form again. |

The SDK refreshes access tokens transparently on a 401. You only see session_expired when the refresh token itself is expired or revoked (90-day inactivity, a logout elsewhere, or a password reset, which revokes every session).

Data

| Status | code | Meaning | | --- | --- | --- | | 400 | invalid_data | Body was not a JSON object or exceeded the size limit. | | 402 | limit_reached | Tenant is at the collection's max_<collection> cap. Body has { feature, limit, current }. | | 404 | not_found | Record does not exist or belongs to another tenant. | | 409 | duplicate_unique_field | A declared unique field collided. |

Files

| Status | code | Meaning | | --- | --- | --- | | 400 | no_file | The request didn't include a file field. | | 402 | limit_reached | Tenant is at the max_files cap. | | 413 | file_too_large | File exceeded 10 MB. | | 415 | unsupported_mime_type | MIME type is on the blocklist (HTML, SVG, JS). |

Billing

| Status | code | Meaning | | --- | --- | --- | | 400 | invalid_plan | planId does not exist on this app. | | 402 | limit_reached | Tenant hit a numeric entitlement limit. | | 409 | already_subscribed | Tenant already has an active subscription to that plan. |

Rate limits, quotas & app state

| Status | code | Meaning | | --- | --- | --- | | 429 | rate_limited | Too many requests. Honor Retry-After; back off. | | 402 | quota_exceeded | Your developer-plan cap was hit (tenants, storage, API requests, emails). See Pricing. | | 402 | app_paused | The app was auto-paused for inactivity. Resume it from the dashboard. |

limit_reached is about your tenant's plan (something you can sell them an upgrade for). quota_exceeded is about your Pluralize plan (you need to upgrade). Two different walls — don't confuse them in your UI.

Troubleshooting the usual suspects

A browser request fails with a CORS error

Direct browser calls only work from origins you've allowlisted. Add your site's origin in Dashboard → Settings → CORS (or pluralize cors add https://yourapp.example). Include every origin you serve from — production, previews, and http://localhost:3000 for local dev. The symptom is the browser blocking the response with a CORS message in the console even though the network tab shows the request reaching Pluralize. See Server-side → Browser integration for the cookie-proxy alternative that avoids CORS entirely.

Every authenticated call returns 401

Three usual causes:

  1. Wrong appId / key pairing — the publishable key and appId must belong to the same app. Re-pull them with pluralize env pull --key.
  2. No token attached — authenticated endpoints need Authorization: Bearer <accessToken> plus x-pluralize-app: <appId>. The SDK adds both for you; raw HTTP callers must set them.
  3. Expired session — if the refresh token is also dead you get session_expired; send the user back to login.

Tenants never receive verification or reset emails

  • Confirm your developer email-send quota isn't exhausted (Dashboard → Usage). Over the cap, Pluralize silently skips the send to preserve anti-enumeration — your call still returns success.
  • Or take delivery into your own hands: pass sendEmail: false and send the branded email yourself. See Password reset and Email verification.

app_paused out of nowhere

Apps with no activity for ~30 days are auto-paused to save resources, and then every request returns 402 app_paused before doing any work. Resume the app from the dashboard; nothing is deleted.

A webhook never arrives

Webhooks are single-attempt with no retries. Check the delivery log in Dashboard → Settings → Webhooks: a invalid_config row means no URL was registered; an http_error row shows the status your endpoint returned; a timeout row means you took longer than 1.5 s to respond. See Webhooks for the verification recipe.