Skip to content

Fix web checkout redemption codes being dropped on cold start (iOS) - #228

Closed
nhamilton94 wants to merge 1 commit into
superwall:mainfrom
nhamilton94:fix/deeplink-redemption-dropped-before-configure
Closed

nhamilton94 wants to merge 1 commit into
superwall:mainfrom
nhamilton94:fix/deeplink-redemption-dropped-before-configure

Conversation

@nhamilton94

Copy link
Copy Markdown

Summary

handleDeepLink calls the deprecated instance overload, which routes immediately with no configuration guard and no replay:

// ios/SuperwallExpoModule.swift:348
let result = Superwall.shared.handleDeepLink(url)
// SuperwallKit — Superwall.swift:1036-1040
@available(*, deprecated, message: "Use the static method Superwall.handleDeepLink(_:) instead.")
public func handleDeepLink(_ url: URL) -> Bool {
  return dependencyContainer.deepLinkRouter.route(url: url)
}

The static overload is the one that handles a not-yet-configured SDK:

// SuperwallKit — Superwall.swift:1063-1068
public static func handleDeepLink(_ url: URL) -> Bool {
  if Superwall.isInitialized, Superwall.shared.configurationStatus == .configured {
    return Superwall.shared.dependencyContainer.deepLinkRouter.route(url: url)
  }
  return DeepLinkRouter.storeDeepLink(url)   // parked, replayed on config
}

DeepLinkRouter.storeDeepLink parks the URL in pendingDeepLink, and listenToConfig replays it once config is .retrieved. The instance method does neither, so the code is discarded.

Why the timing always loses

SuperwallProvider calls handleDeepLink from a useEffect on mount, in the same commit that kicks off configure(). Linking.getInitialURL() resolves in microseconds; configuring requires a network round-trip. The code always arrives first — this isn't a race that's sometimes lost, it's one that can't be won.

If configure() hasn't been entered at all yet, it's worse: Superwall.shared logs an error, trips assertionFailure, and returns a throwaway Superwall() that is never assigned to the singleton, so the URL is routed into a container with no API key.

Because the provider registers its own handler, apps can't work around this by gating their own handleDeepLink call — the SDK's copy still fires early.

Evidence

Across three Superwall applications, entire project history (2026-06-15 → 2026-09-10):

event type count
redemption_start EXISTING_CODES 288
redemption_complete EXISTING_CODES 286
redemption_fail EXISTING_CODES 1
any CODE 0

575 redemption events, 100% EXISTING_CODES. A redeem(.code) has never once reached Superwall's servers from any device.

Why this is hard to notice

Two things make the broken path look healthy:

  1. registerAppTransactionIdIfNeeded (ReceiptManager.swift:115-135) fires redeem(.existingCodes) once on first launch, gated by the AppTransactionIdSent flag. It emits redemption_start and redemption_complete while resending an empty stored-code set, so it grants nothing. On a fresh install this appears ~500ms after app_install and reads as a successful redemption.

  2. willRedeemLink is guard case .code = type else { return } (WebEntitlementRedeemer.swift:413-414), and every didRedeemLink call site (:561, :604, :712) sits inside .code-only paths. So neither callback fires for the EXISTING_CODES events integrators actually observe — it reads as a logging gap rather than a missing redemption.

Nothing is logged when the code is dropped.

Reproduction

  1. Expo app with SuperwallProvider, Web Checkout in Redeem mode.
  2. Force-quit so the next launch is cold.
  3. Tap a redemption deep link (yourscheme://superwall/redeem?code=…).
  4. Observe: app opens; no redemption_start with $type: CODE, no entitlement, no willRedeemLink/didRedeemLink, no error.

Not verified

android/.../SuperwallExpoModule.kt already calls Superwall.handleDeepLink(url) on the companion object. I couldn't check whether the Android SDK applies the same pre-config guard — only the iOS SDK source was available to me. Worth confirming on your side.

A changeset is included (patch).

🤖 Generated with Claude Code

https://claude.ai/code/session_01L8Ek51PUFYeYWBwhbXRupp

`handleDeepLink` called the deprecated instance overload
`Superwall.shared.handleDeepLink`, which routes immediately with no
configuration guard and no replay. `SuperwallProvider` hands the URL over in
the same effect that starts `configure()`, so on a cold start the code always
arrives before the SDK is configured and is silently discarded.

The static `Superwall.handleDeepLink` already handles this: it stores the URL
via `DeepLinkRouter.storeDeepLink` and replays it once config is retrieved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L8Ek51PUFYeYWBwhbXRupp
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant