Redemption Links Deep Dive
User pays on web. App doesn't unlock. That's the #1 support ticket. Redemption links are the fix - here's exactly how they work, how to set them up correctly, and how to handle every failure mode.
What you'll walk away with: The full 6-step flow visualized, SDK version requirements, working Swift and React Native code for both background and cold-start scenarios, AASA and assetlinks.json configuration, three pitfalls with exact fixes, comprehensive error handling code, and a deep link verification checklist.
How Redemption Links Actually Work
User taps "Subscribe on Web"
Safari opens to your Stripe or RevenueCat Billing checkout page
Payment completes on web
Stripe or RevenueCat Billing processes the transaction
RevenueCat generates a redemption link
One-time-use token, 60-minute expiry. Shown on success page and emailed to user
User taps the link on their phone
iOS: Universal Link intercepts and opens your app. Android: App Link does the same
Your app calls Purchases.redeemWebPurchase(url)
SDK validates the token server-side and associates the purchase with the App User ID
Entitlements unlock immediately
CustomerInfo is updated. Your UI reflects the active subscription
Minimum SDK Versions
iOS
purchases-ios 5.14.1+
Android
purchases-android 8.10.6+
React Native
react-native-purchases 8.5.0+
Step 1: Configure Universal Links (iOS) and App Links (Android)
This is where 90% of "redemption not working" issues originate. If your Universal Links or App Links aren't configured correctly, tapping the redemption link opens a browser tab instead of your app. The user sees a webpage, gets confused, and files a support ticket.
You need to host two well-known files at your domain root. Both must be served over HTTPS, return application/json, and be accessible without redirects.
iOS - apple-app-site-association
Host this at https://yourdomain.com/.well-known/apple-app-site-association with no file extension. The appID is your Team ID + Bundle ID. The /* path pattern covers all redemption link URLs.
{
"applinks": {
"apps": [],
"details": [
{
"appID": "TEAMID123.com.yourcompany.yourapp",
"paths": ["/*"]
}
]
},
"webcredentials": {
"apps": ["TEAMID123.com.yourcompany.yourapp"]
}
} Android - assetlinks.json
Host this at https://yourdomain.com/.well-known/assetlinks.json. Get your SHA-256 certificate fingerprint from Play Console → Setup → App integrity, or run keytool against your keystore.
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.yourcompany.yourapp",
"sha256_cert_fingerprints": [
"AA:BB:CC:DD:EE:FF:..."
]
}
}
] apple-app-site-association is accessible at /.well-known/ with no file extension application/json with no redirects applinks:yourdomain.com AndroidManifest.xml with autoVerify="true" Step 2: Handle the Link in Your App
You need to handle two separate scenarios: the app is in the foreground or background when the link is tapped, and the app was completely killed (cold start). They use different entry points. Missing cold start is the most common implementation mistake.
Swift - SceneDelegate (both scenarios)
import RevenueCat
// Case 1: App is open (background or foreground) when link is tapped
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
let url = userActivity.webpageURL else { return }
Task { await redeemIfNeeded(url) }
}
// Case 2: Cold start - app was killed when link tapped
func scene(_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions) {
if let activity = connectionOptions.userActivities.first,
activity.activityType == NSUserActivityTypeBrowsingWeb,
let url = activity.webpageURL {
Task { await redeemIfNeeded(url) }
}
}
// Shared redemption logic
func redeemIfNeeded(_ url: URL) async {
guard url.absoluteString.contains("revenuecat") else { return }
do {
let result = try await Purchases.shared.redeemWebPurchase(url)
await MainActor.run {
NotificationCenter.default.post(name: .entitlementsUpdated, object: result.customerInfo)
}
} catch {
await MainActor.run { handleRedemptionError(error) }
}
} React Native - both scenarios in one hook
import { Linking } from 'react-native';
import Purchases, { PurchasesError } from 'react-native-purchases';
import { useEffect, useCallback } from 'react';
async function redeemIfNeeded(url: string) {
if (!url.includes('revenuecat')) return;
try {
const { customerInfo } = await Purchases.redeemWebPurchase(url);
// Emit an event or call a state update to refresh entitlements in your UI
console.log('Redeemed. Active:', customerInfo.activeSubscriptions);
} catch (error) {
handleRedemptionError(error as PurchasesError);
}
}
export function useRedemptionLink() {
useEffect(() => {
// Case 1: app open - fires on every incoming URL
const sub = Linking.addEventListener('url', ({ url }) => redeemIfNeeded(url));
// Case 2: cold start - app opened via link from killed state
Linking.getInitialURL().then((url) => {
if (url) redeemIfNeeded(url);
});
return () => sub.remove();
}, []);
} Mount this hook at the root of your app
Put useRedemptionLink() in your root App.tsx or navigation root - not inside a specific screen. Redemption links can arrive at any time, regardless of where the user is in the app. If you only handle them on the paywall screen, you'll miss links that arrive while the user is browsing content.
Step 3: Handle Every Error Case
redeemWebPurchase throws for three distinct reasons. Handle each differently - don't show the same error message for all of them. An expired link and a network error need different UI responses and different support instructions.
import { PurchasesError, PURCHASES_ERROR_CODE } from 'react-native-purchases';
function handleRedemptionError(error: PurchasesError) {
switch (error.code) {
case PURCHASES_ERROR_CODE.PURCHASE_NOT_ALLOWED_ERROR:
// Link is expired (60-min window passed)
// RevenueCat auto-sends a new link via email - tell the user
showAlert(
'Link Expired',
'This link has expired. We\'ve sent a fresh one to your email - tap it on your phone.'
);
break;
case PURCHASES_ERROR_CODE.PURCHASE_INVALID_ERROR:
// Link already redeemed - subscription may already be active
showAlert(
'Already Redeemed',
'This link was already used. Your subscription should be active - try closing and reopening the app.'
);
break;
case PURCHASES_ERROR_CODE.NETWORK_ERROR:
// Transient - safe to retry
showAlert(
'Connection Error',
'Check your connection and try tapping the link again.'
);
break;
default:
// Log unknown errors - don't fail silently
console.error('Redemption error:', error.code, error.message);
showAlert(
'Something Went Wrong',
'Contact support with code: ' + error.code
);
}
} The Three Pitfalls
Deep links not configured - link opens in browser
Symptom: user taps the redemption link and a webpage opens instead of the app. The user has no idea what to do. This is a misconfigured AASA file or missing Associated Domains capability in Xcode.
Fix: Use the verification checklist above. Run the Branch AASA validator. Check that your bundle ID in the AASA exactly matches your app's bundle ID (including case). Re-download the provisioning profile after adding the Associated Domains capability - Xcode sometimes doesn't prompt for this.
Note: iOS caches AASA files. After changing your AASA, test on a fresh device or reset the cache by reinstalling the app.
Cold-start scenario not handled
Symptom: redemption works when the app is open but fails silently when the user taps the link from a killed state. The app opens but nothing unlocks.
Fix: Handle the cold-start path explicitly. In React Native, call Linking.getInitialURL() in your root component's useEffect. In Swift, handle it in scene(_:willConnectTo:options:), not just scene(_:continue:). See the code above for both cases.
Test this explicitly by force-quitting your app, then tapping the redemption link from the email client.
Anonymous user identity breaks on reinstall
Symptom: user redeems successfully, reinstalls the app, subscription is gone. RevenueCat associated the purchase with an anonymous App User ID that no longer exists after reinstall.
Fix: encourage users to create an account and call Purchases.logIn(userId) before or during web checkout. When they redeem the link on a device where they're logged in, the purchase attaches to their known user ID - which persists across reinstalls and devices.
This is especially important for high-LTV users. Anonymous purchases are fine for quick testing but risky in production at scale.
The 60-minute expiry is a UX problem you need to design around
RevenueCat auto-resends a fresh link when the original expires - but users don't know that. Design your success page to say: "Check your email and tap the link on your phone. The link works for 60 minutes - if it expires, we'll send a new one automatically." Add a "Resend link" button to your Account screen for users who come back days later. "I paid but nothing works" is always this. Have a support answer ready.
Official Documentation
Related Articles
Having trouble with your redemption flow?
Book a quick call and we'll debug it together - deep links, identity, error handling, all of it.
Book a 15-min Call →