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

  1. On mount, the AdBanner component calls fetchAd() from src/services/adsService.ts.
  2. That issues a GET /api/ads to the Cloudflare Workers backend.
  3. The backend queries the ads table in D1, picks one row using weighted-random selection, and increments that row's impressions counter.
  4. The response is a JSON object: {id, title, body, imageUrl, clickUrl, kind}.
  5. The banner renders it as plain React Native components — TouchableOpacity, Image, Text. No WebView, no injected JavaScript, no iframes.
  6. On tap: the app fires POST /api/ads/click with just the ad's id, then Linking.openURL(clickUrl) opens the destination in the system browser.
  7. On dismiss: the banner hides itself in local component state. Dismissal is session-only; re-opening the app shows a fresh ad.
  8. If the user is a premium subscriber, the banner returns null and never fetches anything.

What is collected vs. not

Data point Collected? Where
Ad impression counter (per ad row)Yes, aggregateads.impressions column
Ad click counter (per ad row)Yes, aggregateads.clicks column
User IDNo
Device IDNo
IP address (viewer)No (only hashed for rate limiting, like every endpoint)
Timestamp of individual eventNo
Session or cookieNo
Which user saw which adImpossible to reconstructSchema 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-promoOwn products (weNow Premium, Saturday Drive tools)Indirect
affiliateAmazon Associates, Awin, Impact Radius, etc.Commission on merchant sales
sponsorDirect deals with advertisersPaid upfront, hand-managed

Tradeoffs vs. traditional ad networks

Concern AdMob (non-personalized) weNow self-served
Third-party native SDK~5 MB binaryNone
IDFA / GAID accessYesNo
Cookies / local storageYesNo
Cross-site trackingYesNo
Consent banner (EU)RequiredNot required
CPM$1–5 typicalDepends on sourced inventory
Time to first dollarImmediateRequires signups and/or sourced sponsors
Code surfaceOpaque SDK~200 LOC of our own code
Crash riskPeriodic SDK incidentsNone

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.