iOS SDK integration
Connect Melvad to your iOS app, configure ad placements, and control loading and display from Swift. Follow the shared integration flow, then choose an example for each supported format.
This guide covers Melvad iOS SDK 1.0.2. Add the binary package through Swift Package Manager or download the complete distribution below.
Other integrations: Cookie syncing OpenRTB formats
1. Prepare your app
- Register with Melvad and create an iOS app inventory. Set its Bundle ID and the URL of its actual App Store page in the app settings.
- Create placements for the required formats. Get
inventoryIDandplacementIDfrom the placement settings or tag. Use the appropriate placement for each format. - Get your numeric
clientIDfrom the Melvad team. The SDK useshttps://pub.supplier.melvad.comas its production endpoint by default. Add the SDK from GitHub in the next step; you can override the endpoint for testing.
You need iOS 16+ and Xcode with Swift 6 support. Examples use UIKit. In SwiftUI, host the ad UIView through UIViewRepresentable and retain the session owner in a coordinator or screen model.
The SDK's Bundle ID and App Store URL must match the app in your inventory. App identity for ad selection is configured in the inventory settings.
2. Add the SDK
- In Xcode, open File → Add Package Dependencies.
- Enter the package URL: https://github.com/Melvad/ios-ads-sdk.
- Select Exact Version: 1.0.2 and add both products to your app target:
MelvadAdsCoreandMelvadAdsRuntime. - Swift Package Manager downloads the device and simulator XCFrameworks with their resources. Prebid is included; do not add a separate second copy.
To use a local package, clone the SDK repository and select its root directory containing Package.swift through Add Local…. Prebuilt XCFrameworks are in the Frameworks folder. Preserve the directory structure, resources and licenses.
For an offline package, download MelvadAdsSDK 1.0.2, unpack it and add the directory containing Package.swift through Add Local…. Keep all binaries and resource bundles together. SHA-256 checksums are available with the release.
MelvadAdsTracking is optional. Add this product only when your app needs ATT-authorized advertising identifiers; see privacy configuration.
See the SDK license for terms of use.
3. Configure requests
Create a configuration for each route: .display for banners and native ads, .video for video and rewarded ads. Pass the values supplied by Melvad and your app's App Store URL to this function.
The default production endpoint is https://pub.supplier.melvad.com; you can omit supplierURL. Set it explicitly for testing. When constructing SupplierConfiguration directly, the same default applies to baseURL.
import UIKit
import WebKit
import MelvadAdsCore
import MelvadAdsRuntime
enum IntegrationError: Error {
case missingBundleID, missingUserAgent
}
@MainActor
func makeConfiguration(
supplierURL: URL = SupplierConfiguration.productionBaseURL,
clientID: Int64,
appStoreURL: URL,
route: SupplierConfiguration.Route,
identityProvider: (any AdvertisingIdentityProvider)? = nil
) async throws -> SupplierConfiguration {
guard let bundleID = Bundle.main.bundleIdentifier else {
throw IntegrationError.missingBundleID
}
let webView = WKWebView(frame: .zero)
guard let userAgent = try await webView.evaluateJavaScript(
"navigator.userAgent"
) as? String, !userAgent.isEmpty else {
throw IntegrationError.missingUserAgent
}
return try SupplierConfiguration(
baseURL: supplierURL,
clientID: clientID,
bundleID: bundleID,
storeURL: appStoreURL,
userAgent: userAgent,
language: Locale.preferredLanguages.first,
route: route,
identityProvider: identityProvider
)
}
supplierURL is the base URL without /get, /video, query parameters or the client identifier. The SDK appends /get/{clientID} or /video/{clientID}. Use HTTPS; this integration does not require disabling ATS.
Below, displayConfiguration and videoConfiguration are results of this function with the corresponding route. Call it with try await from @MainActor and handle configuration errors.
4. Pass user consent
Your app manages its CMP and any ATT request. The SDK does not display the system prompt. Create a PrivacyContext from the user's actual settings; keep unknown values unknown.
By default, MelvadAdsCore and MelvadAdsRuntime do not read IDFA or IDFV and do not require your app to link AdSupport or AppTrackingTransparency for SDK requests.
import MelvadAdsCore
@MainActor
func makePrivacy(
gdprApplies: Bool?,
consentString: String?,
personalizationAllowed: Bool,
globalPrivacyControl: Bool
) -> PrivacyContext {
PrivacyContext(
personalizationAllowed: personalizationAllowed,
gdprApplies: gdprApplies,
consentString: consentString,
globalPrivacyControl: globalPrivacyControl
)
}
To opt in to identifier collection, add the MelvadAdsTracking product and pass SystemAdvertisingIdentityProvider() when creating a configuration:
import MelvadAdsTracking
// Inside a @MainActor method, after your app has handled consent:
let configuration = try await makeConfiguration(
clientID: clientID,
appStoreURL: appStoreURL,
route: .display,
identityProvider: SystemAdvertisingIdentityProvider()
)
The provider refreshes ATT status before each request. It collects identifiers only when ATT is authorized, personalizationAllowed is true, gdprApplies is explicitly false and globalPrivacyControl is false. With denied, restricted or not-determined ATT, neither IDFA nor IDFV is sent. IDFV is not a replacement for denied ATT.
If your app requests ATT, provide NSUserTrackingUsageDescription and use Apple's API. Keep the SDK privacy manifests in your build and review the app privacy report for your configured demand partners.
Requests work without either identifier. After consent changes, call updatePrivacy: the current load is cancelled and the prepared creative is removed. Your app starts the next load explicitly.
5. Create an ad slot owner
The shared flow is load → ready → show. For fullscreen, you can use present(from:) instead of show(). Loading alone does not display the ad. All API calls and callbacks run on MainActor.
The following app-level helper wraps the actual SDK API. It retains the session, handles clicks, and invokes onReady only after loading, so a regular hide() does not immediately show the ad again.
Full AdSlotController.swift code
import UIKit
import MelvadAdsCore
import MelvadAdsRuntime
@MainActor
final class AdSlotController: NSObject, MelvadAdSessionDelegate {
let session: MelvadAdSession
var onReady: ((MelvadAdSession) -> Void)?
var onUnavailable: (() -> Void)?
var onError: ((NSError) -> Void)?
var onEvent: ((String) -> Void)?
private var awaitingLoad = false
init(configuration: SupplierConfiguration, privacy: PrivacyContext) {
session = MelvadAdSession(configuration: configuration)
super.init()
session.updatePrivacy(privacy)
session.delegate = self
}
func load(in slot: UIView, inventoryID: String,
placementID: String, mode: String? = nil) {
slot.isHidden = false
slot.superview?.layoutIfNeeded()
guard slot.window != nil,
slot.bounds.width >= 1, slot.bounds.height >= 1 else {
onError?(NSError(domain: "MelvadIntegration", code: 1,
userInfo: [NSLocalizedDescriptionKey:
"Attach the ad slot to a window and set its size before loading."]))
return
}
awaitingLoad = true
session.load(in: slot, inventoryID: inventoryID,
placementID: placementID, mode: mode,
width: Int(slot.bounds.width),
height: Int(slot.bounds.height))
}
func adSession(_ session: MelvadAdSession,
didChange state: MelvadAdSessionState) {
switch state {
case .ready where awaitingLoad:
awaitingLoad = false
onReady?(session)
case .noFill, .destroyed:
awaitingLoad = false
onUnavailable?()
case .idle, .failed:
awaitingLoad = false
default:
break
}
}
func adSession(_ session: MelvadAdSession, didFail error: NSError) {
onError?(error)
}
func adSession(_ session: MelvadAdSession, didReceiveEvent event: String) {
onEvent?(event)
}
func adSession(_ session: MelvadAdSession, didClick url: URL) {
UIApplication.shared.open(url)
}
}Add this class to your project. Retain the ad slot owners as properties of your UIViewController: the SDK holds its delegate weakly. Use one owner per placement.
// Properties of your UIViewController, not local variables:
private var displayAd: AdSlotController?
private var fullscreenAd: AdSlotController?
private var videoAd: AdSlotController?
In the following examples, privacy is your PrivacyContext, identifiers come from Melvad, and slot is a sized UIView already attached to a window. Load after layout, for example once in viewDidAppear. Do not load on every viewDidLayoutSubviews.
Supported formats
| Format | Route | Mode | Display |
|---|---|---|---|
| Banner / MREC | .display | nil | show() |
| Fullscreen banner | .display | "fullscreen" | present(from:) |
| Adhesion / expandable adhesion | .display | nil | show() |
| Native image / native video | .display | nil | show() |
| Inread / infeed / inslide | .video | "inread" / "infeed" / "inslide" | show() |
| Fullscreen video | .video | "fullscreen" | show() / present(from:) |
| Rewarded | .video | "rewarded" | present(from:) |
| Pre- / mid- / post-roll | .video | nil | MelvadAdBreakController |
The server selects the template and creative from the placement settings. mode specifies presentation behavior; it does not turn a banner placement into native or video inventory. Example dimensions are UIKit points, not physical pixels.
7. Fullscreen banner
Create a display placement with a fullscreen template. Prepare the ad in a UIView on the current screen with positive width and height. After .ready, present it at an appropriate UI transition.
// Prepare a fullscreen banner using displayConfiguration:
fullscreenAd?.session.destroy()
let ad = AdSlotController(configuration: displayConfiguration, privacy: privacy)
fullscreenAd = ad
ad.onError = { error in
print(error.domain, error.code, error.localizedDescription)
}
ad.load(in: slot, inventoryID: inventoryID,
placementID: placementID, mode: "fullscreen")// A method of your UIViewController; call at an appropriate UI transition:
func showPreparedFullscreen() {
guard let session = fullscreenAd?.session,
session.state == .ready,
view.window != nil,
presentedViewController == nil else { return }
do {
try session.present(from: self)
} catch {
print(error.localizedDescription)
}
}
Present in the same scene as the original container. Only one fullscreen ad can occupy a scene at a time. Closing the ad destroys its session; create a new session for the next ad.
8. Adhesion and expandable adhesion
Use .display and a placement configured for adhesion or expandable adhesion. No separate mode: "adhesion" value is needed. The SDK anchors the banner at the bottom safe area; your app reserves space for content above it.
// Use a placement configured for adhesion or expandable adhesion.
displayAd?.session.destroy()
let ad = AdSlotController(configuration: displayConfiguration, privacy: privacy)
displayAd = ad
ad.onReady = { $0.show() }
ad.onUnavailable = { [weak slot] in slot?.isHidden = true }
ad.onError = { [weak slot] error in
slot?.isHidden = true
print(error.localizedDescription)
}
ad.load(in: slot, inventoryID: inventoryID, placementID: adhesionPlacementID)
Fullscreen expansion is available when allowed by the supplier settings and MRAID creative. It requires user interaction. Closing an expanded ad returns to the same banner; closing the banner itself ends the session.
The supported MRAID commands cover opening links, visibility, geometry, expand, close and playVideo. In the current profile, resize() expands to fullscreen; two-part expansion and forced orientation are not supported.
9. Native image and native video
Both formats use displayConfiguration. Select a placement configured for native image or native video. The SDK renders the card from the response; you do not need to fetch and assemble individual assets.
// Use the placement ID for native image OR native video.
displayAd?.session.destroy()
let ad = AdSlotController(configuration: displayConfiguration, privacy: privacy)
displayAd = ad
ad.onReady = { $0.show() }
ad.onUnavailable = { [weak nativeSlot] in nativeSlot?.isHidden = true }
ad.onError = { [weak nativeSlot] error in
nativeSlot?.isHidden = true
print(error.localizedDescription)
}
ad.load(in: nativeSlot, inventoryID: inventoryID, placementID: nativePlacementID)
Place nativeSlot within a feed or article, with regular content before and after it. Give the card enough height; the SDK example uses 460 pt. Call show() for native ads; present(from:) is not supported.
Native video starts muted after display is authorized and at least 50% is visible. It pauses offscreen and in the background. Remove the slot on completion. The current native-video path supports one VAST InLine with a linear MP4 creative; VAST wrapper chains are not supported in this path.
10. Inread, infeed, inslide and fullscreen video
Use videoConfiguration and a video placement. This method loads the ad and enables display when it is ready. Choose one of the four modes.
| Mode | Behavior |
|---|---|
.inread | In an article; playback while at least 50% visible. |
.infeed | In a feed; starts after one second at 50% visibility, pauses offscreen. |
.inslide | A sliding unit: at the bottom on iPhone, on the right on iPad. |
.fullscreen | Fullscreen video with SDK controls. |
// A method of your UIViewController:
func loadVideo(in slot: UIView, configuration: SupplierConfiguration,
privacy: PrivacyContext, inventoryID: String,
placementID: String, mode: MelvadVideoMode) {
videoAd?.session.destroy()
let ad = AdSlotController(configuration: configuration, privacy: privacy)
videoAd = ad
ad.onReady = { $0.show() }
ad.onUnavailable = { [weak slot] in slot?.isHidden = true }
ad.onError = { [weak slot] error in
slot?.isHidden = true
print(error.localizedDescription)
}
ad.load(in: slot, inventoryID: inventoryID, placementID: placementID,
mode: mode.supplierValue)
}
// Call from your screen after layout; choose one mode for this placement:
// loadVideo(in: slot, configuration: videoConfiguration, privacy: privacy,
// inventoryID: inventoryID, placementID: placementID, mode: .inread)
// Other modes: .infeed, .inslide, .fullscreen
For inread/infeed, prepare a slot in your article or feed, typically at 16:9. For inslide and fullscreen, the original UIView must also be attached to a window. For fullscreen, the screen must not have another presented modal. To separate preparation from display, omit show() from onReady and call it later while the session is .ready.
11. Rewarded video
Use videoConfiguration, a dedicated rewarded placement, and the string mode: "rewarded". Rewarded is not a case of MelvadVideoMode. Let the user opt in before starting the ad.
// Call after the user opts in to watch a rewarded ad:
fullscreenAd?.session.destroy()
let ad = AdSlotController(configuration: videoConfiguration, privacy: privacy)
fullscreenAd = ad
ad.onReady = { [weak self] _ in self?.showPreparedFullscreen() }
ad.onUnavailable = { print("Rewarded ad unavailable or closed") }
ad.onError = { error in print(error.localizedDescription) }
ad.onEvent = { event in
if event == "AdVideoComplete" {
print("Video completed; this is not a verified reward")
}
}
ad.load(in: slot, inventoryID: inventoryID,
placementID: rewardedPlacementID, mode: "rewarded")
Reward verification. AdVideoComplete reports video completion only. The current package does not provide a ready SSV contract for server-side reward verification. Do not grant a verified reward based on this callback; agree on the server contract with Melvad before launching rewarded monetization.
12. Pre-roll, mid-roll and post-roll
Your external player plays the main content. MelvadAdBreakController pauses it and displays ads. For AVPlayer, add this adapter; implement the same two methods for another content player.
import AVFoundation
import MelvadAdsRuntime
@MainActor
final class ContentAdapter: MelvadContentPlayback {
let player: AVPlayer
init(player: AVPlayer) { self.player = player }
func playContent() { player.play() }
func pauseContent() { player.pause() }
}
Add an ad UIView over the content player's area. After layout, create the controller with videoConfiguration and an instream placement. This example runs a pre-roll, one mid-roll at 30 seconds, and a post-roll.
// Properties of your UIViewController:
private var contentAdapter: ContentAdapter?
private var adBreaks: MelvadAdBreakController?
// Inside a method, after laying out the ad overlay inside the content player:
adBreaks?.destroy()
let adapter = ContentAdapter(player: contentPlayer)
contentAdapter = adapter
let breaks = MelvadAdBreakController(
content: adapter,
container: adOverlay,
configuration: videoConfiguration,
inventoryID: inventoryID,
placementID: instreamPlacementID,
midrollTime: 30,
privacy: privacy
)
adBreaks = breaks
breaks.onEvent = { print($0) }
breaks.start()
Connect your player's periodic time callback and the end event for the current content item. With AVPlayer, use addPeriodicTimeObserver and AVPlayerItem.didPlayToEndTimeNotification. Forward events on MainActor and remove observers when releasing the screen.
// Forward player callbacks on MainActor:
adBreaks?.contentTimeDidChange(contentPlayer.currentTime().seconds)
// Call the line above on periodic playback updates, not just once.
// When the current content item finishes:
adBreaks?.contentDidFinish()
// When the user's privacy settings change:
adBreaks?.updatePrivacy(updatedPrivacy)
// Before leaving the screen; also remove the player's time/end observers:
adBreaks?.destroy()
adBreaks = nil
contentAdapter = nil
Pre-/mid-/post-roll are insertion points, not mode values. The controller makes a separate video request for each break using the same placement. No-fill or an error returns control to the content; post-roll does not restart it. Create a new controller for a new content item or replay.
13. Lifecycle and integration checks
// Pause and resume the SAME inline creative:
displayAd?.session.hide()
// Later, when the slot should be visible again:
displayAd?.session.show()
// After a privacy change, discard the prepared creative:
displayAd?.session.updatePrivacy(updatedPrivacy)
// Start a new load explicitly with the new privacy context.
// Before removing a slot, reusing a cell, or leaving the screen:
displayAd?.session.destroy()
displayAd = nil
videoAd?.session.destroy()
videoAd = nil
fullscreenAd?.session.destroy()
fullscreenAd = nil
destroy()is terminal. Create a new session afterward. A newloadon a live session replaces its previous load and creative.- A prepared creative expires after at most 120 seconds; Prebid may set a shorter lifetime. Load a new ad after expiry.
show()after an inlinehide()uses the same creative. Closing a modal video destroys its session.- The SDK owns impression, billing and VAST trackers. Do not send duplicate tracking requests from your app.
- On a physical device, check each format, clicks, closing, foreground return, scrolling, cell reuse and consent changes.
| Result | What to check |
|---|---|
noFill | Normal absence of an ad. Check placement status, format and demand settings; collapse the empty slot. |
failed | Record the NSError domain/code. Check the HTTPS endpoint, clientID, inventory/placement, network and app configuration. |
| Ready, but nothing is visible | Was show/present called? Is the container attached, sized and visible? Is the app in the foreground? |
| Fullscreen will not open | Use the same scene, no competing modal or fullscreen ad, and a non-expired creative. Native requires inline display. |
The SDK does not automatically retry an auction. Schedule retries with a frequency limit; do not start an endless load() loop from an error callback. Passing tests with fixture responses does not guarantee live demand for a placement.
For help, contact support@melvad.com with the SDK version, iOS version, format, inventory/placement and reproduction steps. Remove tokens, device identifiers and consent data before sharing logs.
14. Native Open Measurement
Version 1.0.2 includes native OMSDK_Melvad 1.6.12. The runtime manages the measurement session and ad/media events for display and VAST video, including pre-roll, mid-roll and post-roll. No additional OMID initialization is required in your app.
Do not add a second web OMID Service/Session Client or manually send measurement events for the same creative. VAST verification resources come from AdVerifications; playback without usable verification resources continues without an OMID session. This integration does not imply IAB certification.
15. SDK logging and errors
Logging is off by default. Set the minimum level to debug, info, warning or error and provide a handler:
import MelvadAdsCore
MelvadLogger.configure(minimumLevel: .debug) { entry in
print("[\(entry.subsystem)] \(entry.message)")
}
// Disable SDK logging:
MelvadLogger.configure()
The Swift handler runs on the emitting thread; dispatch UI work to MainActor. Objective-C apps can use MelvadDiagnostics.configure(minimumLevel:handler:), whose handler runs on MainActor. SDK records contain lifecycle/status metadata rather than creative markup, consent values or device identifiers.
In adSession(_:didFail:), inspect NSError.localizedDescription, domain, code and NSUnderlyingErrorKey. The underlying network error is retained. noFill remains a normal result, not an error.