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
| Code | When | What to do |
|---|---|---|
invalid_config | paywallId 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_browser | BillingClient 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_required | A 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_paywall | listPurchases / 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
| Code | status | When |
|---|---|---|
network_error | — | fetch could not reach the server (no internet, DNS, TLS, CORS preflight failed). The SDK does not auto-retry. |
aborted | — | The 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/5xx | Backend 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. |
upstream | 502 | Auth backend / acquirer returned 5xx. The backend passes it through as-is, no auto-retry in the SDK. |
Auth — email & password
| Code | status | When |
|---|---|---|
invalid_credentials | 401 | signInWithEmail — wrong email or password. |
user_already_exists / email_exists | 400/422 | signUp — email already registered. Fall back to signInWithEmail for a single-screen “login or signup” UX. |
weak_password | 400 | signUp / updatePassword — password failed the platform’s policy (min 6 chars by default). |
not_authenticated | — | updatePassword / upgradeAnonymousToEmail without an active session. |
Auth — OTP & reset
| Code | status | When |
|---|---|---|
invalid_email | 400 | sendOtp / requestPasswordReset — email format check failed. |
over_email_send_rate_limit | 429 | Too frequent sendOtp / requestPasswordReset — platform rate limit (~1/minute). |
invalid_otp | 401 | verifyOtp — wrong code, expired code, or no OTP was sent for this email. Anti-enumeration: don’t distinguish in UI. |
invalid_type | 400 | verifyOtp — typo in type (not one of 'email' | 'recovery' | 'signup' | 'magiclink' | 'invite'). |
Auth — OAuth
| Code | status | When |
|---|---|---|
popup_blocked | — | signInWithOAuth was called outside a user gesture. Show a “Sign in with Google” button and require an explicit re-click. |
oauth_cancelled | — | User closed the popup before completion. Silently return to the login screen. |
oauth_failed | — | Provider returned an error (access denied, wrong client_id). Show “Couldn’t sign in, try another method”. |
oauth_timeout | — | The popup didn’t deliver a code within 5 minutes (user abandoned without closing). |
oauth_unavailable | — | SDK was called from Node.js or another runtime without window — developer bug. |
exchange_failed | 401 | The auth backend rejected the code (stolen-from-logs code, state typo). “Session expired, try again.” |
Auth — anonymous
| Code | status | When |
|---|---|---|
anonymous_disabled | 403 | signInAnonymously() — allow_anonymous is off in paywall settings. Enable it in the dashboard or hide the “Continue as guest” button. |
Checkout & subscriptions
| Code | status | When / What to do |
|---|---|---|
already_purchased | 409 | createCheckout — 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_failed | — | Emitted 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_price | — | CTA 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. |
forbidden | 403 | getCustomerPortalUrl — no active subscription / acquirer doesn’t support a portal (e.g. Freemius). Show “no subscription to manage”. |
API Gateway
| Code | status | When / What to do |
|---|---|---|
not_enough_queries (QuotaExceededError) | 402 | gateway.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-disabled | 403 | API 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”.