Trusted Arbiter
Compare CloudX bids with supported third-party bids in iOS apps
Trusted Arbiter compares a loaded CloudX bid with supported third-party bids and returns the selected platform. CloudX iOS SDK 3.4.0 supports CloudX, Unity LevelPlay, and PubMatic bid inputs. CloudX iOS SDK 3.5.0 and later also supports custom publisher-supplied bid inputs.
Why use Trusted Arbiter?
When your app loads ads from several platforms, it needs a way to choose which ad to show. Trusted Arbiter provides a shared comparison API for supported bids.
Less comparison logic to maintain
Submit the supported bids to Trusted Arbiter and use the returned platform to select the ad. Your app continues to manage partner SDK integration, ad loading, and display.
Use available bid values
Trusted Arbiter can compare actual bid values where available. Some demand uses estimated prices. See the pricing guidance below for each supported input.
Demo App
The CloudX iOS demo app runs this page’s cycle for interstitials, in Swift and Objective-C. CloudX and AdMob load in parallel, the loaded ads become bids, CloudXCore.shared.arbiter(with:completion:) picks the winner, and the winner is shown from a stored result so the show path makes no network call.
ArbiterInterstitialController.swift
The whole load, arbitrate and show cycle, with both SDKs’ calls and the AdMob revenue report in one file. Rewarded follows this file with the rewarded calls substituted.
ArbiterViewController.swift
Brings up both SDKs, retries with a 2 to 60 second backoff and wires the Show button. This is demo-only layout; take the rules, not the file.
CLXDemoConfigManager.swift
App key and ad unit IDs. This is the first file to edit when you run the demo yourself.
ArbiterInterstitialController.m
The Objective-C version of the controller.
The controller logs through the demo’s DemoAppLogger; swap in your own logging when you copy it. The demo ships Google’s AdMob test units, which report revenue of 0. CloudX does not keep a price of 0, so the AdMob bid has no comparable price while you test. Point the demo at a paying unit to watch prices compete, or give the AdMob bid a manual price for one launch as the demo’s README shows.
When to run the arbiter
Run the arbiter when the candidate ads finish loading — never on the show path. The arbiter call is a network round trip, so a user who taps a button that shows an ad must never be left waiting on it. Arbitrate ahead of the placement, and the show itself becomes an immediate, local decision.
Fullscreen formats (interstitial, rewarded, app open): prepare ahead
Load every candidate in parallel. When they have settled — loaded or failed — submit the loaded candidates to the arbiter and store the result. At the placement, show the stored winner immediately; no arbiter call happens there. Start the cycle again after the ad is shown or hidden, after it fails to display, or when a candidate expires.
If the placement arrives before a winner has been stored, either continue without an ad or show the single candidate that did load. That is an acceptable degraded path, never the primary one.
// Runs as soon as the candidates have loaded, ahead of the placement.
func prepareWinner(configuration: CLXArbiterConfiguration) {
CloudXCore.shared.arbiter(with: configuration) { [weak self] result in
self?.nextWinner = result
}
}
// Runs at the placement. No network call here.
func showInterstitial(from viewController: UIViewController) {
switch nextWinner?.platform.name {
case CLXArbiterPlatform.cloudX.name:
cloudXInterstitial?.show(from: viewController)
case CLXArbiterPlatform.adMob.name:
adMobInterstitial?.present(fromRootViewController: viewController)
default:
break // no winner prepared; continue without an ad
}
nextWinner = nil
}// Runs as soon as the candidates have loaded, ahead of the placement.
- (void)prepareWinnerWithConfiguration:(CLXArbiterConfiguration *)configuration {
__weak typeof(self) weakSelf = self;
[[CloudXCore shared] arbiterWithConfiguration:configuration completion:^(CLXArbiterResult *result) {
weakSelf.nextWinner = result;
}];
}
// Runs at the placement. No network call here.
- (void)showInterstitialFromViewController:(UIViewController *)viewController {
NSString *platform = self.nextWinner.platform.name;
if ([platform isEqualToString:CLXArbiterPlatform.cloudX.name]) {
[self.cloudXInterstitial showFromViewController:viewController];
} else if ([platform isEqualToString:CLXArbiterPlatform.adMob.name]) {
[self.adMobInterstitial presentFromRootViewController:viewController];
}
// no winner prepared; continue without an ad
self.nextWinner = nil;
}View formats (banner, MREC, native): arbitrate, then render
Nothing user-initiated is waiting on a view format, so here it is correct to attach or render the winner directly inside the completion handler — the arbiter completing is the trigger to show. Only the winner’s view or assets may ever be attached; see Banner and MREC arbitration for the attachment and refresh rules.
Supported ad formats
Trusted Arbiter is format-agnostic: it takes any loaded CloudX ad and compares it against the supplied third-party bids, regardless of format. Arbiter payloads are attached to CloudX ads of every format — banner, MREC, native, interstitial, rewarded, and app open.
- Interstitial, rewarded, and app open (fullscreen formats) prepare a winner ahead of the placement and show the stored result there, as described in When to run the arbiter. The step-by-step and controller patterns later on this page follow that rule. Worked examples: Interstitial example and App Open example. Rewarded follows the interstitial pattern unchanged — only the ad type differs.
- Banner, MREC, and native (view formats) arbitrate and then render, and require the additional handling described in Banner and MREC arbitration below, because the losing bid’s view must never be attached to the view hierarchy and the refresh cycle has to be coordinated manually. Worked examples: Banner example and Native example.
Basic API
Create bid candidates from loaded ads, then pass them to the arbiter.
// cloudXAd is the CLXAd object from a CloudX load callback.
// levelPlayAdInfo is the Unity LevelPlay ad info object.
// pubMaticPrice and pubMaticPartnerName come from the PubMatic/OpenWrap bid object.
CLXArbiterBid *cloudXBid = [CLXArbiterBid cloudXBidWithAd:cloudXAd];
CLXArbiterBid *levelPlayBid =
[CLXArbiterBid levelPlayBidWithNetworkName:levelPlayAdInfo.adNetwork
revenue:levelPlayAdInfo.revenue.doubleValue
precision:levelPlayAdInfo.precision];
CLXArbiterBid *pubMaticBid =
[CLXArbiterBid pubMaticBidWithPrice:pubMaticPrice
partnerName:pubMaticPartnerName
extras:nil];
CLXArbiterConfiguration *configuration =
[CLXArbiterConfiguration configurationWithBids:@[cloudXBid, levelPlayBid, pubMaticBid]];
[[CloudXCore shared] arbiterWithConfiguration:configuration completion:^(CLXArbiterResult *result) {
NSLog(@"Selected platform: %@", result.platform.name);
}];// cloudXAd is the CLXAd object from a CloudX load callback.
// levelPlayAdInfo is the Unity LevelPlay ad info object.
// pubMaticPrice and pubMaticPartnerName come from the PubMatic/OpenWrap bid object.
let cloudXBid = CLXArbiterBid.cloudX(ad: cloudXAd)
let levelPlayBid = CLXArbiterBid.levelPlay(
networkName: levelPlayAdInfo.adNetwork,
revenue: levelPlayAdInfo.revenue?.doubleValue ?? 0,
precision: levelPlayAdInfo.precision
)
let pubMaticBid = CLXArbiterBid.pubMatic(
price: pubMaticPrice,
partnerName: pubMaticPartnerName,
extras: nil
)
let configuration = CLXArbiterConfiguration.configuration(
bids: [cloudXBid, levelPlayBid, pubMaticBid],
builderBlock: nil
)
CloudXCore.shared.arbiter(with: configuration) { result in
print("Selected platform: \(result.platform.name)")
}CLXArbiterBid.cloudX accepts the CLXAd object from a CloudX load callback. CLXArbiterBid.levelPlay accepts Unity LevelPlay ad info values. CLXArbiterBid.pubMatic accepts a PubMatic OpenWrap bid price and optional partner name. The extras map is optional on both the LevelPlay and PubMatic bids, and partnerName is optional on PubMatic. The completion callback runs on the main thread, so you can show an ad or update UI directly from it.
result.platform is CLXArbiterPlatform.cloudX, levelPlay, or pubMatic for the selected platform, or CLXArbiterPlatform.none when no winner could be selected — either no bids were supplied, or, with more than one bid, none of them carried a locally comparable price during fallback.
The completion handler is where you store the result, not where you show a fullscreen ad. See When to run the arbiter.
AdMob and Google Ad Manager
CloudX compares a loaded CloudX ad with a loaded AdMob or Google Ad Manager ad. AdMob and Ad Manager are separate demand sources, so both may bid in the same arbitration. This applies to publisher-managed mediation setups, including accounts described commercially as AdMob Pro; there is no separate AdMob Pro SDK API.
Google demand does not normally reveal the price of a loaded ad before it is shown, so there is no pre-bid price for the arbiter to compare against CloudX’s bid. CloudX prices the bid automatically from your historical Google performance, so you do not supply a price and no pre-bid pricing API is required. See how AdMob/GAM estimated pricing works.
If your AdMob account exposes impression-level revenue data pre-bid, you can supply that exact price yourself instead of using the estimate — see Manual input with pre-bid ILRD below.
Report Google paid events back to CloudX (required)
Forwarding Google’s paid events is a required part of the Trusted Arbiter AdMob and Ad Manager integration, not an optional analytics extra. The revenue you report back feeds the historical Google performance CloudX prices your bids from.
After you show an AdMob or Ad Manager ad that won an arbitration, forward Google’s impression-level revenue — the GADAdValue delivered to the ad’s paidEventHandler — into the CloudX SDK with reportRevenueData(_:). Use CLXRevenuePlatformAdMob for AdMob ads and CLXRevenuePlatformGAM for Ad Manager ads. Without this feedback CloudX never learns the realized prices for your ad units, and future arbiter estimates degrade.
GADAdValue.value is an NSDecimalNumber already expressed in currency units on iOS, so pass it straight through. Do not divide by 1,000,000 — only the Android and Unity Google Mobile Ads SDKs report ad values in micros.
func revenuePrecision(from precision: GADAdValuePrecision) -> CLXRevenuePrecision {
switch precision {
case .precise: return .exact
case .estimated: return .estimated
case .publisherProvided: return .publisherDefined
default: return .undefined
}
}
// Attach once to each Google ad you loaded as an arbiter candidate.
adMobInterstitial.paidEventHandler = { [weak adMobInterstitial] adValue in
let servedBy = adMobInterstitial?.responseInfo.loadedAdNetworkResponseInfo
let data = CLXRevenueData.revenueData(
platform: .adMob, // .gam for an Ad Manager ad
revenue: adValue.value.doubleValue,
adFormat: "interstitial"
) { builder in
builder.currencyCode = adValue.currencyCode
builder.precision = revenuePrecision(from: adValue.precision)
builder.networkName = servedBy?.adSourceName
builder.adUnitId = adMobAdUnitId
builder.thirdPartyAdPlacementId = servedBy?.adSourceInstanceName
}
CloudXCore.shared.reportRevenueData(data)
}static CLXRevenuePrecision *CLXRevenuePrecisionFromGAD(GADAdValuePrecision precision) {
switch (precision) {
case GADAdValuePrecisionPrecise: return CLXRevenuePrecision.exact;
case GADAdValuePrecisionEstimated: return CLXRevenuePrecision.estimated;
case GADAdValuePrecisionPublisherProvided: return CLXRevenuePrecision.publisherDefined;
case GADAdValuePrecisionUnknown: return CLXRevenuePrecision.undefined;
}
return CLXRevenuePrecision.undefined;
}
// Attach once to each Google ad you loaded as an arbiter candidate.
__weak GADInterstitialAd *weakAd = adMobInterstitial;
adMobInterstitial.paidEventHandler = ^(GADAdValue *adValue) {
GADAdNetworkResponseInfo *servedBy = weakAd.responseInfo.loadedAdNetworkResponseInfo;
CLXRevenueData *data =
[CLXRevenueData revenueDataWithPlatform:CLXRevenuePlatformAdMob // CLXRevenuePlatformGAM for Ad Manager
revenue:adValue.value.doubleValue
adFormat:@"interstitial"
builderBlock:^(CLXRevenueDataBuilder *builder) {
builder.currencyCode = adValue.currencyCode;
builder.precision = CLXRevenuePrecisionFromGAD(adValue.precision);
builder.networkName = servedBy.adSourceName;
builder.adUnitId = adMobAdUnitId;
builder.thirdPartyAdPlacementId = servedBy.adSourceInstanceName;
}];
[[CloudXCore shared] reportRevenueData:data];
};Attach the handler once per ad object. AdMob re-fires paid events on banner and MREC auto-refresh against the same view, so a handler installed once keeps reporting every refreshed impression. See Publisher-Reported Revenue Data for the full reporting setup and the complete field list.
Create the bid from the ad unit id of the ad you loaded, run the arbiter once the candidates have loaded, and store the winner for the placement:
// An AdMob ad unit. networkName is optional; pass the winning ad source when you know it,
// e.g. responseInfo.loadedAdapterResponseInfo?.adSourceName.
let adMobBid = CLXArbiterBid.adMob(
adUnitId: adMobAdUnitId,
networkName: adMobNetworkName ?? "admob",
manualRevenuePerImpressionUSD: nil,
extras: [:]
)
// An Ad Manager ad unit id takes the form /NNNNNNN/placement/name.
let adManagerBid = CLXArbiterBid.gam(adUnitId: "/21775744923/example/interstitial")
let configuration = CLXArbiterConfiguration.configuration(
bids: [CLXArbiterBid.cloudX(ad: cloudXAd), adMobBid, adManagerBid],
builderBlock: nil
)
// Runs once the candidates have loaded, ahead of the placement.
CloudXCore.shared.arbiter(with: configuration) { [weak self] result in
self?.nextWinner = result
}CLXArbiterBid *adMobBid =
[CLXArbiterBid adMobBidWithAdUnitId:adMobAdUnitId
networkName:adMobNetworkName ?: @"admob"
manualRevenuePerImpressionUSD:nil
extras:@{}];
CLXArbiterBid *adManagerBid =
[CLXArbiterBid gamBidWithAdUnitId:@"/21775744923/example/interstitial"];
CLXArbiterConfiguration *configuration =
[CLXArbiterConfiguration configurationWithBids:@[
[CLXArbiterBid cloudXBidWithAd:cloudXAd],
adMobBid,
adManagerBid
]];
// Runs once the candidates have loaded, ahead of the placement.
__weak typeof(self) weakSelf = self;
[[CloudXCore shared] arbiterWithConfiguration:configuration completion:^(CLXArbiterResult *result) {
weakSelf.nextWinner = result;
}];At the placement, show the stored winner. No network call happens here.
func showInterstitial(from viewController: UIViewController) {
switch nextWinner?.platform.name {
case CLXArbiterPlatform.cloudX.name:
cloudXInterstitial.show(from: viewController)
case CLXArbiterPlatform.adMob.name:
adMobInterstitial.present(fromRootViewController: viewController)
case CLXArbiterPlatform.gam.name:
adManagerInterstitial.present(fromRootViewController: viewController)
default:
break // no winner prepared; continue without an ad
}
nextWinner = nil
}- (void)showInterstitialFromViewController:(UIViewController *)viewController {
NSString *platform = self.nextWinner.platform.name;
if ([platform isEqualToString:CLXArbiterPlatform.cloudX.name]) {
[self.cloudXInterstitial showFromViewController:viewController];
} else if ([platform isEqualToString:CLXArbiterPlatform.adMob.name]) {
[self.adMobInterstitial presentFromRootViewController:viewController];
} else if ([platform isEqualToString:CLXArbiterPlatform.gam.name]) {
[self.adManagerInterstitial presentFromRootViewController:viewController];
}
// no winner prepared; continue without an ad
self.nextWinner = nil;
}A winning Google bid reports its own platform — CLXArbiterPlatform.adMob or CLXArbiterPlatform.gam. You no longer inspect result.platformName to tell these sources apart, as you would for a custom bid.
Pass a blank ad unit id and the bid still builds rather than crashing your app, but it carries no usable identity: it is never priced and the server rejects it.
Manual input with pre-bid ILRD
Some AdMob accounts expose impression-level revenue data pre-bid: the GADAdValue for the loaded ad is available at load time, before the ad is shown. This is a legacy, account-gated capability, so check with your Google account team whether it is enabled for your account. An exact per-impression price that you know before show is the one case where supplying your own price beats CloudX’s estimate.
Pass the pre-bid ad value as manualRevenuePerImpressionUSD and it overrides the estimate:
// GADAdValue.value is an NSDecimalNumber already expressed in currency units,
// so pass it through as-is. Do not divide it by anything.
let adMobBid = CLXArbiterBid.adMob(
adUnitId: adMobAdUnitId,
networkName: adMobNetworkName ?? "admob",
manualRevenuePerImpressionUSD: preBidAdValue.value,
extras: [:]
)// GADAdValue.value is an NSDecimalNumber already expressed in currency units,
// so pass it through as-is. Do not divide it by anything.
CLXArbiterBid *adMobBid =
[CLXArbiterBid adMobBidWithAdUnitId:adMobAdUnitId
networkName:adMobNetworkName ?: @"admob"
manualRevenuePerImpressionUSD:preBidAdValue.value
extras:@{}];How the value is treated:
0.0is a real price. It means this bid is worth nothing — not that the price is missing.- Negative and non-finite values are not prices, so they are treated as absent and logged.
- A blank ad unit id drops the manual price entirely, because a bid with no identity cannot be validated.
manualRevenuePerImpressionUSD is revenue for a single impression, in USD — not CPM. Convert whatever your source reports:
- An AdMob ad value needs no scaling on iOS.
GADAdValue.valueis anNSDecimalNumberalready expressed in currency units, so a value of0.005is0.005per impression. Pass it through as-is — do not divide by 1,000, and do not divide by 1,000,000 either. Only the Android and Unity Google Mobile Ads SDKs report ad values in micros. - A non-USD amount must be converted to USD first.
Step-by-step: arbitrate CloudX and LevelPlay
This walkthrough shows exactly which Unity LevelPlay callback to read and which values to pass into the arbiter. It uses an interstitial, but the same field mapping applies to any format — see Supported ad formats for what changes with Banner and MREC.
Load both candidates
Create the CloudX and LevelPlay interstitials, set their delegates, and start a load on each platform.
self.cloudXInterstitial = [[CloudXCore shared] createInterstitialWithAdUnitId:@"YOUR_CLOUDX_AD_UNIT_ID"];
self.cloudXInterstitial.delegate = self;
[self.cloudXInterstitial load];
self.levelPlayInterstitial = [[LPMInterstitialAd alloc] initWithAdUnitId:@"YOUR_LEVELPLAY_AD_UNIT_ID"];
self.levelPlayInterstitial.delegate = self;
[self.levelPlayInterstitial loadAd];cloudXInterstitial = CloudXCore.shared.createInterstitial(adUnitId: "YOUR_CLOUDX_AD_UNIT_ID")
cloudXInterstitial?.delegate = self
cloudXInterstitial?.load()
levelPlayInterstitial = LPMInterstitialAd(adUnitId: "YOUR_LEVELPLAY_AD_UNIT_ID")
levelPlayInterstitial.setDelegate(self)
levelPlayInterstitial.loadAd()Capture each platform's loaded ad
LevelPlay delivers an LPMAdInfo in its load callback; CloudX delivers a CLXAd. Hold onto both because you read the arbiter inputs from them in the next step.
// Properties: @property (nonatomic, strong) CLXAd *cloudXAd;
// @property (nonatomic, strong) LPMAdInfo *levelPlayInfo;
// CLXInterstitialDelegate
- (void)didLoadAd:(CLXAd *)ad {
self.cloudXAd = ad;
}
// LPMInterstitialAdDelegate
- (void)didLoadAdWithAdInfo:(LPMAdInfo *)adInfo {
self.levelPlayInfo = adInfo;
}var cloudXAd: CLXAd?
var levelPlayInfo: LPMAdInfo?
// CLXInterstitialDelegate
func didLoad(_ ad: CLXAd) {
cloudXAd = ad
}
// LPMInterstitialAdDelegate
func didLoadAd(with adInfo: LPMAdInfo) {
levelPlayInfo = adInfo
}Map the values into bids
Read the LevelPlay fields off LPMAdInfo and pass them to CLXArbiterBid.levelPlay. The CloudX bid takes the CLXAd directly. Submit only the platforms that actually loaded.
LPMAdInfo field | Type | CLXArbiterBid.levelPlay parameter |
|---|---|---|
adNetwork | NSString * | networkName |
revenue | NSNumber * | revenue (unwrap with .doubleValue) |
precision | NSString * | precision |
NSMutableArray<CLXArbiterBid *> *bids = [NSMutableArray array];
if (self.cloudXAd) {
[bids addObject:[CLXArbiterBid cloudXBidWithAd:self.cloudXAd]];
}
if (self.levelPlayInfo) {
CLXArbiterBid *levelPlayBid =
[CLXArbiterBid levelPlayBidWithNetworkName:self.levelPlayInfo.adNetwork
revenue:self.levelPlayInfo.revenue.doubleValue
precision:self.levelPlayInfo.precision];
[bids addObject:levelPlayBid];
}
CLXArbiterConfiguration *configuration =
[CLXArbiterConfiguration configurationWithBids:bids];var bids: [CLXArbiterBid] = []
if let cloudXAd {
bids.append(CLXArbiterBid.cloudX(ad: cloudXAd))
}
if let levelPlayInfo {
bids.append(CLXArbiterBid.levelPlay(
networkName: levelPlayInfo.adNetwork,
revenue: levelPlayInfo.revenue?.doubleValue ?? 0,
precision: levelPlayInfo.precision
))
}
let configuration = CLXArbiterConfiguration.configuration(
bids: bids,
builderBlock: nil
)Run the arbiter and store the winner
Pass the configuration to the arbiter with a completion handler. Run it only after both interstitials have settled: track each load callback and load failure, then submit only the candidates that loaded. This happens ahead of the placement, so the completion handler stores the result rather than showing an ad. The completion runs on the main thread.
__weak typeof(self) weakSelf = self;
[[CloudXCore shared] arbiterWithConfiguration:configuration completion:^(CLXArbiterResult *result) {
weakSelf.nextWinner = result;
}];CloudXCore.shared.arbiter(with: configuration) { [weak self] result in
self?.nextWinner = result
}Show the stored winner at the placement
At the placement, compare the stored result’s platform.name against the platform constants and show that platform’s ad. No arbiter call happens here. CLXArbiterPlatform.none, or no stored winner at all, means there is nothing to show, so continue without an ad. Clear the stored winner afterwards and start the next load cycle.
- (void)showStoredWinner {
NSString *platform = self.nextWinner.platform.name;
if ([platform isEqualToString:CLXArbiterPlatform.cloudX.name]) {
[self.cloudXInterstitial showFromViewController:self];
} else if ([platform isEqualToString:CLXArbiterPlatform.levelPlay.name]) {
[self.levelPlayInterstitial showAdWithViewController:self placementName:nil];
}
// no winner stored, or CLXArbiterPlatform.none: continue without an ad
self.nextWinner = nil;
}func showStoredWinner() {
switch nextWinner?.platform.name {
case CLXArbiterPlatform.cloudX.name:
cloudXInterstitial?.show(from: self)
case CLXArbiterPlatform.levelPlay.name:
levelPlayInterstitial.showAd(viewController: self, placementName: nil)
default:
break // no winner stored, or CLXArbiterPlatform.none: continue without an ad
}
nextWinner = nil
}The ArbiterInterstitialController below is the reference implementation of the prepare-ahead rule: it packages these same steps into a reusable component.
Interstitial example
This interstitial example is the reference implementation of the prepare-ahead rule, arbitrating between two platforms: CloudX and Unity LevelPlay. A winner is prepared before the placement is reached:
- Load CloudX and LevelPlay in parallel.
- Wait until both platforms have loaded or failed.
- Submit only loaded candidates to Trusted Arbiter.
- Cache the selected platform.
- At the placement, show the cached winner immediately.
If both platforms fail, start another load cycle. If the placement is reached before a winner is prepared, continue the app flow without showing an ad.
/// Prepares a Trusted Arbiter winner ahead of time so an interstitial can be shown
/// instantly when a placement is reached.
///
/// Loads the CloudX and LevelPlay interstitials in parallel, waits until both have
/// finished loading or failing, submits the loaded candidates to CloudXCore.shared.arbiter,
/// and caches the selected CLXArbiterPlatform in nextWinner.
final class ArbiterInterstitialController: NSObject {
protocol Listener: AnyObject {
/// Called when the arbiter has selected a platform for the next show.
func arbiterInterstitialController(
_ controller: ArbiterInterstitialController,
didPrepareWinner platform: CLXArbiterPlatform
)
}
weak var listener: Listener?
private let cloudXInterstitial: CLXInterstitial
private let levelPlayInterstitial: LPMInterstitialAd
private var cloudXAd: CLXAd?
private var cloudXLoadDone = false
private var levelPlayAdInfo: LPMAdInfo?
private var levelPlayLoadDone = false
private var nextWinner: CLXArbiterPlatform?
init(cloudXInterstitial: CLXInterstitial, levelPlayInterstitial: LPMInterstitialAd) {
self.cloudXInterstitial = cloudXInterstitial
self.levelPlayInterstitial = levelPlayInterstitial
super.init()
self.cloudXInterstitial.delegate = self
self.levelPlayInterstitial.setDelegate(self)
}
/// Starts a load for each platform that does not currently hold a cached ad.
func loadMissingAds() {
if cloudXAd == nil { cloudXInterstitial.load() }
if levelPlayAdInfo == nil { levelPlayInterstitial.loadAd() }
}
/// Shows the prepared winner, returning true only when a show call was made.
///
/// Returns false when no winner is ready or the cached ad is no longer available, in which
/// case a fresh load cycle is started.
func showAtPlacement(from viewController: UIViewController, placementName: String? = nil) -> Bool {
guard let platformName = nextWinner?.name else { return false }
if platformName == CLXArbiterPlatform.cloudX.name {
return showCloudX(from: viewController, placementName: placementName)
}
if platformName == CLXArbiterPlatform.levelPlay.name {
return showLevelPlay(from: viewController, placementName: placementName)
}
return false
}
/// Runs the arbiter once both platforms have settled, then caches the winning platform.
///
/// Returns early until both loads complete. If neither platform loaded, it restarts the
/// load cycle; otherwise it submits the loaded candidates to CloudXCore.shared.arbiter.
private func maybePrepareWinner() {
guard cloudXLoadDone, levelPlayLoadDone else { return }
if cloudXAd == nil && levelPlayAdInfo == nil {
cloudXLoadDone = false
levelPlayLoadDone = false
loadMissingAds()
return
}
var bids: [CLXArbiterBid] = []
if let cloudXAd {
bids.append(CLXArbiterBid.cloudX(ad: cloudXAd))
}
if let levelPlayAdInfo {
bids.append(CLXArbiterBid.levelPlay(
networkName: levelPlayAdInfo.adNetwork,
revenue: levelPlayAdInfo.revenue?.doubleValue ?? 0,
precision: levelPlayAdInfo.precision
))
}
let configuration = CLXArbiterConfiguration.configuration(bids: bids, builderBlock: nil)
CloudXCore.shared.arbiter(with: configuration) { [weak self] result in
guard let self else { return }
nextWinner = result.platform
listener?.arbiterInterstitialController(self, didPrepareWinner: result.platform)
}
}
private func showCloudX(from viewController: UIViewController, placementName: String?) -> Bool {
if cloudXInterstitial.isReady {
if let placementName {
cloudXInterstitial.show(from: viewController, placement: placementName, customData: nil)
} else {
cloudXInterstitial.show(from: viewController)
}
return true
}
clearCloudXAndLoadMissingAds()
return false
}
private func showLevelPlay(from viewController: UIViewController, placementName: String?) -> Bool {
if levelPlayInterstitial.isAdReady() {
levelPlayInterstitial.showAd(viewController: viewController, placementName: placementName)
return true
}
clearLevelPlayAndLoadMissingAds()
return false
}
private func clearCloudXAndLoadMissingAds() {
cloudXAd = nil
cloudXLoadDone = false
nextWinner = nil
loadMissingAds()
}
private func clearLevelPlayAndLoadMissingAds() {
levelPlayAdInfo = nil
levelPlayLoadDone = false
nextWinner = nil
loadMissingAds()
}
}
extension ArbiterInterstitialController: CLXInterstitialDelegate {
func didLoad(_ ad: CLXAd) {
cloudXAd = ad
cloudXLoadDone = true
maybePrepareWinner()
}
func didFailToLoadAd(_ adUnitId: String, error: CLXError) {
cloudXAd = nil
cloudXLoadDone = true
maybePrepareWinner()
}
func didDisplay(_ ad: CLXAd) {}
func didFailToDisplay(_ ad: CLXAd, error: CLXError) {
clearCloudXAndLoadMissingAds()
}
func didHide(_ ad: CLXAd) {
clearCloudXAndLoadMissingAds()
}
func didClick(_ ad: CLXAd) {}
}
extension ArbiterInterstitialController: LPMInterstitialAdDelegate {
func didLoadAd(with adInfo: LPMAdInfo) {
levelPlayAdInfo = adInfo
levelPlayLoadDone = true
maybePrepareWinner()
}
func didFailToLoadAd(withAdUnitId adUnitId: String, error: Error) {
levelPlayAdInfo = nil
levelPlayLoadDone = true
maybePrepareWinner()
}
func didChangeAdInfo(_ adInfo: LPMAdInfo) {
levelPlayAdInfo = adInfo
}
func didDisplayAd(with adInfo: LPMAdInfo) {}
func didFailToDisplayAd(with adInfo: LPMAdInfo, error: Error) {
clearLevelPlayAndLoadMissingAds()
}
func didCloseAd(with adInfo: LPMAdInfo) {
clearLevelPlayAndLoadMissingAds()
}
func didClickAd(with adInfo: LPMAdInfo) {}
}showAtPlacement(from:placementName:) returns true only when an ad show call was made. didChangeAdInfo(_:) keeps the cached LevelPlay candidate up to date while it remains loaded.
For PubMatic OpenWrap, create a third-party bid with CLXArbiterBid.pubMatic(price:partnerName:extras:). If the arbiter service is unavailable, times out, or fails, the SDK falls back to the highest comparable USD bid among the supplied supported bid inputs.
App Open example
App Open is a fullscreen format, so it prepares a winner ahead of the placement exactly like interstitial and rewarded. What differs is the trigger. Nothing in your UI asks for an App Open ad, so the decision has to be made while the app is in the background — a user returning to the app must never wait on a load or an arbiter round trip.
ArbiterAppOpenController arbitrates a CloudX App Open ad against an AdMob App Open ad:
- Load both platforms at launch, and again after every show.
- Run the arbiter once both have settled, and store the winner.
- On the next foreground transition, show the stored winner and start the next cycle.
- If no winner is stored, return the user to the app without an ad and start a fresh load.
/// Arbitrates a CloudX App Open ad against an AdMob App Open ad and shows the winner on the
/// next foreground transition.
///
/// App Open has no user-initiated show, so the winner must already be chosen by the time the
/// user comes back. loadMissingAds() starts both loads, the arbiter runs as soon as both
/// platforms settle, and showOnForeground(from:) only shows a result that was stored earlier.
final class ArbiterAppOpenController: NSObject {
private let cloudXAppOpen: CLXAppOpen?
private let adMobAdUnitId: String
private var cloudXAd: CLXAd?
private var cloudXLoadDone = false
private var adMobAppOpen: GADAppOpenAd?
/// The ad that is presenting, or presented last. Held so a late paid event still has an ad.
private var presentedAdMobAd: GADAppOpenAd?
private var adMobLoadDone = false
private var cloudXLoadInFlight = false
private var adMobLoadInFlight = false
private var nextWinner: CLXArbiterPlatform?
private var isShowingAd = false
init(cloudXAdUnitId: String, adMobAdUnitId: String) {
cloudXAppOpen = CloudXCore.shared.createAppOpen(adUnitId: cloudXAdUnitId)
self.adMobAdUnitId = adMobAdUnitId
super.init()
cloudXAppOpen?.delegate = self
}
/// Starts a load for each platform that holds neither a cached ad nor a request in flight.
///
/// Each load it starts clears that platform's `LoadDone` flag, so a round only arbitrates
/// once every request it started has come back. Without that reset, a retry for the platform
/// that failed last round lands against a flag still set from the previous round, and the
/// arbiter runs on a one-candidate snapshot.
func loadMissingAds() {
if cloudXAd == nil, !cloudXLoadInFlight {
cloudXLoadInFlight = true
cloudXLoadDone = false
cloudXAppOpen?.load()
}
if adMobAppOpen == nil, !adMobLoadInFlight {
adMobLoadInFlight = true
adMobLoadDone = false
loadAdMob()
}
}
/// Shows the prepared winner, returning true only when a show call was made.
///
/// Call this from a foreground observer. It never holds up the return to the app: with an ad
/// already on screen, or no winner prepared, it returns false and starts the next load cycle.
func showOnForeground(from viewController: UIViewController) -> Bool {
guard !isShowingAd else { return false }
switch nextWinner?.name {
case CLXArbiterPlatform.cloudX.name:
return showCloudX(from: viewController)
case CLXArbiterPlatform.adMob.name:
return showAdMob(from: viewController)
case CLXArbiterPlatform.none.name:
// The arbiter settled on no winner, so both cached ads are unusable. Drop them:
// loadMissingAds() only requests a platform whose slot is empty, so without this
// the same two candidates would sit there and no later round could ever run.
discardCandidatesAndReload()
return false
default:
// nextWinner is nil — a cycle is still in flight. Leave it alone.
return false
}
}
/// Releases both platforms' ads. Call this from the owning object's teardown.
func destroy() {
cloudXAppOpen?.destroy()
adMobAppOpen = nil
presentedAdMobAd = nil
}
/// Runs the arbiter once both platforms have settled, then caches the winning platform.
///
/// Returns early until both loads complete. If neither platform loaded, it restarts the
/// load cycle; otherwise it submits the loaded candidates to CloudXCore.shared.arbiter.
private func maybePrepareWinner() {
guard cloudXLoadDone, adMobLoadDone else { return }
if cloudXAd == nil && adMobAppOpen == nil {
loadMissingAds()
return
}
var bids: [CLXArbiterBid] = []
if let cloudXAd {
bids.append(CLXArbiterBid.cloudX(ad: cloudXAd))
}
if adMobAppOpen != nil {
bids.append(CLXArbiterBid.adMob(adUnitId: adMobAdUnitId))
}
let configuration = CLXArbiterConfiguration.configuration(bids: bids, builderBlock: nil)
CloudXCore.shared.arbiter(with: configuration) { [weak self] result in
self?.nextWinner = result.platform
}
}
private func showCloudX(from viewController: UIViewController) -> Bool {
guard let cloudXAppOpen, cloudXAppOpen.isReady else {
clearCloudXAndLoadMissingAds()
return false
}
isShowingAd = true
cloudXAppOpen.show(from: viewController, placement: "app_foreground", customData: nil)
return true
}
private func showAdMob(from viewController: UIViewController) -> Bool {
guard let ad = adMobAppOpen else {
clearAdMobAndLoadMissingAds()
return false
}
ad.fullScreenContentDelegate = self
// A GADAppOpenAd is single-use: present consumes it. Move it aside now, so a load that
// lands mid-show cannot arbitrate a spent ad. It stays alive in presentedAdMobAd
// because AdMob can deliver the paid event after the ad has already been dismissed,
// and that revenue is what prices the next AdMob bid.
presentedAdMobAd = ad
adMobAppOpen = nil
isShowingAd = true
ad.present(fromRootViewController: viewController)
return true
}
private func loadAdMob() {
GADAppOpenAd.load(withAdUnitID: adMobAdUnitId, request: GADRequest()) { [weak self] ad, error in
guard let self else { return }
if let error {
print("AdMob App Open failed to load: \(error.localizedDescription)")
adMobAppOpen = nil
} else {
ad?.paidEventHandler = { [weak self, weak ad] adValue in
self?.reportAdMobPaidEvent(ad, adValue: adValue)
}
adMobAppOpen = ad
}
adMobLoadInFlight = false
adMobLoadDone = true
maybePrepareWinner()
}
}
/// Forwards AdMob's realized revenue to CloudX. This is a required part of the integration,
/// not optional analytics: CloudX prices future AdMob bids from what you report back.
///
/// revenuePrecision(from:) is the mapping shown in
/// "Report Google paid events back to CloudX" above.
private func reportAdMobPaidEvent(_ ad: GADAppOpenAd?, adValue: GADAdValue) {
let servedBy = ad?.responseInfo.loadedAdNetworkResponseInfo
let data = CLXRevenueData.revenueData(
platform: .adMob,
revenue: adValue.value.doubleValue,
adFormat: "app_open"
) { builder in
builder.currencyCode = adValue.currencyCode
builder.precision = revenuePrecision(from: adValue.precision)
builder.networkName = servedBy?.adSourceName
builder.adUnitId = self.adMobAdUnitId
builder.thirdPartyAdPlacementId = servedBy?.adSourceInstanceName
}
// reportRevenueData returns false when nothing consumed the event. CloudX prices future
// AdMob bids from what you report here, so a silent false is worth surfacing.
if !CloudXCore.shared.reportRevenueData(data) {
print("AdMob App Open paid event was not accepted by the CloudX SDK")
}
}
/// Drops both candidates and starts a new cycle. Used when the arbiter reports no winner.
private func discardCandidatesAndReload() {
cloudXAd = nil
adMobAppOpen = nil
cloudXLoadDone = false
adMobLoadDone = false
nextWinner = nil
loadMissingAds()
}
private func clearCloudXAndLoadMissingAds() {
cloudXAd = nil
cloudXLoadDone = false
nextWinner = nil
isShowingAd = false
loadMissingAds()
}
private func clearAdMobAndLoadMissingAds() {
adMobAppOpen = nil
adMobLoadDone = false
nextWinner = nil
isShowingAd = false
loadMissingAds()
}
}
extension ArbiterAppOpenController: CLXAppOpenDelegate {
func didLoad(_ ad: CLXAd) {
cloudXAd = ad
cloudXLoadInFlight = false
cloudXLoadDone = true
maybePrepareWinner()
}
func didFailToLoadAd(_ adUnitId: String, error: CLXError) {
print("CloudX App Open failed to load: \(error.localizedDescription)")
cloudXAd = nil
cloudXLoadInFlight = false
cloudXLoadDone = true
maybePrepareWinner()
}
func didDisplay(_ ad: CLXAd) {}
func didFailToDisplay(_ ad: CLXAd, error: CLXError) {
print("CloudX App Open failed to display: \(error.localizedDescription)")
clearCloudXAndLoadMissingAds()
}
func didHide(_ ad: CLXAd) {
clearCloudXAndLoadMissingAds()
}
func didClick(_ ad: CLXAd) {}
}
extension ArbiterAppOpenController: GADFullScreenContentDelegate {
func adDidDismissFullScreenContent(_ ad: GADFullScreenPresentingAd) {
clearAdMobAndLoadMissingAds()
}
func ad(_ ad: GADFullScreenPresentingAd, didFailToPresentFullScreenContentWithError error: Error) {
print("AdMob App Open failed to show: \(error.localizedDescription)")
clearAdMobAndLoadMissingAds()
}
}showOnForeground(from:) returns true only when a show call was made. isShowingAd keeps the ad’s own fullscreen presentation from being read as a second foreground transition — without it, a scene or app lifecycle observer can re-enter showOnForeground(from:) while an ad is already on screen.
Drive it from willEnterForegroundNotification, which fires only when the app comes back from the background. Do not use didBecomeActiveNotification: that also fires every time the app merely resumes activity, so an App Open ad would appear after the App Tracking Transparency prompt, Control Center, or an incoming call.
// In the type that owns the controller, for example your scene delegate:
let controller = ArbiterAppOpenController(cloudXAdUnitId: "…", adMobAdUnitId: "…")
NotificationCenter.default.addObserver(
forName: UIApplication.willEnterForegroundNotification,
object: nil,
queue: .main
) { [weak self] _ in
guard let self, let viewController = self.topViewController() else { return }
_ = self.controller.showOnForeground(from: viewController)
}A winning App Open bid reports its platform the same way as any other format: CLXArbiterPlatform.cloudX or CLXArbiterPlatform.adMob. Keep the App Open placement rules from App Open Ads — arbitration changes which network fills the placement, not when it is appropriate to show one.
Banner and MREC arbitration
Banner and MREC are view-based formats: every candidate network renders its ad into a view as soon as it loads, whether or not that view ends up on screen. Trusted Arbiter still selects the winner the same way, but you take on two responsibilities that fullscreen formats don’t have.
Disable auto-refresh
Trusted Arbiter needs full control over when a new fill is requested and when the displayed ad changes, so each network’s own refresh timer must be off:
- Disable auto-refresh for the ad unit in the CloudX dashboard.
- Call
stopAutoRefreshon theCLXBannerAdViewimmediately after creating it (see Banner Ads (320x50)). - Disable auto-refresh on the equivalent API for every other network you arbitrate against.
View attachment
Only the winning bid’s view may be added to the view hierarchy. A losing network’s banner view still renders and fires its own impression the moment it is attached to a superview, so hold every non-winning view off-screen (do not call addSubview:/addSubview(_:) on it) until, or unless, it wins a later round. This differs from the standard banner integration, which adds the view with addSubview at creation time — with Trusted Arbiter, the view must not be attached at creation, only after arbitration selects it as the winner.
Refresh cycle
With auto-refresh off, drive the cycle yourself:
- Run the parallel loads, submit the loaded candidates to the arbiter, and attach the winner’s view.
- As soon as the winner’s impression fires, immediately start loading a new fill from the winning network.
- Retain the non-winning networks’ already-filled ads for the next round. Only re-request a load from a network that did not fill in the previous round.
- Once the outstanding load responses come back, run the arbiter again over the current set of loaded candidates.
- Refresh the displayed ad on a 20-30 second interval, swapping in the new winner’s view each time. Refreshing faster than 20 seconds decreases CPM performance.
Banner example
ArbiterBannerController below is the reference implementation of the arbitrate-then-render rule for view formats: it arbitrates a CloudX and LevelPlay banner, keeps exactly one view attached at a time, and drives the refresh cycle described above.
/// Arbitrates a CloudX and LevelPlay banner on a 20-30 second refresh cycle.
///
/// Attaches only the winning bid's view. Non-winning views are kept loaded but
/// detached so they never render or fire an impression. After the displayed
/// winner's impression fires, starts a new load from that network and keeps
/// the other network's already-filled ad for the next arbitration round.
@interface ArbiterBannerController () <CLXBannerDelegate, CLXAdRevenueDelegate, LPMBannerAdViewDelegate>
@property (nonatomic, weak) UIView *containerView;
@property (nonatomic, weak) UIViewController *presentingViewController;
@property (nonatomic, strong) CLXBannerAdView *cloudXBanner;
@property (nonatomic, strong) LPMBannerAdView *levelPlayBanner;
@property (nonatomic, strong) CLXAd *cloudXAd;
@property (nonatomic, assign) BOOL cloudXLoadDone;
@property (nonatomic, strong) LPMAdInfo *levelPlayAdInfo;
@property (nonatomic, assign) BOOL levelPlayLoadDone;
@property (nonatomic, copy) NSString *attachedPlatformName;
@property (nonatomic, strong) NSTimer *refreshTimer;
@end
@implementation ArbiterBannerController
- (instancetype)initWithContainerView:(UIView *)containerView
presentingViewController:(UIViewController *)presentingViewController
cloudXAdUnitId:(NSString *)cloudXAdUnitId
levelPlayAdUnitId:(NSString *)levelPlayAdUnitId {
self = [super init];
if (self) {
_containerView = containerView;
_presentingViewController = presentingViewController;
_cloudXBanner = [[CloudXCore shared] createBannerWithAdUnitId:cloudXAdUnitId];
_cloudXBanner.delegate = self;
_cloudXBanner.revenueDelegate = self;
[_cloudXBanner stopAutoRefresh];
LPMBannerAdViewConfigBuilder *levelPlayConfigBuilder = [[LPMBannerAdViewConfigBuilder alloc] init];
LPMBannerAdViewConfig *levelPlayConfig = [levelPlayConfigBuilder build];
_levelPlayBanner = [[LPMBannerAdView alloc] initWithAdUnitId:levelPlayAdUnitId
config:levelPlayConfig];
_levelPlayBanner.delegate = self;
// LevelPlay auto-refresh is disabled via LevelPlay's own dashboard/API configuration.
}
return self;
}
/// Starts a load for each network that does not currently hold a filled ad.
- (void)loadMissingAds {
if (!self.cloudXAd) { [self.cloudXBanner load]; }
if (!self.levelPlayAdInfo) {
[self.levelPlayBanner loadAdWithViewController:self.presentingViewController];
}
}
/// Starts the recurring 20-30 second refresh timer. Call once, after the first load cycle begins.
- (void)startRefreshTimer {
[self.refreshTimer invalidate];
self.refreshTimer = [NSTimer scheduledTimerWithTimeInterval:25.0
target:self
selector:@selector(runArbiterIfReady)
userInfo:nil
repeats:YES];
}
- (void)runArbiterIfReady {
if (!self.cloudXLoadDone || !self.levelPlayLoadDone) { return; }
NSMutableArray<CLXArbiterBid *> *bids = [NSMutableArray array];
if (self.cloudXAd) {
[bids addObject:[CLXArbiterBid cloudXBidWithAd:self.cloudXAd]];
}
if (self.levelPlayAdInfo) {
[bids addObject:[CLXArbiterBid levelPlayBidWithNetworkName:self.levelPlayAdInfo.adNetwork
revenue:self.levelPlayAdInfo.revenue.doubleValue
precision:self.levelPlayAdInfo.precision]];
}
if (bids.count == 0) { return; }
CLXArbiterConfiguration *configuration = [CLXArbiterConfiguration configurationWithBids:bids];
[[CloudXCore shared] arbiterWithConfiguration:configuration completion:^(CLXArbiterResult *result) {
[self attachWinner:result.platform.name];
}];
}
/// Detaches the previous winner's view, attaches the new winner's view, and starts a
/// fresh load from the winning network once its impression fires.
- (void)attachWinner:(NSString *)platformName {
[self.cloudXBanner removeFromSuperview];
[self.levelPlayBanner removeFromSuperview];
if ([platformName isEqualToString:CLXArbiterPlatform.cloudX.name]) {
[self.containerView addSubview:self.cloudXBanner];
self.attachedPlatformName = platformName;
} else if ([platformName isEqualToString:CLXArbiterPlatform.levelPlay.name]) {
[self.containerView addSubview:self.levelPlayBanner];
self.attachedPlatformName = platformName;
} else {
self.attachedPlatformName = nil;
}
}
#pragma mark - CLXBannerDelegate
- (void)didLoadAd:(CLXAd *)ad {
self.cloudXAd = ad;
self.cloudXLoadDone = YES;
[self runArbiterIfReady];
}
- (void)didFailToLoadAd:(NSString *)adUnitId error:(CLXError *)error {
self.cloudXAd = nil;
self.cloudXLoadDone = YES;
[self runArbiterIfReady];
}
#pragma mark - CLXAdRevenueDelegate
- (void)didPayRevenueForAd:(CLXAd *)ad {
if ([self.attachedPlatformName isEqualToString:CLXArbiterPlatform.cloudX.name]) {
self.cloudXAd = nil;
self.cloudXLoadDone = NO;
[self.cloudXBanner load];
}
}
#pragma mark - LPMBannerAdViewDelegate
- (void)didLoadAdWithAdInfo:(LPMAdInfo *)adInfo {
self.levelPlayAdInfo = adInfo;
self.levelPlayLoadDone = YES;
[self runArbiterIfReady];
}
- (void)didFailToLoadAdWithAdUnitId:(NSString *)adUnitId error:(NSError *)error {
self.levelPlayAdInfo = nil;
self.levelPlayLoadDone = YES;
[self runArbiterIfReady];
}
- (void)didDisplayAdWithAdInfo:(LPMAdInfo *)adInfo {
if ([self.attachedPlatformName isEqualToString:CLXArbiterPlatform.levelPlay.name]) {
self.levelPlayAdInfo = nil;
self.levelPlayLoadDone = NO;
[self.levelPlayBanner loadAdWithViewController:self.presentingViewController];
}
}
@end/// Arbitrates a CloudX and LevelPlay banner on a 20-30 second refresh cycle.
///
/// Attaches only the winning bid's view. Non-winning views are kept loaded but
/// detached so they never render or fire an impression. After the displayed
/// winner's impression fires, starts a new load from that network and keeps
/// the other network's already-filled ad for the next arbitration round.
final class ArbiterBannerController: NSObject {
private weak var containerView: UIView?
private weak var presentingViewController: UIViewController?
private let cloudXBanner: CLXBannerAdView
private let levelPlayBanner: LPMBannerAdView
private var cloudXAd: CLXAd?
private var cloudXLoadDone = false
private var levelPlayAdInfo: LPMAdInfo?
private var levelPlayLoadDone = false
private var attachedPlatformName: String?
private var refreshTimer: Timer?
init(containerView: UIView, presentingViewController: UIViewController, cloudXAdUnitId: String, levelPlayAdUnitId: String) {
self.containerView = containerView
self.presentingViewController = presentingViewController
cloudXBanner = CloudXCore.shared.createBanner(adUnitId: cloudXAdUnitId)
let levelPlayConfig = LPMBannerAdViewConfigBuilder().build()
levelPlayBanner = LPMBannerAdView(adUnitId: levelPlayAdUnitId, config: levelPlayConfig)
super.init()
cloudXBanner.delegate = self
cloudXBanner.revenueDelegate = self
cloudXBanner.stopAutoRefresh()
levelPlayBanner.setDelegate(self)
// LevelPlay auto-refresh is disabled via LevelPlay's own dashboard/API configuration.
}
/// Starts a load for each network that does not currently hold a filled ad.
func loadMissingAds() {
if cloudXAd == nil { cloudXBanner.load() }
if levelPlayAdInfo == nil, let presentingViewController {
levelPlayBanner.loadAd(with: presentingViewController)
}
}
/// Starts the recurring 20-30 second refresh timer. Call once, after the first load cycle begins.
func startRefreshTimer() {
refreshTimer?.invalidate()
refreshTimer = Timer.scheduledTimer(withTimeInterval: 25.0, repeats: true) { [weak self] _ in
self?.runArbiterIfReady()
}
}
private func runArbiterIfReady() {
guard cloudXLoadDone, levelPlayLoadDone else { return }
var bids: [CLXArbiterBid] = []
if let cloudXAd {
bids.append(CLXArbiterBid.cloudX(ad: cloudXAd))
}
if let levelPlayAdInfo {
bids.append(CLXArbiterBid.levelPlay(
networkName: levelPlayAdInfo.adNetwork,
revenue: levelPlayAdInfo.revenue?.doubleValue ?? 0,
precision: levelPlayAdInfo.precision
))
}
guard !bids.isEmpty else { return }
let configuration = CLXArbiterConfiguration.configuration(bids: bids, builderBlock: nil)
CloudXCore.shared.arbiter(with: configuration) { [weak self] result in
self?.attachWinner(result.platform.name)
}
}
/// Detaches the previous winner's view, attaches the new winner's view, and starts a
/// fresh load from the winning network once its impression fires.
private func attachWinner(_ platformName: String) {
cloudXBanner.removeFromSuperview()
levelPlayBanner.removeFromSuperview()
switch platformName {
case CLXArbiterPlatform.cloudX.name:
containerView?.addSubview(cloudXBanner)
attachedPlatformName = platformName
case CLXArbiterPlatform.levelPlay.name:
containerView?.addSubview(levelPlayBanner)
attachedPlatformName = platformName
default:
attachedPlatformName = nil
}
}
}
extension ArbiterBannerController: CLXBannerDelegate {
func didLoad(_ ad: CLXAd) {
cloudXAd = ad
cloudXLoadDone = true
runArbiterIfReady()
}
func didFailToLoadAd(_ adUnitId: String, error: CLXError) {
cloudXAd = nil
cloudXLoadDone = true
runArbiterIfReady()
}
}
extension ArbiterBannerController: CLXAdRevenueDelegate {
func didPayRevenue(for ad: CLXAd) {
guard attachedPlatformName == CLXArbiterPlatform.cloudX.name else { return }
cloudXAd = nil
cloudXLoadDone = false
cloudXBanner.load()
}
}
extension ArbiterBannerController: LPMBannerAdViewDelegate {
func didLoadAd(with adInfo: LPMAdInfo) {
levelPlayAdInfo = adInfo
levelPlayLoadDone = true
runArbiterIfReady()
}
func didFailToLoadAd(withAdUnitId adUnitId: String, error: Error) {
levelPlayAdInfo = nil
levelPlayLoadDone = true
runArbiterIfReady()
}
func didDisplayAd(with adInfo: LPMAdInfo) {
guard attachedPlatformName == CLXArbiterPlatform.levelPlay.name,
let presentingViewController else { return }
levelPlayAdInfo = nil
levelPlayLoadDone = false
levelPlayBanner.loadAd(with: presentingViewController)
}
}didPayRevenue(for:) (CloudX’s impression signal) and didDisplayAd(with:) (LevelPlay’s impression signal) are what trigger the next load for whichever network is currently attached; the losing network’s already-filled ad is left untouched until it either wins a round or is consumed. runArbiterIfReady() is invoked both by load callbacks and by the refresh timer, so a round only actually swaps the attached view when both networks have settled.
Native example
Native is a view format, so it arbitrates and then renders — but unlike banner and MREC, a native ad has no view until you make one. That is what makes native the easiest format to get wrong: loadAd(into:) renders the ad at load time, before the arbiter has chosen anything.
ArbiterNativeController arbitrates a CloudX native ad against an AdMob native ad and renders exactly one of them into the container view:
- Load both platforms with no view attached.
- Run the arbiter once both have settled.
- Build a view for the winner only, render into it, and attach it.
- Keep the losing platform’s ad loaded and unrendered for the next round.
/// Arbitrates a CloudX native ad against an AdMob native ad and renders only the winner into
/// the container view.
///
/// Both platforms load without a view: CLXNativeAdLoader.loadAd() is called with no ad view so
/// nothing is rendered at load time, and the AdMob ad is held unrendered. The losing bid's
/// assets are never rendered or attached, so they never fire an impression.
final class ArbiterNativeController: NSObject {
private weak var containerView: UIView?
private let cloudXLoader: CLXNativeAdLoader
private let adMobAdUnitId: String
private let makeAdMobAdView: (GADNativeAd) -> GADNativeAdView
private var cloudXAd: CLXAd?
private var cloudXLoadDone = false
private var adMobNativeAd: GADNativeAd?
private var adMobLoadDone = false
private var adMobAdLoader: GADAdLoader?
private var cloudXLoadInFlight = false
private var adMobLoadInFlight = false
private var renderedPlatformName: String?
private var renderedAdView: UIView?
/// The ads behind the attached view. They outlive their own candidate state: a rendered ad
/// stays on screen after its impression, until its replacement is attached.
private var renderedCloudXAd: CLXAd?
private var renderedAdMobAd: GADNativeAd?
/// - Parameter makeAdMobAdView: builds and populates your own GADNativeAdView for a loaded
/// AdMob ad, exactly as in a non-arbitrated AdMob native integration. It is called only
/// for a winning AdMob bid.
init(
containerView: UIView,
cloudXAdUnitId: String,
adMobAdUnitId: String,
makeAdMobAdView: @escaping (GADNativeAd) -> GADNativeAdView
) {
self.containerView = containerView
cloudXLoader = CloudXCore.shared.createNativeAdLoader(adUnitIdentifier: cloudXAdUnitId)
self.adMobAdUnitId = adMobAdUnitId
self.makeAdMobAdView = makeAdMobAdView
super.init()
cloudXLoader.nativeAdDelegate = self
cloudXLoader.revenueDelegate = self
}
/// Starts the first round: loads every platform that is not already holding a fill.
func start() {
loadMissingAds()
}
/// Releases both platforms' ads and clears the container.
func destroy() {
detachRenderedAd()
cloudXLoader.destroy()
adMobNativeAd = nil
adMobAdLoader = nil
}
/// Starts a load for each platform that holds neither a filled ad nor a request in flight.
///
/// Each load it starts clears that platform's `LoadDone` flag, so a round only arbitrates
/// once every request it started has come back.
private func loadMissingAds() {
// loadAd() with no ad view defers rendering. Never use loadAd(into:) here: that renders
// the CloudX ad at load time, before the arbiter has chosen a winner.
if cloudXAd == nil, !cloudXLoadInFlight {
cloudXLoadInFlight = true
cloudXLoadDone = false
cloudXLoader.loadAd()
}
if adMobNativeAd == nil, !adMobLoadInFlight {
adMobLoadInFlight = true
adMobLoadDone = false
loadAdMob()
}
}
/// Runs once both platforms have settled (filled or failed), then renders the winner.
private func maybeArbitrate() {
guard cloudXLoadDone, adMobLoadDone else { return }
var bids: [CLXArbiterBid] = []
if let cloudXAd {
bids.append(CLXArbiterBid.cloudX(ad: cloudXAd))
}
if adMobNativeAd != nil {
bids.append(CLXArbiterBid.adMob(adUnitId: adMobAdUnitId))
}
guard !bids.isEmpty else {
loadMissingAds()
return
}
let configuration = CLXArbiterConfiguration.configuration(bids: bids, builderBlock: nil)
CloudXCore.shared.arbiter(with: configuration) { [weak self] result in
self?.renderWinner(result.platform)
}
}
/// Renders only the winning platform's assets; the loser stays unrendered and unattached.
private func renderWinner(_ platform: CLXArbiterPlatform) {
// Rendering is what fires the impression, so never render the same ad twice.
// maybeArbitrate() runs again whenever the losing candidate reloads, and the same
// platform winning that round is a normal outcome — but by then the served ad has been
// dropped as a candidate, so a repeat win is always a new ad and does render.
guard !isAlreadyRendered(platform) else { return }
guard platform.name != CLXArbiterPlatform.none.name else {
// No winner. Keep whatever is already on screen — detaching it here would drop a
// perfectly good ad — and drop only the candidates that lost, so the next round
// has something new to compare.
discardUnrenderedCandidatesAndReload()
return
}
detachRenderedAd()
switch platform.name {
case CLXArbiterPlatform.cloudX.name:
renderCloudX()
case CLXArbiterPlatform.adMob.name:
renderAdMob()
default:
break
}
}
/// True when this platform's current candidate is the ad already attached to the container.
private func isAlreadyRendered(_ platform: CLXArbiterPlatform) -> Bool {
switch platform.name {
case CLXArbiterPlatform.cloudX.name:
return cloudXAd != nil && cloudXAd === renderedCloudXAd
case CLXArbiterPlatform.adMob.name:
return adMobNativeAd != nil && adMobNativeAd === renderedAdMobAd
default:
return false
}
}
/// Drops every candidate that is not currently rendered, then starts a new cycle.
private func discardUnrenderedCandidatesAndReload() {
if renderedPlatformName != CLXArbiterPlatform.cloudX.name {
cloudXAd = nil
cloudXLoadDone = false
}
if renderedPlatformName != CLXArbiterPlatform.adMob.name {
adMobNativeAd = nil
adMobLoadDone = false
}
loadMissingAds()
}
private func renderCloudX() {
guard let cloudXAd, let containerView else { return }
let adView = CLXNativeAdView()
adView.bindViews(with: nativeAdViewBinder())
guard cloudXLoader.renderNativeAdView(adView, with: cloudXAd) else {
print("CloudX native ad could not be rendered; skipping this round")
clearCloudXAndReload()
return
}
adView.frame = containerView.bounds
adView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
containerView.addSubview(adView)
renderedAdView = adView
renderedPlatformName = CLXArbiterPlatform.cloudX.name
renderedCloudXAd = cloudXAd
}
private func renderAdMob() {
guard let adMobNativeAd, let containerView else { return }
let adView = makeAdMobAdView(adMobNativeAd)
adView.frame = containerView.bounds
adView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
containerView.addSubview(adView)
renderedAdView = adView
renderedPlatformName = CLXArbiterPlatform.adMob.name
renderedAdMobAd = adMobNativeAd
}
private func loadAdMob() {
let loader = GADAdLoader(
adUnitID: adMobAdUnitId,
rootViewController: nil,
adTypes: [.native],
options: nil
)
loader.delegate = self
adMobAdLoader = loader
loader.load(GADRequest())
}
/// Forwards AdMob's realized revenue to CloudX. This is a required part of the integration,
/// not optional analytics: CloudX prices future AdMob bids from what you report back.
///
/// revenuePrecision(from:) is the mapping shown in
/// "Report Google paid events back to CloudX" above.
private func reportAdMobPaidEvent(_ ad: GADNativeAd?, adValue: GADAdValue) {
let servedBy = ad?.responseInfo.loadedAdNetworkResponseInfo
let data = CLXRevenueData.revenueData(
platform: .adMob,
revenue: adValue.value.doubleValue,
adFormat: "native"
) { builder in
builder.currencyCode = adValue.currencyCode
builder.precision = revenuePrecision(from: adValue.precision)
builder.networkName = servedBy?.adSourceName
builder.adUnitId = self.adMobAdUnitId
builder.thirdPartyAdPlacementId = servedBy?.adSourceInstanceName
}
// reportRevenueData returns false when nothing consumed the event. CloudX prices future
// AdMob bids from what you report here, so a silent false is worth surfacing.
if !CloudXCore.shared.reportRevenueData(data) {
print("AdMob native paid event was not accepted by the CloudX SDK")
}
}
/// Removes only the ad view this controller attached, so anything else the publisher keeps
/// in the container — a placeholder, a spinner, a label — survives the round.
///
/// This is also the only place a rendered ad is released. It stays alive for as long as it
/// is on screen, and goes away when its replacement is attached or the controller is
/// destroyed.
private func detachRenderedAd() {
renderedAdView?.removeFromSuperview()
renderedAdView = nil
renderedPlatformName = nil
if let renderedCloudXAd { cloudXLoader.destroyAd(renderedCloudXAd) }
renderedCloudXAd = nil
renderedAdMobAd = nil
}
/// Drops the CloudX candidate, detaching it first if it is the one on screen.
private func clearCloudXAndReload() {
if renderedPlatformName == CLXArbiterPlatform.cloudX.name {
detachRenderedAd()
}
cloudXAd = nil
cloudXLoadDone = false
cloudXLoadInFlight = false
loadMissingAds()
}
/// Starts the next cycle once the winner's impression has fired.
///
/// The rendered view stays on screen and the ad behind it stays alive. A native slot has
/// nothing to fall back to, so detaching the ad the moment it is billed would leave the slot
/// blank for as long as the next round takes. Only the candidate state is dropped, so the
/// spent ad is never bid or rendered again; the ad itself is released in detachRenderedAd(),
/// when its replacement goes up. This is the same rule the banner controller follows.
private func onWinnerImpression(_ platformName: String) {
switch platformName {
case CLXArbiterPlatform.cloudX.name:
cloudXAd = nil
cloudXLoadDone = false
cloudXLoadInFlight = false
case CLXArbiterPlatform.adMob.name:
adMobNativeAd = nil
adMobLoadDone = false
adMobLoadInFlight = false
default:
return
}
loadMissingAds()
}
private func nativeAdViewBinder() -> CLXNativeAdViewBinder {
CLXNativeAdViewBinder { builder in
builder.titleLabelTag = CLXNativeAdViewTagTitleLabel
builder.bodyLabelTag = CLXNativeAdViewTagBodyLabel
builder.iconImageViewTag = CLXNativeAdViewTagIconImageView
builder.callToActionButtonTag = CLXNativeAdViewTagCallToActionButton
builder.mediaContentViewTag = CLXNativeAdViewTagMediaViewContainer
builder.optionsContentViewTag = CLXNativeAdViewTagOptionsContentView
builder.advertiserLabelTag = CLXNativeAdViewTagAdvertiserLabel
}
}
}
extension ArbiterNativeController: CLXNativeAdDelegate {
// nativeAdView is nil here because loadAd() was called without one. The ad is rendered
// later, and only if it wins.
func didLoadNativeAd(_ nativeAdView: CLXNativeAdView?, for ad: CLXAd) {
cloudXAd = ad
cloudXLoadInFlight = false
cloudXLoadDone = true
maybeArbitrate()
}
func didFailToLoadNativeAd(forAdUnitIdentifier adUnitId: String, error: CLXError) {
print("CloudX native ad failed to load: \(error.localizedDescription)")
cloudXAd = nil
cloudXLoadInFlight = false
cloudXLoadDone = true
maybeArbitrate()
}
func didClickNativeAd(_ ad: CLXAd) {}
func didExpireNativeAd(_ ad: CLXAd) {
// detachRenderedAd() releases the rendered ad, so hand it this one instead of
// destroying it twice.
if ad !== renderedCloudXAd { cloudXLoader.destroyAd(ad) }
clearCloudXAndReload()
}
func didCloseNativeAd(_ ad: CLXAd) {
if ad !== renderedCloudXAd { cloudXLoader.destroyAd(ad) }
clearCloudXAndReload()
}
}
extension ArbiterNativeController: GADNativeAdLoaderDelegate {
func adLoader(_ adLoader: GADAdLoader, didReceive nativeAd: GADNativeAd) {
nativeAd.paidEventHandler = { [weak self, weak nativeAd] adValue in
self?.reportAdMobPaidEvent(nativeAd, adValue: adValue)
}
nativeAd.delegate = self
adMobNativeAd = nativeAd
adMobLoadInFlight = false
adMobLoadDone = true
maybeArbitrate()
}
func adLoader(_ adLoader: GADAdLoader, didFailToReceiveAdWithError error: Error) {
print("AdMob native ad failed to load: \(error.localizedDescription)")
adMobNativeAd = nil
adMobLoadInFlight = false
adMobLoadDone = true
maybeArbitrate()
}
}
extension ArbiterNativeController: CLXAdRevenueDelegate {
/// CloudX's impression signal. The rendered ad is spent as a candidate, so request the next
/// one — the same rule the banner controller follows.
func didPayRevenue(for ad: CLXAd) {
guard renderedPlatformName == CLXArbiterPlatform.cloudX.name else { return }
onWinnerImpression(CLXArbiterPlatform.cloudX.name)
}
}
extension ArbiterNativeController: GADNativeAdDelegate {
/// The winning AdMob ad is consumed the moment its impression fires, so request the next one
/// here. Without this the controller keeps bidding a spent ad for the rest of the session.
func nativeAdDidRecordImpression(_ nativeAd: GADNativeAd) {
guard renderedPlatformName == CLXArbiterPlatform.adMob.name else { return }
onWinnerImpression(CLXArbiterPlatform.adMob.name)
}
}didLoadNativeAd(_:for:) delivers a nil nativeAdView because the load was started without one — that nil is the signal that rendering is still yours to trigger. Build the CLXNativeAdView in renderCloudX() and pass it to renderNativeAdView(_:with:) only after the arbiter has named CloudX the winner. That call returns false when the ad can no longer be rendered, which is treated here as a lost round rather than ignored.
The losing platform keeps its filled ad for the next round, so only the platform that was consumed or expired is re-requested. A rendered ad is spent, so each platform drops it as a candidate as soon as the impression fires — didPayRevenue(for:) for CloudX, nativeAdDidRecordImpression(_:) for AdMob — and requests a new one.
Neither handler detaches the view. A native slot has nothing to fall back to, so removing the served ad at its impression would leave the slot blank for as long as the next round takes. The rendered ad stays up and is released in detachRenderedAd(), at the moment its replacement is attached or the controller is destroyed — the same rule the banner controller follows. An ad that expires or is closed while it is on screen is the one case that does detach early, which is why renderedPlatformName is tracked.
renderWinner(_:) returns early when the arbiter picks the platform already on screen, because rendering is what fires the impression and that round did not change the winner.
To refresh a native slot on a timer, use the same cycle as Banner and MREC arbitration: re-arbitrate on a 20-30 second interval and swap the rendered view for the new winner’s. For a Reels-style feed, create one controller per slot — see Native Ads.
Custom Bid Inputs
Use CLXArbiterBid.custom(...) when you want Trusted Arbiter to compare CloudX with a third-party platform that does not have a dedicated bid helper.
CLXArbiterBid *customBid =
[CLXArbiterBid customBidWithPlatformName:@"my_mediation_platform"
networkName:@"winning_demand_source"
revenuePerImpressionUSD:0.00125
precision:CLXArbiterPrecision.exact
extras:@{@"ad_unit": @"third-party-ad-unit-id"}];
CLXArbiterConfiguration *configuration =
[CLXArbiterConfiguration configurationWithBids:@[
[CLXArbiterBid cloudXBidWithAd:cloudXAd],
customBid
]];let customBid = CLXArbiterBid.custom(
platformName: "my_mediation_platform",
networkName: "winning_demand_source",
revenuePerImpressionUSD: 0.00125,
precision: .exact,
extras: ["ad_unit": "third-party-ad-unit-id"]
)
let configuration = CLXArbiterConfiguration.configuration(
bids: [CLXArbiterBid.cloudX(ad: cloudXAd), customBid],
builderBlock: nil
)When a custom bid wins, result.platform is CLXArbiterPlatform.custom and result.platformName contains the platformName supplied on the bid. Pass revenuePerImpressionUSD as revenue for one impression in USD, not CPM. Use CLXArbiterPrecision.exact, estimated, publisherDefined, or undefined to describe that revenue value.