Skip to content

Rewarded ads

How do I show a rewarded ad in Kotlin Multiplatform?

Section titled “How do I show a rewarded ad in Kotlin Multiplatform?”

RewardedAdController and RewardedInterstitialAdController both extend FullScreenAdController and add exactly one thing: a show() overload that takes a reward callback. The interstitial guide covers the shared load / availability / clear semantics so they are not repeated here.

val placement = remember {
AdPlacement(
id = "rewarded_extra_life",
format = AdFormat.Rewarded,
androidAdUnitId = TestAdIds.ANDROID_REWARDED,
iosAdUnitId = TestAdIds.IOS_REWARDED,
)
}
val rewarded = remember(adManager) { adManager.rewarded(placement) }
val scope = rememberCoroutineScope()
scope.launch {
rewarded.load()
val result = rewarded.show(
onRewardEarned = { reward: AdReward ->
grantClientRewardOnce(reward.amountMicros, reward.type)
}
)
when (result) {
AdShowResult.Shown -> Unit
AdShowResult.NotReady -> showRetryUi()
is AdShowResult.Failed -> showAdError(result.error)
}
}

AdReward carries exactly two fields — amountMicros: Long and type: String — and type is the reward name configured in the AdMob UI. Client code should treat it as an opaque identifier to match against, not a display string.

The pattern above is the right shape for a button-driven flow: preload when the user reaches the screen, show when the button is tapped. The preloaded cache is exactly the cache described on the interstitial guide — the same FIFO, the same one-hour TTL, the same generation-counter invalidation. What rewarded adds is the per-show callback and the fact that the reward grant is part of the platform SDK’s presentation lifecycle, not the consumer’s coroutine.

Four facts, because this is where money is lost:

  • The onRewardEarned callback may run after show() returns.
  • AdShowResult.Shown means presented-and-dismissed, not rewarded.
  • AdEvent.RewardEarned is also emitted on the event stream, but events are telemetry and observation.
  • Therefore the client must grant from exactly one place.

The reward is granted inside the presentation phase the lifecycle diagram shows, before the terminal dismissal callback fires. The onRewardEarned callback fires inside that window, which is why awaiting show() alone is not enough to know whether a reward was earned.

A practical note on ordering: on iOS, the platform’s didRewardUserWithReward: callback fires after the user has met the watch-through criterion. On Android, the equivalent callback fires inside the listener registered at load time. The library unifies both behind onRewardEarned, but the unified callback fires at the platform’s natural moment — which is during the ad, not after it. UI that needs to react to a reward in real time should observe the callback, not show()’s result.

How do I make sure I grant the reward only once?

Section titled “How do I make sure I grant the reward only once?”
// Correct — one grant point
rewarded.show(onRewardEarned = { reward -> grantClientRewardOnce(reward.amountMicros, reward.type) })
// Wrong — this double-grants, because the callback already fired
LaunchedEffect(Unit) {
adManager.events.collect { event ->
if (event is AdEvent.RewardEarned) grantClientRewardOnce(event.reward.amountMicros, event.reward.type)
}
}

The double-grant is the most common rewarded-ad bug, and it usually shows up in production telemetry before anyone notices it in QA. The shape of the fix is to pick one grant point — and the callback parameter on show() is the one the API surfaces for that purpose — and treat the event stream as a side-channel.

A related anti-pattern is the “fire and forget” load(): a coroutine that calls load() and never checks the result. The result is a cache that may be empty when show() is called, and an interstitial-shaped surprise on the user-facing path. The caching, retry and timeouts guide has the recommended preload pattern.

The full surface of RewardedAdController is therefore: the inherited FullScreenAdController methods (load, show, isReady, preload, availability, clear, loadState, events, placement) plus the reward-callback overload of show. There is no separate onRewardEarned field on the controller, no event-listener registration, and no global “reward granted” signal. The grant point is the callback parameter, and that is deliberate.

AdPlacement(
id = "rewarded_extra_life",
format = AdFormat.Rewarded,
adUnitIds = AdUnitIds(android = "…", ios = "…"),
fullScreenOptions = FullScreenAdOptions(
serverSideVerification = ServerSideVerificationOptions(
userId = "u123",
customData = "level-7",
),
),
)

