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 IAPPURCHASE coin transaction into the buyer's PURCHASED bucket
Creator subscriptionAuto-renewing IAPCreatorSubscription row (fan → creator)
PlayPalz premium tierAuto-renewing IAPSubscription row, User.subscriptionTier
1-on-1 sessionNon-renewing IAPSession row

Coins are spent in the in-app store (STORE_SPEND). Tipping posts and live streams, and PlayPal cash-out of tips, are being built on the coin ledger; until they ship, nothing writes COIN_GIFT_CREATOR or CREATOR_REDEEM.

A coin has no fixed USD value of its own: PlayPals cash out at the API's COIN_PAYOUT_CENTS_PER_1000 (450¢ per 1,000). anansi's old COST_PER_COIN valuation is gone.

RevenueCat webhook ​

POST /api/v1/webhooks/revenuecat → apps/api/src/modules/revenuecat/.

The handler authorizes the Authorization header (constant time, and fails closed in production when REVENUECAT_WEBHOOK_SECRET is unset), validates the body, processes the event and only then answers 200, so a failure is retried by RevenueCat. Every event is stored with its payload. See Webhooks for the event table and idempotency.

Coin purchases ​

Coins are credited only after the store has verified the purchase, never on the app's word:

App → store purchase (RevenueCat SDK)
    ├─ POST /coins/purchases/sync  ── verifies with RevenueCat API v2 ─┐
    └─ (later) RevenueCat webhook ─────────────────────────────────────┴→ recordCoinPurchase
                                                    → CoinPurchase row + PURCHASE coin transaction
  • CoinPackage is the server-side config: product id → coins (bonus included), label, badge, order, and whether it is offered. Prices are never stored or sent by the server; the app shows the store's localized price. A package stays creditable after it stops being offered.
  • recordCoinPurchase (apps/api/src/modules/coins/) is the only code that credits purchases. Channels translate their payloads into a VerifiedCoinPurchase; RevenueCat is the only channel today, and a web checkout would be another.
  • The sync path makes crediting immediate. The app sends the transaction id the SDK returned; the server looks it up with GET /v2/projects/{id}/purchases?store_purchase_identifier=… and credits only a purchase that matches exactly, belongs to the caller, is a coin package, and isn't refunded. It is off in production (COIN_PURCHASE_SYNC_ENABLED) until sandbox purchases on both stores confirm that the SDK's transaction id, the webhook's transaction_id and the API's store_purchase_identifier are the same value. If they differed, the two paths would credit one purchase twice.
  • Sandbox purchases (TestFlight, Play test tracks) are free, so production refuses them (COIN_ALLOW_SANDBOX_PURCHASES). Test coin purchases against a non-production API.
  • Interrupted purchases: the store screen sends recent transaction ids on open, and Restore Purchases does the same after the store's restore. Purchases awaiting approval (Ask to Buy) show as pending and are credited by the webhook when approved.

VIP passes (creator subscriptions) ​

A fan's access to one PlayPal's VIP content is a 30-day VIP pass: a non-renewing store product, one per price point (vip_pass_2_99 … vip_pass_49_99), shared by every PlayPal. Auto-renewing products can't work here: a store account holds at most one active subscription per subscription group, so a fan couldn't subscribe to two PlayPals at the same price. With passes the store only takes payment; the server decides which PlayPal it was for and how long it lasts.

App                          API                                   Store / RevenueCat
POST /checkout/intents  ──►  checks Pro/Elite, PlayPal, blocks;
                             picks the tier for monthlyPrice
                        ◄──  { intentId, productId }
setAttributes({ checkout_intent_id })
purchaseStoreProduct(productId) ─────────────────────────────────► charge
POST /checkout/intents/:id/complete { transactionId }
                        ──►  verify with RevenueCat API v2 ─────► lookup
                             StorePurchase + VipPass + CreatorEarning
                                               ◄── NON_RENEWING_PURCHASE webhook (same, idempotent)
  • PriceTier is the product config (like CoinPackage): product id, kind (VIP_PASS, SESSION, EDATE), USD list price, pass length, and offered (false until the product exists in both stores). A PlayPal's pass price is User.monthlyPrice, which must equal a VIP_PASS tier's priceCents (PUT /profile/price, PlayPals only). The app shows the store's localized price.
  • CheckoutIntent names the buyer, PlayPal and product before payment. Its id rides along as the RevenueCat subscriber attribute checkout_intent_id, so the webhook can apply the purchase even if the app never reports back. An intent created after the purchase is never matched to it (the attribute may already name the buyer's next checkout).
  • StorePurchase is one verified store transaction, unique per (store, storeTransactionId): the webhook and the app's report record it once between them, under an advisory lock.
  • VipPass: passes for the same fan and PlayPal chain end to end; buying while one runs starts the new one when it ends. CreatorSubscription (source: "pass") mirrors the chain's end and is what access checks read.
  • PurchaseCredit: a purchase that can't be matched to a usable checkout (no attribute, the PlayPal blocked the fan, …) is kept as a credit for 365 days. The buyer's next checkout for the same product uses it instead of charging again.
  • Sync verification and sandbox handling follow the coin settings (COIN_PURCHASE_SYNC_ENABLED, COIN_ALLOW_SANDBOX_PURCHASES). Buying a pass needs a Pro or Elite membership, checked server-side.

Legacy vip_sub_* subscriptions (auto-renewing, one subscription group) are honoured until they lapse: each charge earns the PlayPal named by the fan's single legacy CreatorSubscription, a CANCELLATION with CUSTOMER_SUPPORT is a refund, any other CANCELLATION only turns off renewal, and EXPIRATION ends access. POST /vip-subscriptions, which trusted the app's word, answers 410.

Refunds ​

A CANCELLATION of a pass is a refund: the pass is revoked (later passes move up), unused credits from it are voided, and the PlayPal's earnings from it are reversed while still PENDING. Earnings that already matured are the platform's loss and raise a refund_unrecovered fraud flag, the same rule as tips. REFUND_REVERSED restores all of it. A refund that arrives before its purchase is recorded as a refunded StorePurchase, so the late purchase grants nothing.

PlayPal USD earnings ​

Store purchases (passes, sessions and e-dates) earn PlayPals USD, recorded per purchase as CreatorEarning rows and paid by anansi. Tips stay in coins.

grossCents   = the store's USD price (the tier's list price until the webhook reports it)
taxCents     = ceil(gross × tax_percentage)
storeFee     = ceil((gross − tax) × commission_percentage)      default STORE_COMMISSION_DEFAULT_PCT
platformFee  = ceil(proceeds × PLATFORM_FEE_PCT / 100)          proceeds = gross − tax − storeFee
netCents     = proceeds − platformFee

Rates are turned into whole parts per million and every step is integer arithmetic (computeEarningAmounts). Multiplying cents by a float rate isn't exact: 100 × 0.07 is 7.000000000000001, and rounding that up used to take a cent too many. Each ceil favours the platform by under a cent, and the parts always add back up to the gross. The expected values are pinned in apps/api/__tests__/db/finance/moneyRules.dbtest.ts.

StatusMeaning
ESCROWA session or e-date not yet held; released to PENDING when it happens (or the booker cancels too late), reversed if cancelled with a credit
PENDINGHolding period (EARNINGS_HOLD_DAYS, 30)
AVAILABLEMatured (anansi, hourly); written to Ledger as CREATOR_EARNING + PLATFORM_FEE
SCHEDULEDAttached to a payout by the 25th's run (rows locked, so never twice)
PAIDThe 1st's transfer went through
REVERSEDRefunded before it matured

A reversed Stripe transfer puts its earnings back to AVAILABLE and credits back its PAYOUT ledger row. When the webhook reports the real price and fees after the app's report recorded list-price earnings, unmatured earnings are corrected. Reconciliation checks that matured earnings are ledgered, that earnings match their payout's status, and that each payout's subscriptionCents + sessionCents equals its attached earnings.

Creator Studio earnings and stats ​

PlayPals only (403 PLAYPAL_ONLY). apps/api/src/modules/studio/.

GET /studio/earnings (also GET /admin/users/:id/earnings): net cents by status (escrowCents, pendingCents, availableCents, scheduledCents, paidCents, reversedCents, lifetimeCents = everything not reversed), bySource (net and count, reversed excluded), upcoming (held money coming free, grouped by the PlayPal's local day, next 8 days with any), nextPayoutDate, and the 20 most recent rows. Coin tips come from GET /earnings.

GET /studio/stats?period=7|30|90, computed on read and cached in Redis for 10 minutes (studio:stats:<userId>:<period>). Days are the PlayPal's local days (their booking time zone, else their device's, else UTC).

MetricDefinition
Subscribers, activeFans with an unrevoked VipPass (or a legacy vip_sub subscription) covering the end of the day (now, for today)
NewA fan's access starts that day after 7+ days without any
ChurnedA fan's access ended that day and no pass started within 7 days. Final once those 7 days pass; until then it's lapsing
RevenueCreatorEarning.netCents, not reversed, by source: passes on the purchase day, sessions on the day they ended (held, or a no-show ruling) or were cancelled too late for a refund. Escrowed (still to come) don't count
BookingsSessions due in the period by outcome: completed, no-show by fan / by PlayPal, cancelled by fan / by PlayPal / other (refund, block, admin, nobody came); plus upcoming
Top VIP postsUp to 5 VIP posts by distinct viewers (PostImpression rows created in the period), reactions break ties
TipsCount and coins of unreversed tips received, plus their value at the current cash-out rate

The subscriber maths (modules/studio/subscribers.ts) is pure and unit-tested.

The coin ledger ​

Play Coins live in an immutable, double-entry ledger (packages/database/src/coins/). It is the only source of truth for coin balances: Wallet.coinBalance and User.playcoin_balance are legacy columns that nothing writes any more.

  • CoinAccount — five buckets per user, plus one system account per kind (id sys_<KIND>).
  • CoinTransaction — one movement, with a unique idempotencyKey.
  • CoinEntry — signed amounts. A transaction's entries always sum to zero.

Buckets ​

BucketHoldsSpendCash out
PURCHASEDCoins bought with money, and legacy wallet balances✓never
RECEIVEDTips a regular user received✓never
EARNED_PENDINGTips a PlayPal received, inside the holding period✓after the hold
EARNED_AVAILABLEMatured PlayPal earnings✓✓
DEBTWhat a user owes after a refund clawback (zero or negative)——

Spending draws PURCHASED → RECEIVED → EARNED_PENDING → EARNED_AVAILABLE, so cashable earnings go last. Credits repay DEBT first, and nothing can be spent while it is non-zero.

System accounts are the other side of every movement. STORE_ISSUANCE issues purchased coins (its entries sum negative: the coins outstanding), STORE_SPEND takes store purchases, LEGACY_OPENING carried old wallet balances over, and FORFEITED takes what a deleted account held. PAYOUT_*, REFUND_LOSS and ADJUSTMENT are for cash-out, refunds and admin corrections. System accounts keep no cached balance; sum their entries.

What the database enforces ​

These hold even if application code is wrong. They are triggers and constraints in the 20261021120000_coin_ledger migration:

  • CoinEntry and CoinTransaction are append-only: no UPDATE, DELETE or TRUNCATE.
  • Every transaction has at least two entries and sums to zero, checked at commit.
  • CoinAccount.balance is maintained by the entry trigger and cannot be written directly.
  • A user bucket cannot go negative, and DEBT cannot go positive (CoinAccount_balance_check).

Posting ​

Every coin movement goes through postCoinTransaction, inside runCoinTransaction:

  1. lockUserAccounts opens any missing buckets and locks all buckets of the users involved (SELECT … FOR UPDATE, ordered by id).
  2. The caller plans from the locked balances (planSpend, planCredit).
  3. postCoinTransaction writes it. The same idempotencyKey with the same request returns the original transaction (replayed: true); with a different request it throws IdempotencyConflictError.

runCoinTransaction retries deadlocks and requests racing on one key, and reports an overdraw the database caught as InsufficientCoinsError. Prefer the operation helpers (creditPurchasedCoins, spendCoinsToStore, forfeitCoinsForDeletion) to building entries by hand.

Tips ​

