Every streaming service does this math. We publish ours, the running totals, the worked example, and the literal code the server runs.
All-time totals, straight from the database this page is served from.
Month-by-month detail, with cost assumptions and adjustments, is in the quarterly transparency reports.
Using the currently published assumptions. When Stripe reports your real fee, that number is used instead of the estimate.
Your $11.99 is recorded per real payment (per Stripe invoice), never assumed. A month with no payment pools nothing, the code refuses to allocate money that never arrived, even if a billing period overlaps the month.
Wherever possible we use the fee Stripe actually charged for YOUR payment. Only when no real fee was recorded do we fall back to the published assumption (currently 2.9% + $0.30).
A flat $0.30 per subscriber-month covers streaming, storage, and compute. It’s an estimate, published in advance, and trued against actual bills in the quarterly reports.
7.5% of gross ($0.90 on $11.99). This is the part that pays the humans who run the garden and keeps runway. The cap is published and governed by member vote.
$10.14 on a typical $11.99 month. It is divided among ONLY the artists you played (qualified plays, 30+ seconds), proportional to your listening time, using largest-remainder rounding so the cents always sum exactly, none lost, none invented.
This is src/lib/waterfall.ts, the file the server executes for every allocation, read from disk as you loaded this page. It is pure integer-cent arithmetic with no side effects, which is what lets us test it exhaustively and publish it whole.
// Pure money math for the user-centric payout model (spec §22).
// Everything is integer cents; no I/O so it can be exhaustively tested.
// This module stays free of server-only imports so the transparency
// simulator can run the SAME code in the browser.
// Standard US card estimate used when the real balance transaction fee is
// unavailable (re-exported by lib/stripe for the billing routes).
export function estimateProcessingFee(grossCents: number) {
return Math.round(grossCents * 0.029) + 30
}
export type WaterfallSubscription = {
grossSubscriptionAmount: number
paymentProcessorFeeAmount: number
netReceivedAmount: number
subscriptionSource: string
status: string
}
export type WaterfallConfig = {
infrastructureCostPerSubscriber: number
operatingReservePercent: number
appStorePlatformFeePercent: number
// Admin-configurable processing-fee assumptions; used only when no real
// fee was recorded for the subscription.
paymentProcessingPercent?: number
paymentProcessingFixedFee?: number
}
export type Waterfall = {
gross: number
processing: number
net: number
infra: number
reserve: number
appStoreFee: number
pool: number
}
export function computeWaterfall(
sub: WaterfallSubscription,
config: WaterfallConfig,
): Waterfall {
const gross = sub.grossSubscriptionAmount
// A trialing subscription with no recorded payment has produced no revenue
// yet; allocating from it would pay artists money that never arrived.
if (sub.status === 'trialing' && sub.netReceivedAmount <= 0) {
return { gross, processing: 0, net: 0, infra: 0, reserve: 0, appStoreFee: 0, pool: 0 }
}
const estimatedFee =
config.paymentProcessingPercent !== undefined && config.paymentProcessingFixedFee !== undefined
? Math.round((gross * config.paymentProcessingPercent) / 100) + config.paymentProcessingFixedFee
: estimateProcessingFee(gross)
const processing =
sub.paymentProcessorFeeAmount > 0
? sub.paymentProcessorFeeAmount
: sub.subscriptionSource === 'comped' || sub.status === 'comped'
? 0
: estimatedFee
const net = sub.netReceivedAmount > 0 ? sub.netReceivedAmount : gross - processing
const infra = config.infrastructureCostPerSubscriber
const reserve = Math.round((gross * config.operatingReservePercent) / 100)
const appStoreFee =
sub.subscriptionSource === 'apple_iap'
? Math.round((gross * config.appStorePlatformFeePercent) / 100)
: 0
const pool = Math.max(0, net - infra - reserve - appStoreFee)
return { gross, processing: gross - net, net, infra, reserve, appStoreFee, pool }
}
export type MonthPayment = { grossAmount: number; feeAmount: number; netAmount: number }
// Decide a subscriber's revenue basis for one calendar month, or null if they
// pool nothing that month. This is the guard against the churn over-allocation:
// - recorded per-invoice payment(s) for the month win (real revenue arrived;
// callers pass the SUM when a month holds several payments);
// - otherwise an INVOICE-BILLED subscription (identified by having a Stripe
// subscription id) with no payment this month contributes nothing — either
// a month its Stripe period merely spilled into (a period straddles two
// calendar months, so a churned subscriber's final period overlaps the next
// month too) or a first invoice that hasn't landed yet. Falling back to the
// cached amount would allocate money that never arrived; late payments flag
// the month for recompute instead;
// - a never-invoiced row (comp / manual / dev — no Stripe subscription id)
// keeps its cached amounts, exactly as before.
export function subscriberMonthRevenue<S extends WaterfallSubscription>(
sub: S,
payment: MonthPayment | undefined,
everInvoiced: boolean,
): S | null {
if (payment) {
return {
...sub,
grossSubscriptionAmount: payment.grossAmount,
paymentProcessorFeeAmount: payment.feeAmount,
netReceivedAmount: payment.netAmount,
}
}
if (everInvoiced) return null
return sub
}
export type ListeningShare = { artistId: string; seconds: number }
export type PoolAllocation = {
artistId: string
seconds: number
sharePercentage: number
amount: number
}
// Largest-remainder apportionment: allocations are proportional to qualified
// seconds and always sum exactly to the pool (no lost or invented cents).
export function splitPoolLargestRemainder(
pool: number,
listening: ListeningShare[],
): PoolAllocation[] {
const positive = listening.filter((l) => l.seconds > 0)
const totalSeconds = positive.reduce((s, l) => s + l.seconds, 0)
if (pool <= 0 || totalSeconds <= 0) return []
const shares = positive.map((l) => {
const exact = (pool * l.seconds) / totalSeconds
return { ...l, exact, floor: Math.floor(exact) }
})
let remainder = pool - shares.reduce((s, x) => s + x.floor, 0)
shares.sort((a, b) => b.exact - b.floor - (a.exact - a.floor))
const result: PoolAllocation[] = []
for (const share of shares) {
const bonus = remainder > 0 ? 1 : 0
remainder -= bonus
const amount = share.floor + bonus
if (amount <= 0) continue
result.push({
artistId: share.artistId,
seconds: share.seconds,
sharePercentage: (share.seconds / totalSeconds) * 100,
amount,
})
}
return result
}
export type SplitDefinition = { artistId: string | null; labelId?: string | null; percent: number }
// The routing key for a split recipient. Labels are namespaced with a `label:`
// prefix so the payout engine can tell them apart from artists (artist keys
// stay raw cuids, keeping the existing artist path and its tests unchanged).
export const LABEL_RECIPIENT_PREFIX = 'label:'
function recipientKey(sp: SplitDefinition): string | null {
if (sp.labelId) return `${LABEL_RECIPIENT_PREFIX}${sp.labelId}`
return sp.artistId
}
// Divides one track's earnings among split recipients (integer cents,
// largest remainder). Splits without a linked platform artist/label route back
// to the uploader: no escrow, no waiting money. The uploader keeps whatever
// percentage is not assigned. Recipients are keyed by recipientKey(); a label
// recipient is `label:<id>`, an artist is its raw id.
export function applyTrackSplits(
amount: number,
uploaderArtistId: string,
splits: SplitDefinition[],
): Map<string, number> {
const result = new Map<string, number>()
if (amount <= 0) return result
// A split routes away from the uploader if it targets a label, or a different
// artist. A split targeting the uploader's own artist id is a no-op remainder.
const linked = splits.filter((sp) => {
if (sp.percent <= 0) return false
if (sp.labelId) return true
return Boolean(sp.artistId) && sp.artistId !== uploaderArtistId
})
const linkedPercent = linked.reduce((sum, sp) => sum + sp.percent, 0)
if (linkedPercent <= 0) {
result.set(uploaderArtistId, amount)
return result
}
// Defense-in-depth: percentages should never exceed 100 (the write path
// enforces it), but if bad data slips through, scale them down to sum to 100
// so recipients can never be routed MORE than the track earned (no invented
// cents). At exactly/over 100 the uploader keeps nothing.
const scale = linkedPercent > 100 ? 100 / linkedPercent : 1
const exact = linked.map((sp) => ({
artistId: recipientKey(sp) as string,
exact: (amount * sp.percent * scale) / 100,
}))
let allocated = 0
const floors = exact.map((e) => {
const f = Math.floor(e.exact)
allocated += f
return { ...e, amount: f }
})
let remainder = Math.round((amount * Math.min(linkedPercent, 100)) / 100) - allocated
floors.sort((a, b) => b.exact - b.amount - (a.exact - a.amount))
for (const f of floors) {
if (remainder <= 0) break
f.amount += 1
remainder -= 1
}
let toOthers = 0
for (const f of floors) {
if (f.amount <= 0) continue
result.set(f.artistId, (result.get(f.artistId) ?? 0) + f.amount)
toOthers += f.amount
}
const uploaderShare = amount - toOthers
if (uploaderShare > 0) {
result.set(uploaderArtistId, (result.get(uploaderArtistId) ?? 0) + uploaderShare)
}
return result
}
Your monthly receipt has an “Export JSON” option with your own numbers in this exact shape, so you can re-run the math yourself.