Skip to content

Error handling ​

Every error the library throws is an IAPError — a typed Error subclass with a stable code, a remediation hint, and (when applicable) a cause chain to the underlying failure.

Anatomy of an IAPError ​

typescript
import { IAPError, IAPErrorCode, isIAPError, errorHint } from '@nosslabs/iap';

try {
  await iap.purchase({ productId: 'premium_monthly' });
} catch (error) {
  if (isIAPError(error)) {
    error.code;         // IAPErrorCode — stable enum, safe to switch on
    error.message;      // human-readable description + auto-appended hint
    error.recoverable;  // boolean — should the UI offer "try again"?
    error.cause;        // unknown — original error from native/backend, if any
  }
}

The message looks like:

Backend returned 401 Unauthorized

Hint: Backend returned 401/403. Check that getAuthHeaders() returns a valid Bearer token and that the backend recognizes it.

Hints are auto-appended unless the constructor was called with includeHint: false (rare — only used internally when the message already contains remediation text, e.g. zod validation errors).

Switching on code ​

The error.code field is a stable string enum. Switch on it rather than parsing message:

typescript
import { IAPErrorCode, isIAPError } from '@nosslabs/iap';

try {
  await iap.purchase({ productId: 'premium_monthly' });
} catch (error) {
  if (!isIAPError(error)) throw error;

  switch (error.code) {
    case IAPErrorCode.USER_CANCELLED:
      // No-op — user dismissed the sheet
      break;
    case IAPErrorCode.PURCHASE_PENDING:
      toast('Payment processing — we\'ll notify you when it clears');
      break;
    case IAPErrorCode.BACKEND_AUTH_FAILED:
      // Re-auth the user
      router.push('/login');
      break;
    case IAPErrorCode.BACKEND_UNAVAILABLE:
    case IAPErrorCode.BACKEND_TIMEOUT:
      toast('Network issue — please try again');
      break;
    default:
      toast(`Purchase failed: ${error.message}`);
  }
}

Prefer iap.purchase() discriminated result

For the purchase flow specifically, the discriminated union from iap.purchase() is more ergonomic than try/catch — see Getting started → Buy something. Use try/catch for restorePurchases(), refresh(), and initialize().

All error codes ​

CodeRecoverableWhen it firesWhat to do
INVALID_CONFIGnocreateIAP({ ... }) — schema validation failed. Also at initialize() for a lazily-resolved dependency, e.g. storage.type: 'preferences' with @capacitor/preferences not installedFix the config; re-read the field paths in the message
INVALID_PURCHASE_OPTIONSnoAn argument to purchase() is unusable — an empty androidOfferToken, one given for a subscription, or one the store reports as matching no current offerPass a token from Product.androidOneTimeOffers on a fresh getProducts(); use androidPlanId for subscriptions
NOT_INITIALIZEDnoA method called before initialize() resolvedawait iap.initialize() first
PLATFORM_NOT_SUPPORTEDnopurchase() / restorePurchases() on webGuard the UI behind Capacitor.isNativePlatform()
BILLING_NOT_AVAILABLEnoStore billing service unavailable (@capgo/native-purchases not linked, or no sandbox account)Check npx cap sync ran; check device sandbox account
PRODUCT_NOT_FOUNDnoPurchasing a productId not in the store catalogVerify productId in App Store Connect / Play Console AND in createIAP({ products })
USER_CANCELLEDnoUser dismissed the system purchase sheetNo action needed
PURCHASE_PENDINGnoAndroid: payment awaiting external clearanceTell the user; trust the backend webhook
ALREADY_PURCHASEDnoBuying a non-consumable the user already ownsUse restorePurchases() to re-grant
STORE_ERRORnoGeneric native-side failure (declined card, store down)Surface message; offer retry
UNACKNOWLEDGED_PENDINGyesGoogle purchase >2 days unacknowledgedLibrary auto-retries on next refresh
ALREADY_IN_PROGRESSno¹Concurrent purchase() for same productId, or reset() while a purchase is in flightAwait the in-flight call
BACKEND_UNAVAILABLEyesBackend unreachable / 5xx / network dropLibrary auto-retries; surface to user if persistent
BACKEND_TIMEOUTyesBackend exceeded timeoutMsIncrease timeoutMs or check server perf
BACKEND_AUTH_FAILEDno401 / 403 from backendRe-auth user; check getAuthHeaders()
BACKEND_BAD_RESPONSEno4xx / malformed JSON / schema mismatchBackend doesn't match contract
VERIFICATION_REJECTEDnoBackend returned { valid: false }Don't auto-retry; transaction stays in queue for iap.refresh()
STORAGE_ERRORyesCapacitor Preferences write failedIn-memory state is fine; persistence will retry
INVALID_APP_USER_IDnopurchase({ appUserId }) value (literal or fetcher-returned) isn't an RFC 9562 UUIDPass any UUID version — crypto.randomUUID() (v4), or a UUIDv5 over a stable name for a deterministic binding — or omit appUserId to fall back to subject.key mapping
APP_USER_ID_FETCH_FAILEDnoThe async appUserId fetcher threw or rejectedInspect error.cause for the underlying error (network failure, backend non-2xx)

