by Intelliverse X

Screen ads & sponsorships

Every kiosk's attract loop — the screensaver the Reyeah firmware pulls from GET /apk/getAd — is sellable place-based advertising (DOOH) inventory. This guide is the operator playbook for both revenue levers, in the order you should turn them on:

  1. Direct-sold sponsorships (this page, first half) — local businesses near your venues and the brands stocked inside your machines. Highest revenue per slot, zero adtech take-rate, sellable today with one phone call.
  2. Programmatic DOOH (second half) — a supply-side platform (SSP) such as Vistar Media or Place Exchange auto-fills whatever the direct sellers didn't buy, at market CPMs.

Watch it run in the emulator: leave the kiosk idle and the ad loop starts — each creative is tagged direct-sold / programmatic / house, every completed showing fires a visible POST /apk/saveAdRecord proof-of-play, and the operator phone's Ads tab shows the money view.

How the loop is assembled

Every getAd poll rebuilds the machine's loop (default 8 slots × 15 s, tunable via KIOSKX_AD_LOOP_SLOTS / KIOSKX_AD_SLOT_SECONDS):

1. Direct-sold campaigns    ← active flights targeting this machine,
   (always first)             weighted by the slot share the sponsor bought
2. Operator screensavers    ← legacy ad-management videos (S3, admin-approved)
3. Programmatic SSP fill    ← unsold slots offered to the SSP adapter
4. House content            ← bundled fixture pads the tail; a kiosk
                              never boots ad-less

The device response keeps the exact Reyeah adList shape (id, url, head, type, startTime, endTime, …) so stock firmware plays the loop unmodified. Campaign entries additionally carry campaignId / source / mediaType, which stock firmware ignores but the emulator and console use.

Proof of play closes the loop: the firmware reports each completed showing via POST /apk/saveAdRecord (single record or batched records: [...]). Each report lands in the play ledger — one row per creative per machine per showing — which powers sponsor reports, the console's revenue split, and the SSP proof-of-play feed.


Lever 1 — direct-sold sponsorships

The product you're selling

A slot share in the attract loop of specific machines. With the default 8×15 s loop, one slot ≈ 12.5% of screen time; a weight: 2 campaign gets two showings per rotation. Two sponsor types convert best:

Sponsor Pitch Suggested price
Local business (pizzeria, gym, salon near the venue) "Your ad on a screen your customers walk past every day, one block from your door — $99/mo, we make the creative for you." $75–150 /machine/mo per slot
Stocked-brand co-op (brands already inside the machine) "Your product is in the machine — put it on the screen too. Co-op marketing funds usually cover this." $100–250 /machine/mo per slot, or CPM-equivalent billing

Pricing models supported per campaign:

Rule of thumb on value: a convenience-store kiosk showing a slot ~2,000 times/month at 0.7 impressions/play delivers ~1,400 impressions. At a $99 flat rate that's a ~$70 effective CPM for the sponsor's exact neighborhood audience — and 100% of it is yours, versus ~50–70% net after adtech fees programmatically.

Selling a slot, end to end

Everything below uses the sandbox operator credential (X-API-Key: vtm_e08725ea09876af65f431c6a3742d7c0, scopes ads:read ads:write).

1. Create the campaign (starts as draft):

curl -X POST https://api.kiosk-x.ai/api/v1/campaigns \
  -H "X-API-Key: $KEY" -H "Content-Type: application/json" -d '{
  "name": "Joe'\''s Pizza — 2 Slices $5",
  "sponsorName": "Joe'\''s Pizza (Main Street)",
  "sponsorType": "local-business",
  "targetMachines": ["866903013700022"],
  "weight": 2,
  "flightEnd": "2026-10-31T00:00:00.000+0000",
  "pricing": {"model": "flat-monthly", "monthlyRatePerMachine": 99.0}
}'

2. Get the creative from ContentX. One call turns a text brief into a generated, hosted 9:16 poster attached to the campaign:

curl -X POST https://api.kiosk-x.ai/api/v1/campaigns/{campaignId}/creative/contentx \
  -H "X-API-Key: $KEY" -H "Content-Type: application/json" -d '{
  "brief": "Bold vertical digital-signage poster for Joe'\''s Pizza: two pepperoni slices, 2 SLICES $5, warm lighting"
}'