The model: userId identifies who to credit and customData carries whatever context the server needs to validate the grant. AdMob calls the verification URL configured on the ad unit; that URL receives a callback with the user id, the custom data, the reward type and amount, and a cryptographic signature.

For anything of real value, the server callback should be the authoritative grant while the client callback merely updates the UI optimistically. The client callback can be wrong — a user with a modified app can grant themselves a reward locally — but a server-side callback cannot, because it depends on AdMob’s signed verification. FullScreenAdOptions can also be passed per-call to show(), which is useful when the same placement serves different users or different game sessions.

customData is opaque to AdMob — it is passed through to the verification URL untouched. The convention is to put whatever context the server needs to validate the grant: the level the user is on, the resource they were trying to buy, the campaign that triggered the offer. The library does not parse it; the server does.

A rewarded interstitial is a full-screen format that offers a reward without an explicit opt-in prompt from your UI. It uses the same controller interface and the same show(onRewardEarned) signature as a rewarded ad. The product difference is the policy: a rewarded interstitial can appear at a natural transition without a button the user has to tap, so AdMob requires an introductory screen and the format has stricter policy expectations.

The one-line difference:

val rewardedInterstitial = remember(adManager) {
adManager.rewardedInterstitial(
AdPlacement(
id = "rewarded_interstitial_level_end",
format = AdFormat.RewardedInterstitial,
androidAdUnitId = TestAdIds.ANDROID_REWARDED_INTERSTITIAL,
iosAdUnitId = TestAdIds.IOS_REWARDED_INTERSTITIAL,
)
)
}

The mechanics — caching, retry, TTL, show() reentrancy — are identical to the rewarded ad. Both formats have the same load-side shape, the same show-side shape, and the same reward-callback contract. A rewarded interstitial is a placement-level decision (product and AdMob account configuration) rather than an integration-level decision, which is why the controller interface is identical.

What does the load and show cycle look like?

Section titled “What does the load and show cycle look like?”
Full-screen ad lifecycle: load, cache, show, reloadThe FullScreenSlotCore state machine, its TTL'd FIFO cache, the generation counter bumped by clear(), and why show() is not reentrant per controller.IdleLoadingAdRetry backoffLoadedadmitted to the cachePresentingSDK owns the screenload()successshow()FIFO · maxSize (default 1) · TTL 1h, app-open 4hload failedFailedload() again to retrypresentAd suspendsuntil dismissalDismissedSDK terminal callbackreloadAfterShow?yes → scheduleReloadnoIdle if the cache is empty,Loaded if it is notclear() and the generation counterclear() bumps slotState.generation andretires the cached ads.A load or scheduled reload carries thegeneration it started with and re-checksit before publishing, so it can neverrepopulate a cache the caller just emptied.Presentation ownershippresentAd suspends until dismissal. Apresented ad is destroyed on normal returnonly — never on cancellation.FullScreenPresentationHandle is one-shot:after tryHandOffToCallbacks() the SDK ownsthe presentation and it is never force-closed.show() is not reentrant per controller — asecond call returns NotReady, not a queue.Every suspending operation is bounded: withTimeoutOrNull(loadTimeout) wraps the whole attempt sequence, retry backoff included.CLAUDE.md invariants #1, #2 and #9.
Full-screen ad lifecycle: load, cache, show, reloadCLAUDE.md invariants #1, #2, #9Read this diagram in words

The reward callback fires inside the presentation phase the diagram shows, before the terminal dismissal. That is why awaiting show() alone is not enough to know whether a reward was earned: the Shown result lands at dismissal, which can be tens of seconds after the reward was granted, depending on ad length. A separate suspend on the reward callback, or a separate effect on the RewardEarned event, is the right shape for any UI that needs to react to a successful reward in real time.

A reminder of the non-reentrancy rule from the interstitial guide: show() is not reentrant per controller. A second call while the first presentation is still on screen returns AdShowResult.NotReady rather than queueing. Await the first result before calling again.

A reward that never fires — onRewardEarned never called, no RewardEarned event on the stream — is a different shape of bug. It is almost always one of three things: the user backed out before meeting the watch-through criterion, the ad unit has no fill and show() returned NotReady, or the platform reported the ad as failed and the terminal callback was the only outcome. None of these are bugs in the library; they are the platform SDKs behaving as documented.