Skip to content

API Reference

This page covers the public surface of the EloAds module. For running Elo alongside another ad network, see Other Ad Networks.


Caseless enum entry point. Configure once, then load ads anywhere.

public enum Elo {
public static func configure(
publisherId: String,
adUnitId: String,
shareGeoLocation: Bool = true,
geoLocationPrecision: Int? = 2
)
public static func configure(with configuration: EloConfiguration)
public static var isConfigured: Bool { get }
public static func loadAd(
messages: [ChatMessage],
contextObjects: [ContextObject] = [],
maxHeight: CGFloat? = nil,
maxWidth: CGFloat? = nil
) async -> AdResult
public static func loadAd<M: MessageRepresentable>(
messages: [M],
contextObjects: [ContextObject] = [],
maxHeight: CGFloat? = nil,
maxWidth: CGFloat? = nil
) async -> AdResult
public static func preloadAd(
messages: [ChatMessage],
contextObjects: [ContextObject] = [],
maxHeight: CGFloat? = nil,
maxWidth: CGFloat? = nil
)
11 collapsed lines
public static func preloadAd<M: MessageRepresentable>(
messages: [M],
contextObjects: [ContextObject] = [],
maxHeight: CGFloat? = nil,
maxWidth: CGFloat? = nil
)
public static func enable()
public static func disable()
public static var isDisabled: Bool { get }
public static func setShareGeoLocation(_ enabled: Bool)
public static func setUserIdentifier(_ identifier: String?)
public static func setUserData(_ data: EloUserData?)
public static func setUserIdentity(userIdentifier: String?, userData: EloUserData?)
public static func shutdown()
public static func setDelegate(_ delegate: EloAdDelegate?)
public static func trackBrowserOpened(_ ad: EloAd)
public static func trackBrowserClosed(_ ad: EloAd)
public static func setDebugObserver(
_ observer: (@Sendable (EloDebugEvent) -> Void)?,
capturePayloads: Bool = false
)
public static var sdkVersion: String { get }
}
configure(publisherId:adUnitId:...)
Sets up the SDK for Elo-only integrations. Call once early in your app lifecycle, typically App.init. Only the geo-sharing controls are mirrored here, so the on-by-default opt-out stays visible at the simplest entry point.
configure(with:)
Full form when you need COPPA / TFUA flags, log-level control, or a baseUrl override. Subsequent calls re-configure the SDK and reset the session ID.
loadAd
Requests an ad for the chat context and the slot size. Returns an AdResult.
preloadAd
Warms the in-memory cache for the given context. Fire-and-forget. The cache key is the messages, the context objects, maxHeight, maxWidth, and the consent snapshot, so a preloaded ad is served only to a later load with the same inputs. Pass the same maxHeight and maxWidth to preloadAd and to the load that should use the ad.
enable / disable
Pause and resume new ad requests. While disabled, loadAd returns .noFill(.sessionDisabled).
setShareGeoLocation
Toggle passive geo sharing at runtime. The toggle resets to the configured value after shutdown() + reconfigure — prefer the shareGeoLocation configuration opt-out for a durable setting.
setUserIdentifier
Sets a publisher-supplied user identifier (e.g. your account system’s user ID). When set, it is sent as the visitor_id on ad requests and tracking pings in place of the SDK’s anonymous per-install ID, so delivery, frequency capping, and reporting follow the user across installs and devices. Persisted with the user data as one encrypted Keychain record, so it survives a relaunch and you set it once rather than on every launch — and it survives a sign-out, so pass nil there to restore the anonymous ID. Erased by shutdown(), not by a re-configure. Values are trimmed, a blank string clears, and identifiers over 256 characters are ignored with a warning. Also available on the Android and web SDKs.
setUserData
Shares first-party user data (age, gender, email, phone) on subsequent ad requests. Recorded for upcoming targeting features; no effect on ad selection today. Each call replaces the whole object; nil clears it. Persisted with the identifier in the same encrypted record, so clear it on sign-out. Erased by shutdown(), not by a re-configure. See EloUserData. Also available on the Android and web SDKs.
setUserIdentity
Sets the identifier and the user data in one write. Prefer it to calling the two setters in sequence whenever both are changing (sign-in, sign-out, account switch): the two are persisted as one record, so two calls leave an intermediate state on disk in which one account’s contact details ride the other’s identifier. Each parameter behaves exactly as its single-field setter does.
shutdown
Cancels requests, invalidates the URLSession, and releases resources.
setDelegate
Register an EloAdDelegate to receive lifecycle callbacks. Held weakly, callbacks on @MainActor.
trackBrowserOpened / trackBrowserClosed
Report your own in-app browser. Call trackBrowserOpened when your browser opens the ad’s click URL and trackBrowserClosed when it closes; the SDK sends browser_open, then browser_close with the time in between as dwell_ms. The time is measured on the device, counts time the app spent in the background, and is capped at 24 hours. A second open before the close, and a close without an open, send nothing. Elo-served ads only. Analytics only: never billed. See Your own in-app browser.
setDebugObserver
Registers a closure that receives each EloDebugEvent as it happens: load and preload outcomes, and tracking attempts with their outcomes. The SDK keeps no history of its own. Pass nil to stop observing. For debug builds and on-device inspection only.
sdkVersion
Human-readable SDK version string to include in support tickets.