This forwards the brief to the ContentX AI Studio (POST {CONTENTX_API_BASE}/ai-studio/text-to-image), waits for the generated asset, and attaches the hosted URL with creativeSource: contentx plus the brief reference in contentxRef. If ContentX's GPU fleet is down the upstream error is returned verbatim as a 502 — nothing is faked. You can also skip ContentX and PUT any hosted image/video URL into creative.url directly.

2b. Or upload the poster yourself. When the sponsor hands you a finished file (or ContentX is down), send it inline — this is what the Operator X console's "Upload poster" button calls:

curl -X POST https://api.kiosk-x.ai/api/v1/campaigns/{campaignId}/creative/upload \
  -H "X-API-Key: $KEY" -H "Content-Type: application/json" -d "{
  \"contentType\": \"image/png\",
  \"dataBase64\": \"$(base64 < poster.png | tr -d '\n')\"
}"

PNG / JPEG / WebP portrait posters (≥540x960, ≤10 MB) or an MP4 that fits the 15 s loop slot; anything else is refused with the reason (landscape, too small, wrong type). The file is hosted under kiosk-x/ads/sponsored/ in the media bucket and attached with source: upload. The flight, targets and pricing are untouched — a draft simply becomes activatable.

3. Activate the flight:

curl -X POST https://api.kiosk-x.ai/api/v1/campaigns/{campaignId}/activate -H "X-API-Key: $KEY"

Every targeted kiosk picks it up on its next getAd poll. …/pause and …/complete manage the rest of the lifecycle; drafts can be DELETEd.

4. Read the sponsor report (send this to the sponsor monthly):

curl https://api.kiosk-x.ai/api/v1/campaigns/{campaignId}/report -H "X-API-Key: $KEY"
{
  "plays": 412, "estImpressions": 288.4,
  "estRevenue": 66.0, "estMonthlyValue": 99.0,
  "playsByMachine": [{"machineNo": "866903013700022", "plays": 412}],
  "lastPlayAt": "2026-08-11T14:03:22.000+0000"
}

estRevenue for a flat-monthly flight is the contracted value × the share of the flight elapsed so far (20 of 30 days → 66%); it is $0 while the campaign is a draft or before flightStart, and stops growing at flightEnd. CPM flights earn by estimated impressions instead.

GET /api/v1/campaigns/reports/summary is the fleet-wide money view: every campaign's report plus the loop-wide direct / programmatic / house play split. The operator console's Ads page renders exactly this.

API reference (all under /api/v1, scopes ads:read / ads:write)

Endpoint What it does
POST /campaigns Create (draft)
GET /campaigns?status=&page=&size= List, newest first
GET /campaigns/{id} · PUT · DELETE Read / update / delete-draft
POST /campaigns/{id}/activate|pause|complete Lifecycle
POST /campaigns/{id}/creative/contentx Brief → ContentX asset → creative
POST /campaigns/{id}/creative/upload Inline PNG/JPEG/WebP/MP4 → hosted → creative
GET /campaigns/{id}/report Plays, impressions, revenue
GET /campaigns/reports/summary Fleet summary + source split
GET /dooh/proof-of-play?machineNo=&campaignId=&source=&since= The raw play ledger

Campaigns are operator-owned: your key only ever sees your flights, and targetMachines must be machines in your fleet.


Self-serve QR purchases (buyer-initiated direct sales)

Direct sales without the phone call: every kiosk gets a personalized "Advertise on this screen" QR code. A local business owner scans it and lands in a mobile chat that closes the whole deal — brief, creative, payment — and hands you a ready-to-approve campaign.

curl -o advertise-qr.png \
  "https://api.kiosk-x.ai/api/v1/machines/{machineNo}/advertise-qr?size=640" \
  -H "X-API-Key: $KEY"

ContentX-branded PNG (CX app-icon center, magenta rounded modules, high error correction — the ad studio behind the scan is ContentX-powered, so the code wears that brand; generator ported from ContentX) encoding https://api.kiosk-x.ai/advertise?machine={machineNo}&qr={qrId}, so the chat opens already targeted to that machine and venue. The qrId is stable per machine (safe to print) and drives scan-to-revenue attribution: it lands on the campaign record (sourceQrId, sourceMachineNo) and rides Stripe Checkout's client_reference_id + payment metadata, so revenue in the Stripe dashboard traces back to the physical QR that originated it. Stick it on the kiosk bezel or the venue counter — and the network prints its own: every machine's unsold house slot rotates an "Advertise here — scan to create your ad in minutes" poster (/advertise/poster/{machineNo}.png) carrying that machine's QR.

