Ad mediation engine
app/mediation/ is header bidding for kiosk screens: every unsold loop slot
is auctioned across all attached demand networks, the highest normalized eCPM
above the screen's floor wins, and house/ContentX content fills whatever is
left. Direct-sold sponsorship campaigns are guaranteed inventory — they
take their purchased slot share before any auction runs and cannot be outbid.
Real DOOH networks don't all allow the same participation (Vistar's policy: "We expect you to implement waterfall logic… We do not allow you to host a secondary auction"), so every adapter is attached in one of three participation modes.
Decision order per slot
- Direct-sold —
STORE.active_campaigns_for_machine(active, in-flight, targeting this machine) fills its weighted share of the loop rotation first. No fan-out happens for these slots. - Reservation (
mode="reservation", Blindspot-style booked plays) — walked sequentially in tier order before the auction. A booked play is a commitment, not a bid: if the network returns one, it runs, full stop. - Auction (
mode="auction") — every enabled auction adapter'srequest_bid(SlotContext)runs concurrently, each under its own hard timeout. Highest eCPM ≥ floor wins. - Waterfall tiers (
mode="waterfall_tier", Vistar-compliant) — never price-compared against auction bids. Walked in tier order only when the auction produced no clearing winner, and only then is the network even called — so a waterfall lease is always played, never leased-and-dropped (an unplayed lease damages the network's spend-rate metric). The first fill wins at its own price, floor notwithstanding (the floor gates whether we call, never whether we honor the response). - House — anything unsold always gets the house creative. A kiosk never shows an empty slot.
Adapter contract
class MyNetwork:
async def request_bid(self, ctx: SlotContext) -> Bid | None: ...
from app import mediation
mediation.default_engine().registry.attach(
"my_network", MyNetwork(),
enabled=True, timeout_seconds=0.25, respects_floor=True, priority=None,
mode="auction", # auction | waterfall_tier | reservation
tier=None, # walk order for waterfall/reservation modes
auto_approve=False, # False → creatives queue for operator approval
)
SlotContext carries screen id, machine, OpenOOH venue type + category id,
geo, resolution, slot duration, impression multiplier (est. impressions per
play), loop size, and the advertised floor. Registry changes (attach /
detach / enable) are safe at runtime: auctions snapshot the enabled set at
start, so an in-flight auction completes untouched and the next one sees the
new config.
The five price bases
Bid.price_model expresses the network's native pricing; the engine
normalizes everything to USD eCPM before comparing:
| Basis | Network shape | Normalization |
|---|---|---|
cpm |
OpenRTB seatbid.bid.price (Place Exchange) |
as-is |
per_spot_nanos |
Vistar media_cost: gross nano-dollars per spot |
nanos / 10⁹ / imps_per_spot × 1000 — Decimal-exact, no float drift on large values; the network-quoted Bid.impressions beats the screen multiplier |
per_play |
Blindspot booked per-play rate | price / ipp × 1000 |
contract_cpm |
Adomni-style negotiated fixed rate (no price in response) | as-is, flagged estimated=True for monthly reconciliation |
flat_hourly |
hourly rate for one loop slot | plays/hr = 3600 / (slot_sec × loop_slots), ecpm = price / (plays × ipp) × 1000 |
Real network adapters (behind flags, all off by default)
app/mediation/networks/ implements the four integrate-behind-flag networks
from the DOOH dossier. Each is unit-tested against documented example
payloads with injected transports — no live vendor calls anywhere in the
suite.
| Network | Mode | Price basis | PoP deadline | Flip on with |
|---|---|---|---|---|
| Vistar Media | waterfall_tier (tier 1) |
per_spot_nanos |
one-shot proof_of_play_url, 15 min |
KIOSKX_NET_VISTAR_ENABLED + KIOSKX_VISTAR_NETWORK_ID / KIOSKX_VISTAR_API_KEY / KIOSKX_VISTAR_API_URL |
| Place Exchange | auction (OpenRTB 2.6: imp.qty, imp.dt, dooh object) |
cpm |
burl on play, 1 h retry budget |
KIOSKX_NET_PX_ENABLED + KIOSKX_PX_ENDPOINT / KIOSKX_PX_ORG_ID / KIOSKX_PX_TOKEN |
| Blindspot | reservation (Pull API GET /api/sv/play/{deviceId}) |
per_play |
popUrl promptly, 15 min budget |
KIOSKX_NET_BLINDSPOT_ENABLED + KIOSKX_BLINDSPOT_BASE_URL / KIOSKX_BLINDSPOT_PER_PLAY_USD / KIOSKX_BLINDSPOT_DEVICE_MAP |
| Adomni | auction (VAST pull) |
contract_cpm |
<Impression> URL, 4 h, t=<epoch-ms> param |
KIOSKX_NET_ADOMNI_ENABLED + KIOSKX_ADOMNI_DEVICE_KEY / KIOSKX_ADOMNI_CONTRACT_CPM / KIOSKX_ADOMNI_API_URL |
The Vistar request payload lives in one place
(app.mediation.networks.vistar.build_get_ad_payload) and is shared with the
legacy single-SSP path in app/ssp.py — the two cannot drift.
Until credentials exist, KIOSKX_MEDIATION_MOCKS (default true) attaches
the built-in mock set at startup — mock_px (auction, CPM), mock_adomni
(auction, contract CPM, estimated), mock_vistar (waterfall tier 1,
nano-dollar spots) — whose mock:// PoP callbacks settle in-process, so
production exercises the full pipeline with zero external traffic.
Proof-of-play: deadlines, retries, expiry
report_play is one-shot per auction (Vistar semantics). External winners'
receipts land in a PoP dispatch queue (app/mediation/pop.py):
- the network deadline is recorded at play time (
deadlineAt = played_at + deadlineSeconds); - transient dispatch failures retry with backoff (1 s → 5 s → 30 s → 2 m → 10 m), pumped opportunistically by getAd polls and saveAdRecord reports;
- a play unreported by its deadline is marked
expired— distinctly, never settled — so reconciliation sees exactly what each network was never told about; - Adomni-style
supportsTimestampPoPs gett=<epoch-ms of the play>appended automatically.
The queue is in-memory (STORE architecture) but duck-typed — anything with
enqueue(receipt, played_at=) can replace it (MediationEngine(pop_queue=…))
for a DB-backed outbox later. Inspect it at GET /api/v1/mediation/pop.
Creative approval queue
Programmatic creatives are pending by default: the first time a
(source, creative URL) pair is seen from an adapter without
auto_approve=True, it enters the approval queue and the engine skips it
(audit status creative_pending) — in every mode, at any price. getAd can
never serve an unapproved programmatic creative.
Operator surface (ads:read / ads:write scopes):
GET /api/v1/mediation/creatives?status=pendingPOST /api/v1/mediation/creatives/{id}/approve/…/reject(rejects are permanent:creative_rejected)GET /api/v1/mediation/adapters— attached sources, modes, tiers.
Behaviors fixed by fiat
| Behavior | Rule |
|---|---|
| Missing impression multiplier | Falls back to KIOSKX_AD_IMPRESSIONS_PER_PLAY (default 0.7). A network-quoted Bid.impressions (Vistar) always wins over the screen multiplier. |
| Floor | Inclusive — ecpm == floor clears. Per-screen overrides (raise or lower) replace the global default. respects_floor=False registrations (preferred/PMP deals) bypass the open floor but still lose to higher clearing bids. Reservation and waterfall responses are exempt (committed plays). |
| Tie-break | Highest eCPM → lowest adapter priority number (default = registration order) → lexicographic source id. Deterministic, no randomness. Waterfall/reservation walk order: tier (default = priority) → priority → source id. |
| Invalid bids | Rejected, never clamped: price ≤ 0 (including 0/negative nano-dollars), normalized eCPM > KIOSKX_MEDIATION_MAX_ECPM (default $500), missing creative URL, creative longer than the slot. All logged, all recorded in the auction's audit trail. |
| eCPM spoofing | The engine always recomputes eCPM from the native price; an adapter-supplied ecpm is ignored. |
| Fault isolation | Adapter timeout / exception → no-bid for this auction (auction mode) or walk to the next tier (waterfall/reservation); the rest proceed. All adapters hanging → house within the max adapter timeout. |
| PoP | One-shot per auction; repeats / unknown / stale ids return None, never an error. Direct and house wins produce internal PoP only and never enter the dispatch queue. |
/apk/getAd wiring
Set KIOSKX_MEDIATION_ENABLED=true and getAd's programmatic stage (step 3,
after direct campaigns and operator ads) fills through the engine via
mediation.request_fill(machine, unsold_slots) instead of the single-SSP
adapter. Winning slots register mediation leases so the firmware's existing
/apk/saveAdRecord report settles PoP with the winning network through the
dispatch queue. Flag off (default) keeps the legacy Vistar-style single-SSP
path — behavior is byte-identical to before this module existed.
Standalone use (no getAd): await mediation.select_slot(screen_ctx) decides
one slot; build screen_ctx with mediation.slot_context_for(machine).
Config
| Env | Default | Meaning |
|---|---|---|
KIOSKX_MEDIATION_ENABLED |
false |
Wire the engine into /apk/getAd's programmatic stage. |
KIOSKX_MEDIATION_MOCKS |
true |
Attach the built-in mock demand set at startup. |
KIOSKX_MEDIATION_FLOOR_ECPM |
0.0 |
Global open-auction floor (USD eCPM). |
KIOSKX_MEDIATION_MAX_ECPM |
500.0 |
Reject-above cap for absurd bids. |
KIOSKX_NET_TIMEOUT_SECONDS |
3.0 |
Per-adapter timeout for real networks. |
KIOSKX_NET_{VISTAR,PX,BLINDSPOT,ADOMNI}_ENABLED |
false |
Real network flags (see table above for their credential vars). |
Verification
tests/test_mediation_matrix.py— the exhaustive scenario matrix (131 tests): zero/disabled/one/many adapters, all five price models, floors, ties, timeouts, crashes, direct-sold interplay, burst concurrency, participation modes (waterfall/reservation ordering and no-lease-and-drop), PoP deadlines/retries/expiry, the four real-network payload mappings, creative approval, runtime config lifecycle, and the getAd wiring.scripts/mediation_dry_run.py— a simulated 24h day for a 3-machine fleet stepping through adapter mixes (none → one → three → three + one flaky), printing fill breakdown and eCPM by source and asserting the matrix holds together end to end.