Engineering docs
API Contract (v1) — backend and web are both built against THIS file
Last updated Oct 31, 2026 · 30 sections
Base URL: http://localhost:8000. JSON everywhere. Auth: Authorization: Bearer <jwt> for users,
Authorization: Device <token> for devices. Errors: {"detail": str} with proper status codes.
Dates are YYYY-MM-DD; timestamps are ISO-8601 UTC.
Auth & profile
POST /v1/auth/signup {email, password, business_name}→{token, user:{id,email,role}}(role=advertiser)POST /v1/auth/login {email, password}→{token, user}GET /v1/me→{id, email, role, profile:{business_name, phone?, gstin?}}PATCH /v1/me/profile {business_name?, phone?, gstin?}→ profile- Seeded ops user:
ops@dooh.local/ envOPS_PASSWORD(defaultopsdev123), role=ops.
Creatives (advertiser)
POST /v1/creativesmultipart:file,name→{id, name, kind, duration_s, status:"pending", validation:{...}}Validation (automatic, on upload): image jpg/png ≤10MB; video mp4 h264 ≤50MB ≤10s; portrait or square preferred (warn, don't block). Reject → 422 with reasons.GET /v1/creatives→ list.GET /v1/creatives/{id}/file→ the media (also used by devices).PATCH /v1/creatives/{id} {name}→ rename (owner only).POST /v1/creatives/{id}/filemultipartfile→ REPLACE the media: revalidates (same rules as upload), updates kind/sha256/duration, and RESETS status to "pending" — a replaced file never plays until ops re-approves. Existing schedule entries stay booked but are excluded from device schedules while the creative is not approved (they resume on re-approval).DELETE /v1/creatives/{id}→ 204; 409 if any booking references it.- Approval gate at serve time:
GET /v1/device/scheduleonly returns entries whose creative is currently approved (a re-pending creative's slots serve as idle until re-approved).
Screens & availability (advertiser browse)
GET /v1/screens→[{id, venue_name, zone_name, label, orientation, loop_slots, slot_seconds, rate_per_slot_day}]GET /v1/screens/{id}/availability?start=YYYY-MM-DD&end=YYYY-MM-DD→[{date, slots_total, slots_booked, slots_free}]
Bookings
POST /v1/bookings {campaign_name, creative_id, items:[{screen_id, slot_count, start_date, end_date}]}→{id, price_total, payment_status:"pending", status:"confirmed", items:[...]}409 if any screen-date lacks free slots. Confirm materializesschedule_entries(creative must be approved; if creative still pending → booking statusawaiting_creative, schedule materializes on approval).GET /v1/bookings→ list with per-item delivery counts.POST /v1/bookings/{id}/cancel(future dates only).
Metrics (advertiser)
GET /v1/metrics/campaigns/{booking_id}?from&to→{plays, ots, watchers, attention_ms_avg, attention_rate, age_bands:{"<18":n,"18-24":n,"25-34":n,"35-44":n,"45+":n}, gender:{male,female}, by_day:[{date, plays, ots, watchers}], by_screen:[{screen_id, label, plays, ots, watchers}], by_creative:[{creative_id, name, plays, watchers, attention_ms_avg}], by_hour:[{hour, watchers}]}GET /v1/metrics/campaigns/{booking_id}/export.csv→ ledger rows (one row per play with audience summary), then — after a blank line — aqr_scans_by_creativesection (creative_id,creative_name,qr_scanned). Scans attach to a creative, never to a single play row, so they live in their own section rather than as a fabricated per-row column; the ledger rows above are byte-identical to the plays table.GET /v1/metrics/live→[{screen_id, label, now_playing:{creative_name?, booking_id?}, watchers_now, updated_at}]GET /v1/metrics/overview?days=14→ portfolio at-a-glance across all the advertiser's campaigns:{window_days, plays, ots, watchers, attention_ms_avg, attention_rate|null, has_audience_data, screens_used, active_campaigns, by_day:[{date, plays, ots, watchers}]}GET /v1/metrics/screens/{screen_id}?from&to→ one screen, scoped to the advertiser's own plays:{screen:{id,label,zone_name,venue_name,orientation,loop_slots,slot_seconds,rate_per_slot_day}, device:{status,last_seen_at,watchers_now}|null, occupancy_today:{slots_sold,slots_total}, plays, ots, watchers, attention_ms_avg, attention_rate|null, has_audience_data, by_hour, campaigns:[{booking_id,campaign_name,plays,watchers}], recent_plays:[≤20]}- Null semantics:
attention_rateisnull(never 0.0) when ots=0 — no audience data is not the same as measured-zero attention. Clients must render "—/no audience data yet", not "0.0%". Booking rows now carry top-leveldelivered_plays(sum of items' plays_delivered). - Audience-metric semantics (v1.2): metric responses carry
plays_with_audience(plays whose audience summary is non-zero);attention_ms_avgaverages over those plays only (camera-off plays never dilute it).watchersare WATCH EVENTS — per-play counts; the same person watching N plays counts N times (no cross-play identification, by design). Face-only sensors (browser/ iPad player) reportots == watchers— they cannot see non-lookers and must not invent OTS; clients treatots == watchers > 0as "OTS not measured" and suppress attention_rate.
Ops (role=ops)
GET /v1/ops/creatives?status=pending·POST /v1/ops/creatives/{id}/approve {checklist, mall_signoff_ref?}(see Review checklist, 2026-10-24) ·.../reject {reason}GET /v1/ops/venues→[{id, name, city, zones:[{id, name}]}]PATCH /v1/ops/venues/{id} {name?, city?}·PATCH /v1/ops/zones/{id} {name}→ renamed object.DELETE /v1/ops/venues/{id}/DELETE /v1/ops/zones/{id}→ 204; 409 unless empty (no zones / no screens).POST /v1/ops/venues {name, city}·POST /v1/ops/zones {venue_id, name}·POST /v1/ops/screens {zone_id, label, orientation, loop_slots?, slot_seconds?, rate_per_slot_day}GET /v1/ops/screens→ registry incl.device:{id, status, last_seen_at}|nullPATCH /v1/ops/screens/{id} {label?, orientation?, loop_slots?, slot_seconds?, rate_per_slot_day?}→ updated screen; 409 if loop_slots would shrink below the busiest future day's booked slotsPOST /v1/ops/devices/claim {code, screen_id}→ binds pairing code to screenPOST /v1/ops/devices/{id}/revokeGET /v1/ops/fleet→[{screen_id, label, device_status, last_seen_at, last_batch_at, quarantined_batches}]GET /v1/ops/bookings(all) ·POST /v1/ops/bookings/{id}/mark_paid
Device API (Authorization: Device <token>, except pairing)
POST /v1/device/pairing/start {device_uid}→{code, poll_token}(code TTL 10 min)POST /v1/device/pairing/poll {device_uid, poll_token}→ 202 pending |{device_token, screen:{...}}GET /v1/device/schedule?date=YYYY-MM-DD→{screen:{id,label,loop_slots,slot_seconds}, entries:[{slot_index, creative:{id, kind, url, duration_s, sha256}}], generated_at}(device caches this + media files; url is/v1/creatives/{id}/file)POST /v1/device/events {batch_seq, device_sent_at, plays:[{client_play_id, creative_id, slot_index, started_at, duration_ms, audience:{ots, watchers, attention_ms_total, age_bands:{...}, gender:{...}}}]}→{state:"recorded"|"duplicate"|"rejected", reason?}— idempotent on (device, batch_seq). Clock-drift rule (v1.1):device_sent_at= the device's current clock at upload time. Reject the whole batch (reason="clock_drift", body stored for review) iff|device_sent_at − server_now| > 60sOR any play hasstarted_at > device_sent_at + 60s. Plays that are merely OLD are fine — late upload after offline periods (hours/days) is the normal contract (CLAUDE.md rule 5) and must be recorded, never quarantined for age.POST /v1/device/heartbeat {playing_creative_id?, watchers_now?}→ 204 (feeds /v1/metrics/live)
Conventions
- Pagination not needed in v1 (small N). All list endpoints return newest-first.
- Money: integer rupees. IDs: UUID strings.
Interop clarifications (binding, agreed during build)
- Pairing poll with expired/unknown code → HTTP 410 (device restarts pairing, shows new code).
pairing/startis idempotent: if the device already has an outstanding (unconsumed, unexpired) request — e.g. the player reloaded between the ops claim and its next poll — it returns the SAME code + poll_token (HTTP 200) instead of invalidating the claim. A fresh code is minted only when nothing usable is outstanding, so the on-screen code is stable until it truly expires.- Duplicate events batch → HTTP 200
{"state":"duplicate"}(never an error status). rejectedis terminal: server stores the batch body for review; devices never retry a rejected batch. A re-sent already-terminal seq answersduplicate.- One batch per play;
batch_seqstrictly increasing per device, assigned at queue time, survives device restarts. Uploads arrive in seq order but possibly long after the plays. duration_ms= slot occupancy (slot_seconds × 1000), not creative runtime.- Devices refetch schedule every 300s and on UTC date rollover; 401 anywhere → device drops its token and returns to pairing (queued batches preserved).
- Multi-item bookings: one booking row,
items[]children.attention_ms_avg= Σattention_ms_total / plays;attention_rate= watchers/ots.
Interactive (game) creatives — v1.3
A creative with kind:"game" is a playable ad. It has no single media file: it has a JSON
config (template + copy + rules + reward) and a set of image assets keyed by name.
It goes through the same approval gate as image/video and books slots like any creative.
Config shape (template catch, the only template in v1.3)
{ "template": "catch",
"mode": "basket" | "tap" | "camera", // basket = drag a basket (touch); tap = tap items; camera = face steers basket
"brand": "Fizz Cola", "colors": {"bg":"#160b0d","accent":"#d6202c"},
"headline": "Catch the fizz.", "slogan": "Every cap counts.",
"items": [ {"key":"item","points":1,"weight":7,"size":0.13}, // asset key "item_<key>"... see assets
{"key":"bonus","points":3,"weight":1,"size":0.13},
{"key":"decoy","points":-2,"weight":2,"size":0.11} ],
"round_s": 25, // one round of play
"slot_s": 30, // loop occupancy when this creative's slot comes up (20–120)
"reward": {"title":"10% off","code":"FIZZ10","url":"https://…","note":"Show at counter"} }
Asset keys: logo, hero, and item_<key> for every entry in items. All png/jpg ≤5MB.
Validation (422 on failure): mode ∈ set, 1–6 items, points −10..10, weight ≥1, round_s 10–60,
slot_s 20–120, reward.url must be http(s), strings ≤ 255.
Endpoints
POST /v1/creatives/game {name, config}→ creative (kind:"game", status:"pending",config,assets:{}) — 201.duration_s= config.slot_s.PUT /v1/creatives/{id}/game {name?, config}→ creative; RESETS status to "pending".POST /v1/creatives/{id}/assets/{key}multipartfile→ creative; upserts the asset, RESETS status to "pending". 422 on bad key/type/size.DELETE /v1/creatives/{id}/assets/{key}→ creative (status → pending).GET /v1/creatives/{id}/assets/{key}→ the image (user or device auth).GET /v1/creatives/{id}/game→{id, name, config, assets:{key: "/v1/creatives/{id}/assets/{key}"}}(user or device auth). This is what the player and the portal preview load.GET /v1/creatives/ creative dicts: game creatives carryconfigandassets(key→url);sha256is a hash over config + asset hashes (changes whenever anything changes).- Approval: a game creative may be approved only when
logo,heroand everyitem_<key>asset exist (409 otherwise).
Player contract
GET /v1/device/scheduleentries for game creatives:creative:{id, kind:"game", url:"/v1/creatives/{id}/game", duration_s: config.slot_s, sha256}. The device fetches the JSON and every asset with its device token and caches them like media.- A game slot occupies
slot_sseconds (notscreen.slot_seconds). If a round is in progress when the slot ends, the device may extend the slot until the round ends (≤ +60 s). The play'sduration_msis the ACTUAL occupancy. PlayIn.engagement(optional, game plays only):{sessions, completed, score_sum, best_score, qr_shown, starts_face, starts_tap}→ stored append-only inengagement_summaries.- Metrics: campaign/screen/overview responses add
engagement:{sessions, completed, completion_rate, avg_score, best_score, qr_shown, qr_scanned, starts_face, starts_tap}|null(null when no game plays).qr_scannedcounts anonymous opens of the reward claim page for the same creatives in range.starts_face/starts_tapsum how sessions began (camera vs touch). - Overview responses add
total_spend(integer rupees of non-cancelled bookings created in the window) andspend_by_day:[{date, spend}].
QR claim flow — v1.4
- The engine's reward QR points at the web claim page
/r/{creative_id}?s=<score>&n=<nick>(parent passescreativeIdin thegody:configembed message; without it the QR falls back to the self-contained demo claim view). POST /v1/qr/scans {creative_id, score?}→{ok:true}— 201. Anonymous (no auth): one append-only row per phone that opens the claim page. Counts only, no personal data. 404 unless the creative exists and is a game.GET /v1/qr/reward/{creative_id}→{creative_id, brand, reward:{title, code, url, note}}— anonymous. Served only for approved game creatives; 404 otherwise (the claim page then explains the offer isn't live).qr_scansis an append-only event log (same immutability discipline as the ledger).
Immediate push — v1.3
screens.schedule_version(int) is bumped whenever the screen's playable loop changes: schedule materialization, booking cancel, creative approve/reject/replace/config/asset change (for every screen that has schedule entries for that creative, today or later), screen PATCH.GET /v1/device/scheduleaddsschedule_version.POST /v1/device/heartbeatnow returns 200{"schedule_version": n}(was 204). Devices compare with the cached version and refetch at once when it differs; the refreshed loop is applied at the NEXT slot boundary (not the loop wrap). Clients must tolerate 204 from older servers.
Optional premium layers (v1.3.1)
Two optional asset keys accept mp4 ≤ 20MB (sniffed by ftyp): background (looping clip
behind the whole screen, played muted at reduced opacity) and hero_video (the product animated
on a pure black background; the engine screen-blends it so the black disappears). Never required
for approval; the engine falls back to the static hero when absent.
Spin template + wallet — v1.5
Template spin (game creative config)
{ "template":"spin", "brand", "colors":{bg,accent}, "headline", "slogan",
"symbols":[{"key":"hero","label":"Hero","weight":5}, …], // 4–8, images symbol_<key>
"wilds":[{"key":"maker","label":"The Maker","multiplier":10}, …], // 0–3, images wild_<key>; a wild matches any symbol
"spin_s":6, "spins_per_session":3, "slot_s":45, // spin 4–10 s; 1–5 spins; slot 20–120 s
"prizes":[ {"tier":"big", "title":"Free movie ticket", "odds_one_in":200, "daily_budget":20},
{"tier":"mid", "title":"₹100 off popcorn", "odds_one_in":15, "daily_budget":200},
{"tier":"small", "title":"10% off merch", "odds_one_in":1, "daily_budget":null} ], // 1–3 tiers; exactly one with odds 1 (everyone wins)
"reward":{...} } // legacy fallback (offline / preview) as in v1.3
Assets: logo, hero, symbol_<key> for every symbol, wild_<key> for every wild (all required for
approval); optional win_clip (mp4 ≤20MB, plays on a big win), background, hero_video.
Validation: tiers unique; odds_one_in ≥1; daily_budget ≥1 or null; symbol/wild keys ^[a-z0-9_]{1,24}$ unique.
Server-decided spins (device auth)
POST /v1/device/spins {creative_id, booking_id?, client_spin_id}→{spin_id, outcome:{tier:"big"|"mid"|"small", title, symbol_key, wild_key|null, multiplier|null}, token, claim_url:"/w/claim/{token}", expires_at}— idempotent on (device, client_spin_id). The server draws the tier: for tiers in order big→mid, win iffrandom < 1/odds_one_inAND today's wins for (booking or creative) <daily_budget; else the odds-1 tier. It mints awin_tokensrow (48 h expiry). The engine renders reels to match the outcome (promotional spins are outcome-first). Offline: the engine shows the small tier with the legacyrewardQR (/r/{creative_id}), no token.win_tokens: id, creative_id, booking_id?, device_id, spin_id, tier, title, created_at, expires_at, claimed_at?, coupon_id?. Insert-only except claimed_at/coupon_id set once.
Wallet (public, phone-based — NEW personal-data class, see CLAUDE.md rule 3a)
POST /v1/wallet/otp/start {phone}→ 204. Indian mobile (^\+91[6-9]\d{9}$), 6-digit code, 5 min TTL, max 5 sends/phone/hour, 5 attempts/code. Delivery viaservices/sms.py: send_otp(phone, code)— v1 provider is a log stub; with envWALLET_OTP_DEV=1the response is 200{"dev_code": "…"}.POST /v1/wallet/otp/verify {phone, code, consent:true, consent_version:"2026-09"}→{wallet_token, user:{id, phone_masked:"+91••••••1234", consent_at}}(JWT role=wallet, 90 days). Consent is required (422 without). Storeswallet_users{id, phone_hash (HMAC-SHA256, secret), phone_enc (Fernet), consent_at, consent_version, created_at, last_seen_at, erased_at?}.GET /v1/wallet/claim/{token}(public) →{brand, tier, title, expires_at, status:"open"|"claimed"|"expired"}.POST /v1/wallet/claim {token}(wallet auth) → coupon (201). Rules: 410 expired; 409 claimed by another user; same user re-claim returns the existing coupon (200); ONE coupon per (user, booking) per day → 409{detail, coupon}with the existing one. Coupon code"{BRAND4}-{XXXX}-{XXXX}".GET /v1/wallet/me→{phone_masked, coupons:[{id, brand, title, tier, code, status, campaign_name, issued_at, expires_at, redeemed_at}]}newest first. Coupons expire 30 days after issue.DELETE /v1/wallet/me→ 204: erases phone_hash/phone_enc (right to erasure), coupons stay anonymised (wallet_user_id kept, user row marked erased_at).coupons: id, wallet_user_id, win_token_id, creative_id, booking_id?, tier, title, code (unique), status issued|redeemed|expired, issued_at, expires_at, redeemed_at?, redeemed_by_user_id?.
Redemption (advertiser owning the creative, or ops)
POST /v1/redeem {code}→{ok:true, coupon:{brand, title, tier, status:"redeemed", redeemed_at}}; 409 already redeemed / expired; 404 unknown or not yours.
Metrics
engagementgainsspins, wins, claims, redemptions(spin creatives; 0 otherwise). Campaign CSV gains acoupons_by_tiersection (tier,wins,claims,redemptions). Phone numbers NEVER appear in any advertiser/ops response; ops getsGET /v1/ops/wallet/stats → {users, coupons, redeemed}only.
Embed protocol additions (engine ⇄ parent)
- iframe → parent
{type:"gody:spin", requestId}; parent → iframe{type:"gody:spin:result", requestId, outcome, claim_url|null}. The player answers by callingPOST /v1/device/spinswith its device token; the portal preview / ops play-test answers locally with a simulated outcome andclaim_url:null(engine falls back to the legacy reward QR).
Spin grid (v1.5.1)
config.reels (3–5, default 3) × config.rows (3–5, default 3). The winning line is the middle
row; on square grids both diagonals are also paylines (never used for a granted prize). A 5×5 grid
needs 6+ symbols to read well; the editor says so.
config.layout (v1.5.1): cabinet (default — one tall stack; centred 9:16 column with blurred art
side-fill on wide screens) | split (art left, reels right on wide screens) | backdrop (key art
full-bleed behind everything). /cabinet.html is a convenience entry that pins layout=cabinet.
config.bonus (v1.5.2, spin only): {enabled, reels 3–5 (default 5), rows 3–5 (default 4), title} or
null. When enabled the asset bonus_symbol is required for approval and a mid/big outcome is staged
in two acts (base reels land R BONUS symbols → the stage opens → the top reels spin for the prize).
Budget slot_s ≥ 90 for bonus ads.
Wheel template — v1.6
A prize wheel (reference: casino "wicked wheel" toppers). One tap spins; the wheel lands on the
server-decided tier (outcome-first, same POST /v1/device/spins; spinnable templates = spin, wheel).
{ "template":"wheel", "brand", "colors":{bg,accent}, "headline", "slogan",
"segments": 12, // 8–16 wheel segments
"spin_s": 7, // 5–12 s of spinning
"slot_s": 45,
"prizes":[ {"tier":"big","title":"…","odds_one_in":40,"daily_budget":20,"segments":1},
{"tier":"mid","title":"…","odds_one_in":6,"daily_budget":200,"segments":3},
{"tier":"small","title":"…","odds_one_in":1,"daily_budget":null,"segments":8} ],
"reward":{...} } // legacy fallback as in v1.3
prizes[].segments is VISUAL ONLY (how many wedges show that tier; must sum to segments); the
server draws by odds_one_in + daily_budget exactly as for spin. One spin per session.
Assets: logo, hero required; wheel_center (png, the hub), background, hero_video,
win_clip optional. Metrics/wallet/claims identical to spin (engagement.spins counts wheel spins).
Embed: GameFrame loads /wheel.html?embed=1 for template wheel; same gody:* protocol incl. gody:spin.
Spin bonus (v1.5.2 note): when bonus.reels/rows are omitted the engine defaults the top grid to
the base reel count × (rows+1), so a 3-reel base opens a 3-reel top and a 5-reel base a 5-reel top.
config.bonus.kind (v1.6.1, spin): "reels" (the Wall: bigger reels on top) | "wheel" (the prize
wheel on top, bonus.segments 8–16 wedges). bonus.reels/rows null → follow the base grid. Optional
keyed fx clips (mp4 on pure black, luma-keyed by the engine): fx_ring (loops behind the wheel),
fx_burst (trigger + grand win), fx_coins (wins). hero_video on spin is the "living poster"
(played as the stage art, not keyed).
v1.6.2 — wheel characters + reveal (additive)
wheel config gains optional symbols[] (0–8 {key, label}, keys ^[a-z0-9_]{1,24}$, unique) and
reveal ({key, label} or null). Once listed, symbol_<key> (one portrait per character) and
wild_<key> (the reveal portrait) are REQUIRED for approval, exactly like spin symbols/wilds; keys
not in the config are rejected with 422. Duplicate keys across symbols + reveal → 422. Wheel also
accepts the optional fx clips fx_ring / fx_burst / fx_coins (mp4). Engine (/wheel.html):
portraits cycle around the wedges; the reveal takes the grand wedge(s); hero_video plays as the
living poster on the stage above the wheel; on a grand the reveal pops on the stage, fx_burst +
fx_coins fire, then win_clip plays before the QR results. validation.checks.symbols = count.
Cricket wall template — v1.7 ("Hit for Six")
A floor-size portrait wall; phones are the controllers. Design: docs/CRICKET_DESIGN.md.
{ "template":"cricket", "brand", "colors":{bg,accent}, "headline", "slogan",
"packs": 20, // 12–30 sponsor packs on the wall
"balls": 6, // 3–10 packs each player picks
"bag": {"0":4,"1":5,"2":4,"4":3,"6":2,"out":2}, // sums to packs, out >= 1; reshuffled per innings
"prizes":[ {"tier":"big","title":"…","min_runs":24,"daily_budget":10},
{"tier":"mid","title":"…","min_runs":12,"daily_budget":300},
{"tier":"small","title":"…","min_runs":0,"daily_budget":null} ], // exactly one min_runs 0
"play_url": "", // display-only short URL under the QR
"turn_timeout_s": 25, // 10–120 s to tap Play once called
"slot_s": 300, // 60–600: the wall holds the slot (takeover)
"reward": {...} } // optional legacy fallback
Assets: logo, hero, pack required. Optional images board, scoreboard_logo, helmet_logo,
jersey_logo, bat_sticker, batter, stadium; optional mp4 clip_six, clip_four, clip_out,
clip_dot, clip_single, clip_double, clip_walkin. Engine: /cricket.html?embed=1.
clip_bowl is the shared delivery (run-up to release, 2–4 s): when present the wall plays it
before every outcome clip and hard-cuts to the outcome, so outcome clips should start at the
crease with the ball already in the air. Without it, outcome clips play alone.
Phone (public, per-session secret; CORS open):
GET /v1/play/{screen_id}→{screen:{id,label}, creative_id, config:{brand,headline,slogan,colors,packs,balls,prizes[{tier,title,min_runs}],turn_timeout_s}, assets:{key: "/v1/play/{screen}/assets/{key}"}, queue}. 404 when no approved cricket creative is booked on the screen today.GET /v1/play/{screen_id}/assets/{key}→ the active creative'slogo|hero|pack|board|batter|stadium(never clips).POST /v1/play/{screen_id}/join {name, avatar}→ 201{session_id, secret, …state}. 422 with a sentence when the name fails moderation; 429 when the queue is full.POST /v1/play/sessions/{id}/picks {secret, picks[]}→ state. Exactlyballsdistinct indexes in[0, packs); 409 once sealed.GET /v1/play/sessions/{id}?secret=→{id,status,name,avatar,position,eta_s,picks,has_picks,turn_timeout_s,ready_at}+ when done{balls,runs,balls_faced,out,rank,claim_url}.POST /v1/play/sessions/{id}/start {secret}→ ready → playing (409 otherwise).GET /v1/play/sessions/{id}/card.png?secret=→ 1080×1350 share card (409 until done).GET /v1/play/{screen_id}/board→{rows:[{id,name,avatar,runs,balls,out}], queue}(today, top 10).
status: queued | ready | playing | done | skipped | expired. Only players with sealed picks are
promoted to ready; ready without Play within turn_timeout_s → skipped; playing without a
wall report within 180 s → expired.
Wall (device token):
GET /v1/device/cricket/{creative_id}/poll→{queue:{now,next[],waiting}, turn:{session:{id,name,avatar}, balls:[{pack,value}]}|null, board[]}. Runs housekeeping (timeouts + promotion). The wall polls every 2 s and forwards each as an engine event; aturnis forwarded once per session id.POST /v1/device/cricket/sessions/{id}/done {runs, balls, out}→{id,status,runs,balls,out,rank,claim_url}. Idempotent. Runs are re-derived server-side from the sealed balls; a prize is minted through the spin ledger (spins.symbol_key = "cricket", awin_tokensrow) by the highestprizes[]band the runs reach with daily budget left.
Embed additions: gody:config carries screenId; host → engine gody:cricket:event {event} with
event.type ∈ queue | turn | board; engine → host gody:cricket:turn_done {sessionId, runs, balls, out}
plus the usual gody:round / gody:stats (innings, sixes, outs counters).
Tables: cricket_sessions (migration a1c7e9b2d4f6): display name + emoji only (rule 3).
v1.7.1 — the code + kiosk flow (replaces the phone picker)
The phone only receives a 4-digit code; the kiosk (a tablet beside the wall, /kiosk/{screen_id})
takes the code and the player picks one pack at a time. Config defaults change to packs: 10,
bag: {"0":2,"1":2,"2":2,"4":2,"6":1,"out":1} (packs now 6–30). New optional tagline (≤ 60) is
written onto the stadium's scoreboard in the footage. Migration b2d8f4a6c1e3 adds code,
kiosk_key_hash, bag_order, shown to cricket_sessions.
Phone: POST /v1/play/{screen_id}/join {name, avatar, phone?} → 201 {session_id, secret, code, status:"queued", expires_in_s, packs, balls_per_over, …}. phone is optional and never stored: when WHATSAPP_PROVIDER=aisensy
the code is also sent on WhatsApp through AiSensy (template freebee_cricket_code; a failed send is logged, never surfaced). GET /v1/play/sessions/{id}?secret=
returns code only while queued; while playing it mirrors balls, runs, balls_faced; when done adds
rank and claim_url. A ticket expires after 30 min unused (status: expired). picks and start are gone.
Kiosk (public; the code is the auth, then a per-innings key):
GET /v1/kiosk/{screen_id}→ same shape asGET /v1/play/{screen_id}(assetsnow includeball,ball_gold).POST /v1/kiosk/{screen_id}/claim {code}→{session_id, key, status:"playing", packs, balls_per_over, balls:[], wall_ready:true, …}. 404 unknown/used/expired code; 409"<name> is batting"while another innings is live (one innings per wall). Claiming seals the innings' bag (bag_order).POST /v1/kiosk/sessions/{id}/ball {key, pack}→ the innings plusball:{pack,value}. 409 if the wall has not finished showing the previous ball (wall_ready:false), if the pack is already open, or if the innings is over; 422 for an out-of-range pack. The innings closes itself atballspicks or the first out and mints the prize through the spin ledger (claim_url).GET /v1/kiosk/sessions/{id}?key=→ innings +shown+wall_ready. An innings idle for 90 s closes as it stands (or expires if no ball was played).
Wall (device token): GET /v1/device/cricket/{creative_id}/poll → turn now carries status and shown and
grows ball by ball; after the innings closes it keeps returning until the wall has shown every ball.
POST /v1/device/cricket/sessions/{id}/shown {shown} — the wall reports how many balls it has finished showing
(this is what unlocks the kiosk's next pick). …/done is kept as an idempotent "shown everything" mark.
Embed: the host forwards turn whenever (session, balls.length, status) changes; the engine plays only the
balls it has not shown and posts gody:cricket:shown {sessionId, shown} after each. Big moments take the whole wall;
in attract the wall shows the big QR ("scan for your code · enter it at the kiosk · pick a ball at a time").
v1.8 — the Freebee dice (2026-10-21)
CricketConfig.mode is "bag" (v1.7.1 behaviour, the default for old rows) or
"dice". In dice mode every ball is an independent roll of the six-faced
Freebee dice, server side, from odds (percent per value, must sum to 100,
out > 0; default 1 → 14, 2 → 14, 0 → 14, 4 → 20, out → 18, 6 → 20).
bonus_on_six (default true) adds one ball to the innings for every six,
up to max_balls (default 12, ≥ balls). packs and bag are ignored in
dice mode and not validated.
POST /v1/kiosk/sessions/{id}/ballbody{key}—packis optional and ignored in dice mode. Each ball carriesface(1 single, 2 double, 3 dot, 4 four, 5 out, 6 six) andpackis the ball index.- Kiosk and phone views carry
modeandlimit(balls this innings may face, growing by one per six). The wall poll'sturncarrieslimittoo, so the wall can draw the bonus slots. - The innings ends at
limitballs or the first out, as before. - The wall (
/cricket.html) in dice mode: the 3D dice tumbles in the stage and lands on the result — the bee umpire signals six, four and out; pips for 1, 2 and 3 with the sponsor's logo in the corner — then the delivery and outcome footage play and the over tracker fills; a gold +1 slot pops in on a six. The kiosk shows a single ROLL button instead of the packs.
v1.8 hardening (2026-10-21, after the end-to-end test)
-
Report:
GET /v1/metrics/campaigns/{booking}→engagement.cricketblock (scans,claimed,innings,expired,claim_rate,avg_innings_s,total_play_s,balls,avg_balls,avg_runs,best_runs,sixes,fours,dots,outs,not_out,bonus_balls,runs_distribution,prizes{big,mid,small},by_hour[]); null unless a ticket was issued in range.GET /v1/metrics/campaigns/{booking}/cricket.csv= one anonymous row per ticket (never a name, code or phone). Days and hours in all reports are now IST. Cricket prizes appear incoupons.csvlike spin prizes. -
Kiosk safety:
GET /v1/kiosk/{screen}tickets[]no longer carriescode(who is waiting, never their code).POST /v1/kiosk/{screen}/claimreturns 429 after 5 wrong codes on a screen within 2 minutes (60 s lock). -
Wall stall: a ball the wall has not reported
shownfor 20 s stops blocking the next ball (WALL_STALL_S); the kiosk is never held hostage. -
Idle innings (90 s without a roll) are closed with their prize minted.
-
Names: the block list is normalised like the input (doubled letters and leet cannot slip through); only an unambiguous subset matches inside a name, so Anusha, Gandhi, Nazia, Suleman, Dhanush pass; names in Telugu / Hindi / Urdu script are accepted; Cyrillic look-alikes are folded.
avatarmust be one of the twelve phone avatars. -
Bookings: a second cricket game on the same screen and overlapping dates is refused with 409 (the phone and kiosk can only serve one).
-
Wall reports
shown/doneaccept up to 30 balls (bonus balls). -
WhatsApp:
config.whatsappis true only withWHATSAPP_PROVIDER=aisensy; the phone page then stops asking for a number nothing would use. -
Phone, kiosk and campaign pages: offline auto-retry, prize ladder on the phone, dice copy, kiosk wake lock and keyboard entry, report defaults to the campaign's own dates.
-
Script:
backend/scripts/e2e_cricket.pyruns the whole API flow against a server (--base,--screen), exit 1 on any failure. -
Prizes are optional (v1.8):
CricketConfig.prizesis 0–3 bands; at most one band may need 0 runs. With no bands (or runs below every band) the innings completes withclaim_url: nulland the phone says thanks for playing. -
Ad break (v1.8): optional video asset
ad_break(a 10–15 s spot) plusCricketConfig.ad_break_every_s(15–600, default 45). While nobody is batting the wall plays it full screen every N seconds with the scan QR kept in the corner, muted like all wall footage. Cut off the moment a code is claimed. -
Page opens: the phone play page beacons
POST /v1/qr/scanson open;GET /v1/play/{screen}now returnsbooking_idfor that. The report's cricket block carriesopens(≥scans) so the funnel reads opened → got a code → entered the kiosk → finished → prize.
v1.9 — hourly windows: advertisers share a screen by the hour (2026-10-22)
-
BookingItemIngains optionalstart_time/end_time("HH:MM", local IST). Windows start and end on the hour (WINDOW_STEP_MIN, 60 by default, 30 allowed) inside the mall day (MALL_OPEN_HOUR=10 →MALL_CLOSE_HOUR=22). A cricket wall game MUST have a window (422 otherwise); loop ads ignore it. -
Clash rule: a cricket window is refused with 409 if it overlaps ANY cricket window already booked on that screen on ANY shared day (partial overlaps included; a legacy all-day cricket booking blocks the whole day). Two overlapping items in one request are refused with 422. A window that has already ended today is refused with 422.
-
Price of a windowed item = rate × slots × days × (window hours ÷ mall hours), rounded, minimum ₹1. Items carry
start_time/end_timein booking JSON. -
GET /v1/screens/{id}/cricket-hours?start&end→{open_min, close_min, step_min, steps[{start_min,end_min,label,free,taken_by}], taken[]}: a step is free only if free on every day of the range. -
Resolution by the clock:
/v1/play/{screen}, join, kiosk and board pick the cricket game whose window contains the local time now; a whole-mall-day window counts as all day. Outside every window the 404 says when the next game starts. Grace: the kiosk home and claims keep working 5 minutes after a window ends (a live innings finishes; a code typed a minute late works); images stay served for 3 hours. A code issued for an earlier game is refused at the kiosk with 410 and expired. -
GET /v1/device/scheduleentries carrywindow {start_min,end_min}or null; the player treats a slot outside its window as idle, so the wall switches games on the hour without a schedule push. -
Migration
c3e5a7b9d1f2addsbooking_items.start_min/end_min. -
Per-screen hours (ops):
Screen.open_min/close_min/window_step_min(migrationd5a7c9e1f3b4).POST/PATCH /v1/ops/screens[/{id}]takeopen_time,close_time("HH:MM") andwindow_step_min(30 or 60); 422 if closing ≤ opening or times are off the step; 409 if the new hours no longer contain a window a future booking already holds. PublicGET /v1/screenscarriesopen_time,close_time,window_step_min. -
Windows for every ad type: any
BookingItemInmay carry a window; loop ads then play only inside it (device schedulewindow) and pay for that share of the screen's day. Only cricket windows are exclusive. -
GET /v1/screens/{id}/hours?start&endis the generic grid (cricket-hoursremains as an alias). -
Ops metrics (v1.9):
GET /v1/ops/metrics/network?from&to→ totals + one row per screen (plays, OTS, watchers, attention, bookings, revenue booked, advertisers, cricket scans/innings, device, occupancy today).GET /v1/ops/metrics/screens/{id}?from&to= the screen detail across every advertiser plusby_advertiser[],bookings_in_range,revenue_bookedand thecricketfunnel. Ops may read any campaign'sGET /v1/metrics/campaigns/{booking}(the advertiser's own report).GET /v1/ops/bookingsrows carryadvertiser_email,advertiser_id,phase. -
Preview asset (v1.9): optional
previewkey on every game creative — an mp4 recording or a png/jpg poster of the creative on the wall. Uploaded by the advertiser (POST /v1/creatives/{id}/assets/preview) or by ops (POST /v1/ops/creatives/{id}/assets/preview). Unlike every other asset it does NOT reset approval; it only feeds the booking picker's preview tile. -
Footage library (v1.9):
platform_assets(migratione6b8d0f2a4c6). Ops:GET /v1/ops/library(every platform key with label, hint, video flag, url, size, updated_at),POST /v1/ops/library/{key}(mp4 for clips, png/jpg for stills),DELETE /v1/ops/library/{key}. PublicGET /v1/library/{key}serves the file. Platform keys (CRICKET_PLATFORM_KEYS): the ten stadium clips plus stadium, batter, ball, ball_gold, trophy. A cricket creative'sassets(bundle, creative dict, kiosk/phone assets) include every platform key it does not carry itself, pointing at the library; the sponsor's own upload of a key wins. Advertisers upload only brand keys (CRICKET_BRAND_KEYS): logo, hero, pack, board, scoreboard_logo, helmet_logo, jersey_logo, bat_sticker, ad_break, preview — the editor no longer offers footage uploads. -
Footage at three levels (v1.9): a cricket wall resolves each platform key as SCREEN override → the creative's own file → the library. Ops endpoints:
POST/DELETE /v1/ops/creatives/{id}/assets/{key}(any key; never touches approval),GET /v1/ops/screens/{id}/assets(each key with itssource),POST/DELETE /v1/ops/screens/{id}/assets/{key}(migrationf7c9e1a3b5d7,screen_assets). PublicGET /v1/screens/{id}/assets/{key}. The wall'sGET /v1/creatives/{id}/gameresolves for the calling device's screen; the portal preview (a user) resolves without a screen. The device schedule'ssha256for a cricket creative is a digest of the creative, the library and the screen's overrides, so walls refetch when any footage changes. -
Library per template (v1.9):
PLATFORM_KEYS_BY_TEMPLATE— cricket: the stadium set; spin and wheel:fx_ring,fx_burst,fx_coins./v1/ops/libraryitems carrygroupandtemplates. Spin and wheel creatives inherit the effect clips the same way cricket inherits the stadium (screen > creative > library); their editors no longer offer effect uploads. Symbols, wilds, bonus, win film, background and living poster stay the brand's.
v1.8 — WhatsApp delivery through AiSensy (2026-10-24)
Every message to a shopper's phone goes through one module, app/services/aisensy.py
(AiSensy campaign API; one POST per message). WHATSAPP_PROVIDER = log (default,
delivers nothing) | aisensy. With aisensy, AISENSY_API_KEY is required and the
backend refuses to boot without it in production.
- Wallet OTP
POST /v1/wallet/otp/start→ 204 after AiSensy accepts the authentication templatefreebee_otp({{1}} = code, copy-code button = code). If AiSensy refuses or is unreachable → 502{"detail": "We could not send the code right now. Try again in a minute."}and nothing is minted: the attempt does not count against the 5-per-hour allowance.WALLET_OTP_DEV=1still returns{dev_code}with 200 and must be unset at launch. - Cricket join code: template
freebee_cricket_code({{1}} code, {{2}} brand, {{3}} screen label); failures are logged and swallowed because the phone page shows the code anyway. - Env:
WHATSAPP_PROVIDER,AISENSY_API_KEY,AISENSY_OTP_CAMPAIGN,AISENSY_CRICKET_CAMPAIGN(campaign = the template name approved in AiSensy),AISENSY_API_URL,AISENSY_TIMEOUT_SECONDS,AISENSY_OTP_COPY_CODE_BUTTON. - Privacy unchanged: phone leaves the server only in the AiSensy request body;
logs carry
+91••••••1234; the code is never logged and never in an error.
v1.8 — AI second opinion on wall names (2026-10-24)
POST /v1/play/{screen_id}/join and the kiosk name entry run the display name
through the block list as before; when MODERATION_PROVIDER=anthropic (needs
ANTHROPIC_API_KEY) a name that passes the list is also shown to Claude Haiku
with one question (fine on a mall screen seen by families, in any Indian
language or script?). BLOCK → the same 422 as the list, reason "That name can't
go on the big screen. Please use your real name, without foul language."
Timeouts (1.5 s) and errors fail open and log one warning; decisions are cached
in memory per name. Only the name is sent, nothing else.
Billing — invoices, Razorpay behind env, void + credit (2026-10-24)
An invoice is issued the moment a booking becomes confirmed (creative already
approved at booking time, or ops approving the creative of an awaiting_creative
booking). Numbers are FB-<year>-<6 digits>, sequential per year, never reused
(a voided invoice keeps its number). Money fields are integer paise; GST is
added on top at gst_rate_bp (default 1800 = 18%), rounded half-up to the paise.
Bookings keep price_total in integer rupees as before; subtotal_paise = price_total × 100.
Provider: PAYMENTS_PROVIDER = manual (default) | razorpay. With manual
nothing online exists: /pay and /confirm answer 409, the web hides "Pay now"
and says "Pay by bank transfer; details are on the invoice", ops record payments
with mark_paid. With razorpay the three RAZORPAY_* env vars are required
(the backend refuses to boot in production without them).
Advertiser (ops may read every invoice)
GET /v1/billing/config→{provider, online_payments, key_id|null, bank_details:[str], due_days}. Never the secret.GET /v1/invoices→ newest first:[{id, number, kind:"invoice"|"credit_note", credit_for_invoice_id, booking_id, campaign_name, creative_name, issued_at, due_at, status:"draft"|"issued"|"paid"|"void", subtotal_paise, gst_rate_bp, gst_paise, total_paise, currency:"INR", paid_at, payment_method: "razorpay"|"bank"|"cash"|"other"|null, razorpay_order_id, notes}]. Credit notes carry negative amounts.GET /v1/invoices/{id}→ the row above plusseller:{name, gstin, address, state_code, bank_details},buyer:{name, gstin, address_line1, address_line2, city, pincode, state_code},items:[{screen_label, venue_name, slot_count, days, start_date, end_date, window, amount_paise}],credit_for_number,gst_split:"cgst_sgst"|"igst"(same state code on both sides → CGST+SGST),provider. 404 for another advertiser's invoice.POST /v1/invoices/{id}/pay→{order_id, amount, currency, key_id}for Razorpay Checkout (amount in paise; a second call reuses the same order). 409 when the provider is manual or the invoice is notissued; 502 when Razorpay cannot be reached.POST /v1/invoices/{id}/confirm {order_id, payment_id, signature}→ verifies HMAC-SHA256(order_id|payment_id, key secret) and marks the invoice (and its booking) paid with methodrazorpay. 400 bad signature or order mismatch; 409 already paid by a different payment. Re-confirming the same payment id is a 200 no-op.POST /v1/webhooks/razorpay(no user auth; headerX-Razorpay-Signature= HMAC-SHA256 of the raw body withRAZORPAY_WEBHOOK_SECRET). Handlespayment.captured: finds the invoice byorder_id, checks the amount, marks paid. Idempotent: the same payment twice →{"status":"duplicate"}, another payment on a paid invoice →already_paid, nothing changes either way. Other events →ignored. 400 bad signature, 503 when no webhook secret is configured.
Ops
POST /v1/ops/bookings/{id}/mark_paid {method?: "razorpay"|"bank"|"cash"|"other", notes?}(body optional, defaultbank) → booking dict. Marks the booking's invoice paid with that method (issues one first if the booking somehow has none). 409 if already paid or cancelled.POST /v1/ops/invoices/{id}/void {reason}→ voids an unpaid invoice (409 if paid or already void). The number is kept.POST /v1/ops/invoices/{id}/credit {amount_paise, reason}→ 201 credit note row (kind: "credit_note", negative subtotal/gst/total,credit_for_invoice_id, its own sequential number).amount_paiseis the amount before GST; GST is credited at the invoice's rate. 409 if the credit would exceed what is left on the invoice or the invoice is void. No money moves: Razorpay refunds are done by hand in the Razorpay dashboard for now.
Booking dict addition
GET /v1/bookings,POST /v1/bookings,GET /v1/ops/bookings, mark_paid now includeinvoice: {id, number, status, total_paise, due_at, payment_method} | null(null until the booking is confirmed).
Profile (billing details)
GET /v1/meprofile andPATCH /v1/me/profilegainaddress_line1, address_line2, city, pincode, state_code(two-digit GST state code,^\d{2}$, 422 otherwise). GSTIN is upper-cased. An empty string clears an optional field. Nothing beyond what a GST invoice needs is collected.
Env
PAYMENTS_PROVIDER, RAZORPAY_KEY_ID, RAZORPAY_KEY_SECRET, RAZORPAY_WEBHOOK_SECRET,
INVOICE_DUE_DAYS (7), INVOICE_GST_RATE_BP (1800), BILLING_SELLER_NAME,
BILLING_SELLER_GSTIN, BILLING_SELLER_ADDRESS, BILLING_SELLER_STATE_CODE,
BILLING_BANK_DETAILS (details separated by |, printed on unpaid invoices).
Migration b1ll1n6a0001 (billing_invoices.py): tables invoices, invoice_sequences;
five nullable columns on advertiser_profiles.
Review checklist (mall sign-off) — 2026-10-24
POST /v1/ops/creatives/{id}/approvenow REQUIRES a body:{checklist:{likeness_rights:true, prize_terms_clear:true, mall_rules_fit:true}, mall_signoff_ref?: str≤200}. Any tick missing or false → 422{"detail": "Tick all three checks before approving: …"}(plain sentence, not a pydantic error list). The 409s (already rejected, missing game assets) are unchanged and are checked after the ticks.- On success the ticks, the trimmed reference (
nullwhen blank) andchecked_atare stored oncreatives.review_checklist(JSON, nullable; migrationa9d2f4c6e8b1, filelegal_review_checklist.py) and returned asreview_checkliston every ops creative response (GET /v1/ops/creatives, approve, reject). Advertiser responses (GET /v1/creatives) never carry it. - Reject needs no checklist. Re-approval after a replace / config change asks for the ticks again and overwrites the stored checklist.
- Wallet consent: the web now sends
consent_version:"2026-10"with the reworded consent text (web/lib/wallet.ts); the server stores whatever version the client sends, as before.
Accounts — password reset, messages, team roles (2026-10-24)
Migration a9b1c3d5e7f9 (accounts_reset_team_notifications.py): users.business_id
(backfilled to the user's own id) + users.team_role (owner | editor | viewer, default
owner); new tables password_resets, team_invites, notifications.
Email provider (app/services/email.py, mirrors aisensy.py): EMAIL_PROVIDER = log
(default; logs subject + masked recipient p••••@brite.in, never the body) | resend
(RESEND_API_KEY, EMAIL_FROM). WEB_URL is the base of every link in an email.
EMAIL_DEV_ECHO=1 (dev only, with log; production refuses to boot) makes the two
link-minting endpoints below return the link in JSON.
POST /v1/auth/forgot {email}→ 204 always (never says whether the email exists). Mints a single-use token (SHA-256 stored, 30 min,PASSWORD_RESET_TTL_MINUTES) and emails "Reset your Freebee password" with${WEB_URL}/reset?token=…. With dev echo: 200{reset_url}(nullfor unknown emails).POST /v1/auth/reset {token, password}→ 204; sets the password and burns every outstanding token for that user. Invalid, used or expired → 410"This reset link has expired or was already used. Request a new one."GET /v1/meand the auth responses'usernow carryteam_roleandbusiness_id(null for ops).
Notifications (one row per member of the business; the same text is emailed to each
member when EMAIL_PROVIDER is not log; email failures are logged and swallowed):
- Emitted on: creative approved ("Your ad Diwali Sale is approved and will play from
2 Nov."), creative rejected (with the reason), booking confirmed (at booking time when
the ad is already approved, or when approval confirms an awaiting booking), booking
marked paid (once), campaign starts today, campaign ended yesterday + report ready.
kind∈ creative_approved | creative_rejected | booking_confirmed | booking_paid | campaign_started | campaign_ended.hrefis a portal path (/creatives,/campaigns/{booking_id}). GET /v1/notifications?unread=0|1&limit=100→{unread, items[{id, kind, title, body, href, created_at, read_at}]}newest first.POST /v1/notifications/{id}/read→ the row (404 if not yours).POST /v1/notifications/read-all→{marked, unread: 0}.POST /v1/ops/notifications/run-daily(ops) →{date, started, ended}. Idempotent per booking per user; there is no in-process scheduler, so call it once a day (IST date).
Team roles (thin slice). A business is keyed by its first user's id; creatives,
campaigns, bookings, reports and overview metrics are scoped by User.business_key
(business_id or own id), so every teammate sees the same account. Viewers get 403
"Your role on this team is view only…" on every write to creatives and bookings
(upload, game create/update, assets, rename, replace, delete, book, cancel).
GET /v1/team(advertisers) →{business_id, business_name, my_role, roles[{role, explains}], members[{id, email, role, joined_at}], invites[{id, email, role, created_at, expires_at}]}(pending, unexpired invites only).POST /v1/team/invites {email, role: owner|editor|viewer}(owners only, else 403) → 201 invite +emailed(+invite_urlwith dev echo). Emails "<Business> invited you to Freebee" with${WEB_URL}/signup?invite=<token>(7 days,TEAM_INVITE_TTL_DAYS). 409 if the address is already on the team or has an account with another business; a re-invite replaces the earlier link.DELETE /v1/team/invites/{id}(owners) → 204.POST /v1/auth/invite/lookup {token}(public; a POST so the token never sits in an access-log path) →{email, role, business_name}or 410.POST /v1/auth/signupacceptsinvite_token(thenbusiness_nameis optional and ignored): the new user joins the inviter's business with the invited role; 403 if the email differs from the invited one, 410 if the invite is used or expired. Without an invite,business_nameis required (422).
Prize fulfilment + upload rules (2026-10-24)
Prize tiers: stock and coupon life (additive, every game template)
SpinPrize / WheelPrize / CricketPrize gain stock_total (int ≥1 | null, default null =
unlimited) and expires_days (1–365 | null, default null = COUPON_TTL_DAYS, 30).
- Stock:
issued= win tokens ever minted at that tier for the booking (or the unbooked creative), the same bucket the daily cap uses. Onceissued >= stock_totalthe draw skips the tier and falls through to the next one down (spin/wheel: big → mid → everyone-wins; cricket: down the run bands), exactly like a spent daily cap. - Expiry: a coupon of that tier expires
expires_daysafter it is claimed. GET /v1/metrics/campaigns/{booking}gains top-levelprize_stock: [{tier, title, stock_total, issued, stock_left}](all-time for the booking, not range-filtered;[]for non-game creatives;stock_leftnull when unlimited).
Per-phone fraud limits (claim time, keyed on the phone HASH)
Settings PRIZE_MAX_PER_PHONE_PER_CAMPAIGN_DAY (2) and PRIZE_MAX_PER_PHONE_PER_DAY (5). A
"prize" here is a coupon above its game's everyone-wins tier; everyone-wins coupons never count
and are never limited. The count joins coupons → wallet_users.phone_hash (UTC day), so a
re-registered phone is the same shopper. When either limit is reached, POST /v1/wallet/claim
still returns 201 but the coupon is minted at the everyone-wins tier with
limited: true, message: "You have claimed today's prizes, come back tomorrow",
original_tier kept on the row. Every coupon dict (claim, /wallet/me, 409 payload) now carries
limited (bool) and message (string | null). The older one-coupon-per-(user, campaign)-per-day
409 rule is unchanged and applies first; the per-campaign limit therefore matters once that rule is
relaxed. Double redemption of a code was already impossible (POST /v1/redeem → 409
"already redeemed"); tests/test_prizes_uploads.py::test_same_code_cannot_be_redeemed_twice proves it.
Expiry reminders
coupons gains reminded_at, limited, original_tier (migration prizes_coupon_fulfilment).
remind_expiring_coupons() (app/services/prizes.py) selects coupons with status issued,
reminded_at null and now < expires_at <= now + COUPON_REMINDER_WINDOW_HOURS (24), stamps
reminded_at and, with WHATSAPP_PROVIDER=aisensy, sends template
AISENSY_REMINDER_CAMPAIGN = freebee_coupon_reminder ({{1}} prize title, {{2}} brand,
{{3}} expiry date DD Mon YYYY); with log it logs the masked phone only. Erased wallets are
stamped and skipped. Codes are never logged.
POST /v1/ops/coupons/run-reminders(ops) →{reminded, sent, skipped, failed}; idempotent per coupon, safe to call from a daily scheduler.
Ops fulfilment
GET /v1/ops/wallet/fulfilment?days=7(ops, 1–90) →{days, issued, redeemed, expiring_soon, blocked_by_limit, campaigns:[{booking_id, creative_id, campaign, brand, tier, title, issued, redeemed, stock_total, stock_left}]}. Counts only: never a phone, never a code.
Upload rules: conversion and edits
POST /v1/creatives(andPOST /v1/creatives/{id}/file) accept.mov .webm .mkv .avi .m4vwhen ffmpeg/ffprobe are on the host (app/services/transcode.py; Railway: seedocs/DEPLOY.md). The upload is probed; anything that is not already H.264 yuv420p + AAC in an mp4 is re-encoded to H.264/AAC mp4, yuv420p, faststart, 30 fps, scaled to fit 1080×1920 (portrait, default) or 1920×1080 (sources wider than tall), padded black, cut atVIDEO_MAX_DURATION_S. The original is removed; the stored file is the mp4. Size limits are checked on the original as before.- Creative dict gains
converted: boolandeditable: bool(images always; videos only with ffmpeg).validation.conversion = {from:{filename, ext, codec, container, width, height, rotation, duration_s}, to:{ext, codec, audio, canvas, mode}, capped_to_s}when converted. - Without ffmpeg the foreign containers are refused: 422
{"reasons": ["unsupported file type '.mov': Our server cannot convert this format yet; export as H.264 MP4"]}. mp4 behaves exactly as before (including unreadable files: probe skipped, recorded in checks).
- Creative dict gains
POST /v1/creatives/{id}/edit(owner) body{trim: {start_s, end_s}}and/or{crop: "fit" | "crop"}→ creative dict. Re-encodes into a NEW file (old removed), appendsvalidation.edits[] = {op:"trim", start_s, end_s, mode} | {op:"crop", mode, canvas?}, keepsvalidation.conversion, RESETS status topendingand bumps the schedule version (same semantics as replacing the file). Trimmed length must be ≤VIDEO_MAX_DURATION_S(422). Images:croponly (Pillow fit = black letterbox / crop = centre fill to the matching canvas);trimon an image → 422. Video without ffmpeg → 422 with the reason above. Game creatives → 409.
Device reliability + proof of play (2026-10-24)
Check-in health (additive, every field optional)
POST /v1/device/heartbeataccepts, besidesplaying_creative_idandwatchers_now:player_version(≤40 chars),uptime_s,last_error(≤200 chars),last_error_at,cache:{cached,total},storage:{usage_bytes?,quota_bytes?},camera: off|no_permission|counting,screen:{width,height},online(bool),queued_events. When any of them is present the whole health part is stored as the device's latest snapshot (devices.health, plusreported_at); a check-in without them keeps the previous snapshot. The browser player reportsplayer_version = "web-1.0.0".- The answer is now
{"schedule_version": n, "commands": [{id, kind}]}—commandsare the device's pending remote commands, oldest first (empty for players that never get any). Older players ignore the extra key.
Remote commands
- Table
device_commands(id, device_id, kind, created_by, created_at, acked_at, result). Kinds:reload|restart_engine|clear_cache|refetch_schedule. - Ops:
POST /v1/ops/devices/{device_id}/commands {kind}→ 201 command;GET /v1/ops/devices/{device_id}/commands(last 20, newest first). Ops role required (403 otherwise); 404 for an unknown or revoked device. - Device:
POST /v1/device/commands/{id}/ack {result?}→{id, kind, acked_at, result}. Device token only; 404 when the command belongs to another device; idempotent (a second ack keeps the first result). The player executes: reload = page reload; restart_engine = tear down and rebuild the loop in place; clear_cache = drop Cache Storage + refetch; refetch_schedule. GET /v1/ops/devices→ one card per active device:{id, device_uid, status, screen:{id,label,zone,venue}|null, last_seen_at, playing_creative_id, watchers_now, health, attention:[str], last_command, pending_commands}.attentionis the server's plain-English list of reasons the device needs a look (no check-in for > 2 min, player reports offline, cache incomplete, camera permission denied, ≥ 10 queued records, recent error); the list is sorted most-attention first.
Offline cache + dedupe (unchanged contract, confirmed)
- Play records already carry
client_play_id; the server ignores a play whose (device, client_play_id) was recorded before even when it arrives under a newbatch_seq, and a re-sentbatch_seqanswersduplicate. A retry therefore never double-counts.
Proof of play
GET /v1/bookings/{id}/proof?format=csv|json(advertiser owning the booking, or ops; 404 otherwise). Every play record of the booking, oldest first: play_id, screen_id, screen, creative_id, creative, started_at, duration_ms, watchers (null/blank when the screen had no working sensor). CSV ends with#proof booking_id=… records=n first=<iso> last=<iso> signature=<hex>; JSON carriesproof:{records, first, last, signature}. Signature = HMAC-SHA256 with a key derived fromSECRET_KEY(HMAC(SECRET_KEY, "proof-of-play")) over the CSV content before the footer, or over the canonical JSON (sort_keys, no spaces) ofrecords.POST /v1/proof/verify(no auth; multipart fieldfileor the file as the raw body) →{valid, records, first, last, reason?}. Any edit to a row, a removed row or a changed count givesvalid: false; a non-proof file givesreason: "not a proof file".
Bidding for busy windows (2026-10-24)
Every window stays first come, first served at the fixed rate, except busy windows that ops
mark on screens with bidding switched on. Sealed bids, first price: winners pay their own bid.
Migration bidding_busy_windows (bidding_busy_windows.py): screens.bidding_enabled, table
bidding_rounds, and bookings.bid_round_id / bid_per_slot_day / bid_at / cancel_reason.
Money unit. A bid and a floor are integer rupees per slot per day for that window (an hour
window's share of the day is already inside the number). Price of a bid = bid × slots × days.
The default floor is the fixed price of one slot for one day of that window
(rate × window hours ÷ screen hours, rounded, min ₹1).
Ops (role=ops)
PATCH /v1/ops/screens/{id} {bidding_enabled: bool}(with the other screen fields). Switching off while the screen has open rounds → 409. Screen dicts (ops and publicGET /v1/screens) carrybidding_enabled.POST /v1/ops/screens/{id}/bidding/rounds {start_date, end_date, start_time?, end_time?, floor_per_slot_day?, closes_at?, slots_on_offer?, note?}→ 201 round (ops shape below). Times are "HH:MM" IST on the screen's step (both omitted = all day). Defaults: floor as above,slots_on_offer= the screen'sloop_slots(1…loop_slots),closes_at=BIDDING_CLOSE_LEAD_HOURS(48) before the window first starts (ISO; a value without a zone is read as UTC). 409 bidding off / overlaps another open round on the screen; 422 window not in the future, close not before the window start, default close already past ("pick a close time").GET /v1/ops/screens/{id}/bidding→{bidding_enabled, rate_per_slot_day, loop_slots, rounds[]}(open first). Ops round = public round +{note, created_at, closed_at, result:{winners, losers, slots_offered, slots_sold, winning_booking_ids}|null, bids:[{booking_id, campaign_name, advertiser_email, bid_per_slot_day, slot_count, bid_at, status, cancel_reason}]}(best first). Ops run the auction, so they see every amount.DELETE /v1/ops/bidding/rounds/{id}→ 204 only while open with no bids ever placed; else 409.POST /v1/ops/bidding/rounds/{id}/close→{result, round}: force-close one round now.POST /v1/ops/bidding/close-due→{closed: n, rounds:[result]}: closes every open round whosecloses_athas passed. Idempotent (a closed round is never decided twice; a second call closes 0); there is no in-process scheduler, so call it every few minutes from a cron.GET /v1/ops/bookingsphasegainsbidding;POST /v1/ops/bookings/{id}/mark_paidon a bidding booking → 409.
Advertiser
GET /v1/screens/{id}/bidding?start&end(any signed-in user; dates optional, both or neither) →{bidding_enabled, rounds:[{id, screen_id, screen_label, start_date, end_date, start_time, end_time, floor_per_slot_day, slots_on_offer, closes_at, status, bidders}]}— open rounds only. Sealed: floor, bidder count (distinct businesses with a live bid) and close time; never an amount.POST /v1/bookingsgains optionalbid_per_slot_day(int ≥ 1).- Without it, any item whose dates and hours touch an open round (an all-day item touches every round on those days) → 409 with a sentence that explains bidding, the floor and the close time. Items outside busy hours book as before.
- With it: exactly one item (422 otherwise), matching the round's dates and hours exactly (422),
slot_count ≤ slots_on_offer(422), bid ≥ floor (422 "below the floor"), round open and not pastcloses_at(409), one bid per business per round, even after withdrawing (409 "raise it instead"), not a cricket game (422). → 201 booking withstatus:"bidding", no invoice, holds no capacity,price_total= bid × slots × days. Viewers → 403 as for any booking.
POST /v1/bookings/{id}/bid {bid_per_slot_day}(editor/owner) → booking dict. Raise only: an amount ≤ the current bid → 422; round closed or past its close time → 409; viewers 403; another business's booking 404. A raise movesbid_at(ties go to whoever reached the amount first).POST /v1/bookings/{id}/cancelon an open bid withdraws it (cancel_reason:"withdrawn"); the business cannot bid again in that round.- Booking dicts gain
cancel_reason("outbid"|"withdrawn"| null) andbid: null | {per_slot_day, bid_at, outcome:"open"|"won"|"outbid"|"withdrawn", round:<public round>, next_window:{screen_id, start_date, end_date, start_time, end_time}|null}— the caller's own bid only.next_window(outbid only) is the first run of the same length and hours after the busy window with enough free slots and no open round (60-day horizon); the web links it to/book?screen&start&end&from&to. - Bidding bookings are left out of spend totals, screen revenue, cricket resolution and window clashes.
Close rule
At close: offer = min(slots_on_offer, free slots on the window's busiest day) (fixed bookings made
before the window was marked busy keep theirs). Live bids are ranked by amount desc, then bid_at
asc, then booking time; each bid wins if its slot_count still fits, otherwise loses (a smaller
bid further down may still fill the remainder). Winners: price stays at their bid; ad approved →
confirmed, schedule materialized and the invoice issued through the normal billing path (integer
paise = price × 100 + GST); ad still in review → awaiting_creative (confirms + invoices on approval
like any booking). Notification bid_won. Losers: cancelled, cancel_reason:"outbid", nothing
charged, notification bid_lost naming the next free window. Notification kinds gain bid_won,
bid_lost (href /campaigns/{booking_id}).
Env: BIDDING_CLOSE_LEAD_HOURS (48).
Security review (2026-10-24, branch fix/security-review)
Behaviour changes a client can observe; nothing else in this contract moved.
POST /v1/invoices/{id}/confirm→ 400 unless the invoice already has the Razorpay order that/paycreated for it ANDorder_idequals it (the old check was skipped when/payhad never run, so another order's valid signature could mark the invoice paid).- Viewers now get 403 on
POST /v1/creatives/{id}/editandPATCH /v1/me/profiletoo. - 429
{detail}withRetry-After(seconds) from the throttles inapp/core/ratelimit.py(in-process windows, client = right-mostX-Forwarded-For) and DB counts:POST /v1/auth/login30 attempts/IP/5 min and 20 wrong passwords/account/15 min (then even the right password waits);/auth/signup20/IP/h;/auth/forgot10/IP/h;/auth/reset+/auth/invite/lookup30/IP/15 min;POST /v1/team/invites30/business/24 h;POST /v1/wallet/otp/start60/IP/h across phones (the 5/phone/h rule is unchanged);POST /v1/proof/verify30/IP/min. Every number is an env var (LOGIN_MAX_ATTEMPTS_PER_IP,LOGIN_MAX_FAILURES_PER_EMAIL,SIGNUP_MAX_PER_IP,FORGOT_MAX_PER_IP,FORGOT_MAX_PER_USER,TOKEN_LOOKUPS_MAX_PER_IP,TEAM_INVITES_MAX_PER_BUSINESS,WALLET_OTP_MAX_STARTS_PER_IP,CRICKET_WHATSAPP_MAX_PER_IP,PROOF_VERIFY_MAX_PER_IP);RATE_LIMITS_ENABLED=0turns the in-process ones off. POST /v1/auth/forgotstill answers 204 for everything; beyond 3 links per account per hour it sends nothing. The email now goes out after the response.POST /v1/play/{screen}/joinwithphone: past 10 WhatsApp sends per IP per 10 min the ticket is still issued (201, code on screen) but no WhatsApp goes out.- User JWTs carry
pv(keyed fingerprint of the password hash). After a password reset (or anOPS_PASSWORDrotation) every earlier token answers 401 "Your password was changed. Sign in again." Tokens minted before this release carry nopvand expire normally. - Creative uploads: a
.png/.jpgmust start with PNG/JPEG magic bytes and an.mp4with an ISO-BMFFftypbox, else 422{"reasons": ["the file is named .png but is not a png or jpg image"]}; the served Content-Type follows the bytes. ffmpeg/ffprobe open uploads with-protocol_whitelist file -format_whitelist mov,matroska,avionly. - Every API response carries
X-Content-Type-Options: nosniff,X-Frame-Options: DENY,Referrer-Policy: no-referrer. - Web
/player/api/*proxies onlyv1/device/**,v1/creatives/{id}/(file|game|assets/{key}),v1/library/{key},v1/screens/{id}/assets/{key}(404 otherwise) and forwards only aDevice …Authorization header.
Platform jobs and hardening (2026-10-26, branch feat/jobs-hardening)
Built-in scheduler
app/core/scheduler.py: a daemon thread inside the API process runs the platform's background
jobs, so no external cron is needed. It starts in the FastAPI lifespan when SCHEDULER_ENABLED
is on (unset = on with ENVIRONMENT=production, off everywhere else, including tests). Every
SCHEDULER_TICK_SECONDS (30) it reads the IST wall clock and runs what is due:
| Job | When | What |
|---|---|---|
bidding.close_due | every 5 minutes | decides every bidding round past its close time |
notifications.run_daily | daily 06:30 IST | campaign starts today / ended yesterday messages |
prizes.remind_expiring_coupons | daily 10:00 IST | WhatsApp reminder for coupons expiring within a day |
reports.send_weekly | Mondays 08:00 IST | weekly report email + notification (below) |
The due rule reads job_runs, so it survives restarts: a daily/weekly job is due when its most
recent slot has passed and no run has started since; a late boot catches up within 12 h (24 h for
the weekly report), otherwise it waits for the next slot. Every job was already idempotent, so a
catch-up or a manual run next to a scheduled one changes nothing twice. A job that raises is rolled
back, recorded with ok:false and logged once; the thread keeps going. Railway runs one uvicorn
process, so one scheduler runs; with more replicas a DB lock is needed first (TODO in the code).
The ops endpoints POST /v1/ops/bidding/close-due, /v1/ops/notifications/run-daily and
/v1/ops/coupons/run-reminders remain and behave as before (they do not write job_runs).
Table job_runs (migration jobs_job_runs): id, job, trigger ("schedule"|"manual"), started_at, finished_at, ok (null while running), detail (a short scrubbed summary such as {"closed": 0} or
RuntimeError: …; never a code, token or phone).
GET /v1/ops/jobs(ops) →{scheduler:{enabled, running}, jobs:[{job, label, explains, schedule, next_run_at|null, running, last_run:{id, job, trigger, started_at, finished_at, ok, detail}|null}]}in the table's order.next_run_atis null while the scheduler is off.POST /v1/ops/jobs/{job}/run(ops) → the run row (trigger:"manual";ok:falsewithdetailwhen the job failed, still 200). 404 unknown job; 409 while the same job is already running.- Web: ops Today page, "Background jobs" card: last run per job and a Run now button.
Proof of play v2
Downloads are now version 2: the signature binds the booking id, the booking's screen ids, its date range and the generation time as well as the rows.
- CSV: first line
#proof version=2, then the header and rows as before, then the footer#proof booking_id=<id> screens=<id,id> from=<YYYY-MM-DD> to=<YYYY-MM-DD> generated_at=<iso> records=<n> first=<iso> last=<iso> signature=<hex>. The signature covers every byte of the file up to (not including)signature=. - JSON:
{version:2, booking_id, screen_ids[], start_date, end_date, generated_at, records[], proof:{records, first, last, signature}}; signature over the canonical JSON (sort_keys, no spaces) of every top-level field exceptproof. POST /v1/proof/verify→{valid, version: 2|1|null, records, first, last, …}. v2 adds the boundbooking_id, screen_ids, start_date, end_date, generated_at. Version 1 files (rows-only signature, no version line) still verify and answerversion: 1pluswarning: "This is a version 1 proof file. Its signature covers the play rows only, not the booking, screens or dates, so check those against your booking yourself."(nobooking_idechoed). Removing the version line or field from a v2 file does not turn it into a valid v1 file. Non-proof input:version: null, reason: "not a proof file".
Roles
POST /v1/redeemnow needs an owner or editor (viewers 403, the usual view-only sentence); ops unchanged.PATCH /v1/team/members/{user_id} {role: owner|editor|viewer}(owners only, 403 otherwise) → member{id, email, role, joined_at}. 404 when the user is not on your team. 409 "A team needs at least one owner. Make someone else an owner first." when it would demote the only owner (an owner may change their own role while another owner exists). Takes effect on the next request.DELETE /v1/team/members/{user_id}(owners only) → 204. 409 for yourself ("You cannot remove yourself…"), for the last owner, and for the person who opened the account (every row of the business is keyed on their id: "Change their role instead, or ask our team to move the account"). 404 when not on your team. The removed person keeps their sign-in but becomes the owner of an empty account of their own (business_id= own id, profile business name cleared); their copies of the team's notifications are deleted. Access ends on their next request.- Web: Team page members list gains a role select and a Remove button with an inline confirm (owners only; no Remove on yourself or on the person who opened the account).
Weekly report email
app/services/reports.py send_weekly(db) (the Monday job): for every business with activity in the
last Monday-to-Sunday IST week (plays, reward QR scans, or spend), each member gets one email
"Your weekly Freebee report" with plays, watchers (or "not measured on these screens" when no sensor
counted anyone), QR code scans and spend (integer rupees of bookings made that week, not cancelled,
not an open bid) and a link to ${WEB_URL}/reports, through app/services/email.py
(EMAIL_PROVIDER=log sends nothing and logs the masked address). It is also recorded as a
notification, kind weekly_report, title "Your weekly report is ready", href
/reports?week=<monday>. Idempotent per member per week; an email failure is logged and swallowed.
Result {week_start, week_end, businesses, notified, emailed, skipped, failed}.
Web content security policy
The frame-ancestors-only policy is replaced by a full CSP (web/lib/csp.mjs):
- Next pages:
middleware.tssets a fresh nonce per request;script-src 'self' 'nonce-…'plus*.posthog.comandcheckout.razorpay.com. The root layout readsheaders(), so every page is rendered per request (a page built ahead of time cannot carry a nonce). - Game engine pages (
/catch|spin|wheel|cricket|fortune|cabinet|clock.html, static files with inline scripts):script-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com(qrcodejs). The trade-off is documented inlib/csp.mjs. - Both:
connect-srcself, the API origin fromNEXT_PUBLIC_API_URL,*.posthog.com, the Sentry ingest origin (fromNEXT_PUBLIC_SENTRY_DSN, else*.sentry.io),api.razorpay.com,lumberjack.razorpay.com;img-src/media-srcself, the API,data:,blob:;style-srcself,'unsafe-inline', Google Fonts;font-srcself,data:,fonts.gstatic.com;frame-srcself,api.razorpay.com,checkout.razorpay.com;worker-srcself,blob:;frame-ancestors,form-action,base-uriself;object-src 'none'.next devadds'unsafe-eval'andws:.
Dashboard extras (2026-10-26, branch feat/dashboard-extras)
Additive only; nothing above changes. Migration extras_weekly_report_waitlist
(extras_weekly_report_waitlist.py): users.weekly_report (bool, default true) and table
waitlist.
Home weekly line
- No API change:
GET /v1/metrics/overview?days=14already returnsby_day:[{date, plays, ots, watchers}](14 rows, oldest first, UTC days, zeros included). Home draws plays per day and, when any day has watchers, watchers as a second line.
Screen sparklines (ops)
GET /v1/ops/metrics/screens/sparklines?days=7(ops;days2–31, default 7) →{days, dates:[YYYY-MM-DD], screens:[{screen_id, label, plays:[n per day], total}]}. One call for the whole network, IST days, oldest first, today last; every screen gets a row (zeros when idle). Declared before/v1/ops/metrics/screens/{screen_id}, which is unchanged.
Video thumbnails (poster frames)
- With ffmpeg on the host (
transcode.available()), every stored video gets a JPEG poster frame next to its file (<stem>.thumb.jpg, frame at 1 s, or the first frame of a shorter clip, 480 px wide). Generated onPOST /v1/creatives,POST /v1/creatives/{id}/file,POST /v1/creatives/{id}/edit,POST /v1/ops/library/{key}andPOST /v1/ops/screens/{id}/assets/{key}; removed with the file. Never required: without ffmpeg, or when ffmpeg fails, nothing is stored andthumbnail_urlisnull(the web keeps its<video>preview). - Creative dicts gain
thumbnail_url:"/v1/creatives/{id}/thumbnail"for a video with a poster frame, elsenull(images and games are alwaysnull). GET /v1/creatives/{id}/thumbnail→image/jpeg(same access as/file: owner, ops or a paired device; 404 when there is none).GET /v1/ops/libraryitems gainthumbnail_url("/v1/library/{key}/thumbnail"| null);POST /v1/ops/library/{key}answersthumbnail_urltoo.GET /v1/ops/screens/{id}/assetsitems gainthumbnail_url(the screen override's, or the library's when the key falls back to the library; null for stills).- Public, like the clips themselves:
GET /v1/library/{key}/thumbnail,GET /v1/screens/{screen_id}/assets/{key}/thumbnail. POST /v1/ops/footage/thumbnails/backfill(ops) →{available, videos, created, already, missing_file, failed, message?}. One pass over video creatives, library clips and screen overrides; idempotent (a second call creates 0). Without ffmpeg: 200 withavailable:false,created:0and amessage.
Device commands
- No API change: the ops Devices page now offers
refetch_scheduleas a button ("Refetch schedule": a new booking or approval is not showing yet; downloads today's loop again).
Printable campaign report
- No API change. The web route
/campaigns/{booking_id}/printrenders the report as static tables (the four bands, the funnel, top screens, the proof-of-play summary from the signed CSV footer) and opens the browser's print window ("Choose Save as PDF").?noprint=1skips the automatic print window. Audience values the camera never measured print as "Not measured", never as sample numbers.
Preferences
GET /v1/me/preferences→{weekly_report: bool}(defaulttrue).PATCH /v1/me/preferences {weekly_report?: bool}→ the same shape. Any signed-in user, viewers included: it is the caller's own inbox, not team data, and changes nothing a teammate sees or receives. 422 for a non-boolean.- The weekly email job (
backend/app/services/reports.pysend_weekly, built onfeat/jobs-hardening) must skip every user whoseusers.weekly_reportis false. The Reports page promises: "Every Monday morning: plays, watchers, scans and spend for the week, with a link here."
Waitlist
POST /v1/waitlist {business_name (1–200), email, city (1–100), screens_of_interest? (≤300), website?}→ 201{ok:true}. Public (no auth).websiteis a honeypot: when filled the answer is the same 201 and nothing is stored. A repeat email updates its row (never a duplicate, never revealed). Email is lower-cased. 422 bad email or blank name/city. 429 withRetry-AfterpastWAITLIST_MAX_PER_IP(10) per IP per hour (in-process limiter).GET /v1/ops/waitlist(ops) →[{id, business_name, email, city, screens_of_interest, created_at, updated_at}]newest first.GET /v1/ops/waitlist.csv(ops) →created_at,business_name,email,city,screens_of_interest; cells a spreadsheet would run as a formula are prefixed with'.- Stored: a prospective advertiser's business contact, given on purpose. Never shopper data.
AI helpers (2026-10-26, branch feat/ai-helpers)
Four helpers. AI suggests; the advertiser decides: nothing changes an ad until they click, and
a kept change sends the ad back for approval like any other edit. Every route needs a user token;
every POST needs an editor (viewers 403). Env: AI_PROVIDER (off default | anthropic,
needs ANTHROPIC_API_KEY), AI_MODEL, AI_TIMEOUT_SECONDS (15), AI_CHECK_NAMES_TIMEOUT_SECONDS
(4), AI_COPY_MAX_PER_BUSINESS_PER_HOUR (20), AI_CHECK_NAMES_MAX_PER_BUSINESS_PER_HOUR (300),
AI_COPY_CACHE_HOURS (24), ANTHROPIC_API_URL (tests/stub only; the wall name check uses it too).
Production refuses to boot with AI_PROVIDER=anthropic and no key.
Status
GET /v1/ai/status→{copy:{ready, reason, limits:{headline:28, slogan:60, prize_titles:32, whatsapp_blurb:160}, langs:{te, hi}, per_hour}, check_names:{ready, reason}, enhance:{ready:true, reason:null, presets:[{key, label, what}]}, preview_clip:{ready, reason, seconds:[6,8,10], motions:[slow_zoom, pan_left, pan_right]}}.reasonwhen not ready: copy / names"Not set up yet; our team can switch this on", clips"Our server cannot render clips yet; our team can switch this on".
Copy suggestions
POST /v1/ai/copy {kind: headline|slogan|prize_titles|whatsapp_blurb, brand (1-80), product (1-160), audience?, tone?, game_template?, offer? (prize titles: the offer in the advertiser's words), lang?: te|hi, competitors?: [str]}→{kind, limit, lang, options:[{text, why, chars, translation?}], cached}. 1 to 5 options.- Server-side rules applied to whatever the model returns (an option that breaks one is dropped,
never cut):
text≤ the limit above, no em-dashes, no claim words (best, number one, No. 1, #1, cheapest, guaranteed, cure, immunity, doctor, weight loss, invest, profit, loan, ...), no name fromcompetitors, no duplicates;why≤ 120 chars;translationdropped (null) if over the limit. The system prompt also forbids other brands, real people and invented facts. - 503
{detail:"Not set up yet; our team can switch this on"}when off. 422 whencompetitorsis non-empty (never sent to the model) or the model refuses ("No suggestions: <reason>"). 502 provider error, unreadable answer, or nothing passed the rules. 429 withRetry-AfterpastAI_COPY_MAX_PER_BUSINESS_PER_HOURmodel calls per business per hour (in-process window). - Cached in memory by a hash of the normalised request (case and spacing ignored) + model for
AI_COPY_CACHE_HOURS; a cache hit returnscached:trueand does not count against the limit. Only the copy inputs are sent: no email, phone or account id.
Wrong-name guard
POST /v1/ai/check-names {texts:[1-20 strings ≤255], brand?}→ always 200{ready, checked, flags:[{index, text, kind: celebrity|competitor|misspelt_brand, name, reason}]}. Fail-open: provider off, overAI_CHECK_NAMES_MAX_PER_BUSINESS_PER_HOUR, error, timeout or an odd answer →checked:falseand only cached flags.reasonis fixed server text: celebrity "This looks like a real, famous person's name; we cannot show a real person's name or likeness without their written permission." · competitor "This looks like another company's brand; your ad cannot name or give away a brand you do not own without their permission." · misspelt_brand "This looks like a misspelling of a famous brand (X); check the spelling, because a near-copy of a real brand will not be approved." The advertiser's ownbrandis never flagged. Cached per (brand, text).GET /v1/ops/creatives/{id}/name-check(ops) → the same shape for a saved game ad's brand, headline, slogan, tagline, prize titles and reward title; each flag addsfield("Brand", "Headline", "Prize (big)", "Reward", ...). Non-game creatives →flags:[].
Image enhance (Pillow, always on)
- Presets:
auto(60% auto-contrast, half-strength grey-world colour balance, mild unsharp mask; size unchanged),brighten(auto + lifted shadows),punchy(auto + contrast 1.12, colour 1.25),fit_portrait/fit_landscape(auto, then contain on 1080x1920 / 1920x1080 over a darkened blur of the image; transparent padding for images with alpha). Never changes what is in the picture. POST /v1/creatives/{id}/enhance/preview {preset, asset_key?}→image/jpeg, Before | After side by side (each ≤720 px),Cache-Control: no-store. Nothing is saved.POST /v1/creatives/{id}/enhance {preset, asset_key?}("Keep this") → creative dict. Image creatives: a NEW file (png stays png, else jpg), revalidated like an upload,validation.edits[]gains{op:"enhance", preset, at}. Game ads:asset_key(logo, hero, pack, any image asset butpreview) gets a new file (≤5MB; a too-big png falls back to jpg, else 422). Both keep the previous file and appendvalidation.versions[] = {filename, sha256, replaced_at, by, key?}; status RESETS topending, the sha changes and the schedule version bumps. Versions survive later edits, replaces and game saves and are deleted with the creative.- 422: video creative,
asset_keyon a non-game or missing on a game, a video/preview asset, unreadable image. 404: unknown asset key or not the owner.
Preview clip from an image
POST /v1/creatives/{id}/preview-clip {seconds: 6|8|10 (default 8), motion: slow_zoom|pan_left| pan_right (default slow_zoom), name?}→ 201 a NEW video creative (namedefault "<image name> (clip)", statuspending,validation.made_from = {creative_id, op:"preview_clip", seconds, motion, at}). Silent H.264 yuv420p mp4, 30 fps, faststart, 1920x1080 for images wider than tall else 1080x1920; ffmpeg only ever reads a png our server rendered. The image creative is untouched.- 422
{"reasons": ["Our server cannot render clips yet; our team can switch this on"]}when ffmpeg is missing orTRANSCODE_ENABLED=0; 422 for a non-image creative or a bad body.
People counting on the browser player (2026-10-26, branch feat/audience-counting)
The browser player can now count everyone who walks past, not only the faces that look. No change to play events or metrics; one additive heartbeat field.
- Play events (unchanged shape). In people mode
audience.ots= distinct people seen during the play (counted once per person, seedocs/AUDIENCE_MEASUREMENT.md), never less thanwatchers;watchersandattention_ms_totalare the face-based counts as before (a watcher is a face detected for 1 s or more). Soots > watchersis normal for a people-mode screen. In faces mode (the fallback on a weak box) the player keeps reportingots == watchers, and clients keep treatingots == watchers > 0as "who passed was not measured". Camera off: all zeros, as before. - Metrics (unchanged).
has_audience_data = ots > 0andattention_rate = watchers / otson the campaign report,/v1/metrics/overview,/v1/metrics/screens/{id},/v1/ops/metrics/screens/{id}and/v1/ops/metrics/network; a screen that starts sending people counts switches from sample numbers to real numbers with no server change. - Heartbeat, additive and optional.
POST /v1/device/heartbeatacceptsvision: {mode: "people"|"faces", model: str ≤ 80, fps: 0..120, cpu: 0..100}while the camera is counting (camera: "counting").cpuis the player's estimate of the percent of one CPU core spent on detection;fpsis detection passes per second. It is stored with the rest of the health snapshot and shows up ashealth.visiononGET /v1/ops/devices. Other values formodeare refused with 422; older players simply do not send it. - Player switches.
/player?vision=people|faces|offsets the counting mode and saves it on the device (vision_modein the player's local storage);?vision=1turns the camera on in the saved mode (people when none was saved),?vision=0turns it off. People mode falls back to faces by itself when the person model does not load and warm up within 10 s, or one detection pass takes longer than 1.5 s; the status overlay says which mode is running and why. - The browser player now reports
player_version = "web-1.1.0".
Sound on the wall (2026-10-26, branch feat/deck-sound)
screens.sound_enabled(bool, default false, migrationscreen_sound_enabled). Malls often forbid audio, so game engines play sound cues only on screens where ops switched it on.PATCH /v1/ops/screens/{id}acceptssound_enabled;GET /v1/ops/screensand the PATCH response return it. The PATCH bumpsschedule_versionas before, so devices refetch.GET /v1/device/schedule→screenaddssound_enabled.- Engine embed protocol (web/components/game-frame.tsx):
gody:configcarriessound(boolean); later changes arrive as{type:"gody:sound", on}(a secondgody:configwould reload the engine). Engines also honour?sound=1. The web player passessound_enabled && !muted(per-device mute on the StatusOverlay;?sound=1|0overrides for field tests). See docs/SOUND.md.
Mall partner, holiday hours, maintenance, partner API keys, mall branding (2026-10-26)
Migration mall_partner (mall_partner.py, down_revision screen_sound_enabled): users.venue_id,
venues.revenue_share_bp (default 3000) + venues.brand (JSON), screens.special_hours (JSON) +
maintenance / maintenance_reason / maintenance_since, tables mall_payouts and api_keys.
Mall partner role
users.rolegainsmall. A mall user belongs to exactly one venue (users.venue_id) and signs in at the normalPOST /v1/auth/login;userandGET /v1/mecarryvenue:{id, name}(null for other roles). The web sends mall users to/mall.- A mall token is refused with 403
"Mall partner accounts can only open the mall pages."on everything except/v1/mall/*,/v1/me,/v1/api-keys,/v1/notifications,/v1/auth/*. Advertisers and ops get 403 on/v1/mall/*. - Privacy: mall responses carry counts, brands, dates and the mall's share only. Never an
advertiser's email, phone, team, gross invoice or rate. A campaign's "brand" is the game
creative's
config.brand, else the advertiser's business name.
Mall (read only, always scoped to the caller's venue; any other id → 404)
GET /v1/mall/venue→{id, name, city, revenue_share_bp, brand:{name, accent, logo_url, display_name}}GET /v1/mall/overview?from&to→{venue, revenue_share_bp, from, to, totals:{plays, ots, watchers, attention_ms_avg, attention_rate|null, bookings, cricket_scans, cricket_innings, screens, online, share_booked_paise}, screens_total, screens_online, screens_maintenance, hours_booked_today, share_this_month_paise, share_unpaid_paise, upcoming:[{brand, start_date, end_date, screens, live}]}. Totals are the network totals limited to the venue, withrevenue_bookedreplaced byshare_booked_paise= round_half_up(revenue booked in range × 100 × share_bp / 10000).GET /v1/mall/screens→[{id, label, zone_name, orientation, device:"online"|"offline"|"unpaired", last_seen_at, status:"online"|"offline"|"unpaired"|"maintenance"|"closed", status_now, maintenance, today:{closed, open_time, close_time, open_min, close_min, special, note, spans:[{start_min, end_min, brand, all_day, booking_key}], hours_booked, now_playing (brand)|null, watchers_now|null}, special_hours[]}].GET /v1/mall/screens/{id}→ one card; 404 for a screen of another venue.GET /v1/mall/statement→{venue, revenue_share_bp, months:[{month:"YYYY-MM", booked_paise, share_bp, amount_due_paise, lines, status:"pending"|"paid", in_progress, paid_amount_paise|null, paid_at|null, reference|null}] (newest first, always includes the current month), totals:{booked_paise, due_paise, paid_paise}}.GET /v1/mall/statement/{YYYY-MM}→ the month row plusvenueanditems:[{month, date, kind: "invoice"|"credit_note", invoice_number, brand, screens[], start_date, end_date, booked_paise}].
Statement maths (integer paise, half-up). Month = IST calendar month of the invoice's paid_at
(credit notes: their issued_at). For every paid invoice of a booking with items on the venue's
screens: booked = round_half_up(subtotal_paise × Σ(item.price on this venue) / booking.price_total).
A credit note against a paid invoice counts negative, split the same way. Unpaid, void and bidding
invoices never count. amount_due = round_half_up(Σ booked × share_bp / 10000) per month. Example:
₹2,000 on venue A + ₹666 on venue B, paid → A books 200000 paise, B 66600; a ₹100 credit → A −7502
(−7501.875 rounded half-up away from zero); at 30% A is due 57749. A month is paid once ops record
a payout; the paid row keeps the amount actually paid (paid_amount_paise).
Ops: the venue's partner side
GET /v1/ops/venuesrows (andPOST/PATCHresponses) gainrevenue_share_bp,brand:{name, accent, logo_url, display_name},mall_users:[{id, email, created_at}].PATCH /v1/ops/venues/{id}takesrevenue_share_bp(0–10000) andbrand:{name?, accent?}(accent^#[0-9a-fA-F]{6}$or empty; 422 otherwise).DELETE /v1/ops/venues/{id}→ 409 while it has mall contacts.POST /v1/ops/venues/{id}/mall-users {email}→ 201{user:{id, email, venue_id, created_at}, temporary_password, emailed}. The temporary password is returned ONCE and never emailed (the email, when a provider delivers, only says a login exists). 409 when the email has an account.DELETE /v1/ops/mall-users/{id}→ 204: removes the login and revokes its API keys.GET /v1/ops/venues/{id}/statementand/statement/{YYYY-MM}: the mall's statement, for ops.POST /v1/ops/venues/{id}/payouts {month, reference?}→ 201 the month row, nowpaidwithpaid_amount_paise= the amount due at that moment. 409 for the current or a future month, or a month already paid.POST /v1/ops/venues/{id}/logomultipartfile(png/jpg, sniffed, ≤ the game asset limit) → the brand dict;DELETE /v1/ops/venues/{id}/logoremoves it. PublicGET /v1/venues/{id}/logo(404 when none).
Holiday hours and maintenance (ops, per screen)
PATCH /v1/ops/screens/{id}takesspecial_hours:[{date, closed, open_time?, close_time?, note?}](the full list replaces the stored one; past dates are dropped; one row per date; an open row needs both times with close after open, else 422 with a sentence),maintenance: boolandmaintenance_reason(≤200). Ops screen dicts gainspecial_hours[](times as "HH:MM"),maintenance,maintenance_reason,maintenance_since,status_now:{state, on_air, reason, message},venue_id.- A date that becomes closed (or gets shorter hours) notifies every business with a live booking
on that screen that day, once per change: kind
screen_closed, "Screen X is closed on 2 Nov, your plays will be made up." (href/campaigns/{booking_id}). Nothing is re-booked automatically yet; making up the plays is an ops job. POST /v1/bookings: an item whose dates include a closed day → 409 "X is closed on 2 Nov (Diwali). Pick dates around it or book another screen."; a windowed item outside a short day's hours → 409 "X is only open 12:00–18:00 on 2 Nov, …". All-day items on a short day are fine.GET /v1/screens/{id}/hours(andcricket-hours) gainclosed_dates[]andspecial_days[{date, open_min, close_min}]; with a closed day in range every step isfree:falsewithtaken_by: "Closed on 2 Nov"; on a short day the steps outside its hours say "Short hours on 2 Nov".- Device:
POST /v1/device/heartbeatanswersstatus:{state:"on_air"|"maintenance"|"closed", on_air, message}andmaintenance: bool;GET /v1/device/schedulecarries the samestatusandday:{closed, open_min, close_min, special}. Closed = a closed special day, or outside a special day's own hours (normal days are unchanged). The maintenance reason never reaches the device. Heartbeats acceptshowing: "ads"|"back_soon"(stored in the health snapshot). - Player: while not on air it shows a calm "Back soon" card with the Freebee wordmark and the
message, treats every slot as idle (no play record is written behind the card) and reports
showing:"back_soon". Ops Today lists screens in maintenance in the alerts strip.
Partner API keys
api_keys: id, name, scope (venue|business|network), owner_user_id, venue_id, business_id, key_hash (SHA-256, unique), prefix (first 12 chars), created_at, last_used_at, revoked_at. A key isfbk_+ 40 url-safe characters; the full key is in the create response only.GET /v1/api-keys→[{id, name, scope, prefix, venue_id, venue_name, created_at, last_used_at, revoked_at}](ops: all; mall: its venue's; advertiser: its business's).POST /v1/api-keys {name, venue_id?}→ 201 the row pluskey. Ops:venue_id→ venue key, none → network key. Mall owners → a key for their venue. Advertiser owners → a business key. Editors and viewers → 403 "Only an owner can create or revoke API keys." At most 10 live keys per venue or business (409).DELETE /v1/api-keys/{id}→ 200 the row withrevoked_at(idempotent); 404 for a key you cannot see. A revoked key answers 401 from the next request.- Auth for
/v1/partner/*: headerX-API-Key. 401 missing / unknown / revoked; 429 withRetry-AfterpastPARTNER_API_MAX_PER_MIN(60) requests per key per minute; 403 when the key's scope does not fit the endpoint.last_used_atupdates at most once a minute.
GET /v1/partner/screens (venue or network key) → [{id, label, zone_name, orientation, venue_id, status, device, last_seen_at, maintenance, today:{closed, open_time, close_time, special, hours_booked}}]
curl -s https://api.example/v1/partner/screens -H "X-API-Key: fbk_…"
GET /v1/partner/metrics?from=YYYY-MM-DD&to=YYYY-MM-DD (venue key → {scope:"venue", venue_id, from, to, totals (as mall overview, with share_booked_paise), screens_total, screens_online, hours_booked_today, revenue_share_bp}; network key → {scope:"network", from, to, totals})
curl -s "https://api.example/v1/partner/metrics?from=2026-10-01&to=2026-10-31" -H "X-API-Key: fbk_…"
GET /v1/partner/proof/{booking_id}?format=json|csv (business key: the business's own bookings only,
404 otherwise; network key: any) → the same signed proof-of-play file as GET /v1/bookings/{id}/proof.
curl -s "https://api.example/v1/partner/proof/7f3c…?format=csv" -H "X-API-Key: fbk_…" -o proof.csv
Mall branding on the shopper pages
venues.brand = {name?, accent?, logo?}.GET /v1/play/{screen},GET /v1/kiosk/{screen}andGET /v1/wallet/claim/{token}carryvenue_brand: {name, logo_url, accent} | null(null until ops set a name, logo or accent;namefalls back to the venue name). The kiosk, phone join page and claim page show "At <name>" with the logo; Freebee's own wordmark stays.
Env: PARTNER_API_MAX_PER_MIN (60), MALL_DEFAULT_REVENUE_SHARE_BP (3000). Seed: mall@dooh.local
/ malldev123 tied to the seeded venue.