configure parameters:

publisherId Type String
Your publisher ID from Elo.
adUnitId Type String
Your ad unit ID from Elo.
shareGeoLocation Type Bool Default true
Passive geo sharing. On by default — pass false (or call Elo.setShareGeoLocation(false)) to opt out. The SDK never requests location permission itself. See Privacy & Consent.
geoLocationPrecision Type Int? Default 2
Decimal places used to round shared coordinates (~1.1 km at the default). nil keeps full precision.

loadAd parameters:

messages Type [ChatMessage]
Conversation context. The SDK forwards the latest 30 messages.
contextObjects Type [ContextObject] Default []
Optional screen or page metadata for richer targeting.
maxHeight Type CGFloat? Default nil
The most height, in points, the slot can give the ad. nil means unbounded. Rounded down to whole points.
maxWidth Type CGFloat? Default nil
The most width, in points, the slot can give the ad. nil means unbounded. Rounded down to whole points.

You describe the slot and the server chooses the format. The server returns the format that fits maxHeight and maxWidth on the ad, and EloAdView renders that format: the card needs at least 192 points of height, and the strip fits a slot from 72 to 191 points. When no format fits, the result is .noFill. Leaving maxHeight nil returns the card. Elo.preloadAd and EloChatSession.loadAd take the same two parameters. See Ad Formats.


Full configuration object passed to Elo.configure(with:). Use this when the convenience configure doesn’t expose what you need — COPPA / TFUA, log level, or baseUrl.

public struct EloConfiguration: Sendable {
public var elo: EloNetworkConfiguration
public var coppa: Bool
public var tfua: Bool
public var logLevel: EloLogLevel
public var baseUrl: URL
public var shareGeoLocation: Bool
public var geoLocationPrecision: Int?
public init(
elo: EloNetworkConfiguration,
coppa: Bool = false,
tfua: Bool = false,
logLevel: EloLogLevel = .warn,
baseUrl: URL = EloConfiguration.defaultBaseUrl,
shareGeoLocation: Bool = true,
geoLocationPrecision: Int? = 2
)
}
public struct EloNetworkConfiguration: Sendable {
public let publisherId: String
public let adUnitId: String
public init(publisherId: String, adUnitId: String)
}
Elo.configure(
with: EloConfiguration(
elo: EloNetworkConfiguration(
publisherId: "YOUR_PUBLISHER_ID",
adUnitId: "YOUR_AD_UNIT_ID"
),
logLevel: .warn
)
)

Key fields:

elo
First-party Elo demand configuration.
coppa
COPPA child-directed treatment flag. Defaults to false.
tfua
Under-age treatment flag. Defaults to false.
logLevel
SDK logging threshold. Defaults to .warn.
baseUrl
API endpoint for Elo ad requests. Defaults to production.
shareGeoLocation
Prebid-style passive geo sharing (default true). When enabled, the SDK reads an already-authorized last-known CoreLocation fix and sends it as OpenRTB device.geo, rounded per geoLocationPrecision. It never requests location permission itself — your app must already be authorized. Opt out with shareGeoLocation: false or Elo.setShareGeoLocation(false). See Privacy & Consent.
geoLocationPrecision
Decimal places used to round passive geo coordinates, clamped to 0–6. Default 2 (~1.1 km) is privacy-conscious; set nil to keep full precision. Only applies when shareGeoLocation is enabled.

