Linked Paywall Guide

Integrate your paywall design with the Nami platform.

Linked Paywalls in the Nami platform provide a way for the app developer to provide their own design instead of using Nami's cloud-based Paywall Creator.

2 common use cases for Linked Paywalls include:

  • Quickly integrate a paywall that you have already built in your app with the Nami platform
  • Create custom purchase experiences in your paywall that are not supported with our Paywall Creator

Data elements in a Linked Paywall can still be configured through our Control Center and Linked Paywalls work with all our Campaign rules as well. See this related article for more details on setting up Linked Paywalls in the Control Center.

The main steps to working with a Linked Paywall and the Nami SDK are:

  1. Register a callback handler with Nami. We'll call this method when your Linked Paywall needs to be shown in the app.
  2. Write code to build and display your paywall UI.
    1. Retrieve any localized data from Nami required to build your paywall UI.
  3. Raise the paywall when the user asks to see it.
  4. Tell Nami to execute a purchase when a user buys a product SKU.
  5. React to a completed purchase if there are any post-purchase actions you wish to make.

Below we'll explore each item in more detail.

Register Linked Paywall Callback Handler

In order for Nami to show your custom written paywall, you'll need to register a callback with the SDK. We recommend doing this early in your code when the app starts executing. A good spot is right after you call the Nami.configure() method.

🚧

If your development framework allows multiple callbacks to be registered, you'll want to ensure you either only register a single callback or guard against the same callback being added multiple times. This can be an issue on platforms like React Native.

The paywall handler callback looks like this:

