Skip to content

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

Rendering diagram…

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

ProductTypeRecorded as
Play CoinsConsumable IAPCOIN_TOPUP ledger entry, Wallet.coinBalance credited
Creator subscriptionAuto-renewing IAPCreatorSubscription row (fan → creator)
PlayPalz premium tierAuto-renewing IAPSubscription row, User.subscriptionTier
1-on-1 sessionNon-renewing IAPSession 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/revenuecatapps/api/src/modules/revenuecat/.

The handler is well built and worth reading as a model for other integrations:

  1. Authorization — compares the Authorization header against REVENUECAT_WEBHOOK_SECRET.
  2. Validation — zod-parses the body (RCWebhookBodySchema); 400 on failure.
  3. Acknowledge first — responds 200 before processing, so a slow database never causes RevenueCat to retry.
  4. IdempotencyRevenueCatEvent has a unique eventId; a replayed event is detected and skipped before any side effect.
ts
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

ts
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

EventHandling
INITIAL_PURCHASE, RENEWAL, NON_RENEWING_PURCHASERoute by product id: credit coins, sync platform subscription, or sync a creator (VIP) subscription
EXPIRATION, CANCELLATIONMark the subscription inactive
UNCANCELLATIONReactivate
PRODUCT_CHANGEMove the user to the new tier
TRANSFERMove 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.

prisma
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

Rendering diagram…

Phase 2 — the 1st: execute transfers

Rendering diagram…

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 = true
  • stripeAccountId is 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

VariableDefaultMeaning
PLATFORM_FEE_PCT20Platform's cut, integer percent
MIN_PAYOUT_CENTS100Minimum net payout ($1.00)
COST_PER_COIN0.02USD value of one Play Coin
PAYOUT_LOCK_TTL_MS3600000Redis lock TTL (1 hour)
STRIPE_SECRET_KEYPlatform account key
ADMIN_SECRETBearer token for anansi's admin API

Admin API

All routes require Authorization: Bearer <ADMIN_SECRET>.

MethodPathPurpose
GET/healthHealth check (unauthenticated)
GET/admin/payout/previewDry run — no writes, no Stripe calls
POST/admin/payout/calculateTrigger Phase 1 manually
POST/admin/payout/transferTrigger Phase 2 manually
GET/admin/payout/statusPayout records and summary for a period
POST/admin/payout/retryRe-attempt failed transfers
GET/admin/creators/:id/earningsPreview one creator's earnings

Always run /admin/payout/preview before a manual calculate.

Margins

Money is taxed twice on the way through:

  1. App store commission — 15–30% taken by Apple/Google before PlayPalz sees anything.
  2. Platform feePLATFORM_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

ts
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.

Internal documentation — PlayPalz platform