First-party user data passed to Elo.setUserData(_:). Every field is optional — set only what you know.

public struct EloUserData: Sendable, Equatable {
public var age: Int?
public var gender: EloGender?
public var email: String?
public var phone: String?
public init(
age: Int? = nil,
gender: EloGender? = nil,
email: String? = nil,
phone: String? = nil
)
}
public enum EloGender: String, Sendable, Equatable {
case male
case female
case other
}
Elo.setUserData(EloUserData(age: 34, gender: .female, email: account.email))
Elo.setUserData(nil) // sign-out; or Elo.setUserIdentity(userIdentifier: nil, userData: nil) to clear both
age
Age in years. Child-directed traffic is gated by coppa / tfua, not by this value.
gender
.male, .female, or .other.
email
Email address, as you hold it. Case and surrounding whitespace don’t matter — the ad server normalizes both before hashing.
phone
Phone number with its country code (E.164, e.g. "+15551234567"). The server strips formatting punctuation; a number without a country code is ambiguous and is discarded rather than guessed at.

Rules worth knowing:

  • Pass contact details as they are. They travel over HTTPS and are SHA-256 hashed at the ad server before anything is stored, so Elo never persists a plain email address or phone number. Hashing them yourself produces a digest the server can’t reproduce.
  • The SDK does not validate what you set. Values are forwarded as supplied; the server normalizes each field and drops what it can’t use, so there is one set of rules rather than two that can disagree.
  • Each call replaces the whole object. nil clears it, as does an object with no field set.
  • Persisted on the device, encrypted at rest. The data is stored with the setUserIdentifier value as one Keychain record, a ThisDeviceOnly item so it never reaches iCloud Keychain or an encrypted device backup, and read back at launch — so you set it once rather than on every launch. If the Keychain is unavailable the SDK keeps it in memory for that process; nothing is ever written in plaintext. shutdown() erases the stored record; a re-configure does not.
  • It survives a sign-out, so clearing it is an obligation. Without Elo.setUserData(nil) the previous account’s details keep riding this device’s requests. Set this and the identifier from one auth-state hook: that call site covers sign-in, sign-out, and an account switch, and it is what keeps the stored copy from going stale when the user changes their email or phone. When both are changing, write them with Elo.setUserIdentity(userIdentifier:userData:) rather than the two setters in sequence, which briefly stores one account’s details under the other’s identifier.
  • The data never joins tracking pings, and the ad server discards it entirely on requests flagged COPPA or TFUA, and on any request where GDPR applies.
  • Transmitting an email address and phone number is a disclosure obligation — see Privacy & Consent.

public enum EloLogLevel: Int, Comparable, Sendable {
case verbose
case debug
case info
case warn
case error
case none
}

Enum returned by loadAd(messages:). switch exhaustively to handle every case.

public enum AdResult: Sendable {
case loaded(EloAd, eCpm: Double)
case noFill(NoFillReason)
case error(message: String)
}
.loaded(let ad, let eCpm)
Elo filled the request. eCpm is the price the Elo server quoted for the ad (USD-equivalent CPM, always >= 0). Use it to compare against another network. Cache-served preloads return the price of the original fill.
.noFill(let reason)
No ad was served. The reason describes why. Not an error.
.error(let message)
Something went wrong, such as a transport or decoding failure.
public enum NoFillReason: Sendable, Equatable {
case noBids
case timeout
case notConfigured
case networkError
case sessionDisabled
case other(String)
}
.noBids
The Elo server answered with no ad for this request.
.timeout
The request did not complete within the SDK’s deadline (3 seconds).
.notConfigured
loadAd was called before configure, or after shutdown().
.networkError
The ad-server request failed with a transport or server error.
.sessionDisabled
Elo.disable() was called and the SDK is currently paused.
.other(let message)
A reason that doesn’t fit the categories above.

The ad data. Pass to EloAdView to display.

