Troubleshooting SDK Integration
The dashboard's Settings → SDK Integration page provides a self-service health check for your Monetai SDK integration. This page describes how to interpret each card's status and how to diagnose common integration issues.
Reading the validation page
The page shows three status cards:
| Card | What it checks |
|---|---|
| SDK Connection | Whether initialize() has ever been called from your app |
| Event Reception | Whether both getOffer() (offer requested) and logViewProductItem() (paywall impression recorded) calls arrived in the last 24 hours |
| SDK Operation | Whether the products recommended via getOffer() are actually being shown on the paywall (last 24 hours) |
Card-by-card diagnosis
🔴 SDK Connection: Not connected
The SDK has never reported an initialize() call from your app.
Check:
- The Monetai SDK library is included in your app build
initialize()runs at app launchsdkKeymatches the one shown in the dashboard
🟡 Event Reception: Partial / 🔴 Not received
Only one of getOffer() and logViewProductItem() calls is arriving, or neither is.
| Missing event | Likely cause |
|---|---|
getOffer() arrives, logViewProductItem() missing | Paywall code is missing the impression call |
logViewProductItem() arrives, getOffer() missing | The SDK isn't being asked for offers |
| Both missing | The SDK isn't integrated yet, or hasn't been used in the last 24 hours |
🟡 SDK Operation: Issues N
Some users received an offer for a SKU but did not view that SKU on the paywall. This is the most common — and most informative — signal. Use the 'Five integration principles' below to check your integration patterns, then drill into per-screen diagnosis with 'Diagnosing "Issues N" by row pattern'.
Five integration principles
Most integration bugs trace back to violating one of these.
1. Use the SKU returned by getOffer() directly in your paywall UI
The most common bug: an app calls getOffer(), receives a SKU recommendation, but the UI displays a different SKU.
❌ Anti-pattern:
const offer = await MonetaiSDK.getOffer('main_paywall');
saveToStore(offer); // saved but never used
showPaywall(BASE_DISCOUNT_SKU); // UI always uses a fixed SKU (e.g. your existing 60% discount SKU)
✅ Correct:
// BASE_DISCOUNT_SKU = the SKU at the discount rate you've been operating with
// before introducing Monetai (e.g. your existing 60%-off SKU).
const offer = await MonetaiSDK.getOffer('main_paywall');
const sku = offer?.products[0]?.sku ?? BASE_DISCOUNT_SKU;
showPaywall(sku);
2. Call getOffer() close to the moment the paywall is shown
❌ Anti-pattern: call getOffer() once at app launch, cache the result, then read from cache hours later when the paywall opens.
This breaks validation in three ways:
- Match rate drops to 0%, since validation matches each user's view event against the same user's
getOffer()call within a 24-hour window, in that order - The cached recommendation may be out of date — the AI model continually refines its decisions based on user behavior, so a recommendation cached hours ago may no longer be the right one
getOffer()calls accumulate even for users who never reach the paywall, leaving the SDK with calls that have no matching impression
✅ Correct: when your promotion trigger condition fires, call getOffer(), use the result to render the paywall, then call logViewProductItem() once the SKU appears on screen — all within the same code path.
// When the promotion trigger fires (e.g. user enters the time-sale flow)
async function onTimeSaleTrigger() {
const offer = await MonetaiSDK.getOffer('time_sale_paywall'); // call right after trigger
const sku = offer?.products[0]?.sku ?? BASE_DISCOUNT_SKU;
showPaywall(sku); // render UI immediately
logViewProductItem({ placement: 'time_sale_paywall', productId: sku, ... }); // log impression right after
}
3. Each paywall screen needs its own placement
A placement is an identifier for a specific paywall screen. Two different paywall screens (e.g., onboarding paywall vs exit-intent paywall) must have different placement values, even if they show the same SKUs.
If you reuse the same placement across multiple screens, the validation page can no longer distinguish between them, and per-screen analytics become unreliable.
4. Always call logViewProductItem() when a SKU is rendered
❌ Anti-pattern 1: only call logViewProductItem() when getOffer() returns a recommendation.
const offer = await MonetaiSDK.getOffer('time_sale_paywall');
if (offer != null) {
showPaywall(offer.products[0].sku);
logViewProductItem({ placement: 'time_sale_paywall', productId: offer.products[0].sku, ... });
// ↑ users who got null never enter the if branch, so the call is skipped
}
❌ Anti-pattern 2: skip the paywall entirely when getOffer() returns null — users who get null never reach the paywall, so no impression event fires either.
Both patterns silently drop impressions for users who got null from getOffer(), making A/B comparison impossible. To accurately compare the AI-optimized group against your existing pricing, both sides must be tracked.
✅ Correct: log impressions every time the paywall renders a SKU, regardless of whether getOffer() returned an offer object or null.
const offer = await MonetaiSDK.getOffer('time_sale_paywall');
const sku = offer?.products[0]?.sku ?? BASE_DISCOUNT_SKU;
showPaywall(sku);
logViewProductItem({ placement: 'time_sale_paywall', productId: sku, ... }); // call for users who got null too
5. Wait for SDK initialization to complete before calling getOffer()
If getOffer() is called before initialize() has finished, it returns null. Make sure your app awaits the initialization handle (Promise / callback / completion delegate) before calling getOffer().
Diagnosing "Issues N" by row pattern
When the SDK Operation card shows issues, expand the placement accordions and look at which rows are 0%.
Pattern A: All rows 0% in a placement, with getOffer count > 0
The SDK is being asked for offers but no resulting view events match. Most likely:
-
Wrong placement attached:
getOffer()is called from a screen that isn't the one actually rendering the discounted SKUs (e.g., the call lives on the regular subscription page, while the discounted SKUs only appear on a separate time-sale screen).❌ Anti-pattern:
getOffer('time_sale_paywall')is called, but the user actually sees the regular subscription page (which has its ownsubscription_paywallplacement). No view event fortime_sale_paywallis ever fired for that user, so the match fails.// Regular subscription page — this screen's placement is 'subscription_paywall'
function showRegularSubscriptionPage() {
await MonetaiSDK.getOffer('time_sale_paywall'); // the placement we want to validate
showPaywall(REGULAR_PRICE_SKU);
logViewProductItem({ placement: 'subscription_paywall', productId: REGULAR_PRICE_SKU, ... });
// ↑ this screen logs its own placement → no view event will match the 'time_sale_paywall' recommendation
}✅ Correct: only call
getOffer()from the screen where the discounted SKUs are actually shown.// Time-sale bottom sheet — the screen that actually renders the discounted SKU
async function onTimeSaleTrigger() {
const offer = await MonetaiSDK.getOffer('time_sale_paywall');
const sku = offer?.products[0]?.sku ?? BASE_DISCOUNT_SKU;
showPaywall(sku);
logViewProductItem({ placement: 'time_sale_paywall', productId: sku, ... });
} -
logViewProductItem()missing entirely: the SKU appears on screen but no view event fires. Search forlogViewProductItem()in the relevant paywall code path to verify it's actually being called (see Principle 4 code example).
Pattern B: Some rows 0%, others normal
The SDK return value is being partially ignored. Most likely:
-
Hardcoded SKU on UI:
getOffer()recommended SKUX, but the paywall shows a fixed SKUY. View events fire forYonly;Xshows 0%. (See Principle 1.)❌ Anti-pattern:
getOffer()'s result is received but ignored; the paywall always shows a fixed SKU.const offer = await MonetaiSDK.getOffer('time_sale_paywall');
// offer is received but ignored — always show the fixed SKU
showPaywall(BASE_DISCOUNT_SKU);
logViewProductItem({ placement: 'time_sale_paywall', productId: BASE_DISCOUNT_SKU, ... });
// → other recommended SKUs (40%, 50%) get no view events, showing 0% (only 60% shows 100%)✅ Correct: render the SKU returned by
getOffer()and log the view event with the same SKU.const offer = await MonetaiSDK.getOffer('time_sale_paywall');
const sku = offer?.products[0]?.sku ?? BASE_DISCOUNT_SKU;
showPaywall(sku);
logViewProductItem({ placement: 'time_sale_paywall', productId: sku, ... });
// → the recommended SKU matches the view event -
Conditional view logging: view logging is gated on
getOffer()'s result (object vsnull). (See Principle 4.) -
SKU naming mismatch: the SKU registered in the dashboard differs from the actual SKU sold by the store (e.g.,
.60suffix missing). Cross-check the dashboard SKU against your App Store / Play Console product IDs.
Recommended call pattern (code examples)
The recommended call sequence: promotion trigger fires → getOffer() → render UI → logViewProductItem(), all in the same flow within seconds.
- React Native
- iOS (Swift)
- Android (Kotlin)
import MonetaiSDK from '@hayanmind/monetai-react-native';
import { useEffect } from 'react';
// 1. Initialize the SDK once at app start (e.g. in App.tsx useEffect).
useEffect(() => {
MonetaiSDK.initialize({
sdkKey: 'YOUR_SDK_KEY',
userId: 'USER_ID',
});
}, []);
// 2. Called when the promotion trigger fires (e.g. user enters the time-sale flow, time-based trigger).
async function openTimeSalePaywall() {
// Right after the trigger, request the offer.
const offer = await MonetaiSDK.getOffer('time_sale_paywall');
// Pick the SKU. If null, fall back to your existing baseline-discount SKU.
const sku = offer?.products[0]?.sku ?? BASE_DISCOUNT_SKU;
// Resolve store details and render the paywall.
const storeProduct = await fetchStoreProduct(sku);
renderPaywall(storeProduct);
// Log the view event right after the SKU appears on screen.
await MonetaiSDK.logViewProductItem({
placement: 'time_sale_paywall',
productId: sku,
price: storeProduct.discountedPrice,
regularPrice: storeProduct.regularPrice,
currencyCode: storeProduct.currencyCode,
});
}
import MonetaiSDK
// 1. Initialize the SDK once at app start (e.g. SwiftUI App init / AppDelegate).
@main
struct YourApp: App {
init() {
Task {
try? await MonetaiSDK.shared.initialize(
sdkKey: "YOUR_SDK_KEY",
userId: "USER_ID"
)
}
}
var body: some Scene { WindowGroup { ContentView() } }
}
// 2. Called when the promotion trigger fires (e.g. user enters the time-sale flow, time-based trigger).
func openTimeSalePaywall() async {
// Right after the trigger, request the offer.
let offer = try? await MonetaiSDK.shared.getOffer(placement: "time_sale_paywall")
// Pick the SKU. If nil, fall back to your existing baseline-discount SKU.
let sku = offer?.products.first?.sku ?? BASE_DISCOUNT_SKU
// Resolve store details and render the paywall.
let storeProduct = await StoreKit.fetchProduct(sku: sku)
renderPaywall(storeProduct)
// Log the view event right after the SKU appears on screen.
try? await MonetaiSDK.shared.logViewProductItem(
ViewProductItemParams(
placement: "time_sale_paywall",
productId: sku,
price: storeProduct.discountedPrice,
regularPrice: storeProduct.regularPrice,
currencyCode: storeProduct.currencyCode
)
)
}
import com.monetai.sdk.MonetaiSDK
import com.monetai.sdk.models.ViewProductItemParams
// 1. Initialize the SDK once at app start (e.g. MainActivity.onCreate).
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
MonetaiSDK.shared.initialize(
context = this@MainActivity,
sdkKey = "YOUR_SDK_KEY",
userId = "USER_ID"
) { _, _ -> }
}
}
// 2. Called when the promotion trigger fires (e.g. user enters the time-sale flow, time-based trigger).
fun openTimeSalePaywall() {
// Right after the trigger, request the offer.
MonetaiSDK.shared.getOffer(placement = "time_sale_paywall") { offer, _ ->
// Pick the SKU. If null, fall back to your existing baseline-discount SKU.
val sku = offer?.products?.firstOrNull()?.sku ?: BASE_DISCOUNT_SKU
// Resolve store details and render the paywall.
val storeProduct = BillingClient.fetchProduct(sku)
renderPaywall(storeProduct)
// Log the view event right after the SKU appears on screen.
MonetaiSDK.shared.logViewProductItem(
ViewProductItemParams(
placement = "time_sale_paywall",
productId = sku,
price = storeProduct.discountedPrice,
regularPrice = storeProduct.regularPrice,
currencyCode = storeProduct.currencyCode,
)
)
}
}
📌 Note on
nullreturns: whengetOffer()returnsnull, your app should still render the paywall using a default SKU and still calllogViewProductItem(). The default SKU can be registered on the Monetai dashboard so that the backend automatically returns it for users assigned to the baseline / unknown groups, removing the need for client-sidenullhandling.
A note on the 24-hour window
The validation page's match rate uses a 24-hour rolling window. Two implications:
- Low-traffic SKUs may not yet have enough samples to show a meaningful rate within 24 hours. If you registered a SKU very recently, check again the next day.
- Recently launched campaigns (started within the last 24 hours) may show partial data. Wait at least 24 hours after the campaign starts before drawing conclusions.
Still stuck?
If the validation page still shows issues after applying the principles above, reach out via Slack support channel or email and include:
- A screenshot of the SDK Operation card and the expanded placement(s) showing the issue rows
- The relevant code path (function name / file) where
getOffer()andlogViewProductItem()are called - App version and SDK version