Commerce & Wallet
Store products, the Play Coin wallet, and subscriptions. All endpoints require authentication.
Money is always integer cents. Play Coins are whole units.
Products
| Method | Path | Purpose |
|---|---|---|
GET | /products | List products |
GET | /products/featured | Featured products |
GET | /products/:id | One product |
POST | /products/:id/buy | Buy with Play Coins |
The store sells profile customisation — avatars, avatar frames, banners, banner frames, profile backgrounds, themes. Buying creates an Order with OrderItem rows, an Inventory row for ownership, and a STORE_SPEND coin transaction, all in one database transaction. Too few coins returns 402 with code INSUFFICIENT_COINS, and nothing is recorded. Equipping an owned item is recorded as a UserAsset.
Wallet
| Method | Path | Purpose |
|---|---|---|
GET | /wallet | Coin balances by bucket |
GET | /wallet/transactions | The caller's coin history, newest first (?cursor=&limit=, limit 1–100) |
GET | /me/coins | Spendable coins, as { balance } |
Balances come from the coin ledger. GET /wallet:
{
"purchased": 120, "received": 0, "earnedPending": 0, "earnedAvailable": 0,
"debt": 0, "spendable": 120, "withdrawable": 0,
"coinBalance": 120
}coinBalance equals spendable, kept for clients that show a single number. spendable and withdrawable are 0 while debt is non-zero.
GET /wallet/transactions returns { items, nextCursor }. Each item is { id, type, createdAt, amount, buckets: [{ bucket, amount }] }, where amount is the net change to the caller's buckets. Pass nextCursor back as cursor for the next page.
Coin errors share codes across endpoints:
| Status | code | Meaning |
|---|---|---|
402 | INSUFFICIENT_COINS | Spendable coins don't cover it; data has needed and available |
403 | COIN_DEBT | Spending is on hold until a refund clawback is repaid |
409 | IDEMPOTENCY_CONFLICT | The request key was already used for a different request |
Wallet.coinBalance and User.playcoin_balance are legacy and no longer written.
Tips
| Method | Path | Purpose |
|---|---|---|
GET | /tips/config | Presets and limits for the tip sheet |
POST | /tips | Tip a post or a live stream (needs an Idempotency-Key header) |
POST | /tips/:id/hide-message | The recipient hides a tip's message |
POST /tips takes { targetType: "post" | "livestream", targetId, coins, message?, confirmed? } and an Idempotency-Key header: one per tip attempt, 8–128 characters of [A-Za-z0-9_-]. The same key with the same body replays the tip (200, replayed: true); with a different body it is 409. A new tip is 201 with { tip, replayed, wallet }. See Monetization for the rules.
| Status | code | Meaning |
|---|---|---|
400 | TIP_AMOUNT_INVALID | Outside 1–10,000, or not a whole number |
400 | TIP_CONFIRMATION_REQUIRED | Over confirmAbove; resend with confirmed: true |
400 | TIP_MESSAGE_NOT_ALLOWED | data.reason: too_long, contact_or_link, control_characters, blocked_term |
400 | TIP_SELF | Your own post |
400 | IDEMPOTENCY_KEY_REQUIRED | Missing or malformed header |
402 | INSUFFICIENT_COINS | Not enough spendable coins |
403 | BIRTHDATE_REQUIRED | No date of birth on file |
403 | TIP_DAILY_LIMIT | Over the 24-hour cap; data has cap, sentToday, remaining |
400 | LIVE_ENDED | The live stream isn't on air |
404 | — | Post deleted or unpublished, stream missing, blocked either way, or recipient inactive |
409 | IDEMPOTENCY_CONFLICT | Key reused for a different tip |
429 | TIP_RATE_LIMITED | Too many tips in the last minute |
GET /wallet/transactions items for tips carry tip: { id, targetType, postId, liveStreamId, sender, recipient }.
Earnings (PlayPals)
| Method | Path | Purpose |
|---|---|---|
GET | /earnings | Balances, rate, eligibility, upcoming releases, requests and payouts |
POST | /earnings/cashouts | Request a cash-out { coins } (needs an Idempotency-Key header) |
POST | /earnings/cashouts/:id/cancel | Withdraw a request not yet in a payout run |
GET /earnings returns balances { available, pending, spendOnly, debt }, rate { centsPer1000, minCoins, stepCoins }, canCashOut, blockers (payout_account, verification, age), nextMaturityAt, upcoming [{ maturesAt, coins }], nextPayoutDate, cashouts and payouts. See Monetization.
| Status | code | Meaning |
|---|---|---|
400 | CASHOUT_AMOUNT_INVALID | Below the minimum, or not a multiple of the step |
402 | INSUFFICIENT_COINS | More than EARNED_AVAILABLE |
403 | CASHOUT_NOT_ELIGIBLE | Payout account, verification, or age missing |
403 | COIN_DEBT | A refund debt is open |
404 | CASHOUT_NOT_FOUND | Not yours, or doesn't exist |
409 | CASHOUT_NOT_CANCELLABLE | Already scheduled, paid or returned |
Subscriptions
| Method | Path | Purpose |
|---|---|---|
GET | /me/subscription | Your platform subscription status |
GET | /me/vip-subscriptions | PlayPals whose VIP content you can see, and until when |
POST | /vip-subscriptions | Retired: 410 VIP_FLOW_RETIRED. Use /checkout/intents |
Two kinds of subscription
| Platform subscription | Creator (VIP) subscription | |
|---|---|---|
| Model | Subscription | CreatorSubscription |
| Endpoint | GET /me/subscription | GET /me/vip-subscriptions |
| Meaning | PlayPalz premium tier | Access to one creator's channel |
| Price | Fixed tiers | The creator's monthlyPrice |
| Source | RevenueCat webhook | Verified 30-day VIP passes (VipPass); see Monetization |
Each /me/vip-subscriptions row has source: "pass" (access ends at expiresAt) or "revenuecat_legacy" (an old auto-renewing subscription, active until the store expires it).
Checkout (VIP passes)
| Method | Path | Purpose |
|---|---|---|
GET | /price-tiers?kind=VIP_PASS | Price tiers of a kind, with offered |
POST | /checkout/intents | Start a checkout: { "playpalId": "…", "kind": "VIP_PASS" } → 201 { checkout } |
GET | /checkout/intents/:id | Poll a checkout (yours only) |
POST | /checkout/intents/:id/complete | Report the store purchase: { "transactionId": "…" } |
GET | /me/purchase-credits | Unused credits, soonest-expiring first |
checkout is { intentId, kind, playpalId, productId, status, expiresAt, accessUntil }, with status open | fulfilled | expired, and paidWithCredit on creation (an unused credit for the product was applied, so there is nothing to buy).
The app sets the RevenueCat subscriber attribute checkout_intent_id to intentId before purchaseStoreProduct(productId), then calls complete. complete answers result: fulfilled, credited (kept as a credit), or pending (the store or RevenueCat hasn't confirmed it yet, or sync is off; the webhook will apply it, so poll the checkout).
| Status | Code | When |
|---|---|---|
403 | MEMBERSHIP_REQUIRED | No Pro or Elite membership |
403 | BLOCKED | A block exists either way |
400 | SELF_PURCHASE | Your own pass |
404 | PLAYPAL_NOT_FOUND | Not an active PlayPal |
409 | TIER_UNAVAILABLE | The PlayPal's tier isn't on sale yet |
403 | PURCHASE_NOT_YOURS | complete with someone else's transaction |
400 | PRODUCT_MISMATCH | complete with a purchase of another product |
409 | PURCHASE_REFUNDED | complete with a refunded purchase |
403 | SANDBOX_NOT_ALLOWED | A sandbox purchase where they're refused |
404 | CHECKOUT_NOT_FOUND | Not yours, or doesn't exist |
What a subscription unlocks
An active CreatorSubscription is what canAccessRoom checks for vip rooms:
const sub = await prisma.creatorSubscription.findFirst({
where: {
fanId: userId,
creatorId: room.channel.userId,
status: "active",
OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }],
},
});Channel membership alone is not enough.
Buying coins
Coins are a consumable in-app purchase. This API never takes payment; it credits purchases the store has verified. See Monetization for the whole flow.
| Method | Path | Purpose |
|---|---|---|
GET | /coins/packages | Packages on sale, in display order |
POST | /coins/purchases/sync | Verify and credit store transactions now |
GET /coins/packages returns { packages: [{ productId, coins, bonusCoins, label, badge }] }. coins includes the bonus. There is no price: the app shows the store's localized price, and leaves out any package the store doesn't return.
POST /coins/purchases/sync takes { transactionIds: string[] } (1–20 store transaction ids, from the SDK) and returns { enabled, results: [{ transactionId, outcome }], wallet }. Outcomes:
outcome.status | Meaning |
|---|---|
credited | Verified and credited now (coins) |
already_credited | Credited before, by the webhook or an earlier sync |
ignored | Not credited: reason is sandbox_not_allowed, unknown_product or bad_quantity |
not_found | RevenueCat has no one-time purchase with exactly this id |
not_yours | It belongs to another customer |
refunded | It was refunded |
not_coins | It isn't a coin product |
unverified | RevenueCat couldn't be reached; the webhook will credit it |
With enabled: false (sync off, or the RevenueCat API isn't configured) nothing is checked, and the app waits for the webhook instead.
Adding a coin product: create it in App Store Connect and Play Console, then insert a CoinPackage row (offered = true to sell it). No code change.
Creator payouts
Not exposed on this API. Payouts are calculated and executed by anansi on a monthly cycle, behind its own admin API.