A tip moves coins from one user to another in a single TIP transaction (sendTip in packages/database/src/coins/operations.ts; the API side is apps/api/src/modules/coins/tips.ts).

  • Where the coins land depends on the recipient's userType at commit time: a regular user's RECEIVED (spend-only, forever), or a PlayPal's EARNED_PENDING. Coins a user received before becoming a PlayPal stay spend-only.
  • Holding period. Each tip to a PlayPal opens an EarningLot that matures after EARNINGS_HOLD_DAYS (30). Until then the coins can be spent but not cashed out. Spending pending earnings draws the latest-maturing lots first.
  • What paid for it. TipFunding rows record the sender's buckets, and for purchased coins the exact CoinLot. Every purchase (and legacy carry-over) opens a lot; every spend of purchased coins draws lots oldest first. A refund can then find the tips its coins paid for.
  • Tips outlive their post. Tip.postId is a plain column: editing, deleting or purging the post never touches the tip or its coins. A trigger refuses deleting a tip or changing anything but its hidden-message fields.

Checks, in order: amount within min–max (1–10,000), confirmation above confirmAbove (500), a date of birth on file, the message filter, the post visible to the sender (not deleted, published, no block either way), not your own post, an active author. Then, under the sender's account lock: at most TIP_PER_MINUTE tips a minute, and the rolling 24-hour cap (20,000 coins; 2,000 for accounts under 7 days old; 1,000 for under-18s). A retried request (same Idempotency-Key) replays without re-checking limits.

After commit, and never undoing the tip: a TIP_RECEIVED notification, and an abuse review.

Abuse signals (tipAbuse.ts) open a FraudFlag for review, at most one open per kind and sender/recipient pair:

KindWhen
new_account_to_playpalAn account under 7 days old tips one PlayPal 1,000+ coins in 24 hours
tip_concentration80%+ of a sender's 30-day tips (2,000+ coins) go to one recipient
shared_networkSender and PlayPal used the same IP address in the last 30 days

A new flag adds FRAUD_HOLD_EXTENSION_DAYS (30) to the pending earnings from that sender, so they can't be cashed out before review. Refund-ratio signals arrive with refunds.

Messages (up to 140 characters) refuse links, email addresses, phone numbers, control and bidi characters, and any term in TIP_MESSAGE_BLOCKLIST. The recipient can hide a message, which also clears it from their notification, and report it.

Refunds and clawbacks ​

When a store refunds a coin purchase (RevenueCat CANCELLATION), refundCoinPurchase takes the coins back in one REFUND_CLAWBACK transaction, in this order:

  1. The purchase's own unspent coins (its CoinLot).
  2. The buyer's other coins, in spend order.
  3. Pending earnings from tips the purchase paid for, newest tip first, found through TipFunding.lotId. Only reached when the buyer can't cover the refund, so a creator keeps an honest tip whenever the buyer can pay. Matured earnings and a regular user's received coins are never taken.
  4. The rest becomes the buyer's DEBT. Spending is blocked while it is open, and the next coins they receive repay it first.

The refunded coins leave circulation (STORE_ISSUANCE is credited). A CoinRefund row records the split (fromPurchase, fromBuyerBalance, fromRecipients, toDebt; a CHECK makes them add up to coins), with one CoinRefundReversal per tip reversed. Neither can be deleted.

After commit:

  • Each recipient whose pending earnings were taken gets a notification saying why.
  • A buyer left owing gets one saying their next purchase covers it.
  • Coins that reached someone and couldn't be taken back raise a refund_unrecovered fraud flag. That amount is the platform's loss unless the buyer repays; An admin can write such debt off against REFUND_LOSS (see Admin tools).
  • A buyer with 2+ refunds in 90 days covering 30%+ of what they bought raises refund_ratio.

Idempotent and ordered. One refund per store transaction, re-checked under the lock, so concurrent deliveries apply once. A refund that arrives before its purchase was credited is recorded, and the purchase is then never credited.

Reversed refunds (App Store REFUND_REVERSED): reverseCoinRefund puts back exactly what was taken. Debt the refund created is cancelled as far as it is still owed; the buyer's coins return as PURCHASED (never cashable); recipients get their pending earnings back. A reversal that arrives before its refund is recorded, and the late refund then does nothing.

Refunds received before clawbacks shipped were stored but not acted on. Apply them once with pnpm coins:reprocess-refunds --apply in apps/api (see Runbooks).