public struct EloAd: Sendable, Hashable, Identifiable {
public let id: String
public let creativeId: String
public let title: String
public let description: String?
public let imageUrl: String?
public let ctaText: String?
public let clickUrl: String?
public init(
id: String,
creativeId: String,
title: String,
description: String?,
imageUrl: String?,
ctaText: String? = nil,
clickUrl: String? = nil,
renderer: AdRenderer? = nil
)
public func releaseResources()
}
id
The ad opportunity: one per showing. The ad server keys render, impression, and click under it, so it is the value that correlates a confirmed impression to its funnel row, and what the SDK’s render/impression dedup keys on.
creativeId
The creative that filled the opportunity. Stable across showings, since the same creative is served to the same user many times. Use it to recognize a creative for frequency or creative-level reporting; never to identify a showing.
ctaText
The creative’s own call-to-action label (Install, Learn more), when the demand source supplies one. EloAdView draws it as a pill in place of the disclosure chevron: in the strip’s trailing slot, or on the card’s own row under the copy. nil on creatives that send no label. CTA copy is backend-managed: there is no publisher-supplied override.
init(...)
Builds a display-only ad for SwiftUI previews and tests. It sends no render, impression, click, or tap events. id must be unique per fill, not per creative.
releaseResources()
Call only when you discard the ad without rendering it (for example, you compared prices and picked another network). Don’t call it on ads you pass to EloAdView. Safe to call more than once.

An EloAd is one showing, not one creative: two ads with the same creativeId and different ids are two opportunities, each with its own tracking. Equality and hashing follow id.

The ad also carries the format the server chose for it, which EloAdView reads. The format and the tracking handles are package-internal. There is no public API to send render, impression, or click tracking yourself: render the ad with EloAdView or .eloKeyboardBannerAd.


EloAdView renders an ad and sends render, impression, click, and tap tracking itself. It is the only way to send that tracking.

public struct EloAdView: View {
public init(
ad: EloAd,
sponsoredLabel: EloAdDisclosure = .default
)
public init(
result: AdResult,
sponsoredLabel: EloAdDisclosure = .default
)
public init(
result: AdResult?,
sponsoredLabel: EloAdDisclosure = .default
)
public init<M: MessageRepresentable>(
messages: [M],
contextObjects: [ContextObject] = [],
maxHeight: CGFloat? = nil,
maxWidth: CGFloat? = nil,
sponsoredLabel: EloAdDisclosure = .default,
showLoadingPlaceholder: Bool = true,
loadingAccessibilityLabel: String = "Loading sponsored content",
releaseLoadedAdOnDisappear: Bool = true,
onResult: @escaping (AdResult) -> Void = { _ in }
)
}

Use init(result:) to pass an AdResult directly. The view collapses to nothing on .noFill(_), .error(_), or nil. init(ad:) and init(result:) render the format the ad carries.

init(messages:) owns the request: it calls Elo.loadAd with the messages, context objects, maxHeight, and maxWidth, shows EloAdLoadingView while the request is in flight, renders the ad on fill, and collapses on no-fill or error. It does not send EloChatSession.events.

sponsoredLabel Default .default ("Ad")
The ad disclosure: a line under the brand name on the card, a badge on the creative mark’s top-trailing corner on the strip — see EloAdDisclosure. A string literal converts implicitly; a String variable needs the explicit wrap.
maxHeight / maxWidth Default nil
The slot size, in points, sent with the request. The server chooses the format that fits; see loadAd parameters. The loading placeholder draws the strip below 192 points and the card otherwise.
showLoadingPlaceholder Default true
Show the skeleton while the request is in flight.
loadingAccessibilityLabel Default "Loading sponsored content"
VoiceOver label for the skeleton.
releaseLoadedAdOnDisappear Default true
Release the loaded ad when the view disappears. Set it to false when onResult moves the result into longer-lived state that renders it with init(result:).
onResult Default no-op
Reports each load outcome.

The server chooses the format and EloAdView renders it. Two formats exist today; see Ad Formats.

FormatDescription
CardA chat-row card set like a feed ad: a header row with the brand mark beside the brand name and the disclosure under it, the description as body copy at the card’s full width (up to three lines), and a CTA pill on a row of its own at the trailing edge, or a chevron closing the header row without one. Its height follows the creative, from 87 to 175 points, so it needs a slot of at least 192 points; an unbounded slot gets it.
StripA two-line strip (thumbnail, attribution, one-line description, and a CTA pill or chevron) for persistent slots anchored to the composer or keyboard. Fits a slot from 72 to 191 points tall. The .eloKeyboardBannerAd modifier requests it.

