Drafted 2026-08-28. Not built.
All three storefronts show zero ratings — Mac App Store, Microsoft Store and
the iPhone App Store (Microsoft confirmed via the Store catalogue API on
2026-08-28: RatingCount: 0). A zero-rated listing both ranks worse and converts
worse, so every channel in marketing/README.md §4 is throttled behind this, and
driving traffic at the listings first makes it compound rather than solve itself.
The obvious fix — ask existing users by name — does not scale here, and it is worth being precise about why:
- Store buyers are not contactable. Neither Apple nor Microsoft gives the developer the customer. They can only be reached inside the app.
- The directly-contactable pool is tiny. Paddle customers (the
SUMMER26counter read one use), TestFlight testers, and anyone who has emailed support.
Ten ratings cannot be manufactured out of five people. The in-app prompt is the only route that scales, which makes it a precondition for Product Hunt rather than a nicety to add afterwards.
Both platform SDKs are already dependencies, because the Production Unlock entitlement needs them:
requirements.txt:28 winrt-Windows.Services.Store>=3.2 ; win32
requirements.txt:35 pyobjc-framework-StoreKit>=10.0 ; darwin
Windows.Services.Store carries StoreContext.RequestRateAndReviewAppAsync, and
StoreKit carries SKStoreReviewController. So this is one new module and one
call site, not a new SDK integration.
Add app/core/review_prompt.py, following the shape already established by
app/core/store_entitlement.py (Windows) and app/core/mac_store_entitlement.py
(macOS): a single public surface, per-channel implementations behind it, and
imports guarded so the module can never break a build it does not apply to.
Public surface:
review_available() -> bool # does this build have a store to rate on
note_successful_shipment() -> None # count a success; cheap, call freely
maybe_request_review(parent) -> None # apply the gates, then ask the OSmaybe_request_review is the only thing a view calls. Every gate lives inside it
so no call site has to know the rules.
| Build | Flag | Mechanism | Behaviour |
|---|---|---|---|
| Microsoft Store | STORE_BUILD |
StoreContext.RequestRateAndReviewAppAsync() |
Returns a status — the only channel that tells you anything |
| Mac App Store | MAS_BUILD |
SKStoreReviewController.requestReview() |
Fire and forget; no callback, no outcome |
| Direct download | LICENSE_REQUIRED |
none | No automatic prompt at all — see below |
The direct-download build must never show a review prompt. There is nowhere to leave a review: Microsoft requires ownership through the Store, and Mac App Store reviews require the app to have come from it. Prompting a direct-download user sends them somewhere they cannot act, which is worse than not asking.
The honest ask for that channel is different — a passive menu item under Help, "Star the project on GitHub", opening the repository. No dialog, no timing logic, no counter. It is also the only channel where the repository's single star can move.
maybe_request_review returns without doing anything unless all hold:
review_available()— a store build.- Production mode, and
production_allowed(). A test-mode user has not yet had the experience being rated, and has paid nothing. - At least 3 successful shipments or batches, cumulative across sessions. Not the first: a first label is a nervous test, and asking then rates the anxiety rather than the product.
- At least 7 days since first run.
- At least 120 days since the last prompt.
- Fewer than 3 prompts ever. Apple's own ceiling is three per 365 days; this is deliberately below it.
- Nothing went wrong this session — no failed purchase, no refund, no API error, no licence-gate dialog, no update notice. A prompt following friction collects the friction.
- The window is active. Do not prompt into a background window.
At the moment of satisfaction, which here is the label rendering successfully
— not the click that bought it. In
app/ui/views/create_shipment_view.py the purchase runs at line ~1516
(buy_shipment via run_async); the hook belongs in its success handler, after
the label is displayed. app/ui/views/batch_view.py has the equivalent on batch
completion.
Fire on a single-shot ~2 second timer after the success UI settles, so the prompt never races the label render or interrupts a print. If the user has navigated away or the window lost focus in those two seconds, skip it — there will be another shipment.
app/core/settings.py, alongside the existing store_unlock_confirmed_at:
first_run_at: Optional[str] = None # ISO; needed for gate 4
review_success_count: int = 0 # gate 3
review_last_prompted_at: Optional[str] = None # gate 5
review_prompt_count: int = 0 # gate 6first_run_at does not currently exist and must be stamped on first launch.
Back-fill it for existing installs to now rather than leaving it null, so the
7-day gate starts from the upgrade rather than firing immediately.
Do not sentiment-gate. The tempting design — "Enjoying Easy-Post? " and only sending the people who say yes to the store — violates Apple's guidelines and is the single most common way this feature gets an app rejected. Everyone who passes the gates gets the same system prompt, regardless of what they think.
Do not build a custom dialog. Use each platform's native call. A homemade "Rate us" window that mimics the system one is both a rejection risk and less effective.
Do not incentivise. No discount, no unlock, no "rate us to remove this".
Do not ask for five stars. The ask is for an honest rating. Anything else is review manipulation on both stores.
Expect nothing back from Apple. SKStoreReviewController.requestReview() may
show nothing at all — the system decides, throttles to three per year per device,
and never reports what happened. Treat a call as requested, never as shown
and never as rated. Microsoft's RequestRateAndReviewAppAsync does return a
status, which makes it the only channel with any feedback.
Local instrumentation can only ever count prompts requested — no platform tells you whether a rating resulted. So the real measurement is store-side, and it already has a route:
- Microsoft:
RatingCountandAverageRatingfrom the public display catalogue, no authentication —https://displaycatalog.mp.microsoft.com/v7.0/products/9NDSDL5LV5B5?languages=en-US&market=US - Apple:
customerReviewson the App Store Connect API, using the key already wired intotools/asc_analytics.py.
Worth a small tools/ratings_watch.py printing all three counts in one line, run
weekly. That, against the local prompt counter, gives a crude conversion rate —
which is all anyone gets here.
The gates are the whole feature, so test those rather than the platform call.
- Each gate individually blocks, by manipulating settings directly.
- All gates passing calls the platform shim exactly once.
- The platform shim is mockable: assert the decision, not Apple's or Microsoft's behaviour, neither of which is testable in CI.
- Import safety: importing
review_prompton Linux, on a dev run, and with the SDK missing must not raise — same guarantee the two entitlement modules make. LICENSE_REQUIREDbuild never calls anything.- Timer path: window loses focus within the 2 seconds → no prompt.
easypost_mobile_companion is Flutter, so none of the above applies. It needs the
in_app_review package and its own trigger — the natural moment there is a
tracked parcel reaching delivered, not app launch. Same gates in spirit, same
policy constraints, different codebase. Worth doing, but as its own piece of work.
This blocks Product Hunt (marketing/product-hunt-launch.md), which is gated on
ratings existing. It does not block the Microsoft listing rewrite or the macOS
promotional text, both of which are ready and independent.
Reasonable order: build this → let it run while the listing copy goes in → watch the counts weekly → launch when the listings are no longer showing zero.