# MoneyMaker > Self-hosted alternative to RevenueCat. One backend for App Store (StoreKit 2), Google Play Billing and Stripe subscriptions: receipt validation, server notifications, entitlements, remote paywall offerings, metrics (MRR, churn, trials), outgoing signed webhooks. Base URL: https://moneymaker-io.web.app/v1 ## Install SDKs (1.1.1) - iOS (Swift Package Manager): `https://github.com/amineprimesmr/moneymaker`, from `1.1.1`. - Android (JitPack): `implementation("com.github.amineprimesmr:moneymaker:1.1.1")` + `maven("https://jitpack.io")`. Amazon Appstore: optional module `sdk/android/moneymaker-amazon`. - React Native / Expo: `npx expo install moneymaker-react-native` then `npx expo prebuild` (development build, not Expo Go). - Flutter: `moneymaker_flutter: { git: { url: https://github.com/amineprimesmr/moneymaker, path: sdk/flutter, ref: 1.1.1 } }` (Flutter 3.44+). ## Setup status (use it to know what is left) `GET /v1/projects/{id}/setup` → `{ progress (0–1), next, steps: [{ id, title, done, optional, help }] }`. Steps auto-validate from real traffic (SDK call, Apple/Google notification received, Stripe webhook, first purchase). Human guide: https://moneymaker-io.web.app/guide ## Instructions for coding agents integrating MoneyMaker into an app You need from the user: a PUBLIC key (`mm_pub_…`, safe to ship in apps) and the entitlement id to gate (default `premium`). The SECRET key (`mm_sk_…`) must never be put in an app binary — server-side only. ### iOS / macOS / watchOS (Swift, StoreKit 2) 1. Add Swift package: `https://github.com/amineprimesmr/moneymaker` → product `MoneyMaker`. Or copy `sdk/ios/Sources/MoneyMaker` into the project. 2. At launch (App init): ```swift import MoneyMaker MoneyMaker.configure(apiKey: "mm_pub_…", appUserID: currentUser?.id) // nil = anonymous ``` 3. After sign-in: `try await MoneyMaker.shared.logIn(user.id)` (anonymous purchases are merged). On sign-out: `MoneyMaker.shared.logOut()`. 4. Gate features: `MoneyMaker.shared.isEntitled("premium")` (sync, cached, offline-safe). SwiftUI: observe `@ObservedObject var mm = MoneyMaker.shared` and read `mm.customerInfo`, or use `.requiresEntitlement("premium")`. 5. Paywall: `let offerings = try await MoneyMaker.shared.loadOfferings()`; `offerings.current?.packages` each has `.product` (StoreKit `Product`), `.localizedPrice`, `.hasFreeTrial`, `.metadata`. Buy: `try await MoneyMaker.shared.purchase(package)` → `.success(info) | .userCancelled | .pending`. Or present the drop-in `MoneyMakerPaywall(termsURL:privacyURL:)` (renders the remote paywall config when set — see “Paywalls, targeting, placements & A/B experiments”). 6. Restore button: `try await MoneyMaker.shared.restorePurchases()`. Manage: `await MoneyMaker.shared.showManageSubscriptions()`. 7. Remove RevenueCat (`Purchases.configure`, `Purchases.shared.*`) and any custom StoreKit transaction listener — the SDK already listens to `Transaction.updates` and finishes transactions after server validation. ### Android (Kotlin, Play Billing 7) 1. Add `maven("https://jitpack.io")` to repositories and `implementation("com.github.amineprimesmr:moneymaker:1.1.1")` (brings `com.android.billingclient:billing-ktx:7.1.1`). 2. `MoneyMaker.configure(applicationContext, apiKey = "mm_pub_…", appUserId = user?.id)` in `Application.onCreate`. 3. `MoneyMaker.shared.logIn(id)` (suspend), `MoneyMaker.shared.isEntitled("premium")`, `MoneyMaker.shared.customerInfo` (StateFlow). 4. `val offerings = MoneyMaker.shared.loadOfferings()`; `MoneyMaker.shared.purchase(activity, offerings.current!!.packages.first())`. Purchases are verified and acknowledged by the server. 5. `MoneyMaker.shared.restorePurchases()`. ### Web / Stripe - Checkout: `POST /v1/customers/{appUserId}/stripe/checkout` with public key, body `{ "priceId": "price_…", "successUrl": "https://…", "cancelUrl": "https://…" }` → `{ url }`. Redirect to url. - Portal: `POST /v1/customers/{appUserId}/stripe/portal` body `{ "returnUrl": "https://…" }` → `{ url }`. - Subscriptions created elsewhere are linked when `metadata.app_user_id` is set on the Stripe subscription or customer. ### Your own backend - Check access: `GET /v1/customers/{appUserId}` with public or secret key → `{ activeEntitlements: ["premium"], entitlements: {...}, purchases: [...] }`. - Receive events: configure webhook URLs in the dashboard. Each POST has header `MoneyMaker-Signature: t=,v1=` where `hex = HMAC_SHA256(signingSecret, t + "." + rawBody)`. Reject if older than 5 minutes. Event `type` ∈ INITIAL_PURCHASE, RENEWAL, TRIAL_STARTED, TRIAL_CONVERTED, CANCELLATION, UNCANCELLATION, EXPIRATION, BILLING_ISSUE, BILLING_RECOVERED, REFUND, REVOKED, PRODUCT_CHANGE, NON_RENEWING_PURCHASE, PAUSED, GRANT, TEST. ## Project setup by an agent (personal access token `mm_pat_…`) - `POST /v1/projects` `{ "name": "MyApp", "appStoreAppId": "6400000000" (optional: pre-fills bundle id, Apple ID and ranking tracking), "googlePackageName": "com.myapp" (optional), "config": { "currency": "EUR", "entitlements": { "premium": ["com.myapp.monthly", "com.myapp.annual"] }, "apple": { "bundleId": "com.myapp", "appAppleId": 6400000000 }, "google": { "packageName": "com.myapp" } } }` → `{ projectId, publicKey, secretKey }` (secret shown once). - `PATCH /v1/projects/{id}` `{ "config": { "offerings": { "default": { "packages": [ { "id": "annual", "productIds": { "app_store": "com.myapp.annual", "play_store": "annual", "stripe": "price_…" }, "metadata": { "badge": "-40%" } } ] } }, "currentOffering": "default", "webhooks": [ { "url": "https://api.myapp.com/moneymaker" } ] } }` - `PUT /v1/projects/{id}/credentials` `{ "apple": { "issuerId", "keyId", "privateKey": "-----BEGIN PRIVATE KEY-----…" }, "google": { "serviceAccount": {…json…} }, "stripe": { "secretKey": "rk_live_…", "webhookSecret": "whsec_…" } }` - `GET /v1/projects/{id}` → `endpoints.appleNotifications` (paste in App Store Connect → App Information → App Store Server Notifications V2, production and sandbox), `endpoints.googleRtdn` (Pub/Sub push subscription URL), `endpoints.stripeWebhook`. ## One-step store connections (secret key, PAT or dashboard session) - Stripe: `POST /v1/projects/{id}/connect/stripe` `{ "secretKey": "sk_live_…" | "rk_live_…" }` → validates the key, creates the Stripe webhook endpoint automatically, imports every subscription and 12 months of paid invoices. Response: `{ account, livemode, importedSubscriptions, importedInvoices }`. - App Store Connect: `POST /v1/projects/{id}/connect/appstore` `{ "issuerId", "keyId", "privateKey": "-----BEGIN PRIVATE KEY-----…", "appAppleId": "6400000000", "setNotificationUrl": true? }` (App Store Connect API key, App Manager role) → reads the app, imports subscription/IAP product ids into the first entitlement, starts ranking tracking, and points App Store Server Notifications V2 (production + sandbox) at MoneyMaker when the URL is empty or `setNotificationUrl: true`. - Google Play: `POST /v1/projects/{id}/connect/google` `{ "packageName", "serviceAccount": {…} }` → verifies Play access, imports subscription ids, returns the Pub/Sub push endpoint for RTDN. ## Migrate from RevenueCat `POST /v1/projects/{id}/import/revenuecat` `{ "secretKey": "sk_…", "appUserIds": ["uid1", …] (only for legacy v1 keys) }` → `{ jobId }`; poll `GET /v1/projects/{id}/import/{jobId}` → `{ status: queued|running|done|failed, result: { customers, purchases } }`. Imports current subscriptions and lifetime purchases (sandbox excluded). When the real store purchase later reaches MoneyMaker (SDK or notification) the imported copy is replaced, never double-counted. One-time payments (Checkout, Payment Links, charges) are imported and tracked too. Stripe customers are linked through metadata `app_user_id`, `firebaseUID`, `user_id` or `uid`. ## RevenueCat-compatible API (migrate a backend by changing base URL + key) Base URL `https://moneymaker-io.web.app/v1/rc` (every route is also served without `/rc`, e.g. `/v1/subscribers/{id}`). Auth `Authorization: Bearer mm_pub_…` (public key, read + receipts + attributes, like RevenueCat's SDK key) or `mm_sk_…` (secret key, everything): - `GET /v1/rc/v1/subscribers/{app_user_id}` → RevenueCat v1 `{ subscriber: { entitlements, subscriptions, non_subscriptions, subscriber_attributes, … } }`. - `DELETE /v1/rc/v1/subscribers/{app_user_id}` (secret) → `{ app_user_id, deleted: true }` (GDPR). - `POST /v1/rc/v1/subscribers/{app_user_id}/attributes` `{ "attributes": { "$email": { "value": "…", "updated_at_ms": … } } }` (null/"" deletes). - `GET /v1/rc/v1/subscribers/{app_user_id}/offerings` → `{ current_offering_id, offerings: [{ identifier, description, metadata, packages: [{ identifier, platform_product_identifier }] }] }` (store chosen from `X-Platform: ios|android|stripe`). - `POST /v1/rc/v1/subscribers/{app_user_id}/alias` `{ "new_app_user_id": "…" }`. - `POST /v1/rc/v1/receipts` `{ app_user_id, fetch_token, product_ids?, price?, currency?, attributes? }`: Apple = StoreKit 2 signed transaction (JWS); Google = purchase token (`X-Platform: android`); Stripe = `sub_…` or `cs_…`. Base64 StoreKit 1 receipts → 422 `legacy_receipt_unsupported`. - `POST /v1/rc/v1/subscribers/{app_user_id}/entitlements/{entitlement}/promotional` (secret) `{ "duration": "daily" … "lifetime" }` or `{ "end_time_ms": … }` (stacks like RevenueCat). - `POST /v1/rc/v1/subscribers/{app_user_id}/entitlements/{entitlement}/revoke_promotionals` (secret). - `POST …/subscriptions/{product_id}/revoke|refund|defer` → 501 `not_supported` (do it in the store console; notifications sync the result). - Webhooks in RevenueCat format: `PATCH` config `{ "webhooks": [{ "id": "rc", "url": "https://…", "format": "revenuecat" }] }` and set the header your receiver checks with `PUT …/credentials { "webhookAuthorization": { "rc": "Bearer " } }`. Bodies are `{ api_version, event: { type, app_user_id, product_id, period_type, price, is_trial_conversion, … } }`. During a migration, read both RevenueCat and MoneyMaker and keep the entitlement expiring last (old app versions still buy through RevenueCat). ## Integrations `PUT /v1/projects/{id}/credentials` `{ "integrations": { "slackWebhookUrl", "discordWebhookUrl", "mixpanelToken", "amplitudeApiKey", "segmentWriteKey", "posthog": { "apiKey", "host"? } }, "appsflyer": { "devKey" } }` (null removes one). AppsFlyer app ids: `PATCH` config `{ "integrations": { "appsflyer": { "appId": "id123", "androidAppId": "com.app" } } }`; the app must set attributes `$appsflyerId` (+ `$idfa`, `$idfv` when allowed). Events are sent as `mm_` (Mixpanel/Amplitude/Segment/PostHog) and `rc_*_event` (AppsFlyer, RevenueCat-compatible names). ### Server-side connectors (attribution & CRM) Same `PUT /v1/projects/{id}/credentials` `{ "integrations": { "": { …fields, "sandbox"?: true } } }`; blank fields keep the stored value, `null` removes the connector. Sandbox events are skipped unless `sandbox: true`; `TEST` events never go out. Failures are recorded on the event (`integrations.`) and never block webhooks. - `meta` `{ datasetId, accessToken, apiVersion?, testEventCode? }` → Conversions API app events (`action_source: "app"`): TRIAL_STARTED=StartTrial, INITIAL_PURCHASE/TRIAL_CONVERTED=Subscribe, RENEWAL/NON_RENEWING_PURCHASE=Purchase. Needs `$fbAnonId` or `$idfa`/`$gpsAdId`; `advertiser_tracking_enabled` = `$attConsentStatus == "authorized"` on iOS. - `adjust` `{ appToken, s2sToken, eventTokens: { "INITIAL_PURCHASE": "abc123", … } }` → `https://s2s.adjust.com/event`; only mapped types are sent; ids `$adjustId`, `$idfa`, `$idfv`, `$gpsAdId`, `$androidId`. - `branch` `{ branchKey }` → `/v2/event/standard` (START_TRIAL, SUBSCRIBE, PURCHASE) or `/v2/event/custom` (`mm_`). - `singular` `{ sdkKey }` (needs `$ip` + ad ids + bundle id in config), `kochava` `{ appGuid }` (needs `$kochavaDeviceId`), `tenjin` `{ apiKey }` (revenue only, `/v0/purchase`). - `braze` `{ restEndpoint, apiKey }` (users/track: purchases, events, attributes; `$brazeAliasName`/`$brazeAliasLabel` or external_id), `onesignal` `{ appId, restApiKey }` (user tags `mm_*`, by `$onesignalId` or external_id), `iterable` `{ apiKey, region? }`, `customerio` `{ siteId, apiKey, region? }`, `clevertap` `{ accountId, passcode, region? }`, `airship` `{ token, region? }`, `intercom` `{ accessToken, region? }` (event + custom attributes `mm_last_event`, `mm_product_id`, `mm_is_subscriber` which must exist in Intercom), `ga4` `{ firebaseAppId, apiSecret, androidFirebaseAppId? }` (Measurement Protocol, needs `$firebaseAppInstanceId`; revenue = `purchase`), `mparticle` `{ apiKey, apiSecret, pod? }`, `statsig` `{ serverSecret }`, `telemetrydeck` `{ appId }`. Reserved attributes read by connectors: `$email $displayName $phoneNumber $ip $idfa $idfv $gpsAdId $androidId $attConsentStatus $appsflyerId $adjustId $branchId $fbAnonId $kochavaDeviceId $tenjinId $singularId $onesignalId $onesignalUserId $brazeAliasName $brazeAliasLabel $iterableUserId $customerioId $clevertapId $airshipChannelId $intercomId $mparticleId $firebaseAppInstanceId $appleAdsToken $osVersion $appVersion $deviceModel $locale`. ### Apple Search Ads The app sends the AdServices token (`AAAttribution.attributionToken()`) as attribute `$appleAdsToken`. The backend exchanges it once at `https://api-adservices.apple.com/api/v1/` and stores `appleAds { attribution, orgId, campaignId, adGroupId, keywordId, adId, conversionType, countryOrRegion, clickDate }` on the customer (returned by `GET /v1/projects/{id}/customers/{appUserId}`). Metrics expose `revenueByCampaign` (`apple_ads:` or `organic`). Only ids are available (names need the Apple Ads campaign API). ### Scheduled exports `PATCH` config `{ "exports": [{ "id": "s3-daily", "destination": "s3"|"gcs"|"webhook"|"email", "schedule": "daily"|"weekly", "datasets": ["transactions","events","customers","subscriptions"], "format": "csv"|"jsonl", "s3": { "bucket", "region", "prefix"? }, "gcs": { "bucket", "prefix"? }, "webhook": { "url" } }] }` (max 10; `null` clears). Secrets: `PUT …/credentials { "exports": { "": { "s3": { "accessKeyId", "secretAccessKey" } | "gcs": { "serviceAccount": {…} } | "webhookSecret"? } } }` (`null` removes). Runs daily at 03:00 UTC (weekly = Mondays) and writes one file per dataset with rows changed since the last successful run: `//.`. Transactions/events by `at`, customers/subscriptions by customer `updatedAt`. S3 uses SigV4 `PutObject` (IAM user needs `s3:PutObject`), GCS uses the JSON upload API (service account needs `storage.objects.create`), webhooks receive `{ projectId, exportId, dataset, format, since, until, filename, content }` signed `MoneyMaker-Signature` (export `webhookSecret`, else the project signing secret). Email is not available yet (no mail provider): such exports are skipped. ## App Store intelligence (rankings, alerts, ratings, reviews) - Search: `GET /v1/appstore/search?term=whoop&country=fr`. - Track apps (own or competitors, max 25): `PUT /v1/projects/{id}/appstore` `{ "apps": [{ "appId": "933944389", "own": false }] }`. First scan starts immediately (25 biggest markets), then hourly; every storefront (177) is refreshed at least every 6 h. Charts: top free / paid / grossing, overall and in the app's category. - `GET /v1/projects/{id}/appstore` (summary: countriesRanked, bestRank, rating, topRankings) · `GET …/appstore/{appId}/ranks` (rank, prevRank, bestRank, history per country/chart) · `GET …/appstore/{appId}/ratings` (per country) · `GET …/appstore/{appId}/reviews?country=FR&rating=1` · `POST …/appstore/{appId}/reviews/{reviewId}/translate { "target": "fr" }`. - Alerts: `GET /v1/projects/{id}/alerts`, all businesses `GET /v1/alerts` (PAT). Types: NEW_COUNTRY, TOP_100, TOP_10, TOP_1, JUMP (+10), DROP (−15), LEFT_CHART. Delivered to Slack/Discord and to your webhooks as `RANKING_`. ## Customer Center Self-service subscription screen (like RevenueCat Customer Center): active subscriptions, manage/cancel, restore, refund, contact support, cancellation survey + retention offer. - Config (PATCH /v1/projects/:pid with `config.customerCenter`, null to remove): `{ enabled, supportEmail?, retentionOffer?: { productIdApple?, offerIdApple?, googleOfferId?, message }, survey: { question, options: string[1..10] } }`. - GET /v1/customer-center (public key) → that config (`{enabled:false}` if unset). - POST /v1/customers/:id/customer-center/feedback (public key) `{ reason, comment?, action }`, action ∈ cancel | accepted_offer | refund | dismissed | contact_support. Stored in `projects/{pid}/feedback` and sent to integrations as event `CUSTOMER_CENTER_FEEDBACK`. - iOS (SwiftUI, iOS 16+): `.moneyMakerCustomerCenter(isPresented: $show)` or `MoneyMakerCustomerCenter()`. Cancel flow: survey → retention offer (alert) → system manage-subscriptions sheet. Apple promotional offers must be signed server-side: pass `signOffer: { productID, offerID in PromotionalOfferSignature(...) }`; without it the offer message is shown only. Refunds use `Transaction.beginRefundRequest`. Also `MoneyMaker.shared.loadCustomerCenterConfig()` / `sendCustomerCenterFeedback(reason:comment:action:)` for custom UIs. - Android: `MoneyMaker.shared.showCustomerCenter(activity)` opens Play subscription management (deep link with sku + package); `customerCenterConfig()`, `sendCustomerCenterFeedback(reason, action, comment)`. ## Paywalls, targeting, placements & A/B experiments Dashboard tab "Paywalls & tests". Everything lives in the project config (`PATCH /v1/projects/{id}` `{ "config": { … } }`) and is validated server-side (400 with an explicit `error` code). - Remote paywall: `config.offerings..paywall` = `{ template: "stack"|"cards"|"minimal", title?, subtitle?, cta?, features?: [{ icon? (SF Symbol), text }] (≤12), heroImage? (https URL), colors?: { background?, accent?, text? } (#RGB/#RRGGBB/#RRGGBBAA), showCloseButton? (default true), closeDelay? (0–30 s), footerLinks?: [{ title, url (https) }] (≤6), localizations?: { "fr": { title?, subtitle?, cta?, features?, footerLinks? }, "en": {…} } }`. Localized fields override base fields one by one (language code, then its 2-letter prefix). Served unchanged inside `GET /v1/offerings` → `offerings..paywall`. - Targeting: `config.targeting` = `{ rules: [{ id, name?, conditions: { countries?: ["FR"], platforms?: ["ios"|"android"|"web"], appVersionMin?, appVersionMax? ("1.2.3", inclusive), attributes?: { key: value } (customer attributes, exact match), newCustomersOnly? (no purchase/entitlement ever) }, offeringId }], placements: { "": { offeringId?, rules?: [same rule format] } } }` (null removes). Rules are evaluated in order, first match wins. A condition whose context is missing in the request never matches. Unknown offeringIds are rejected. - Experiments: `config.experiments` = `[{ id, name, status: "draft"|"running"|"stopped", placement? (omit or "default" = the current offering), variants: [{ id, offeringId, weight }] (2–5) }]`. Send the whole array. `startedAt`/`stoppedAt` are set by the server. At most one running experiment per placement; variants are frozen once an experiment left draft; a stopped experiment cannot be restarted. - Assignment: deterministic, `sha256(appUserId + ":" + experimentId)` → uniform [0,1) → weighted variant. Same user = same variant on every device. Requests without `appUserId` are never enrolled. - Resolution order for each placement (and for `current` = placement "default"): 1) running experiment → the user's variant offering (experiments override targeting); 2) first matching targeting rule (placement rules for a placement, global `rules` for `current`); 3) placement `offeringId`; 4) for placements: the resolved `current`; for `current`: `config.currentOffering`, else the first offering. - `GET /v1/offerings?appUserId=&platform=ios|android|web&appVersion=&country=FR` (public key; platform guessed from User-Agent, country from `X-Country-Code` if omitted) → `{ current, offerings, placements: { "": offeringId }, experiments: [{ experimentId, variant, placement, offeringId }] }`. Enrollment is recorded once per user/experiment in `projects/{pid}/experimentEnrollments/{experimentId}_{appUserId}` (first exposure time = `at`). - Paywall events: `POST /v1/customers/{appUserId}/paywall-events` (public key) `{ type: "impression"|"close"|"purchase_start"|"purchase"|"cancel", offeringId, placement?, experimentId?, variant? }` → stored in `projects/{pid}/paywallEvents`, daily counters in `projects/{pid}/paywallStats/{YYYY-MM-DD}` (`counts..`), per-experiment counters in `projects/{pid}/experimentStats/{experimentId}`. `GET /v1/projects/{id}/paywall-stats?days=30` → `{ byDay, totals: { : { impression, purchase, … } } }`. - Results: `GET /v1/projects/{id}/experiments/{experimentId}/results` → `{ experiment, currency, variants: [{ id, offeringId, enrolled, impressions, conversions, revenueMicros, conversionRate, impressionConversionRate, arpuMicros, probabilityToBeBest }] }`. Conversion = enrolled user with a first purchase (`transactions.kind == "purchase"`) at/after enrollment (sandbox excluded, transactions after `stoppedAt` excluded). Revenue = net purchases + renewals − refunds in project currency; ARPU = revenue / enrolled. probabilityToBeBest = Bayesian P(best conversion rate), Beta(1+conv, 1+enrolled−conv) posteriors approximated by normals, integrated numerically. - iOS: `loadOfferings()` sends appUserId/platform/appVersion/country automatically. `offerings.offering(forPlacement: "onboarding")` or `MoneyMaker.shared.offering(forPlacement:)`. `MoneyMakerPaywall(placement: "onboarding")` renders the remote paywall (fallback: built-in layout) and tracks impression/close/purchase_start/purchase/cancel with the experiment variant. `.presentPaywallIfNeeded(requiredEntitlement: "pro", placement: "onboarding")` shows it as a sheet when the user lacks the entitlement. Custom UI: `MoneyMaker.shared.trackPaywallEvent(.impression, offering:, placement:)`. `Offering.paywall` exposes the decoded `PaywallConfig` (`texts(for: languageCode)`). - Android: `loadOfferings()` sends the same context; `offerings.offeringForPlacement("onboarding")`, `offering.paywallJson` (raw config, render it yourself), `MoneyMaker.shared.trackPaywallEvent(PaywallEventType.IMPRESSION, offering, placement)`. ## Charts (RevenueCat Charts parity) - `GET /v1/projects/{id}/charts/{name}?days=30&resolution=day|week|month&store=app_store|play_store|stripe&country=FR&product=pro_monthly` (secret key, PAT or session; any role) → `{ name, unit: "count"|"micros"|"ratio", resolution, currency, days, filters, series: [{ date, value, ...breakdown }], summary, note? }`. `date` = bucket start (UTC; weeks start Monday). Money in micros of the project currency; ratios 0–1 (null when the denominator is 0). Sandbox and promotional grants excluded. CSV: add `format=csv` (or `{name}.csv`). List of names: `GET /v1/charts`. - Levels (value at bucket end, from `daily` snapshots when present and unfiltered, otherwise reconstructed from purchases `purchasedAt`/`expiresAt`, refunds/revocations cut at `updatedAt`): `active_subscriptions`, `active_trials`, `mrr`, `arr` (= 12 × MRR). summary.value = last point. - Movements (events): `active_subscriptions_movement` and `mrr_movement` → `new` (INITIAL_PURCHASE non-trial + TRIAL_CONVERTED), `reactivated` (same, for a user who churned before), `churned` (negative: EXPIRATION/REVOKED non-trial, subscription REFUND), value = net. No expansion/contraction (product changes count as churn + new). `active_trials_movement` → new, converted (−), expired (−), cancelled. - Flows (transactions): `revenue` (value = gross; revenue, refunds, net, transactions), `refunds` (count; amount), `refund_rate` (refunds / gross), `non_subscription_purchases` (transactions without a period; revenue), `new_customers` (customers by `firstSeenAt`). - Cohorts by first seen (bucket): `conversion_to_paying` (paid within `conversionDays`, default 7; `immature` = cohort members younger than the window), `initial_conversion` (trial or purchase within the window), `realized_ltv_per_customer` / `realized_ltv_per_paying_customer` (net revenue to date / cohort size or payers). - Trials by start bucket: `trial_conversion_funnel` (started → converted → stillActive, plus cancelled/pending), `trial_conversion_rate`, `trial_cancellation_rate`. - `subscription_retention`: monthly cohorts by first paid subscription transaction; point = cohort (`value` = size), `p0…pN` = share still paying N months later (annual payments cover 12 months; null = not reached yet); `periods=12` (max 36). - `churn`: churned subscriptions in bucket / active subscriptions at bucket start. - `subscription_status`: active / cancelled_active (auto-renew off) / grace / billing_retry / expired. Store state is only known now: past buckets only split active vs expired. - `paywall`: from `projects/{id}/paywallEvents` (impression, purchase_start, purchase, close/cancel); value = purchases / impressions. Without paywall events: value null and a `note`. - Dashboard: "Graphiques" tab (chart, period, resolution, store/country/product filter, CSV). ## Predicted LTV, audiences, anomalies, virtual currency - Predicted LTV: `GET /v1/projects/:pid/ltv-prediction?groupBy=product|country|store` → `{currency, groups: [{key, customers, observedMonths, retentionCurve, predictedCurve, churnParams: {model: "sbg"|"geometric", alpha?, beta?, monthlyChurn}, arpuMonthMicros, ltv12, ltv24, confidence: low|medium|high}]}`. Shifted-beta-geometric fit on monthly cohort survival (right-censored), geometric fallback with little data; ARPU is net of refunds per paid month. - Audiences (saved segments): `config.audiences: [{id, name, filters: {store?, country?, productId?, entitlement?, status?: active|trial|churned|billing_issue|never_paid, firstSeenAfter?, firstSeenBefore?, spentMinMicros?, attributes?: {k: v}, appleAdsCampaign?}}]` (PATCH /v1/projects/:pid). `GET /v1/projects/:pid/audiences` (with counts), `POST /v1/projects/:pid/audiences/preview {filters}`, `GET /v1/projects/:pid/audiences/:aid/customers?limit=&cursor=` (paginated) or `?format=csv`. - Anomalies: every morning each metric (revenue, new subscriptions, trials, refunds, billing issues, churn) of yesterday is compared with the trailing 28-day mean/std (z-score ≥ 3 by default; `config.anomalies: {enabled, threshold 2–6, weeklySummary}`), stored in `projects/{pid}/anomalies`, pushed to the iOS app and Slack/Discord. `GET /v1/projects/:pid/anomalies`. Weekly summary every Monday 08:00 Paris (revenue WoW, MRR, new subscribers, top country). - Virtual currency: `config.virtualCurrencies: [{code, name, grants: {productId: amount}}]`. Credited automatically on INITIAL_PURCHASE / RENEWAL / NON_RENEWING_PURCHASE / TRIAL_CONVERTED of a mapped product, debited on REFUND (idempotent per store transaction). `GET /v1/customers/:id/wallet` (public key) → `{virtualCurrencies: {CODE: {code, name, balance}}}`; `POST /v1/customers/:id/wallet/:code/spend {amount, idempotencyKey}` and `POST /v1/customers/:id/wallet/:code/grant {amount, idempotencyKey?, reason?}` with the secret key only (402 insufficient_balance, 409 idempotency_key_reused). SDKs: `MoneyMaker.shared.virtualCurrencies()` (iOS) / `moneyMaker.virtualCurrencies()` (Android). ## Ad monetization (AdMob, AppLovin…) & benchmarks - Impression-level ad revenue: `POST /v1/customers/:id/ad-events` (public key) `{type: impression|click|rewarded, network, adUnitId, placement?, revenueMicros, currency, precision}` (`valueMicros`/`currencyCode` accepted as aliases; precision 0–3 or unknown|estimated|publisher_provided|precise, as in Google Mobile Ads AdValue). Raw events in `projects/{pid}/adEvents`, daily aggregates in `projects/{pid}/adDaily/{YYYY-MM-DD}` (revenue converted to the project currency). SDKs: iOS `MoneyMaker.shared.trackAdRevenue(valueMicros:currencyCode:precision:adUnitID:network:placement:)` / `trackAdImpression(adUnitID:)`; Android `moneyMaker.trackAdRevenue(valueMicros, currencyCode, precision, adUnitId)` / `trackAdImpression(adUnitId)` — call from the paid event handler. - AdMob rewarded Server-Side Verification: set the SSV callback URL of the ad unit to `https:///v1/admob/ssv/` and pass the app user ID as SSV `userId`. The ECDSA signature is verified against Google's keys (verifier-keys.json, cached ≤ 24 h). If `config.admob.rewards[adUnitId] = {currency: "GEMS", amount?}` maps to a virtual currency (amount defaults to `reward_amount`), the wallet is credited once per `transaction_id` (log in `projects/{pid}/adRewards`). 403 invalid_signature / unknown_key_id. - Charts: `ad_revenue`, `ad_impressions`, `ad_ecpm` (revenue per 1000 impressions), `arpdau` (ad revenue / DAU; DAU = customers seen in the last 24 h, recorded in the nightly snapshot). `/v1/projects/:pid/metrics` and `/v1/overview` add `adRevenueMicros` and `revenueInclAdsMicros`. - Benchmarks: `GET /v1/projects/:pid/benchmarks` → per metric (trial_conversion_rate, monthly_churn, refund_rate, arppu, realized_ltv_12m, initial_conversion, mrr_growth_30d; amounts in EUR micros) `{value, status: ok|insufficient_data, percentiles {p10..p90}, percentileRank, score, higherIsBetter, bucket {category, tier, projects}}`. Built nightly from anonymized projects with ≥ 30 paying customers; a bucket (App Store primary genre — or `config.category` — × monthly price tier) is published only with ≥ 5 projects, with fallback to broader buckets. ## MCP server & CLI - `tools/mcp/index.js`: dependency-free MCP server (stdio). `claude mcp add moneymaker -e MM_TOKEN=mm_pat_… -- node /path/tools/mcp/index.js`. Tools: list_projects, get_overview, get_metrics, get_chart, get_ltv_prediction, list_customers, get_customer, grant_entitlement, list_events, get_offerings, update_offerings. - `tools/cli/mm.js`: same commands as a CLI (`mm list-projects`, `mm get-metrics --project `…), auth with `MM_TOKEN`. ## Team, roles & audit log - Roles: `owner` (members without explicit role, e.g. the creator), `admin` (everything except granting ownership), `developer` (read + configuration, products, customers, grants, imports, connections, public/secret key issuance; no member management, no store credentials, no key revocation), `viewer` (read-only: every non-GET project call → 403 `insufficient_role`; webhook signing secret hidden). Secret keys (`mm_sk_`) act as admin but cannot manage members/invites (user token required). Stored in the project doc as `roles: { uid: role }`. `GET /v1/projects` returns `role` for each project. - Members: `GET /v1/projects/{id}/members` → `{ role (yours), members: [{ uid, email, role, isCreator }], invites }` (invite links only for admins). `PATCH /v1/projects/{id}/members/{uid} { role: "admin"|"developer"|"viewer" }` (admin; only owners can change another owner; last owner cannot be demoted). `DELETE /v1/projects/{id}/members/{uid}` (admin, or yourself to leave; last owner cannot leave). - Invitations (no email is sent — share the link): `POST /v1/projects/{id}/invites { email, role }` (admin, user token) → `{ id, email, role, expiresAt, link: "https://moneymaker-io.web.app/?invite=inv_…" }` (valid 14 days, single use). `GET /v1/projects/{id}/invites` (pending), `DELETE /v1/projects/{id}/invites/{inviteId}` (revoke). Invitee: `GET /v1/invites/{token}` → `{ projectId, projectName, role, email }`, `POST /v1/invites/{token}/accept` (signed-in user whose account email matches the invited email, else 403 `invite_email_mismatch`). The dashboard opens `?invite=` after sign-in and asks for confirmation. - Audit log: every successful mutating call on `/v1/projects/{id}/…` is recorded in `projects/{id}/audit` as `{ uid ("key:secret" for secret keys), action, at, details }` — actions `config.update`, `credentials.update`, `keys.issue`, `grant.update`, `invite.create|revoke|accept`, `member.role|remove`, otherwise `METHOD /route`. details hold field names and ids, never secret values. `GET /v1/projects/{id}/audit?limit=100&before={at}` (admin) → `{ entries: [{ id, uid, email, action, at, details }] }`. ## Trusted entitlements, offline mode & Test Store - Signed responses: customer-info responses (`GET /v1/customers/{id}`, `POST …/apple`, `…/google`, `…/alias`, `…/redeem`, `…/test-purchase`) carry `X-Request-Time: ` and `X-MoneyMaker-Signature: v1=` over the UTF-8 string `${X-Nonce}.${X-Request-Time}.${sha256hex(rawBody)}` (send a random `X-Nonce` request header; empty string if absent). Public key: `GET /v1/signing-key` (no auth) → `{ algorithm: "Ed25519", format: "raw", publicKey: }`. Server key: env `MM_SIGNING_KEY` (PEM, base64 PKCS8 or base64 32-byte seed), else generated once in Firestore `system/signing` (server-only). - SDK: iOS `MoneyMaker.configure(apiKey:…, entitlementVerification: .disabled | .informational (default) | .enforced)`, Android `entitlementVerification = EntitlementVerificationMode.INFORMATIONAL`. Read `customerInfo.verification` (iOS `.verified/.failed/.notRequested/.verifiedOnDevice`, Android `VERIFIED/FAILED/NOT_REQUESTED/VERIFIED_ON_DEVICE`). `.enforced`: a failed verification → all entitlements inactive. Pin the key at release with `MoneyMaker.signingPublicKey = ""` (set before configure); otherwise the SDK fetches `/v1/signing-key` once and pins it (trust on first use). Android verifies on API 33+ only (older → NOT_REQUESTED). - Offline: if posting a purchase fails (network / 5xx) the SDK queues it (iOS: JWS in UserDefaults, transaction not `finish()`ed until accepted; Android: purchase token in SharedPreferences, the server acknowledges only after accepting) and retries on launch, foreground and network return (`flushPendingTransactions()` / `flushPendingPurchases()`). While the server is unreachable `refreshCustomerInfo()` computes entitlements on device from StoreKit `Transaction.currentEntitlements` / owned Play purchases, mapped with the `entitlements` map returned by `GET /v1/offerings` (`{ entitlementId: [productIds] }`, cached at `loadOfferings()`), `verification = verifiedOnDevice`. - Test Store: `POST /v1/projects/{id}/keys { kind: "test" }` → `mm_test_…` key (dashboard: Réglages → « Clé Test Store »). An SDK configured with it never calls StoreKit / Play Billing: `purchase(package)` → `POST /v1/customers/{appUserId}/test-purchase { productId, type?: "subscription"|"non_consumable"|"consumable", periodMonths? }` (test key only, else 403 `test_key_required`) → CustomerInfo. Creates a purchase with `store: "test"`, `isSandbox: true`, price 0, subscriptions expire after `periodMonths` (default 1) × 30 days. Sandbox purchases are excluded from metrics/charts and shown with a « sandbox » pill in the dashboard. Map test product ids in `config.entitlements` like any other product (use the same ids as the App Store / Play products). ## Web purchases (web paywall, purchase links, redemption, portal, Paddle) - Purchase link / hosted web paywall: `https://moneymaker-io.web.app/pay/{projectId}/{offeringId}?app_user_id=…&email=…&code=PROMO`. Renders the offering's remote paywall (same `paywall` fields/templates as the iOS paywall, localizations by browser language) with the packages whose `productIds.stripe` is a Stripe price id (`price_…`). Checkout = Stripe Checkout (Stripe must be connected). `code` applies a Stripe promotion code; otherwise buyers can type one in Checkout. Dashboard: « Paywalls & tests » → « Achats web » generates/copies the link. - Public endpoints used by the page (no key, nothing secret): `GET /v1/web/{pid}/offering/{oid}` (offering + paywall + Stripe prices), `POST /v1/web/{pid}/offering/{oid}/checkout { packageId, appUserId?, email?, promoCode? }` → `{ url }` (the price id is never taken from the client), `GET /v1/web/{pid}/success?session_id=cs_…`. - Config: `PATCH /v1/projects/{id}` `{ config: { web: { enabled, redemptionUrl, successUrl } } }`. `redemptionUrl` = universal link or custom scheme of your app (e.g. `myapp://redeem`). - Anonymous web buyer (no `app_user_id`): the purchase is stored under `$web:…`; `/pay/success` shows a redemption link `{redemptionUrl}?mm_redeem=mmr_…` + QR code (single-use token stored in `projects/{pid}/redemptions`). In the app: iOS `try await MoneyMaker.shared.redeemWebPurchase(token:)` (helper `MoneyMaker.webRedemptionToken(from: url)`), Android `MoneyMaker.redeemWebPurchase(token)` (`webRedemptionToken(uri)`). REST: `POST /v1/customers/{appUserId}/redeem { token }` (public key) → CustomerInfo; 409 `already_redeemed` if used by another user. - Customer portal: `https://moneymaker-io.web.app/manage/{projectId}?token=…` opens the Stripe Billing Portal. Get a signed link (valid 7 days) with `POST /v1/customers/{appUserId}/manage-link` (public key) → `{ url }`; the success page also links to it. - Paddle Billing: webhook `POST /v1/webhooks/paddle/{projectId}` (verifies `Paddle-Signature` ts/h1 HMAC-SHA256, ±5 min). Credentials: `PUT /v1/projects/{id}/credentials { paddle: { webhookSecret, apiKey?, environment: "production"|"sandbox" } }` (or dashboard → Connexions → Paddle). Handles `subscription.*` (created, updated, activated, trialing, past_due, paused, resumed, canceled) and one-time `transaction.completed`; store = `paddle`, productId = Paddle price id (`pri_…`, use it in entitlements). User mapping: `customData.app_user_id` at Paddle checkout, otherwise `$paddle:{customer_id}`. ## Other stores: Amazon Appstore, Samsung Galaxy Store, Roku Pay - Store names: `amazon`, `galaxy`, `roku` (purchases, events, charts `store` filter, RevenueCat-compat `store` field). Use the store product id in entitlements (Amazon: the term SKU for subscriptions, else the SKU; Galaxy: item id; Roku: product id). - Amazon Appstore: credentials `PUT /v1/projects/{id}/credentials { amazon: { sharedSecret, packageName?, allowSandbox? } }`. Client: `POST /v1/customers/{appUserId}/amazon { receiptId, amazonUserId, productId?, sandbox?, priceMicros?, currency? }` (public key) → customer info. Verified with RVS `GET https://appstore-sdk.amazon.com[/sandbox]/version/1.0/verifyReceiptId/developer/{sharedSecret}/user/{amazonUserId}/receiptId/{receiptId}`; sandbox only when `allowSandbox`. HTTP 410 = canceled. RVS gives no price: send `priceMicros`/`currency` from the SDK's `Product.getPrice()`. Real-Time Notifications: endpoint `POST /v1/webhooks/amazon/{projectId}?token=…` (shown in the project's `endpoints.amazonNotifications`); SNS signature verified (SignatureVersion 1/2, cert host `sns.*.amazonaws.com`), `SubscriptionConfirmation` auto-confirmed via `SubscribeURL` and its token exposed as `amazonConfirmationToken` on `GET /v1/projects/{id}`; every notification re-verifies the receipt with RVS. Unknown buyers: `$amazon:{hash}`. - Samsung Galaxy Store: credentials `{ galaxy: { serviceAccountId, privateKey, packageName } }` (Seller Portal service account, `publishing` scope). Client: `POST /v1/customers/{appUserId}/galaxy { purchaseId, productId?, type?: "consumable", period?: "P1M" }`. Items: `GET https://iap.samsungapps.com/iap/v6/receipt?purchaseID=`. Subscriptions additionally: `GET https://devapi.samsungapps.com/iap/seller/v6/applications/{packageName}/purchases/subscriptions/{purchaseId}` with `Authorization: Bearer` (from `POST /auth/accessToken`, RS256 JWT) + `service-account-id`. Purchase id = `subscriptionFirstPurchaseID`. Server notifications (Samsung ISN) are not handled yet: re-post the purchaseId to refresh. - Roku Pay: credentials `{ roku: { devApiKey } }`. Client: `POST /v1/customers/{appUserId}/roku { transactionId, period?, type? }`. Verified with `GET https://apipub.roku.com/listen/transaction-service.svc/validate-transaction/{devApiKey}/{transactionId}` (JSON); `isEntitled` + `expirationDate` drive status (entitled with past expiry = billing retry), keyed on `OriginalTransactionId`. Push notifications: `POST /v1/webhooks/roku/{projectId}?token=…` (`endpoints.rokuNotifications`); signed JWT (RS256, JWKS `https://assets.cs.roku.com/keys/partner-jwks.json`) or legacy JSON (`responseKey` echoed back); the transaction is re-validated with the API either way. Unknown buyers: `$roku:{rokuCustomerId}`. - RevenueCat-compat `POST /v1/receipts`: `X-Platform: amazon` (`fetch_token` = receiptId, `store_user_id` = Amazon user id), `X-Platform: galaxy` (`fetch_token` = purchaseId), `X-Platform: roku` (`fetch_token` = transactionId). ## Stripe App (Dashboard Stripe) - App « MoneyMaker » pour le Dashboard Stripe (code : stripe-app/, id io.moneymaker.stripe) : sur la fiche d'un client Stripe, statut MoneyMaker (app user id, accès actifs, abonnements de tous les stores, LTV, événements récents, bouton « Offrir un accès ») ; dans le tiroir, MRR, revenu 28 j, abonnements actifs, essais et répartition par store. - Réglages de l'app : coller la clé secrète du projet (mm_sk_…), stockée dans le Secret Store Stripe (portée account) ; le projet est déduit de la clé. - Routes : GET /v1/stripe-app/customer/:stripeCustomerId, GET /v1/stripe-app/overview, POST /v1/stripe-app/customer/:stripeCustomerId/grant {entitlement, days: 3|7|14|30|90|180|365|null}. Double auth : en-tête Stripe-Signature (fetchStripeSignature, payload {"user_id","account_id"} transmis dans X-Stripe-User-Id / X-Stripe-Account-Id, tolérance 5 min) + Authorization: Bearer mm_sk_…. - Côté serveur, le secret de signature de l'app (absec_…) se configure dans la variable d'environnement STRIPE_APP_SECRET (virgules pour une rotation) ou le document Firestore system/stripeApp { signingSecret }. - Liaison client Stripe → app user id : index stripe_cus:{cus_…} créé par les webhooks/import Stripe (metadata.app_user_id). Sinon : « Client non relié ». ## Reference - Metrics: `GET /v1/projects/{id}/metrics?days=30` — MRR, ARR, MRR movement (new/churned/net), active subs/trials/customers, revenue (gross, refunds, net, new vs renewal) by day/country/store/product, active subscribers by country, ARPPU, ARPU, LTV, refund rate, monthly churn, trial conversion (overall and per product), previous-period comparison, daily history. All businesses: `GET /v1/overview` (PAT). - Refund Control (App Store): set `config.refundControl` = `{ "enabled": true, "preference": "decline" | "grant" | "none", "customerConsented": true }` via `PATCH /v1/projects/{id}` config. When Apple sends a `CONSUMPTION_REQUEST` notification, MoneyMaker answers immediately (Apple deadline 12 h) with App Store Server API v2 `PUT /inApps/v2/transactions/consumption/{transactionId}` (sandbox host for sandbox notifications), using the In-App Purchase key from `PUT /v1/projects/{id}/credentials` `apple`. Body: `customerConsented`, `deliveryStatus` (DELIVERED if the purchase is known, else UNDELIVERED_OTHER), `sampleContentProvided: false`, `consumptionPercentage` (milliunits: subscriptions = elapsed share of current period, non-consumables = 100000, consumables omitted), `refundPreference` (decline → DECLINE, grant → GRANT_FULL, none → omitted). Nothing is sent unless `enabled` and `customerConsented` are true (Apple requires the customer's consent). Signals stored with each request: accountTenure bucket (from customer firstSeenAt), totalSpentMicros, refundsCount, entitlementActive. `GET /v1/projects/{id}/refund-requests?sandbox=true?` → `{ config, stats: { total, sent, skipped, failed, pending, refunded, declined, refundRate }, requests }`; outcomes are filled by `REFUND` / `REFUND_DECLINED` notifications. - Cohorts: `GET /v1/projects/{id}/cohorts?months=12` — monthly cohorts by first payment: subscription retention (annual payments cover 12 months) and cumulative revenue per customer. - CSV export: `GET /v1/projects/{id}/export/customers|transactions|events` (secret key or session). - Customers: `GET /v1/projects/{id}/customers`, `GET /v1/projects/{id}/customers/{appUserId}`, grant: `POST …/customers/{appUserId}/grant { entitlement, days? }` (omit days = lifetime, 0 = revoke). - Events: `GET /v1/projects/{id}/events`. Transactions: `GET /v1/projects/{id}/transactions`. - Errors: JSON `{ error, message }` with HTTP 400/401/403/404/5xx. - Dashboard: https://moneymaker-io.web.app