An ad carrying ctaText draws a CTA pill, in the strip’s trailing slot or on the card’s own CTA row; one without draws the disclosure chevron. The two formats then take different tap rules. The card is one tap target from edge to edge: in a transcript the card reads as a single object, so its pill labels the action rather than being a second button. The strip taps only on the pill when the creative sends a label, because the strip sits under the reader’s thumb beside the composer and an edge-to-edge target there invites accidental taps; without a label the whole row taps behind the chevron. Tracking stays on the container in both cases, so viewability is unaffected.

The strip badges the disclosure onto the creative mark’s top-trailing corner rather than leading the headline with it, falling back to the strip’s own corner for a creative with no mark; the card sets it as a line under the brand name — see EloAdDisclosure. Both marks are borderless rounded squares: 36pt on the card, 40pt on the strip. Creative artwork is usually a favicon or an apple-touch-icon, drawn for the app-icon silhouette, so a rounded square keeps the ends of a wide mark that a circular crop took off.

A CTA pill on either format can also carry an attention treatment the ad server selects: a decorative arrow after the label, a single pop of the button, or one shimmer across the pill. There is nothing to set in code, and the default is the static pill. Motion begins after one continuous second at 50% visibility and repeats every few seconds while the ad is on screen, cancels off screen, and is suppressed by Reduce Motion; the arrow widens the pill, while the pop and the shimmer are drawn and leave the layout and the tap target alone.

The impression follows the MRC standard: at least 50% of the ad visible for one continuous second. Visibility is geometric (what clips the ad, and the window; never alpha or isHidden), scaled by the share of the ad the window’s hit test still reaches, so a sheet or alert drawn over the ad suspends the measurement and its removal starts a fresh dwell. The dwell also requires the app to be frontmost. Three kinds of cover stay undetectable, as they are for any hit-test based check: a cover that declines touches itself (allowsHitTesting(false)), a SwiftUI sibling drawn over the ad inside the same view (a ZStack or .overlay scrim has no view of its own for the hit test to return), and a cover in a separate UIWindow.

EloAdView also reports taps inside an Elo-served ad to Elo analytics as an ad_tap event: the region under the finger (thumbnail, headline, description, CTA, chevron, or chrome), the tap position as a fraction of the view’s own size, the view’s size, whether the tap opened the destination, the time since the impression, and a per-showing tap index. Coordinates are relative to the ad view and never to the screen, at most fifty taps are reported per showing. It is analytics only: never billed, and separate from click tracking. Declare it as Product Interaction in your nutrition label; see Privacy & Consent.

The view sets no accessibilityHint: VoiceOver announces the element’s label and its button role, and the “Ad” disclosure carries that it is an ad. Call-to-action copy is backend-managed and arrives on the creative as EloAd.ctaText, so there is nothing publisher-supplied to pass here.


The ad disclosure. On the card it is a line of text under the brand name in the header row. On the strip it is a small badge hung on the creative mark’s top-trailing corner, or on the strip’s own top-trailing corner when a creative carries no mark. Passed as sponsoredLabel to every EloAdView initializer and to .eloKeyboardBannerAd.

public struct EloAdDisclosure: Sendable, Equatable, ExpressibleByStringLiteral {
public var text: String
public var font: Font?
public var color: Color?
public var accessibilityIdentifier: String?
public init(
_ text: String,
font: Font? = nil,
color: Color? = nil,
accessibilityIdentifier: String? = nil
)
public static let `default`: EloAdDisclosure // "Ad"
}
text
Disclosure copy, localized by you. Trimmed before rendering; blank drops the disclosure rather than drawing an empty line or capsule.
font
nil keeps the SDK’s type: 12pt regular under the brand name on the card, 8pt medium on the strip’s badge. Setting a font pins one across both.
color
The text color on the card. On the strip it fills the badge, and the copy is drawn over it in the strip background color. nil falls back to EloAdStyle.badgeColor, which restyles every surface at once, and then to the SDK’s secondary text color on the card and its own near-white disc with a hairline black ring and black copy on the strip. Those are the only fixed colors in the SDK, because the badge sits on creative artwork rather than on a surface the theme controls. Supplying a color takes the ring off and inverts the copy, so pick one with contrast of its own.
accessibilityIdentifier
Names the disclosure for UI tests. It is its own element, separate from the headline’s Text.

