# API Contract (v1) — backend and web are both built against THIS file

Last updated Oct 31, 2026 · Page: https://freebeeads.com/docs/repo/api-contract · Markdown: https://freebeeads.com/docs/md/repo/api-contract.md · All docs: https://freebeeads.com/llms-full.txt

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 &lt;token&gt;, 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 "&lt;Business&gt; 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:

| Job | When | What |
|---|---|---|
| `bidding.close_due` | every 5 minutes | decides every bidding round past its close time |
| `notifications.run_daily` | daily 06:30 IST | campaign starts today / ended yesterday messages |
| `prizes.remind_expiring_coupons` | daily 10:00 IST | WhatsApp reminder for coupons expiring within a day |
| `reports.send_weekly` | Mondays 08:00 IST | weekly report email + notification (below) |

The due rule reads `job_runs`, so it survives restarts: a daily/weekly job is due when its most
recent slot has passed and no run has started since; a late boot catches up within 12 h (24 h for
the weekly report), otherwise it waits for the next slot. Every job was already idempotent, so a
catch-up or a manual run next to a scheduled one changes nothing twice. A job that raises is rolled
back, recorded with `ok:false` and logged once; the thread keeps going. Railway runs one uvicorn
process, so one scheduler runs; with more replicas a DB lock is needed first (TODO in the code).
The ops endpoints `POST /v1/ops/bidding/close-due`, `/v1/ops/notifications/run-daily` and
`/v1/ops/coupons/run-reminders` remain and behave as before (they do not write `job_runs`).

Table `job_runs` (migration `jobs_job_runs`): `id, job, trigger ("schedule"|"manual"), started_at,
finished_at, ok (null while running), detail` (a short scrubbed summary such as `{"closed": 0}` or
`RuntimeError: …`; never a code, token or phone).

- `GET /v1/ops/jobs` (ops) → `{scheduler:{enabled, running}, jobs:[{job, label, explains, schedule,
  next_run_at|null, running, last_run:{id, job, trigger, started_at, finished_at, ok, detail}|null}]}`
  in the table's order. `next_run_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}}]`
```bash
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}`)
```bash
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`.
```bash
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 &lt;name&gt;" 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.