Admin tools ​

Corrections go through the ledger's own operations (packages/database/src/coins/admin.ts), so its rules still hold, and each writes an AdminAuditLog row in the same transaction. Every one needs a reason, kept on both the ledger transaction and the audit row. They're in the admin panel on the user's page, and under Coins in the sidebar.

ActionAPIWhat it does
ViewGET /admin/users/:id/coinsBuckets, pending earnings and next maturity, last 50 transactions, cash-outs, refunds, flags
AdjustPOST /admin/users/:id/coins/adjust {bucket, amount, reason}Signed amount against ADJUSTMENT. Credits open the matching lot (a pending credit holds for EARNINGS_HOLD_DAYS) and repay debt first; a debit can't exceed the bucket
Write off debtPOST /admin/users/:id/coins/write-off {coins?, reason}Clears all or part of the debt against REFUND_LOSS
FreezePOST /admin/users/:id/coins/freeze {reason}, …/unfreezeSets User.coinFrozenAt. Frozen users can't tip, buy in the store or cash out; they still receive. Taken under the account lock, so nothing in flight slips past it
Reverse a tipPOST /admin/tips/:id/reverse {reason}TIP_REVERSAL: the recipient gives back the tip's own pending earnings first, then other coins in spend order, and owes the rest as debt. The sender gets each part back into the bucket (and purchase lot) it came from. Once per tip
Revoke PlayPalPOST /admin/users/:id/revoke-playpal {mode, reason}userType back to user, badge removed; new tips are spend-only. release leaves current earnings cashable. forfeit cancels waiting cash-outs, turns all earnings into RECEIVED and marks the verification REVOKED. Cash-outs already in a payout run finish
Fraud flagsGET /admin/fraud-flags?status=, POST /admin/fraud-flags/:id/resolve {status, note}Queue of open flags; resolve as cleared or actioned, with a note

Approving a creator verification refuses anyone without an 18+ birthdate on file (CREATOR_NOT_ADULT), the same age check cash-outs make.

Reconciliation ​

ReconciliationRun rows record each run's checks. anansi runs them daily at 04:30 UTC; an admin can run them from Coins → Reconciliation (POST /admin/coins/reconciliation, ledger checks only). A failing check is logged at error level and the run is marked drift.

CheckHolds when
ledger_balancedAll entries sum to zero, and so does every transaction
balances_match_entriesEach account's cached balance equals the sum of its entries
buckets_in_rangeNo user bucket below zero, no DEBT above zero
purchase_lots_matchEach user's open CoinLots add up to their PURCHASED balance
earning_lots_within_pendingPending EarningLots don't exceed EARNED_PENDING
tips_fundedEach tip's TipFunding adds up to its coins
payout_clearing_matches_open_cashoutsPAYOUT_CLEARING equals requested + scheduled cash-outs
payout_settled_matches_paid_cashoutsPAYOUT_SETTLED equals paid cash-outs
no_unsettled_cashouts_on_completed_payoutsNo cash-out left scheduled on a completed payout
purchases_recordedEvery PURCHASE transaction has its CoinPurchase
refunds_marked_on_purchasesEvery refund is reflected on its purchase
stripe_transfers_match (daily only)Recent completed payouts' Stripe transfers exist, for the recorded amount, and aren't reversed unless the payout is

The legacy Ledger ​

The older single-entry Ledger table still carries anansi's USD rows (PLATFORM_FEE, PAYOUT). It no longer records coins.

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.

Never mutate a row in either ledger. Corrections are new 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:

netCents = subscriptionCents + sessionCents + coinCashoutCents

  subscriptionCents = Σ netCents of AVAILABLE VIP_PASS / LEGACY_VIP_SUB earnings
  sessionCents      = Σ netCents of AVAILABLE SESSION / EDATE earnings
  coinCashoutCents  = Σ CoinCashout.cents of the creator's requested cash-outs

Every part is already net: earnings carry their own store and platform fees (see PlayPal USD earnings), and coin cash-outs pay no fee because the take is in the coin payout rate. The calculation is a preview; the write step attaches what's still there at that moment (scheduleCreatorEarnings and scheduleCashouts, each locking what it attaches) and records the split on the payout (subscriptionCents, sessionCents, coinCents). The run writes no period PLATFORM_FEE row any more: fees are ledgered per earning when it matures.

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)

