Self-Served Ads
weNow shows a single ad card at the bottom of the main weather screen to non-premium users. The entire ad system is first-party and deliberately built to avoid every privacy pitfall of traditional mobile advertising.
Why self-served?
Third-party mobile ad SDKs (AdMob, Meta Audience Network, Unity, AppLovin) all share a common set of problems: they ship a heavy native SDK, request IDFA/GAID access, set cookies or local storage, and process personal data for fraud detection even in "non-personalized" mode. This triggers GDPR/ePrivacy consent requirements in the EU — and a consent banner contradicts weNow's privacy-first positioning.
Self-serving ads from our own backend avoids all of that. Under GDPR and ePrivacy, first-party content with no tracking does not require consent. The app fetches an ad from our own API the same way it fetches weather data — there is no legal or ethical distinction.
How it works
- On mount, the
AdBannercomponent callsfetchAd()fromsrc/services/adsService.ts. - That issues a
GET /api/adsto the Cloudflare Workers backend. - The backend queries the
adstable in D1, picks one row using weighted-random selection, and increments that row'simpressionscounter. - The response is a JSON object:
{id, title, body, imageUrl, clickUrl, kind}. - The banner renders it as plain React Native components —
TouchableOpacity,Image,Text. No WebView, no injected JavaScript, no iframes. - On tap: the app fires
POST /api/ads/clickwith just the ad'sid, thenLinking.openURL(clickUrl)opens the destination in the system browser. - On dismiss: the banner hides itself in local component state. Dismissal is session-only; re-opening the app shows a fresh ad.
- If the user is a premium subscriber, the banner returns
nulland never fetches anything.
What is collected vs. not
| Data point | Collected? | Where |
|---|---|---|
| Ad impression counter (per ad row) | Yes, aggregate | ads.impressions column |
| Ad click counter (per ad row) | Yes, aggregate | ads.clicks column |
| User ID | No | — |
| Device ID | No | — |
| IP address (viewer) | No (only hashed for rate limiting, like every endpoint) | — |
| Timestamp of individual event | No | — |
| Session or cookie | No | — |
| Which user saw which ad | Impossible to reconstruct | Schema has no join path |
The ads table has no foreign keys to any user-identifying table. There is no row-level event log. The only data that exists is two integer counters per ad row.
Ad kinds
All three render identically — the distinction is editorial, for billing and reporting:
kind |
Description | Revenue model |
|---|---|---|
cross-promo | Own products (weNow Premium, Saturday Drive tools) | Indirect |
affiliate | Amazon Associates, Awin, Impact Radius, etc. | Commission on merchant sales |
sponsor | Direct deals with advertisers | Paid upfront, hand-managed |
Tradeoffs vs. traditional ad networks
| Concern | AdMob (non-personalized) | weNow self-served |
|---|---|---|
| Third-party native SDK | ~5 MB binary | None |
| IDFA / GAID access | Yes | No |
| Cookies / local storage | Yes | No |
| Cross-site tracking | Yes | No |
| Consent banner (EU) | Required | Not required |
| CPM | $1–5 typical | Depends on sourced inventory |
| Time to first dollar | Immediate | Requires signups and/or sourced sponsors |
| Code surface | Opaque SDK | ~200 LOC of our own code |
| Crash risk | Periodic SDK incidents | None |
The tradeoff is clear: lower headline revenue per impression, but full control, no consent UI, no brand compromise, and no SDK blast radius.
Premium removes ads
When paid premium launches, the showAds flag on PremiumContext becomes !isPremium and paying subscribers stop seeing any ads automatically. The AdBanner component returns null before fetching anything — premium users' devices never even make the /api/ads request.