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:
- 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.
- 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:
flat-monthly—monthlyRatePerMachine× targeted machines. Simple, predictable, what local businesses expect. Reports still show plays and estimated impressions so the sponsor sees what they got.cpm—cpmRate× estimated impressions / 1000. Use for brand co-ops that want media-style billing. Impressions = plays × the machine'simpressions_per_playestimate (venue-dependent; see the DOOH section).
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.
Print the QR
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
- 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. - 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. - 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 usetext-to-video— orimage-to-videoseeded 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. - 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.
- 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
- Vistar Media — apply as a supply partner (network application), submit
your screens (the
/dooh/inventoryexport 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 getnetwork_id+api_keyfor the sandbox and later production. - 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.
- Measurement uplift. Replace the seeded
impressions_per_playestimates 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
- A live SSP account (credentials, screen approval, creative certification).
- Physical measurement: real dwell/traffic per venue to replace the impressions-per-play estimates.
- The Reyeah firmware plays whatever
getAdreturns, but its nativesaveAdRecordreporting cadence should be verified on hardware — the endpoint accepts both single and batched records to be safe.