Coin cash-outs ​

  1. Maturation (anansi, hourly at :15): earning lots past maturesAt move from EARNED_PENDING to EARNED_AVAILABLE (MATURATION). A fraud flag pushes maturesAt out, so flagged earnings wait.
  2. Request (POST /earnings/cashouts): at least CASHOUT_MIN_COINS (2,000), in steps of CASHOUT_STEP_COINS (100), at COIN_PAYOUT_CENTS_PER_1000 (450 = $4.50 per 1,000 coins). The coins move to PAYOUT_CLEARING at once (CASHOUT_REQUEST), so they can't be spent or requested twice. Needs a payout account (stripeAccountId + payoutsEnabled), approved verification, an 18+ birthdate, and no coin debt. Only EARNED_AVAILABLE can be cashed out: purchased and received coins never can, whoever holds them.
  3. Cancel while requested puts the coins back (CASHOUT_CANCEL).
  4. The 25th: the payout run attaches requested cash-outs (scheduled).
  5. The 1st: after a successful transfer, each is paid (CASHOUT_SETTLE, PAYOUT_CLEARING → PAYOUT_SETTLED). A failed transfer leaves them scheduled for the retry.
  6. Reversed transfer (transfer.reversed on POST /webhooks/stripe-transfers): the payout is reversed and its cash-outs return to EARNED_AVAILABLE (CASHOUT_RETURN). Partial reversals are logged for an admin.

Transfers carry a Stripe idempotency key (payout:<payoutId>): if a transfer went through but recording it failed, the retry gets the same transfer back instead of paying twice. That key only lasts 24 hours, so before creating a transfer anansi also lists the transfers in payout_<period> for that account. If one already carries the payout's metadata.payoutId, it records that one instead of paying again.

The 25th's top-up is split into several when the month's payouts pass Stripe's $50,000 limit per top-up. Its statement descriptor is PlayPalz YYYYMM, because Stripe allows at most 15 characters. The Stripe sandbox suite caught both: the earlier PlayPalz YYYY-MM was refused outright.

Before the 1st's transfers, anansi checks the platform's available USD balance. If it can't cover every pending payout (the 25th's top-up can still be clearing), it transfers nothing, leaves the payouts pending, and returns an error so the run can be repeated.

The 25th's run is safe to repeat, and to run by hand for one PlayPal (creatorIds). Each earning and cash-out is attached to one payout only, and an empty payout is deleted. Each payout writes its own PAYOUT ledger row, keyed anansi:payout:<payoutId>. Rows written before that change used anansi:payout:<period>:<creatorId>, which collided when a PlayPal was paid twice in a month. A fully reversed transfer writes an offsetting PAYOUT row (payout-reversal:<payoutId>, positive), so paying the returned money again doesn't count it twice.

Configuration ​

VariableDefaultMeaning
PLATFORM_FEE_PCT20Platform's cut of store-purchase proceeds, integer percent. Read by the api when earnings are recorded
MIN_PAYOUT_CENTS100Minimum net payout ($1.00)
PAYOUT_LOCK_TTL_MS3600000Redis 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>.

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 fee — PLATFORM_FEE_PCT (20%) of the remainder.

So on a $9.99 VIP pass with no tax, roughly $6.99–$8.49 reaches PlayPalz, the platform keeps about $1.40–$1.70, and the creator receives about $5.59–$6.79.

Stripe Connect ​

Creators set up their payout account in the app during creator onboarding. Each creator gets an Accounts v2 recipient account (dashboard: none, platform-collected requirements). The account id is stored on User.stripeAccountId, and payoutsEnabled gates whether the creator is included in a payout run.

payoutsEnabled is set from Stripe's own state: it is true when the account's stripe_transfers capability is active. The onboarding endpoints and the Stripe webhook both re-read the account to set it.

Payout outcome events are still ignored

The webhook handles account events only. transfer.failed and payout.paid aren't consumed yet, so a failed creator payout is visible only in anansi's records and the Stripe dashboard.

Internal documentation — PlayPalz platform