The SDK draws the disclosure itself, where nothing can truncate or displace it, which is why this is a value and not a view. On the strip the badge is overlaid on the creative rather than laid out beside the headline, so it costs the headline no width and adds no height to the row. It hangs off the mark’s corner rather than sitting flush inside it, and it is squared up from a capsule for short copy (the default is two characters), relaxing back into a capsule as the copy gets longer.

String literals convert implicitly, so sponsoredLabel: "Anzeige" still compiles. A String variable needs the wrap:

EloAdView(
result: adResult,
sponsoredLabel: EloAdDisclosure(NSLocalizedString("ad.sponsored", comment: ""))
)

Optional styling overrides applied via the eloAdStyle view modifier. Only non-nil fields are applied; everything else uses dark-mode-aware system defaults.

public struct EloAdStyle: Sendable, Equatable {
public var cardBackground: Color?
public var titleColor: Color?
public var descriptionColor: Color?
public var badgeColor: Color?
public var callToActionBackground: Color?
public var callToActionForeground: Color?
public var borderColor: Color?
public var borderWidth: CGFloat?
public var cornerRadius: CGFloat?
public var descriptionOverflow: EloAdDescriptionOverflow?
public var font: EloAdFont?
public var colorScheme: ColorScheme?
public var chrome: EloAdChrome?
public static let `default`: EloAdStyle
}
public enum EloAdFont: Sendable, Hashable {
case system(design: Font.Design = .default)
case custom(String)
}
public enum EloAdChrome: Sendable, Hashable, CaseIterable {
case standard
case bare
}
extension View {
public func eloAdStyle(_ style: EloAdStyle) -> some View
}
cardBackground
Card background color.
titleColor
Ad title text color.
descriptionColor
Ad description text color.
badgeColor
The disclosure text color on the card, the fill of the disclosure badge on the strip, and the color of the trailing chevron. On the strip the disclosure copy is drawn over the badge in the strip background color, and setting this takes the badge’s default ring off. An EloAdDisclosure.color overrides it for that one surface.
callToActionBackground
Fill of the CTA pill drawn for creatives that carry an EloAd.ctaText. Defaults to the accent color. Also forwarded to renderer-backed fills that draw their own CTA.
callToActionForeground
CTA pill text color. nil draws white on the default accent color, and black or white, whichever contrasts more, on a callToActionBackground you set.
borderColor
Card border color; requires borderWidth to render.
borderWidth
Card border width.
cornerRadius
Corner radius of the surface. Defaults to 14 on the card and 16 on the strip.
descriptionOverflow
How an over-long description behaves on the strip: .marquee (default) scrolls it on one line; .truncate tail-truncates. nil uses marquee. The card wraps its description to up to three lines and ends longer copy with an ellipsis, whatever this is.
font
Typeface for the headline, description, CTA label, and disclosure badge: .system(design:) or .custom(name) for a font registered with your app. The SDK keeps its own sizes and weights. nil uses the system font. An EloAdDisclosure.font still overrides the badge.
colorScheme
Forces .light or .dark colors on the ad and its loading skeleton. nil follows the SwiftUI environment.
chrome
.standard (default) draws the background, border, and rounded corners. .bare draws none of them, so the ad sits inside your own row; the card also drops all of its inner padding, and the strip keeps its padding for the tap inset. EloAdLoadingView follows it.

EloAdDescriptionOverflow is an enum with cases .marquee and .truncate; marquee makes a few passes, then settles into a tail-truncated line, and falls back to a static ellipsis under Reduce Motion. It applies to the strip only.


The shimmer skeleton EloAdView(messages:) and .eloKeyboardBannerAd show while a request is in flight. Use it directly when you run the request yourself and render the result with init(result:).