What the buyer goes through

  1. Chat brief (/advertise) — an AI concierge (our LiteLLM gateway; falls back to a scripted interview with tappable quick replies if the gateway is down) collects: business name, contact email, what they're advertising, banner or 15-second video, reach (see below), local-business vs stocked-brand co-op, flight length (1–6 months), slot tier (standard = 1 of 8 loop slots, double = 2), and creative direction.
  2. Reach — buyers advertising events rarely want one screen. The chat presents live counts and prices for: this kiosk · every kiosk in a ZIP · multiple ZIPs · a whole city · the entire USA network. Geo targeting is resolved to machines at flight time, so a new kiosk entering a targeted ZIP joins the flight automatically. Only machines whose operator has opted into the marketplace (marketplaceOptIn, default on for demo operators) are sellable this way.
  3. Creative — AI-generated via ContentX AI Studio (max 3 generations total, enforced server-side; upstream GPU outages surface honestly as 502 with attempts preserved): banners use text-to-image, videos use text-to-video — or image-to-video seeded with the AI banner the buyer already approved. Or uploaded: PNG/JPEG/WebP ≤10 MB, min 540×960, portrait for banners; MP4 ≤60 MB, duration ≤ the 15s loop slot for video (landscape plays letterboxed). Stored on S3 like other ad creatives.
  4. Payment — Stripe Checkout (hosted page; we never touch card data) for the server-computed quote (rates below × resolved machine count × months × slots, volume tier applied). Line items and the charged total always reflect the live reach count. The signed, idempotent webhook flips the purchase to pending approval and creates the draft campaign(s) with the creative attached and buyer contact recorded.
  5. Status page — the buyer gets a tokenized link (/advertise/status/{token}) showing pending → live → proof-of-play stats per screen (or rejection reasons), including the per-operator approval breakdown for wide-reach campaigns.

Self-serve pricing: volume tiers

Per-machine-per-month, per slot (consistent with the $75–150 local / $100–250 co-op guidance above; base rates env-tunable):

Reach (resolved kiosks) Tier Local business Stocked-brand co-op
1 kiosk full rate $99.00 $175.00
2–5 kiosks (ZIP-scale) 10% off $89.10 $157.50
6–20 kiosks (city-scale) 20% off $79.20 $140.00
21+ kiosks (network-scale) 30% off $69.30 $122.50

total = rate(sponsorType) × volume multiplier × slots × machines × months — computed only server-side (app/adflow.py::quote); the chat model cannot invent prices.

Your approval queue (partial approval for wide reach)

Nothing flights without the operator. A purchase that resolves to several operators' machines creates one draft campaign per operator; each of you approves or rejects for your own kiosks only (the admin key decides all still-pending entries). Approving activates your campaign immediately — the buyer's ad flights on the approved subset while others still review. Review at /advertise/approvals (paste your API key) or via:

Endpoint What
GET /api/v1/ad-purchases?status=pending_approval The queue (scopes ads:read)
POST /api/v1/ad-purchases/{id}/approve Activate for YOUR machines — kiosks pick it up on the next getAd poll (ads:write)
POST /api/v1/ad-purchases/{id}/reject {"reason": …} Reason is shown to the buyer (ads:write)

Refunds are manual: rejecting records the reason but does not move money. Refund from the Stripe dashboard → Payments → the purchase's payment intent (the id is on the purchase record as stripePaymentIntent). For a partially-rejected wide-reach purchase, refund the rejected share pro-rata (machines rejected ÷ machines purchased).

Geo targeting on the Campaigns API

Operator campaigns can target geography instead of machine lists: POST /api/v1/campaigns accepts targetGeo: {"zips": ["60614"], "city": "Chicago", "nationwide": true} (any subset) in place of targetMachines. Matching is at flight time against each machine's zip/city, scoped to your own fleet.

Config

