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 onmessage.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:
- Wrong
appId/ key pairing — the publishable key andappIdmust belong to the same app. Re-pull them withpluralize env pull --key. - No token attached — authenticated endpoints need
Authorization: Bearer <accessToken>plusx-pluralize-app: <appId>. The SDK adds both for you; raw HTTP callers must set them. - 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: falseand 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.