Skip to Content
SDK v3newError codes

Error codes

Every SDK 3.0 error throws a PaywallError — a typed extension of Error with two extra fields: code (stable identifier) and status (HTTP status, if the error came from the network). This lets you write code that reacts to a specific case without parsing message.

import { PaywallError, QuotaExceededError } from '@monetize.software/sdk/core'; try { await billing.createCheckout({ priceId }); } catch (e) { if (e instanceof QuotaExceededError) { paywall.open(); return; } if (e instanceof PaywallError && e.code === 'already_purchased') { showRestoredView(); return; } throw e; }

Shape

class PaywallError extends Error { readonly code: string; // stable identifier — base your handling on this readonly status?: number; // HTTP status when the error came from the network readonly cause?: unknown; // original response body — for context-aware logic } class QuotaExceededError extends PaywallError { readonly code: 'not_enough_queries'; readonly status: 402; readonly balances: Balance[]; readonly queryType: string; readonly currentBalance: Balance | null; }

message is not a stable API. The SDK may change phrasing in minor releases. Branch on code / status / instanceof only.

Configuration and initialization

CodeWhenWhat to do
invalid_configpaywallId or apiOrigin not passed; apiOrigin does not match bootstrap.settings.custom_domain; managed-auth call without a managed AuthClient.Double-check the SDK init. apiOrigin must be the paywall’s custom_domain from the dashboard — not appbox.space and not localhost.
apikey_in_browserBillingClient was constructed with apiKey in a browser context (window.document present). Thrown synchronously from the constructor — a server-SDK key in client code exposes your entire account.Move BillingClient to a trusted backend and read apiKey from env. For deliberate e2e tests injecting the key into a browser, pass allowInsecureBrowserUsage: true (never in production).
identity_requiredA user-scoped method (createCheckout, getCustomerPortalUrl, listPurchases, cancelSubscription) was called without either a connected AuthClient or apiKey + identity.email/identity.userId.Browser: wire auth: true. Server: pass apiKey and call billing.setIdentity({ email, userId }) (or set identity in the constructor).
identity_not_on_paywalllistPurchases / cancelSubscription (apiKey path) for an identity that’s never interacted with this paywall.Verify the email/userId you pass; owner can only act on their own users. Cross-paywall lookup is blocked by design.

Network and transport

CodestatusWhen
network_errorfetch could not reach the server (no internet, DNS, TLS, CORS preflight failed). The SDK does not auto-retry.
abortedThe request was cancelled via AbortSignal (user closed the modal, host unmounted the component). Don’t log it as an error — it’s a user-driven cancellation.
http_<N>4xx/5xxBackend returned an error without a code in the body — fallback to http_${status} (e.g. http_500, http_429). The text is in message, the body in cause.
upstream502Auth backend / acquirer returned 5xx. The backend passes it through as-is, no auto-retry in the SDK.

Auth — email & password

CodestatusWhen
invalid_credentials401signInWithEmail — wrong email or password.
user_already_exists / email_exists400/422signUp — email already registered. Fall back to signInWithEmail for a single-screen “login or signup” UX.
weak_password400signUp / updatePassword — password failed the platform’s policy (min 6 chars by default).
not_authenticatedupdatePassword / upgradeAnonymousToEmail without an active session.

Auth — OTP & reset

CodestatusWhen
invalid_email400sendOtp / requestPasswordReset — email format check failed.
over_email_send_rate_limit429Too frequent sendOtp / requestPasswordReset — platform rate limit (~1/minute).
invalid_otp401verifyOtp — wrong code, expired code, or no OTP was sent for this email. Anti-enumeration: don’t distinguish in UI.
invalid_type400verifyOtp — typo in type (not one of 'email' | 'recovery' | 'signup' | 'magiclink' | 'invite').

Auth — OAuth

CodestatusWhen
popup_blockedsignInWithOAuth was called outside a user gesture. Show a “Sign in with Google” button and require an explicit re-click.
oauth_cancelledUser closed the popup before completion. Silently return to the login screen.
oauth_failedProvider returned an error (access denied, wrong client_id). Show “Couldn’t sign in, try another method”.
oauth_timeoutThe popup didn’t deliver a code within 5 minutes (user abandoned without closing).
oauth_unavailableSDK was called from Node.js or another runtime without window — developer bug.
exchange_failed401The auth backend rejected the code (stolen-from-logs code, state typo). “Session expired, try again.”

Auth — anonymous

CodestatusWhen
anonymous_disabled403signInAnonymously()allow_anonymous is off in paywall settings. Enable it in the dashboard or hide the “Continue as guest” button.

Checkout & subscriptions

CodestatusWhen / What to do
already_purchased409createCheckout — the user already has an active subscription (the backend blocks to avoid a double charge). PaywallUI flips into the restored success view automatically. In headless code — show “you already have a subscription” and offer getCustomerPortalUrl(). For renewal/upgrade flows, retry with ignoreActivePurchase: true.
checkout_failedEmitted via paywall.on('error', ...) — PaywallRoot could not open the checkoutUrl or did not get a valid URL from the backend. Inspect error.cause for details.
no_priceCTA button was pressed without a selected price. Happens when layout config is broken (price_grid without priceIds, cta_button without priceId). Bug in admin config.
forbidden403getCustomerPortalUrl — no active subscription / acquirer doesn’t support a portal (e.g. Freemius). Show “no subscription to manage”.

API Gateway

CodestatusWhen / What to do
not_enough_queries (QuotaExceededError)402gateway.call() — user’s quota is exhausted. The SDK already refreshes balances before throwing. PaywallUI catches it automatically and opens the upgrade modal; headless callers handle it themselves.
provider-disabled403API Provider is disabled in paywall settings or tokenization is not configured. Configuration error.

Visibility and trial

These are not errors — the SDK emits dedicated events (visibility_blocked / trial_blocked) instead of throwing. Don’t try/catch for them, subscribe instead:

paywall.on('visibility_blocked', (v) => { /* country doesn't match */ }); paywall.on('trial_blocked', (status) => { /* trial still active */ });

Details in Trial and PaywallUI.

Low level: backend codes with originals

When the backend returns JSON with a code field in the body, the SDK passes it into PaywallError.code unchanged:

// HTTP 400 from the auth backend: { "code": "weak_password", "msg": "Password should be at least 8 characters" } // → throw new PaywallError('weak_password', 'Password should be at least 8 characters', { status: 400, cause: <body> })

If there’s no code in the body — fallback to http_${status}:

// HTTP 503 without a JSON body or without `code`: // → throw new PaywallError('http_503', 'Service Unavailable', { status: 503, cause: <body|null> })

cause always carries the original response body (parsed JSON object or null) — pull out structural fields for context-aware handling:

catch (e) { if (e instanceof PaywallError && e.code === 'http_409') { const body = e.cause as { hasActivePurchase?: boolean } | null; if (body?.hasActivePurchase) { // ...special-case handling } } }

Cookbook

Unified handler

function isUserMistake(e: unknown): boolean { if (!(e instanceof PaywallError)) return false; return [ 'invalid_credentials', 'invalid_otp', 'invalid_email', 'weak_password', 'user_already_exists', 'email_exists', 'oauth_cancelled', 'popup_blocked', 'aborted', 'already_purchased' ].includes(e.code); } function isInfraIssue(e: unknown): boolean { if (!(e instanceof PaywallError)) return false; return e.code === 'network_error' || e.code === 'upstream' || (e.status ?? 0) >= 500; } try { await someAction(); } catch (e) { if (isUserMistake(e)) showInline(e); else if (isInfraIssue(e)) showToast('Service unavailable, please try later'); else reportToSentry(e); }

Retry on network_error

The SDK does not auto-retry. If you need it, wrap on the host side:

async function withRetry<T>(fn: () => Promise<T>, attempts = 2): Promise<T> { for (let i = 0; i < attempts; i++) { try { return await fn(); } catch (e) { if (i === attempts - 1) throw e; if (!(e instanceof PaywallError) || e.code !== 'network_error') throw e; await new Promise((r) => setTimeout(r, 500 * (i + 1))); // 500ms, 1s } } throw new Error('unreachable'); } const session = await withRetry(() => auth.signInWithEmail({ email, password }));

Don’t retry checkout operations. createCheckout is already idempotent via Idempotency-Key, but a retry on network_error may land in the window where the backend has already created a session with the acquirer — and the user may see duplicates. If you must retry, use the same idempotencyKey, not an auto-generated one.

Sentry / Datadog logging

function reportPaywallError(e: PaywallError, context: Record<string, unknown> = {}) { Sentry.captureException(e, { tags: { paywall_code: e.code, paywall_status: String(e.status ?? '') }, extra: { cause: e.cause, ...context } }); }

code is a good grouping tag. status is useful for filtering “5xx only”.

Next steps

  • Events — events that don’t throw (visibility_blocked, trial_blocked, purchase_failed).
  • Security — XSS, custom fetch, idempotency.
  • PaywallUI — state machine + state.error for rendering.