¹ recoverable: false by default, because the common cause is a caller bug — two purchase() calls for one product. reset() raises it with recoverable: true and no per-code hint, since there the remedy really is "await the purchase and try again".

Get the per-code hint programmatically:

typescript
import { errorHint, IAPErrorCode } from '@nosslabs/iap';

console.log(errorHint(IAPErrorCode.BACKEND_AUTH_FAILED));
// → "Backend returned 401/403. Check that getAuthHeaders() returns a valid Bearer token..."

recoverable flag ​

error.recoverable: boolean is a hint to your UI: should the "try again" button appear?

  • Recoverable (true) — transient: backend was down, network blipped, storage write failed. The library has likely retried already and exhausted attempts; a user-initiated retry might succeed.
  • Non-recoverable (false) — fatal: bad config, invalid receipt, user cancelled, platform unsupported. Retry will produce the same error.

Default classifications:

  • Recoverable: BACKEND_UNAVAILABLE, BACKEND_TIMEOUT, STORAGE_ERROR, UNACKNOWLEDGED_PENDING
  • Everything else: non-recoverable

You can override per-throw via the constructor's recoverable option, but stick with defaults unless you have a strong reason.

The cause chain ​

When the library wraps an underlying error, cause preserves the original:

typescript
try {
  await iap.refresh();
} catch (error) {
  if (isIAPError(error)) {
    console.error('IAP error:', error.code, error.message);

    if (error.cause instanceof Error) {
      // Original fetch error, JSON parse error, etc.
      Sentry.captureException(error.cause);
    }
  }
}

Always log error.cause separately from error itself if you're shipping to Sentry / Datadog — the cause has the underlying stack trace, while IAPError has the orchestration context.

Logging hygiene ​

The library logs at the logLevel you configure (default 'info'). Recommended levels:

  • Production: 'warn' — captures STORE_ERROR, BACKEND_AUTH_FAILED, etc., without the noisy debug trace.
  • Development: 'debug' — full trace of every native event, backend call, and entitlement diff.
  • Tests: 'silent' — keep test output clean.

Plug in a custom logger to ship to your observability stack:

typescript
const config = {
  // ...
  options: {
    logLevel: 'warn',
    logger: {
      error: (msg, ctx) => Sentry.captureMessage(msg, { extra: ctx }),
      warn:  (msg, ctx) => Sentry.captureMessage(msg, { extra: ctx, level: 'warning' }),
      info:  () => {}, // drop info in prod
      debug: () => {}, // drop debug in prod
    },
  },
};

Don't log receipt tokens

The library never logs raw transaction.token (StoreKit transactionId / Google purchaseToken) by default. If you wrap the logger, don't JSON.stringify(error.cause) blindly — cause may contain a network response with the token. Strip token-shaped strings before sending to your observability backend.

What iap.initialize() can throw ​

Surprisingly little. initialize() is designed to be resilient because failing init breaks every consumer's app boot.

Scenarios:

  • Native plugin missing → logs warning, sets web-stub mode, init succeeds.
  • Backend unreachable during background refresh → swallowed, emitted via error event, init succeeds.
  • Storage read fails → logs error, in-memory state is empty, init succeeds.
  • INVALID_CONFIG → usually throws at the createIAP() call (synchronous). One case reaches initialize(): a dependency resolved lazily there, such as storage.type: 'preferences' with @capacitor/preferences absent.