Env Meaning
LITELLM_BASE_URL / LITELLM_API_KEY / LITELLM_MODEL Our LiteLLM gateway + the app-kioskx virtual key. No key → scripted interview.
KIOSKX_STRIPE_SECRET_KEY / KIOSKX_STRIPE_WEBHOOK_SECRET Stripe keys (test mode today; drop live keys in the kiosk-x-stripe secret to go live). No key → clearly-labelled simulated checkout (sandbox only).
KIOSKX_MEDIA_BUCKET S3 bucket for uploaded creatives (falls back to app-served media).
KIOSKX_AD_CREATIVE_MAX_ATTEMPTS AI generation cap per purchase (default 3, banner + video combined).
KIOSKX_AD_RATE_LOCAL / KIOSKX_AD_RATE_COOP Self-serve base rates (USD /machine/mo/slot).
KIOSKX_AD_VIDEO_MAX_MB / KIOSKX_AD_VIDEO_MAX_SECONDS Uploaded video caps (default 60 MB / 16s — 15s slot + mux slack).

Lever 2 — programmatic DOOH (SSP fill)

Direct sales won't fill every slot on every machine. A DOOH SSP (Vistar Media, Place Exchange) auctions the leftovers to programmatic buyers. Realistic economics per venue-quality screen: $7–22 net CPM at fill rates that start low (10–30%) and grow as buyers discover the inventory.

Monthly math per machine, with the default loop and a 0.7 impressions/play convenience-store estimate: ~2,900 loop rotations/day ≈ 360 plays/slot/day. If programmatic fills 3 unsold slots at 30% fill: 3 × 360 × 0.3 × 0.7 × 30 ≈ 6,800 impressions/mo → at a $12 net CPM ≈ $80/machine/mo, on top of direct sales. Airport/mall venues with higher impressions-per-play multiples scale proportionally.

What's already wired

Screen registration. Every machine carries a screen block — panel size and resolution (Reyeah RK3288 kiosks: 21.5″ 1080×1920 portrait), venue name, address, geo, operating hours, loop shape, impressions-per-play estimate, and an OpenOOH Venue Taxonomy category id (e.g. 202 retail.convenience_store, 20501 retail.mall.concourse, 10105 transit.airports.gates). The inventory export is what an SSP ingests at onboarding:

curl https://api.kiosk-x.ai/api/v1/dooh/inventory -H "X-API-Key: $KEY"

SSP adapter. app/ssp.py speaks the Vistar Media Ad Serving API shape: POST /api/v1/get_ad/json with network_id, api_key, device_id, venue attributes and display_area (size + supported media), responses carrying one advertisement per display area with a one-shot proof_of_play_url. It is called only for slots the direct-sold campaigns didn't take.

Proof-of-play feed. The same saveAdRecord ledger drives the SSP side: when the kiosk reports a programmatic creative's play, the PoP URL is fetched (one-shot, per Vistar semantics). Our own export at GET /api/v1/dooh/proof-of-play gives you the raw ledger for reconciliation.

Mock SSP. Without an SSP account the whole flow still runs end to end against the built-in mock (/ssp/mock/get_ad/json, /ssp/mock/pop/{leaseId}, receipts at /ssp/mock/receipts): realistic creative leases, one-shot PoP, and revenue attribution at a configurable assumed CPM. This is what the sandbox and emulator use.

Config flags

Env Default Meaning
KIOSKX_SSP_ENABLED true Offer unsold slots to the SSP at all
KIOSKX_SSP_PROVIDER mock mock or vistar
KIOSKX_VISTAR_API_URL Vistar sandbox URL Ad-serving endpoint
KIOSKX_VISTAR_NETWORK_ID / KIOSKX_VISTAR_API_KEY empty Issued by Vistar at onboarding
KIOSKX_SSP_MOCK_CPM 12.0 Assumed net CPM for mock revenue estimates
KIOSKX_SSP_MOCK_MAX_FILL 3 Mock's simulated fill (slots per loop)

Setting KIOSKX_SSP_PROVIDER=vistar plus the two credentials is the entire go-live switch — the request/response handling, PoP posting, and revenue attribution paths are identical to the mock.

Onboarding with a real SSP

  1. Vistar Media — apply as a supply partner (network application), submit your screens (the /dooh/inventory export maps 1:1 to their screen submission fields: venue id/name, geo, OpenOOH category, size, loop), pass their creative-rendering certification (image + video at your panel spec), then get network_id + api_key for the sandbox and later production.
  2. Place Exchange — OpenRTB-based; same inventory data, delivered as their screen onboarding sheet. Would need an OpenRTB bid-request adapter — the inventory model and PoP ledger here already carry every field it needs.
  3. Measurement uplift. Replace the seeded impressions_per_play estimates with venue-measured numbers (Placer.ai foot traffic, or the SSP's own audience measurement) — buyers pay materially better CPMs for measured screens.

Gaps that need the real world