Freebee Ads docs

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 / env OPS_PASSWORD (default opsdev123), role=ops.

Creatives (advertiser)

  • POST /v1/creatives multipart: 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}/file multipart file → 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/schedule only 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 materializes schedule_entries (creative must be approved; if creative still pending → booking status awaiting_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 — a qr_scans_by_creative section (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_rate is null (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-level delivered_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_avg averages over those plays only (camera-off plays never dilute it). watchers are 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) report ots == watchers — they cannot see non-lookers and must not invent OTS; clients treat ots == watchers > 0 as "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}|null
  • PATCH /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 slots
  • POST /v1/ops/devices/claim {code, screen_id} → binds pairing code to screen
  • POST /v1/ops/devices/{id}/revoke
  • GET /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| > 60s OR any play has started_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/start is 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).
  • rejected is terminal: server stores the batch body for review; devices never retry a rejected batch. A re-sent already-terminal seq answers duplicate.
  • One batch per play; batch_seq strictly 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} multipart file → 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 carry config and assets (key→url); sha256 is a hash over config + asset hashes (changes whenever anything changes).
  • Approval: a game creative may be approved only when logo, hero and every item_<key> asset exist (409 otherwise).

Player contract

  • GET /v1/device/schedule entries 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_s seconds (not screen.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's duration_ms is the ACTUAL occupancy.
  • PlayIn.engagement (optional, game plays only): {sessions, completed, score_sum, best_score, qr_shown, starts_face, starts_tap} → stored append-only in engagement_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_scanned counts anonymous opens of the reward claim page for the same creatives in range. starts_face / starts_tap sum how sessions began (camera vs touch).
  • Overview responses add total_spend (integer rupees of non-cancelled bookings created in the window) and spend_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 passes creativeId in the gody:config embed 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_scans is 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/schedule adds schedule_version.
  • POST /v1/device/heartbeat now 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 iff random < 1/odds_one_in AND today's wins for (booking or creative) < daily_budget; else the odds-1 tier. It mints a win_tokens row (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 legacy reward QR (/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 via services/sms.py: send_otp(phone, code) — v1 provider is a log stub; with env WALLET_OTP_DEV=1 the 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). Stores wallet_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

  • engagement gains spins, wins, claims, redemptions (spin creatives; 0 otherwise). Campaign CSV gains a coupons_by_tier section (tier,wins,claims,redemptions). Phone numbers NEVER appear in any advertiser/ops response; ops gets GET /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 calling POST /v1/device/spins with its device token; the portal preview / ops play-test answers locally with a simulated outcome and claim_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's logo|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. Exactly balls distinct 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; a turn is 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", a win_tokens row) by the highest prizes[] 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 as GET /v1/play/{screen_id} (assets now include ball, 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 "&lt;name&gt; 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 plus ball:{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 at balls picks 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}/ball body {key} — pack is optional and ignored in dice mode. Each ball carries face (1 single, 2 double, 3 dot, 4 four, 5 out, 6 six) and pack is the ball index.
  • Kiosk and phone views carry mode and limit (balls this innings may face, growing by one per six). The wall poll's turn carries limit too, so the wall can draw the bonus slots.
  • The innings ends at limit balls 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.cricket block (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 in coupons.csv like spin prizes.

  • Kiosk safety: GET /v1/kiosk/{screen} tickets[] no longer carries code (who is waiting, never their code). POST /v1/kiosk/{screen}/claim returns 429 after 5 wrong codes on a screen within 2 minutes (60 s lock).

  • Wall stall: a ball the wall has not reported shown for 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. avatar must 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 / done accept up to 30 balls (bonus balls).

  • WhatsApp: config.whatsapp is true only with WHATSAPP_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.py runs the whole API flow against a server (--base, --screen), exit 1 on any failure.

  • Prizes are optional (v1.8): CricketConfig.prizes is 0–3 bands; at most one band may need 0 runs. With no bands (or runs below every band) the innings completes with claim_url: null and the phone says thanks for playing.

  • Ad break (v1.8): optional video asset ad_break (a 10–15 s spot) plus CricketConfig.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/scans on open; GET /v1/play/{screen} now returns booking_id for that. The report's cricket block carries opens (≥ 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)

  • BookingItemIn gains optional start_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_time in 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/schedule entries carry window {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 c3e5a7b9d1f2 adds booking_items.start_min/end_min.

  • Per-screen hours (ops): Screen.open_min/close_min/window_step_min (migration d5a7c9e1f3b4). POST/PATCH /v1/ops/screens[/{id}] take open_time, close_time ("HH:MM") and window_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. Public GET /v1/screens carries open_time, close_time, window_step_min.

  • Windows for every ad type: any BookingItemIn may carry a window; loop ads then play only inside it (device schedule window) and pay for that share of the screen's day. Only cricket windows are exclusive.

  • GET /v1/screens/{id}/hours?start&end is the generic grid (cricket-hours remains 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 plus by_advertiser[], bookings_in_range, revenue_booked and the cricket funnel. Ops may read any campaign's GET /v1/metrics/campaigns/{booking} (the advertiser's own report). GET /v1/ops/bookings rows carry advertiser_email, advertiser_id, phase.

  • Preview asset (v1.9): optional preview key 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 (migration e6b8d0f2a4c6). 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}. Public GET /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's assets (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 its source), POST/DELETE /v1/ops/screens/{id}/assets/{key} (migration f7c9e1a3b5d7, screen_assets). Public GET /v1/screens/{id}/assets/{key}. The wall's GET /v1/creatives/{id}/game resolves for the calling device's screen; the portal preview (a user) resolves without a screen. The device schedule's sha256 for 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/library items carry group and templates. 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 template freebee_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=1 still 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 plus seller:{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 not issued; 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 method razorpay. 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; header X-Razorpay-Signature = HMAC-SHA256 of the raw body with RAZORPAY_WEBHOOK_SECRET). Handles payment.captured: finds the invoice by order_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, default bank) → 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_paise is 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 include invoice: {id, number, status, total_paise, due_at, payment_method} | null (null until the booking is confirmed).

Profile (billing details)

  • GET /v1/me profile and PATCH /v1/me/profile gain address_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}/approve now 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 (null when blank) and checked_at are stored on creatives.review_checklist (JSON, nullable; migration a9d2f4c6e8b1, file legal_review_checklist.py) and returned as review_checklist on 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} (null for 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/me and the auth responses' user now carry team_role and business_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. href is 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_url with 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/signup accepts invite_token (then business_name is 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_name is 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. Once issued >= stock_total the 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_days after it is claimed.
  • GET /v1/metrics/campaigns/{booking} gains top-level prize_stock: [{tier, title, stock_total, issued, stock_left}] (all-time for the booking, not range-filtered; [] for non-game creatives; stock_left null 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 (and POST /v1/creatives/{id}/file) accept .mov .webm .mkv .avi .m4v when ffmpeg/ffprobe are on the host (app/services/transcode.py; Railway: see docs/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 at VIDEO_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: bool and editable: 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).
  • 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), appends validation.edits[] = {op:"trim", start_s, end_s, mode} | {op:"crop", mode, canvas?}, keeps validation.conversion, RESETS status to pending and bumps the schedule version (same semantics as replacing the file). Trimmed length must be ≤ VIDEO_MAX_DURATION_S (422). Images: crop only (Pillow fit = black letterbox / crop = centre fill to the matching canvas); trim on 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/heartbeat accepts, besides playing_creative_id and watchers_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, plus reported_at); a check-in without them keeps the previous snapshot. The browser player reports player_version = "web-1.0.0".
  • The answer is now {"schedule_version": n, "commands": [{id, kind}]} — commands are 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}. attention is 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 new batch_seq, and a re-sent batch_seq answers duplicate. 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 carries proof:{records, first, last, signature}. Signature = HMAC-SHA256 with a key derived from SECRET_KEY (HMAC(SECRET_KEY, "proof-of-play")) over the CSV content before the footer, or over the canonical JSON (sort_keys, no spaces) of records.
  • POST /v1/proof/verify (no auth; multipart field file or the file as the raw body) → {valid, records, first, last, reason?}. Any edit to a row, a removed row or a changed count gives valid: false; a non-proof file gives reason: "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 public GET /v1/screens) carry bidding_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's loop_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 whose closes_at has 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/bookings phase gains bidding; POST /v1/ops/bookings/{id}/mark_paid on 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/bookings gains optional bid_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 past closes_at (409), one bid per business per round, even after withdrawing (409 "raise it instead"), not a cricket game (422). → 201 booking with status:"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 moves bid_at (ties go to whoever reached the amount first).
  • POST /v1/bookings/{id}/cancel on an open bid withdraws it (cancel_reason:"withdrawn"); the business cannot bid again in that round.
  • Booking dicts gain cancel_reason ("outbid" | "withdrawn" | null) and bid: 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 /pay created for it AND order_id equals it (the old check was skipped when /pay had never run, so another order's valid signature could mark the invoice paid).
  • Viewers now get 403 on POST /v1/creatives/{id}/edit and PATCH /v1/me/profile too.
  • 429 {detail} with Retry-After (seconds) from the throttles in app/core/ratelimit.py (in-process windows, client = right-most X-Forwarded-For) and DB counts: POST /v1/auth/login 30 attempts/IP/5 min and 20 wrong passwords/account/15 min (then even the right password waits); /auth/signup 20/IP/h; /auth/forgot 10/IP/h; /auth/reset + /auth/invite/lookup 30/IP/15 min; POST /v1/team/invites 30/business/24 h; POST /v1/wallet/otp/start 60/IP/h across phones (the 5/phone/h rule is unchanged); POST /v1/proof/verify 30/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=0 turns the in-process ones off.
  • POST /v1/auth/forgot still 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}/join with phone: 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 an OPS_PASSWORD rotation) every earlier token answers 401 "Your password was changed. Sign in again." Tokens minted before this release carry no pv and expire normally.
  • Creative uploads: a .png/.jpg must start with PNG/JPEG magic bytes and an .mp4 with an ISO-BMFF ftyp box, 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,avi only.
  • Every API response carries X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: no-referrer.
  • Web /player/api/* proxies only v1/device/**, v1/creatives/{id}/(file|game|assets/{key}), v1/library/{key}, v1/screens/{id}/assets/{key} (404 otherwise) and forwards only a Device … 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:

JobWhenWhat
bidding.close_dueevery 5 minutesdecides every bidding round past its close time
notifications.run_dailydaily 06:30 ISTcampaign starts today / ended yesterday messages
prizes.remind_expiring_couponsdaily 10:00 ISTWhatsApp reminder for coupons expiring within a day
reports.send_weeklyMondays 08:00 ISTweekly 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_at is null while the scheduler is off.
  • POST /v1/ops/jobs/{job}/run (ops) → the run row (trigger:"manual"; ok:false with detail when 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=&lt;id&gt; screens=<id,id> from=&lt;YYYY-MM-DD&gt; to=&lt;YYYY-MM-DD&gt; generated_at=&lt;iso&gt; records=&lt;n&gt; first=&lt;iso&gt; last=&lt;iso&gt; signature=&lt;hex&gt;. 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 except proof.
  • POST /v1/proof/verify → {valid, version: 2|1|null, records, first, last, …}. v2 adds the bound booking_id, screen_ids, start_date, end_date, generated_at. Version 1 files (rows-only signature, no version line) still verify and answer version: 1 plus warning: "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." (no booking_id echoed). 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/redeem now 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.ts sets a fresh nonce per request; script-src 'self' 'nonce-…' plus *.posthog.com and checkout.razorpay.com. The root layout reads headers(), 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 in lib/csp.mjs.
  • Both: connect-src self, the API origin from NEXT_PUBLIC_API_URL, *.posthog.com, the Sentry ingest origin (from NEXT_PUBLIC_SENTRY_DSN, else *.sentry.io), api.razorpay.com, lumberjack.razorpay.com; img-src/media-src self, the API, data:, blob:; style-src self, 'unsafe-inline', Google Fonts; font-src self, data:, fonts.gstatic.com; frame-src self, api.razorpay.com, checkout.razorpay.com; worker-src self, blob:; frame-ancestors, form-action, base-uri self; object-src 'none'. next dev adds 'unsafe-eval' and ws:.

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=14 already returns by_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; days 2–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 on POST /v1/creatives, POST /v1/creatives/{id}/file, POST /v1/creatives/{id}/edit, POST /v1/ops/library/{key} and POST /v1/ops/screens/{id}/assets/{key}; removed with the file. Never required: without ffmpeg, or when ffmpeg fails, nothing is stored and thumbnail_url is null (the web keeps its <video> preview).
  • Creative dicts gain thumbnail_url: "/v1/creatives/{id}/thumbnail" for a video with a poster frame, else null (images and games are always null).
  • 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/library items gain thumbnail_url ("/v1/library/{key}/thumbnail" | null); POST /v1/ops/library/{key} answers thumbnail_url too. GET /v1/ops/screens/{id}/assets items gain thumbnail_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 with available:false, created:0 and a message.

Device commands

  • No API change: the ops Devices page now offers refetch_schedule as 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}/print renders 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=1 skips 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} (default true).
  • 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.py send_weekly, built on feat/jobs-hardening) must skip every user whose users.weekly_report is 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). website is 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 with Retry-After past WAITLIST_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]}}. reason when 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 from competitors, no duplicates; why ≤ 120 chars; translation dropped (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 when competitors is 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 with Retry-After past AI_COPY_MAX_PER_BUSINESS_PER_HOUR model 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 returns cached:true and 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, over AI_CHECK_NAMES_MAX_PER_BUSINESS_PER_HOUR, error, timeout or an odd answer → checked:false and only cached flags. reason is 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 own brand is 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 adds field ("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 but preview) gets a new file (≤5MB; a too-big png falls back to jpg, else 422). Both keep the previous file and append validation.versions[] = {filename, sha256, replaced_at, by, key?}; status RESETS to pending, 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_key on 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 (name default "<image name> (clip)", status pending, 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 or TRANSCODE_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, see docs/AUDIENCE_MEASUREMENT.md), never less than watchers; watchers and attention_ms_total are the face-based counts as before (a watcher is a face detected for 1 s or more). So ots > watchers is normal for a people-mode screen. In faces mode (the fallback on a weak box) the player keeps reporting ots == watchers, and clients keep treating ots == watchers > 0 as "who passed was not measured". Camera off: all zeros, as before.
  • Metrics (unchanged). has_audience_data = ots > 0 and attention_rate = watchers / ots on 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/heartbeat accepts vision: {mode: "people"|"faces", model: str ≤ 80, fps: 0..120, cpu: 0..100} while the camera is counting (camera: "counting"). cpu is the player's estimate of the percent of one CPU core spent on detection; fps is detection passes per second. It is stored with the rest of the health snapshot and shows up as health.vision on GET /v1/ops/devices. Other values for mode are refused with 422; older players simply do not send it.
  • Player switches. /player?vision=people|faces|off sets the counting mode and saves it on the device (vision_mode in the player's local storage); ?vision=1 turns the camera on in the saved mode (people when none was saved), ?vision=0 turns 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, migration screen_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} accepts sound_enabled; GET /v1/ops/screens and the PATCH response return it. The PATCH bumps schedule_version as before, so devices refetch.
  • GET /v1/device/schedule → screen adds sound_enabled.
  • Engine embed protocol (web/components/game-frame.tsx): gody:config carries sound (boolean); later changes arrive as {type:"gody:sound", on} (a second gody:config would reload the engine). Engines also honour ?sound=1. The web player passes sound_enabled && !muted (per-device mute on the StatusOverlay; ?sound=1|0 overrides 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.role gains mall. A mall user belongs to exactly one venue (users.venue_id) and signs in at the normal POST /v1/auth/login; user and GET /v1/me carry venue:{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, with revenue_booked replaced by share_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 plus venue and items:[{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/venues rows (and POST/PATCH responses) gain revenue_share_bp, brand:{name, accent, logo_url, display_name}, mall_users:[{id, email, created_at}].
  • PATCH /v1/ops/venues/{id} takes revenue_share_bp (0–10000) and brand:{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}/statement and /statement/{YYYY-MM}: the mall's statement, for ops.
  • POST /v1/ops/venues/{id}/payouts {month, reference?} → 201 the month row, now paid with paid_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}/logo multipart file (png/jpg, sniffed, ≤ the game asset limit) → the brand dict; DELETE /v1/ops/venues/{id}/logo removes it. Public GET /v1/venues/{id}/logo (404 when none).

Holiday hours and maintenance (ops, per screen)

  • PATCH /v1/ops/screens/{id} takes special_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: bool and maintenance_reason (≤200). Ops screen dicts gain special_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 (and cricket-hours) gain closed_dates[] and special_days[{date, open_min, close_min}]; with a closed day in range every step is free:false with taken_by: "Closed on 2 Nov"; on a short day the steps outside its hours say "Short hours on 2 Nov".
  • Device: POST /v1/device/heartbeat answers status:{state:"on_air"|"maintenance"|"closed", on_air, message} and maintenance: bool; GET /v1/device/schedule carries the same status and day:{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 accept showing: "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 is fbk_ + 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 plus key. 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 with revoked_at (idempotent); 404 for a key you cannot see. A revoked key answers 401 from the next request.
  • Auth for /v1/partner/*: header X-API-Key. 401 missing / unknown / revoked; 429 with Retry-After past PARTNER_API_MAX_PER_MIN (60) requests per key per minute; 403 when the key's scope does not fit the endpoint. last_used_at updates 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} and GET /v1/wallet/claim/{token} carry venue_brand: {name, logo_url, accent} | null (null until ops set a name, logo or accent; name falls 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.

API contract · Docs