So await iap.initialize() should very rarely throw in practice. The few cases that DO throw are coding errors (calling initialize() after destroy()).

What iap.purchase() can throw ​

purchase() does NOT throw on user-cancellation, pending, or verification failure — those are surfaced via the PurchaseResult discriminated union (status: 'cancelled' | 'pending' | 'verification_failed' | 'failed').

Android can't always distinguish cancellation

On iOS, a user dismissing the StoreKit sheet reliably yields status: 'cancelled'. On Android, Google Play Billing does not distinguish user-cancel from other purchase failures at the level @capgo/native-purchases exposes — so an Android cancellation surfaces as status: 'failed' (with an IAPError whose code is STORE_ERROR). Treat failed on Android the same as cancelled for UX purposes (don't show a scary error toast for it).

It DOES throw on:

  • PRODUCT_NOT_FOUND (productId not in config.products)
  • INVALID_PURCHASE_OPTIONS (an unusable androidOfferToken — empty, or given for a subscription)
  • NOT_INITIALIZED
  • ALREADY_IN_PROGRESS (concurrent purchase for same productId; also raised by reset() while a purchase is in flight)
  • PLATFORM_NOT_SUPPORTED (web)
  • INVALID_APP_USER_ID (appUserId not an RFC 9562 UUID)
  • APP_USER_ID_FETCH_FAILED (async fetcher threw / rejected; original attached as cause)

These are programming errors / pre-flight failures, not user-flow errors — surface them in dev, silence them in production (the user can't fix them).

Permanent vs transient classification (recovery) ​

When recovery runs at initialize() time, every entry in the unfinished-transactions list is re-verified with your backend. The backend's response shape is { valid: true, ... } or { valid: false, error: string, message?: string }. Before v0.4.0, every valid:false was retained for retry on the next launch — fine for transient backend hiccups, but pathological for permanent domain failures like TRANSACTION_NOT_FOUND (Apple/Google has no record of this transactionId), where retrying achieves nothing and hammers your backend on every app resume.

From v0.4.0, recovery classifies the error code against options.permanentErrorCodes:

  • In the set → drop the entry (best-effort acknowledge() to clear the platform queue, then unfinished.remove(), then emit 'recovery-dropped-permanent').
  • Not in the set → retain (legacy behavior — retry on next launch).

Default set:

ts
import { DEFAULT_PERMANENT_ERROR_CODES } from '@nosslabs/iap';
// ['TRANSACTION_NOT_FOUND', 'PRODUCT_MISMATCH']

Extend with your backend's custom permanent codes:

ts
createIAP({
  options: {
    permanentErrorCodes: [...DEFAULT_PERMANENT_ERROR_CODES, 'MY_BACKEND_CODE'],
  },
});

Disable entirely (revert to retry-forever):

ts
createIAP({
  options: { permanentErrorCodes: [] },
});

Listen for drops via the new event for ops observability:

ts
iap.on('recovery-dropped-permanent', ({ productId, error, message }) => {
  console.warn(`[iap] dropped permanently-invalid tx for ${productId}: ${error}`, { message });
});

The default codes match the documented contract used by Attesto's backend recipes — both TRANSACTION_NOT_FOUND and PRODUCT_MISMATCH are answers, not infrastructure failures (they're returned with HTTP 200 + valid:false, never as 5xx). Codes deliberately excluded from the default set: STALE_TRANSACTION (sometimes legitimately retryable), SIGNATURE_INVALID (Apple cert rotation has caused historical false positives), and any INVALID_REQUEST (caller bug — shouldn't fire from recovery, which resends previously-valid request shapes).

Backend lag assumption

The default set assumes your backend queries Apple App Store Server API / Google Play Developer API with eventually-consistent reads (typical for Attesto's recipe pattern). If your backend reads from a replicated database with replication lag exceeding app-launch cadence, a TRANSACTION_NOT_FOUND response could be transient — in that case configure permanentErrorCodes: [] (or a custom set) until the lag is reconciled.

Next ​

Released under the MIT License. Pairs with Attesto for server-side receipt validation.