public struct EloAdLoadingView: View {
public init(
showCallToActionPlaceholder: Bool = false,
loadingAccessibilityLabel: String = "Loading sponsored content",
maxHeight: CGFloat? = nil
)
}
showCallToActionPlaceholder Default false
Whether the skeleton draws a CTA pill. Whether a fill draws one follows the creative, which is unknown while the request is in flight, so pass true only when you know the fill renders its own CTA.
loadingAccessibilityLabel Default "Loading sponsored content"
VoiceOver label for the skeleton.
maxHeight Default nil
The maxHeight of the request the skeleton stands in for. Below 192 points it draws the strip; otherwise, or when nil, the card. This is the same rule the server’s formats follow, so the fill lands at the skeleton’s size.

Lifecycle callbacks for an ad. Register with Elo.setDelegate(_:). The SDK holds the delegate weakly; all methods are dispatched on the main actor and have default no-op implementations, so you only implement what you care about.

@MainActor
public protocol EloAdDelegate: AnyObject {
func eloAdDidLoad(_ ad: EloAd)
func eloAdDidFailToLoad(error: EloError)
func eloAdDidTrackImpression(_ ad: EloAd)
func eloAdDidReceiveClick(_ ad: EloAd)
}

public enum EloError: Error, Sendable, LocalizedError {
case notConfigured
case networkError(URLError)
case serverError(statusCode: Int, message: String?)
case decodingError(String)
case timeout
case cancelled
public var errorCode: Int { get }
}

Input types for Elo.loadAd(messages:).

public enum MessageRole: String, Sendable, Hashable {
case user
case assistant
case summary
}
public struct ChatMessage: Sendable, Hashable {
public let role: MessageRole
public let content: String
public let id: String?
public let createdAt: Date?
public init(role: MessageRole, content: String)
public init(role: MessageRole, content: String, id: String?, createdAt: Date?)
}

The roles are named for AI-chat hosts, but human-to-human messengers use the same two values: map the device owner to .user and the other participant to .assistant. Only the split between “this user” and “the other side” matters for contextual targeting.

ChatMessage(
role: message.isFromLocalUser ? .user : .assistant,
content: message.text,
id: message.id,
createdAt: message.sentAt
)

id and createdAt are optional. Give each message the id and time your app already keeps for the turn: the SDK sends them with the message, so Elo knows when each turn happened and can connect a turn to a Search API call that sends the same id as X-Elo-Message-Id. Set them once, when the turn is created, and reuse them on every request. Without createdAt, Elo records the time it received the message, which is the time of the ad request that carried it. A .summary message should carry the id and createdAt of the turn it summarizes. An id longer than 256 Unicode code points or containing non-printable characters, and a createdAt before 2020 or more than a day ahead, are left out of the request with a warning. Neither affects the preload cache.

Use .summary to send a condensed description of the conversation in place of the full history — a typical pattern is one .summary message followed by the most recent .user / .assistant exchange. See Load an ad for examples.

If your app already has a message type, conform it to MessageRepresentable instead of mapping by hand:

public protocol MessageRepresentable: Sendable {
var role: MessageRole { get }
var content: String { get }
var id: String? { get }
var createdAt: Date? { get }
var message: ChatMessage { get }
}

Elo.loadAd and Elo.preloadAd both have generic overloads accepting [M] where M: MessageRepresentable. id and createdAt have default implementations that return nil, so an existing conformance keeps compiling.


public struct ContextObject: Codable, Sendable, Hashable {
public let type: String
public let description: String
public init(type: String, description: String)
}
ContextObject
Screen or page metadata attached to a loadAd request for richer targeting.

Elo.setDebugObserver(_:capturePayloads:) reports what the SDK does as a stream of EloDebugEvent values. The SDK keeps no history, totals, or snapshot of its own: an app that wants a recent-loads list or tracking totals records the events itself. With no observer attached, the SDK does no debug work and stores no load history, tracking history, or request payloads.

#if DEBUG
Elo.setDebugObserver { event in
print(event)
}
#endif

The observer can be called from any thread and is never called while the SDK holds an internal lock, so it is safe to call back into Elo from it. Unlike setDelegate(_:), the closure is held strongly; pass nil to clear it. The observer is a process-level setting, independent of configure and shutdown(), so set it once, for example at app startup. It is a debugging aid, not an analytics pipeline: use EloAdDelegate for ad lifecycle callbacks in production.

