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 | PURCHASE coin transaction into the buyer's PURCHASED bucket |
| 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 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 transactionCoinPackageis the server-side config: product id → coins (bonus included), label, badge, order, and whether it isoffered. 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 aVerifiedCoinPurchase; 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'stransaction_idand the API'sstore_purchase_identifierare 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)PriceTieris the product config (likeCoinPackage): product id, kind (VIP_PASS,SESSION,EDATE), USD list price, pass length, andoffered(false until the product exists in both stores). A PlayPal's pass price isUser.monthlyPrice, which must equal aVIP_PASStier'spriceCents(PUT /profile/price, PlayPals only). The app shows the store's localized price.CheckoutIntentnames the buyer, PlayPal and product before payment. Its id rides along as the RevenueCat subscriber attributecheckout_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).StorePurchaseis 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 − platformFeeRates 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.
| Status | Meaning |
|---|---|
ESCROW | A 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 |
PENDING | Holding period (EARNINGS_HOLD_DAYS, 30) |
AVAILABLE | Matured (anansi, hourly); written to Ledger as CREATOR_EARNING + PLATFORM_FEE |
SCHEDULED | Attached to a payout by the 25th's run (rows locked, so never twice) |
PAID | The 1st's transfer went through |
REVERSED | Refunded 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).
| Metric | Definition |
|---|---|
| Subscribers, active | Fans with an unrevoked VipPass (or a legacy vip_sub subscription) covering the end of the day (now, for today) |
| New | A fan's access starts that day after 7+ days without any |
| Churned | A fan's access ended that day and no pass started within 7 days. Final once those 7 days pass; until then it's lapsing |
| Revenue | CreatorEarning.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 |
| Bookings | Sessions 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 posts | Up to 5 VIP posts by distinct viewers (PostImpression rows created in the period), reactions break ties |
| Tips | Count 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 (idsys_<KIND>).CoinTransaction— one movement, with a uniqueidempotencyKey.CoinEntry— signed amounts. A transaction's entries always sum to zero.
Buckets
| Bucket | Holds | Spend | Cash out |
|---|---|---|---|
PURCHASED | Coins bought with money, and legacy wallet balances | ✓ | never |
RECEIVED | Tips a regular user received | ✓ | never |
EARNED_PENDING | Tips a PlayPal received, inside the holding period | ✓ | after the hold |
EARNED_AVAILABLE | Matured PlayPal earnings | ✓ | ✓ |
DEBT | What 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:
CoinEntryandCoinTransactionare append-only: noUPDATE,DELETEorTRUNCATE.- Every transaction has at least two entries and sums to zero, checked at commit.
CoinAccount.balanceis maintained by the entry trigger and cannot be written directly.- A user bucket cannot go negative, and
DEBTcannot go positive (CoinAccount_balance_check).
Posting
Every coin movement goes through postCoinTransaction, inside runCoinTransaction:
lockUserAccountsopens any missing buckets and locks all buckets of the users involved (SELECT … FOR UPDATE, ordered by id).- The caller plans from the locked balances (
planSpend,planCredit). postCoinTransactionwrites it. The sameidempotencyKeywith the same request returns the original transaction (replayed: true); with a different request it throwsIdempotencyConflictError.
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
userTypeat commit time: a regular user'sRECEIVED(spend-only, forever), or a PlayPal'sEARNED_PENDING. Coins a user received before becoming a PlayPal stay spend-only. - Holding period. Each tip to a PlayPal opens an
EarningLotthat matures afterEARNINGS_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.
TipFundingrows record the sender's buckets, and for purchased coins the exactCoinLot. 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.postIdis 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:
| Kind | When |
|---|---|
new_account_to_playpal | An account under 7 days old tips one PlayPal 1,000+ coins in 24 hours |
tip_concentration | 80%+ of a sender's 30-day tips (2,000+ coins) go to one recipient |
shared_network | Sender 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:
- The purchase's own unspent coins (its
CoinLot). - The buyer's other coins, in spend order.
- 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. - 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_unrecoveredfraud flag. That amount is the platform's loss unless the buyer repays; An admin can write such debt off againstREFUND_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.
| Action | API | What it does |
|---|---|---|
| View | GET /admin/users/:id/coins | Buckets, pending earnings and next maturity, last 50 transactions, cash-outs, refunds, flags |
| Adjust | POST /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 debt | POST /admin/users/:id/coins/write-off {coins?, reason} | Clears all or part of the debt against REFUND_LOSS |
| Freeze | POST /admin/users/:id/coins/freeze {reason}, …/unfreeze | Sets 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 tip | POST /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 PlayPal | POST /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 flags | GET /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.
| Check | Holds when |
|---|---|
ledger_balanced | All entries sum to zero, and so does every transaction |
balances_match_entries | Each account's cached balance equals the sum of its entries |
buckets_in_range | No user bucket below zero, no DEBT above zero |
purchase_lots_match | Each user's open CoinLots add up to their PURCHASED balance |
earning_lots_within_pending | Pending EarningLots don't exceed EARNED_PENDING |
tips_funded | Each tip's TipFunding adds up to its coins |
payout_clearing_matches_open_cashouts | PAYOUT_CLEARING equals requested + scheduled cash-outs |
payout_settled_matches_paid_cashouts | PAYOUT_SETTLED equals paid cash-outs |
no_unsettled_cashouts_on_completed_payouts | No cash-out left scheduled on a completed payout |
purchases_recorded | Every PURCHASE transaction has its CoinPurchase |
refunds_marked_on_purchases | Every 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.
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
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:
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-outsEvery 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 = truestripeAccountIdis set (Stripe Connect onboarding completed)netCents >= MIN_PAYOUT_CENTS(default $1.00)
Coin cash-outs
- Maturation (anansi, hourly at :15): earning lots past
maturesAtmove fromEARNED_PENDINGtoEARNED_AVAILABLE(MATURATION). A fraud flag pushesmaturesAtout, so flagged earnings wait. - Request (
POST /earnings/cashouts): at leastCASHOUT_MIN_COINS(2,000), in steps ofCASHOUT_STEP_COINS(100), atCOIN_PAYOUT_CENTS_PER_1000(450 = $4.50 per 1,000 coins). The coins move toPAYOUT_CLEARINGat 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. OnlyEARNED_AVAILABLEcan be cashed out: purchased and received coins never can, whoever holds them. - Cancel while
requestedputs the coins back (CASHOUT_CANCEL). - The 25th: the payout run attaches requested cash-outs (
scheduled). - The 1st: after a successful transfer, each is
paid(CASHOUT_SETTLE,PAYOUT_CLEARING→PAYOUT_SETTLED). A failed transfer leaves themscheduledfor the retry. - Reversed transfer (
transfer.reversedonPOST /webhooks/stripe-transfers): the payout isreversedand its cash-outs return toEARNED_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
| Variable | Default | Meaning |
|---|---|---|
PLATFORM_FEE_PCT | 20 | Platform's cut of store-purchase proceeds, integer percent. Read by the api when earnings are recorded |
MIN_PAYOUT_CENTS | 100 | Minimum net payout ($1.00) |
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 $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.