NamiPaywallManager.registerPaywallHandler( { ( fromVC: UIViewController?, products : [NamiSKU]?, developerPaywallID : String, paywallMetadata : NamiPaywall) in
  // Provide your custom view controller here
}
[NamiPaywallManager registerWithApplicationPaywallProvider:^(UIViewController * _Nullable presentFromVC, NSArray<NamiSKU *> * _Nullable skus, NSString * _Nonnull developerPaywallID, NamiPaywall * _Nonnull paywallMetadata) {
  // Build paywall from paywallMetadata and display to user
}];
NamiPaywallManager.registerPaywallRaiseListener { context, namiPaywall, namiSkus, developerPaywallId ->
  // code to display your paywall goes here
}
NamiPaywallManager.registerPaywallRaiseListener((context, namiPaywall, namiSkus, developerPaywallId) -> {
        // code to display your paywall goes here
        return Unit.INSTANCE;
});
const eventEmitter = new NativeEventEmitter(NamiEmitter);

const onPaywallShouldRaise = (event) => {
  // Add code to present UI for your application paywall
}

useEffect(() => {
  eventEmitter.addListener('AppPaywallActivate', onPaywallShouldRaise);
}, []);
onPaywallShouldRaise = (event) => {
  // Add code to present UI for your application paywall
}

componentDidMount(){
 const eventEmitter = new NativeEventEmitter(NamiEmitter);
 eventEmitter.addListener('AppPaywallActivate', onPaywallShouldRaise);
}
NamiPaywallManager.paywallRaiseEvents().listen((paywallRaiseRequestData) {
    // code to display your paywall goes here
});

The method invoked by the callback provides a few pieces of important data. On React Native all these elements will be present in the event dictionary.

  • developerPaywallID The Developer Paywall ID set in the Control Center for the paywall to be displayed.
  • namiPaywall All the metadata, style data, and key-value pairs set for the paywall in the Control Center
  • skus A list of product SKU objects that are part of the paywall, given to you in the order that you specify in the Nami Control Center.
  • (Android only) application context - The current android context.

More information on the data available in these objects is available here:

Build the Paywall UI

With the data provided in the callback shown above, you may use any approach that makes sense in your app to build the UI for the purchase screen. Your design should support up to 3 SKUs being returned in the skus object in the callback.

We also recommend reviewing the guidelines of each store you sell your app on to ensure your paywall design meets their requirements.

For each in-app purchase SKU on your paywall, you'll need to show the user the correct localized price for the SKU and if the IAP is a subscription, you will also want to display the length of the subscription.

These data are available in the skus array returned in the callback. There are a few fields you may want to use. Note that the variable names can vary slightly per platform as these data elements come directly from each store platform.

  • localizedDuration (Apple only) - if the in-app purchase is a subscription, this is a string description of the length such as Per Year or Per 6 months.
  • localizedDurationSingular (Apple only) - for subscription IAPs, an all lowercase string of the subscription length such as year or day.
  • subscriptionPeriod (Android only) - a string describing the length of the subscription.
  • price or localizedPrice - the purchase price of the SKU in the correct currency for the device
  • localizedTitle or title - the title of the SKU entered into the store platform (such as the Google Play Console or App Store Connect) localized for the device.

See the code samples below on how to access these elements from a single sku object from the skus array. The code samples also show which fields are available on each platform.

if let product: SKProduct = sku.product {
  let formattedLocalizedPrice = product.localizedPrice
  let formattedProductName = product.localizedTitle
  let formattedDuration = product.localizedDuration
  let formattedDurationSingular = product.localizedDurationSingular
}
SKProduct *product = sku.product;
NSString *formattedLocalizedPrice = product.localizedPrice
NSString *formattedProductName = product.localizedTitle
NSString *formattedDuration = product.localizedDuration
NSString *formattedDurationSingular = product.localizedDurationSingular
val price = namiSku.skuDetails.price
val title = namiSku.skuDetails.title
val subscriptionPeriod = namiSku.skuDetails.getSubscriptionPeriodEnum()
String price = namiSKU.getSkuDetails().getPrice();
String title = namiSKU.getSkuDetails().getTitle();
String subscriptionPeriod = SkuDetailsKt.getSubscriptionPeriodEnum(namiSKU.getSkuDetails());
var price = sku.localizedPrice;
var title = sku.localizedTitle;
var price = namiSku.price;
var title = namiSku.title;
var subscriptionPeriod = namiSku.periodUnit;

📘

Missing SKU Objects

The Nami SDK talks to the various store frameworks such as StoreKit on Apple's App Store and the Play Billing library on the Google Play Store. If the SKU reference IDs you provided in the Control Center do not match IDs defined on the store, or the SDK is unable to communicate properly with the store framework, it is possible that the skus list will be missing objects or be empty compared to what you set in the Control Center.

If this happens, be sure to check that you have used the correct SKU ID in your code and in the Control Center.

The Paywall metadata object will also pre-load an image for the background if one has been configured for that paywall. You can obtain the image directly from the paywall metadata object.

If the image is not available on the device or you are coding in a framework like React Native where the paywall data is a dictionary of values and not an object, you will need to fetch the image via the URL string in the paywall data.

Note that the Nami platform provides 2 images, one for phone devices and one for tablets. Make sure to load and use the correct one for your device.

var backgroundImage = namiMetaPaywall.backgroundImage
if (backgroundImage == nil) {
  
  var imageURLKey = "background_image_url_phone"
  if UIDevice.current.userInterfaceIdiom == .pad {
    imageURLKey = "background_image_url_tablet"
  }
  
  if let imageURLString = namiMetaPaywall.namiPaywallInfoDict[imageURLKey] as? String, 
     let imageURL = URL(string: imageURLString),
     let imageData = try? Data(contentsOf: imageURL) {
       backgroundImage = UIImage(data: imageData)
     }
}
UIImage *backgroundImage = paywallMetadata.backgroundImage;
if (backgroundImage == NULL) {
  NSString *imageURLKey = @"background_image_url_phone";
  if ([[UIDevice currentDevice] UIUserInterfaceIdiomPad] == UIUserInterfaceIdiomiPad) {
    imageURLKey = @"background_image_url_tablet"
  }
  NSString *imageURLString = paywallMetadata.namiPaywallInfoDict[imageURLKey];
  NSURL *imageURL = [NSURL URLWithString:imageURLString];
  NSData *imageData = [NSData dataWithContentsOfURL:imageURL];
  if (imageData != nil) {
    backgroundImage = [UIImage imageWithData:imageData];
  }
}
namiPaywall.backgroundImage?.let { bitmap ->
    paywallView.background = BitmapDrawable(resources, bitmap)
} ?: run {
    val imageUrl = namiPaywall.backgroundImageUrlPhone
    // Use ImageUrl to fetch and load image and then set as background
}
Bitmap backgroundImage = namiPaywall.getBackgroundImage();
if (backgroundImage != null) {
    paywallView.setBackground(new BitmapDrawable(getResources(), backgroundImage));
} else {
    String imageUrl = namiPaywall.getBackgroundImageUrlPhone();
    // Use ImageUrl to fetch and load image and then set as background
}
@override
Widget build(BuildContext context) {
  return MaterialApp(
      home: Scaffold(
          body: Container(
              decoration: BoxDecoration(
                  image: DecorationImage(
                      image: NetworkImage(namiPaywall.backgroundImageUrlPhone),
                      fit: BoxFit.fill)),
              child: Container())));
}

Generally Nami will try to pre-load assets and products if possible, but it may not always be able to acquire either depending on network status.

Raise the Paywall

If you want to raise the linked paywall at any point in your app, first make sure it is set as the User-Initiated Paywall in your current live campaign in your app. See instructions here. Then simply call the raisePaywall method.

You can optionally check to make sure the SDK has all the data required to display the paywall before trying to show it with the canRaisePaywall method.

if (NamiPaywallManager.canRaisePaywall()) {
  NamiPaywallManager.raisePaywall(fromVC: nil)
}
if ( [NamiPaywallManager canRaisePaywall] ) {
  [NamiPaywallManager raisePaywallFromVC:nil];
}
NamiPaywallManager.preparePaywallForDisplay { success, error ->
    if (success) {
        NamiPaywallManager.raisePaywall(activity)
    } else {
        // Log error or retry prepare or take any other appropriate action
    }
}
NamiPaywallManager.preparePaywallForDisplay((success, error) -> {
    if (success) {
        NamiPaywallManager.raisePaywall(activity);
    } else {
        // Log error or retry prepare or take any other appropriate action
    }
    return Unit.INSTANCE;
});
NativeModules.NamiPaywallManagerBridge.raisePaywall()
var preparePaywallResult = await NamiPaywallManager.preparePaywallForDisplay();

if (preparePaywallResult.success) {
    NamiPaywallManager.raisePaywall();
}

Make a Purchase

On a Linked Paywall, when the user decides to make a purchase, you can let the Nami SDK know to execute the purchase with the buySKU method.

Below is an example of how to use this method. This code has 3 main parts:

  • Fetch a NamiSKU object by the SKU's reference ID
  • Ask Nami to attempt to purchase the SKU
  • Complete the purchase based on the resulting state of the purchase attempt
let skuID = "monthy_product"
NamiPurchaseManager.skusForSKUIDs(skuIDs: [skuID]) { (success: Bool, skus: [NamiSKU]?, invalidSKUIDs: [String]?, error: Error?) in
  if let sku = skus?.first {
    NamiPurchaseManager.buySKU(sku) { (purchases: [NamiPurchase], state: NamiPurchaseState, error: Error?) in
     // react to the state of the purchase
     if state == .purchased {
       // Purchase succeeded
     }
     else if state == .failed {
       // Purchase failed
     }
     else if state == .deferred {
       // The purchase has been deferred and may complete at a later time 
     }
   }
}
NSString *skuID = @"monthy_product";
[NamiPurchaseManager skusForSKUIDsWithSkuIDs:@[skuID] productHandler:^(BOOL success, NSArray<NamiSKU *> * _Nullable namiSkus, NSArray<NSString *> * _Nullable inavlidProductIDs, NSError * _Nullable error) {
  NamiSKU *sku = skus.firstObject;
  if (sku != NULL) {
    [NamiPurchaseManager buySKU:sku fromPaywall:nil responseHandler:^(NSArray<NamiPurchase *> * _Nonnull purchases, NamiPurchaseState state, NSError * _Nullable error) {
      // optionally react to the result of the purchase request
       if (state == NamiPurchaseStatePurchased) {
         // Purchase succeeded
       }
      else if (state == NamiPurchaseStateFailed) {
        // Purchase failed
      }
      else if (state == NamiPurchaseStateDeferred) {
        // Purchase has been deferred and may complete later
      }
    }];
  }
}];
private val onPurchaseComplete: ((NamiPurchaseCompleteResult) -> Unit) = { result ->
    if (result.isSuccessful) {
        // Purchase is successful
    } else {
        // you can check optional message or billingResponseCode
        // to get more information about failure
        val code = result.billingResponseCode
        val errorMessage = result.message
    }
}

NamiPurchaseManager.buySKU(activity, namiSKU.skuId, onPurchaseComplete)
NamiPurchaseManager.INSTANCE.buySKU(activity, namiSkuId, namiPurchaseCompleteResult -> {
    if (namiPurchaseCompleteResult.isSuccessful()) {
        // Purchase is successful
    } else {
        // you can check optional message or billingResponseCode
        // to get more information about failure
        Integer code = namiPurchaseCompleteResult.getBillingResponseCode();
        String errorMessage = namiPurchaseCompleteResult.getMessage();
    }
    return Unit.INSTANCE;
});
purchase(sku.skuIdentifier);

const purchase = (skuIdentifier) => {
  NativeModules.NamiPurchaseManagerBridge.buySKU(
    skuIdentifier,
    '',
    (purchased) => {
      if (purchased) {
        // purchase was successful
      } else {
       	// purchase failed 
      }
    }
  );
}
NamiPurchaseCompleteResult result = await NamiPurchaseManager.buySKU(skuRefId);
    if (result != null) {
      // Purchase Complete with state ${result.purchaseState}
      if (result.purchaseState == NamiPurchaseState.purchased) {
        // For example, take user to previous screen and close the widget
        Navigator.pop(context);
      } else {
        // check error message to get more insights if you'd like to log
        // error may or may not be there
        var errorMessage = result.error;
      }
    } else {
      // Purchase Complete with null result, something went wrong!
    }

In the code block above, be sure to close the paywall when the purchase process is completed. You will likely always want to do this when the purchase succeeds. When other purchase states occur, you may want to message your user and then may decide to leave them on the paywall or close it, depending on what makes the most sense for your app.

Note that as part of the purchasing process, your application may lose focus, or the application is left entirely before coming back. Additionally, some purchase flows may indicate failure initially, but be successful at some later point in time.

Note that buySKU has a simple return block that will be invoked with success or failure so the common case of an immediately successful purchase can be acted on easily.

React to a Completed Purchase

In addition to the code that you can execute after the buySKU method completes, you can also set up additional callbacks to react to purchases being completed or to changes to entitlements in your app.

More details on these methods and how to use them can be found here:

Additional Linked Paywall Documentation