public enum EloDebugEvent: Sendable {
case configured(EloDebugConfiguration)
case shutdown
case requestPayloadCaptured(requestId: UUID, json: String)
case responsePayloadCaptured(requestId: UUID, json: String)
case serverRequestIdAssigned(requestId: UUID, serverRequestId: String)
case adOperation(EloDebugAdOperation)
case trackingAttempted(
operationId: UUID,
event: EloTrackingEvent,
serverRequestId: String?,
payloadJSON: String?,
at: Date
)
case trackingCompleted(
operationId: UUID,
event: EloTrackingEvent,
outcome: EloTrackingOutcome,
at: Date
)
}
public struct EloDebugConfiguration: Sendable, Equatable {
public let publisherId: String
public let adUnitId: String
public let requestTimeout: TimeInterval
public let validationIssues: [ValidationIssue]
public let timestamp: Date
}
public struct EloDebugAdOperation: Sendable, Equatable {
public let requestId: UUID?
public let timestamp: Date
public let messageCount: Int
public let contextCount: Int
public let latencyMs: Int
public let outcome: EloAdLoadOutcome
public let format: String?
public let maxHeight: Int?
public let maxWidth: Int?
public let ctaEnabled: Bool?
}
public enum EloAdLoadOutcome: Sendable, Equatable {
case loaded(eCpm: Double)
case noFill(NoFillReason)
case error(EloErrorCategory) // .network, .decoding, .internalState
}
public enum EloTrackingEvent: Sendable, Equatable, Hashable, CaseIterable {
case render
case impression
case click
case tap
case browserOpen
case browserClose
case viewable
}
public enum EloTrackingOutcome: Sendable, Equatable {
case delivered
case unobservable
case failed(EloTrackingFailureCategory) // .invalidURL, .network, .httpStatus(Int), .cancelled
}
.configured
configure completed. Every earlier event belongs to a previous configuration, so an observer should discard its history here. validationIssues is empty for a clean integration.
.shutdown
shutdown() ran.
.requestPayloadCaptured / .responsePayloadCaptured
The redacted request body and the decoded ad response, re-encoded for inspection. Sent only with capturePayloads: true. Correlate with EloDebugAdOperation.requestId. Cache hits, disabled sessions, and transport failures produce neither.
.serverRequestIdAssigned
The ad opportunity id the server assigned to a request. The ad server keys the request’s impression and click under the same id, which is also EloAd.id.
.adOperation
A loadAd or preloadAd finished. Carries message and context counts (never their content), the latency, the outcome, the format the ad renders as (strip or card), the maxHeight and maxWidth the request sent, and the ad’s ctaEnabled presentation value, nil when not sent or without a fill. requestId is nil when no server request was made (a cache hit, a disabled session, or a call before configure).
.trackingAttempted
A tracking operation started. Its result arrives later as .trackingCompleted with the same operationId.
.trackingCompleted
A tracking operation resolved with an EloTrackingOutcome.

EloTrackingEvent covers render, impression, and click, which EloAdView sends; .tap, every tap inside an Elo-served ad view, whether or not it opened the destination (see EloAdView); .browserOpen and .browserClose, the calls to Elo.trackBrowserOpened and Elo.trackBrowserClosed; and .viewable, sent once when an Elo-served ad first meets the viewability bar, with the ad view’s size and position in the window, the window size, the visible fraction, and the time since render. Tap, browser, and viewable events are analytics only and never billed; the impression is recorded separately.

EloTrackingOutcome.delivered means every required Elo tracker endpoint returned 2xx. .unobservable means tracking completed on the device but the SDK cannot observe server-side receipt; Elo-direct clicks resolve this way while client-side click delivery is disabled. .failed carries a privacy-safe category. Tracking events never include tracker URLs, request headers, or chat content.

payloadJSON on .trackingAttempted is the analytics body the operation posts. Only .tap, .browserOpen, .browserClose, and .viewable have one; render and impression are URL pings, and click posts nothing. It is nil unless the observer was registered with capturePayloads: true. It carries tap geometry and timing, the browser dwell time, or the ad view’s on-screen geometry, plus the ad opportunity id and the creative id, and never chat content.