Monetization & Money Flow
Money enters through Apple and Google, is recorded by RevenueCat, is accounted for in a double-entry ledger, and leaves through Stripe Connect to creators. No card details ever touch PlayPalz infrastructure.
The flow of funds
The important structural fact: payment collection and payout are entirely decoupled. RevenueCat tells the platform what was bought; the ledger records what is owed; Stripe pays it out on a monthly cycle. Nothing is settled in real time.
What fans can buy
| Product | Type | Recorded as |
|---|---|---|
| Play Coins | Consumable IAP | COIN_TOPUP ledger entry, Wallet.coinBalance credited |
| Creator subscription | Auto-renewing IAP | CreatorSubscription row (fan → creator) |
| PlayPalz premium tier | Auto-renewing IAP | Subscription row, User.subscriptionTier |
| 1-on-1 session | Non-renewing IAP | Session row |
Coins are then spent inside the app on store items (COIN_SPEND_STORE) or gifted to creators during livestreams (COIN_GIFT_CREATOR), which creates the matching CREATOR_REDEEM credit on the creator's side.
Coins are valued by COST_PER_COIN, default $0.02.
RevenueCat webhook
POST /api/v1/webhooks/revenuecat → apps/api/src/modules/revenuecat/.
The handler is well built and worth reading as a model for other integrations:
- Authorization — compares the
Authorizationheader againstREVENUECAT_WEBHOOK_SECRET. - Validation — zod-parses the body (
RCWebhookBodySchema); 400 on failure. - Acknowledge first — responds
200before processing, so a slow database never causes RevenueCat to retry. - Idempotency —
RevenueCatEventhas a uniqueeventId; a replayed event is detected and skipped before any side effect.
const existing = await prisma.revenueCatEvent.findUnique({ where: { eventId: event.id } });
if (existing) return; // already processed
// … handle …
await prisma.revenueCatEvent.create({ data: { eventId: event.id } });The secret check is conditional
const secret = process.env.REVENUECAT_WEBHOOK_SECRET;
if (secret) { /* verify */ }If the variable is unset the endpoint accepts anything, and an attacker can credit themselves coins or grant subscriptions. Convenient locally; make sure it is set in every deployed environment, and consider failing closed in production.
Events handled
| Event | Handling |
|---|---|
INITIAL_PURCHASE, RENEWAL, NON_RENEWING_PURCHASE | Route by product id: credit coins, sync platform subscription, or sync a creator (VIP) subscription |
EXPIRATION, CANCELLATION | Mark the subscription inactive |
UNCANCELLATION | Reactivate |
PRODUCT_CHANGE | Move the user to the new tier |
TRANSFER | Move entitlements between app user ids |
Product ids are mapped in RevenueCatTypes.ts (COIN_PRODUCT_MAP, APP_PRODUCT_TIER_MAP). Adding a new SKU means editing those maps — an unmapped product id logs a warning and is otherwise ignored.
Creator (VIP) subscriptions are only partly webhook-driven
Partial The webhook cannot tell which creator a VIP purchase was for, because that is a RevenueCat customer attribute the handler does not read. The mobile client compensates by calling POST /api/v1/vip-subscriptions with the target user id immediately after purchase.
That means a purchase completing while the app is backgrounded or killed can leave a paid subscription unrecorded. Resolving vip_user_id from the subscriber attributes in the webhook would close the gap.
The ledger
Every movement of value is an append-only Ledger row. Balances live on Wallet (coinBalance and balance in cents), and the ledger explains how they got there.
enum LedgerType {
COIN_TOPUP // acquired coins, typically via IAP
COIN_SPEND_STORE // spent coins in the in-app store
COIN_GIFT_CREATOR // gifted coins to a creator
CREATOR_REDEEM // creator earned redeemable USD from gifts
PLATFORM_FEE // platform fee withheld
PAYOUT // funds transferred out via Stripe
ADJUSTMENT // audited admin correction
}Each row carries coinDelta, usdDelta, external references (revenueCatEventId, stripeTransferId, stripeChargeId, …) and a unique idempotencyKey. That unique constraint is the last line of defence against double-posting: even a bug that retries a credit cannot apply it twice.
Never mutate a ledger row. Corrections are new ADJUSTMENT entries.
Creator payouts (anansi)
anansi runs the payout cycle in two phases, both guarded by a Redis distributed lock so duplicate runs across replicas are impossible.
Phase 1 — the 25th: calculate and top up
Phase 2 — the 1st: execute transfers
Failures are isolated per creator — one rejected transfer does not stop the rest of the run, and failed payouts are re-attempted with POST /admin/payout/retry.
Earnings calculation
apps/anansi/src/services/earnings.ts:
grossCents = subscriptionCents + coinGiftCents
subscriptionCents = activeSubscribers × creator.monthlyPrice
coinGiftCents = Σ Ledger.usdDelta where type = CREATOR_REDEEM in [periodStart, periodEnd)
netCents = grossCents × (1 − PLATFORM_FEE_PCT / 100)A creator is skipped entirely unless all of these hold:
payoutsEnabled = truestripeAccountIdis set (Stripe Connect onboarding completed)netCents >= MIN_PAYOUT_CENTS(default $1.00)
Subscription revenue is counted, not verified
subscriptionCents is computed as count of currently-active subscribers × today's monthly price, not from actual settled RevenueCat transactions for the period. So a subscriber who churned mid-period is not counted, a creator who raised their price mid-period is paid the new price for the whole period, and a failed store renewal is invisible.
For low volume this is close enough. Before revenue scales, this should be derived from ledger entries written at the moment each renewal webhook lands, the same way coin gifts already are.
Configuration
| Variable | Default | Meaning |
|---|---|---|
PLATFORM_FEE_PCT | 20 | Platform's cut, integer percent |
MIN_PAYOUT_CENTS | 100 | Minimum net payout ($1.00) |
COST_PER_COIN | 0.02 | USD value of one Play Coin |
PAYOUT_LOCK_TTL_MS | 3600000 | Redis lock TTL (1 hour) |
STRIPE_SECRET_KEY | — | Platform account key |
ADMIN_SECRET | — | Bearer token for anansi's admin API |
Admin API
All routes require Authorization: Bearer <ADMIN_SECRET>.
| Method | Path | Purpose |
|---|---|---|
GET | /health | Health check (unauthenticated) |
GET | /admin/payout/preview | Dry run — no writes, no Stripe calls |
POST | /admin/payout/calculate | Trigger Phase 1 manually |
POST | /admin/payout/transfer | Trigger Phase 2 manually |
GET | /admin/payout/status | Payout records and summary for a period |
POST | /admin/payout/retry | Re-attempt failed transfers |
GET | /admin/creators/:id/earnings | Preview one creator's earnings |
Always run /admin/payout/preview before a manual calculate.
Margins
Money is taxed twice on the way through:
- App store commission — 15–30% taken by Apple/Google before PlayPalz sees anything.
- Platform fee —
PLATFORM_FEE_PCT(20%) of the remainder.
So on a $10 creator subscription, roughly $7.00–$8.50 reaches PlayPalz, the platform keeps about $1.40–$1.70, and the creator receives about $5.60–$6.80.
Stripe Connect
Creators onboard to Stripe Connect and their account id is stored on User.stripeAccountId; payoutsEnabled gates whether they are included in a run.
The Stripe webhook is an empty handler
router.post("/stripe", async (req, res) => {});Nothing consumes Stripe events — not account.updated (Connect onboarding completion), transfer.failed, or payout.paid. payoutsEnabled and stripeAccountId therefore have to be set by some path other than Stripe telling us onboarding finished. Implementing this handler is the natural next step for the payments work.
