Live & Sessions
Livestreaming, and the booking flow for 1-on-1 sessions. All endpoints require authentication.
Livestreams
| Method | Path | Purpose |
|---|---|---|
GET | /livestreams/credentials | Get (or lazily create) your Mux stream credentials |
POST | /livestreams/credentials/regenerate | Rotate the stream key |
POST | /livestreams | Create a livestream record |
POST | /livestreams/:id/enable | Enable the Mux live stream |
POST | /livestreams/:id/end | End it |
GET | /livestreams/:id | Fetch a livestream (with playback id) |
GET | /livestreams/:id/messages | Chat history |
POST | /livestreams/:id/messages | Send a chat message |
Credentials exist from registration
registerUser creates a LiveStream record with Mux credentials, so GET /livestreams/credentials normally returns an existing record rather than provisioning one.
{
"id": "clx…",
"streamKey": "…",
"muxPlaybackId": "…",
"muxLivestreamId": "…",
"status": "idle"
}The creator publishes RTMP to Mux with streamKey; viewers play HLS using muxPlaybackId.
streamKey is a credential
Anyone holding it can broadcast as that creator. Never log it, never include it in a response meant for anyone but the owner, and rotate it with /livestreams/credentials/regenerate if it leaks.
Status is webhook-driven
LiveStream.status and User.isLive are set by Mux webhooks (video.live_stream.active / .idle), not by the client. Do not set them from the app. See Livestreaming.
Chat
Livestream chat is persisted as LiveStreamMessage rows, so late joiners see history. Coin gifting during a stream produces the CREATOR_REDEEM ledger entries that feed creator payouts.
Sessions and e-dates
Paid 1-on-1 sessions (ONE_ON_ONE, 13+) and e-dates (EDATE, verified 18+ on both sides).
| Method | Path | Purpose |
|---|---|---|
POST | /sessions/holds | Hold a slot and open its checkout |
GET | /sessions/mine | Your bookings (role, status = upcoming | past, cursor, limit) |
GET | /sessions/:id | One booking |
PATCH | /sessions/:id/cancel | Cancel (policy below) |
POST | /sessions/:id/reschedule | Move it: { "startAt": "<ISO>" } |
POST | /sessions | Retired: 410 SESSION_FLOW_RETIRED (it booked without payment) |
Booking
POST /sessions/holds{ playpalId, kind, durationMinutes, startAt, notes? }→201 { session, checkout }. The slot isHELDfor 10 minutes.checkoutis a checkout for the length's product (kind: "SESSION" | "EDATE", withsessionId); if the booker has an unused credit for that product it's alreadyfulfilled(paidWithCredit: true).- The app pays it exactly like a VIP pass (
checkout_intent_idattribute,purchaseStoreProduct,POST /checkout/intents/:id/complete, or the webhook). - Paid →
CONFIRMED: a private two-personChannelRoomis created, and the PlayPal's earning is recorded inESCROWuntil the session happens. If the hold lapsed and its time was taken meanwhile, the payment becomes a credit instead (reason: "booking_unavailable").
Who can book (checked at the hold and again when paid):
| Code | Meaning |
|---|---|
ELITE_REQUIRED | Booking needs an Elite membership |
PASS_REQUIRED | An active VIP pass for this PlayPal |
SESSION_TYPE_DISABLED | The PlayPal isn't taking this kind |
BLOCKED | A block either way |
BIRTHDATE_REQUIRED, MINORS_NOT_ACCEPTED | 1-on-1: under-18 bookers only if the PlayPal accepts them |
AGE_VERIFICATION_*, UNDERAGE, PLAYPAL_NOT_VERIFIED | E-dates (see Age Verification) |
DURATION_NOT_OFFERED, PRICE_UNAVAILABLE | The length isn't offered, or has no price on sale |
SLOT_UNAVAILABLE, SLOT_TAKEN | Not an open slot, or someone got it first |
No double booking. Two Postgres exclusion constraints (btree_gist) refuse any two sessions that hold time (HELD, CONFIRMED, IN_PROGRESS) and overlap, for the same PlayPal or the same booker, counting the PlayPal's buffer (blockedUntil). A lapsed hold is marked EXPIRED before each new hold.
Policy
| Who, when | Booker gets | PlayPal |
|---|---|---|
| Booker, 24+ hours ahead | A credit for the same product (valid 365 days) | Earning reversed |
| Booker, under 24 hours | Nothing back | Paid (earning starts its 30-day hold) |
| PlayPal, any time | A credit | Earning reversed; 3 cancellations in 30 days raise a playpal_cancellations flag |
| A store refund | Session cancelled, no credit | Earning reversed (if still held) |
Refunds are credits, not store refunds: Apple doesn't let the platform refund an in-app purchase. The booker can move a booking once, 24+ hours ahead, to another open slot (RESCHEDULE_LIMIT, TOO_LATE_TO_RESCHEDULE); the PlayPal can't move it (they cancel instead). Losing a VIP pass doesn't affect sessions already booked. Session views include canCancel, cancelRefunds and canReschedule for the viewer.
Session.status: HELD → CONFIRMED → IN_PROGRESS → COMPLETED, or CANCELLED, NO_SHOW_USER, NO_SHOW_PLAYPAL, EXPIRED (a hold never paid). outcome records why it ended.
Reminders and joining
A BullMQ job scheduler (session-lifecycle queue, job sweep, every minute) runs runSessionSweep (apps/api/src/modules/booking/lifecycle.ts) in the API process (SESSION_WORKER=false turns it off on a replica). Each run:
- marks lapsed holds
EXPIRED; - rules on no-shows and closes sessions past their end (see The session room);
- sends both people a
SESSION_REMINDERa day before (skipped for sessions booked less than a day ahead, or already within 15 minutes), 15 minutes before, and at the start, with times in each recipient's ownUser.timezone; - reminds a fan 3 days before their last VIP pass for a PlayPal ends, and once after it ends (within 7 days), unless another pass carries on.
Each reminder is recorded on the row when sent (reminder24hSentAt, reminder15mSentAt, startNoticeSentAt; VipPass.expiringNoticeSentAt, expiredNoticeSentAt) by a conditional update, so a late, repeated or concurrent sweep never sends one twice. Rescheduling clears the session's markers; a cancelled session simply stops matching. Booking confirmations and cancellations are sent when they happen, not by the sweep.
Session views carry joinableFrom (5 minutes before the start), joinableUntil (the fixed end) and canJoin. The Chat tab pins bookings starting within 24 hours above the DM list.
The session room
apps/api/src/modules/booking/room.ts; policy constants in ROOM_POLICY.
| Endpoint | What it does |
|---|---|
POST /sessions/:id/join | A LiveKit token for this session's room |
POST /sessions/:id/end | Either person ends a running session early, for both |
POST /sessions/:id/events | { type: "screenshot" | "reported" } from the room, for the audit trail |
Join checks, in order: the caller is one of the two people (else 404); the status is CONFIRMED or IN_PROGRESS (SESSION_CANCELLED, SESSION_OVER); now is between 5 minutes before the start and the end (TOO_EARLY, SESSION_OVER); no block either way (403 BLOCKED); for e-dates, assertEdateEligible for both sides, re-checked at every join (403 UNDERAGE etc.). It then creates the LiveKit room (maxParticipants 2, departure timeout 120 s so brief drops survive), issues a token that can only join that room and publish camera and microphone, with a TTL of at most 15 minutes and never past end + 2 minutes, marks the caller's *JoinedAt, moves the session to IN_PROGRESS, and schedules a delayed end job on the session-lifecycle queue at the end time. The response carries endsAt and serverNow, so the app's countdown runs on the server's clock. The app calls join again to reconnect; the end never moves.
canAccessRoom applies the same participant, status and time-window rule to a session's room, so /rooms/:id/token is no way around it.
LiveKit webhook. participant_joined / participant_left in a session room set playpalJoinedAt / purchaserJoinedAt and write joined / left events.
End. At the end (the delayed job, with the sweep as a backstop) the session becomes COMPLETED, the LiveKit room is deleted (disconnecting both), and the PlayPal's escrowed earning is released: PENDING with availableAt = endedAt + 30 days. Ending early does the same (outcomeended_early_by_user / ended_early_by_playpal).
No-shows, ruled at start + 10 minutes:
| Who joined | Status / outcome | Booker | PlayPal earning |
|---|---|---|---|
| PlayPal only | NO_SHOW_USER | Nothing back | Released |
| Booker only | NO_SHOW_PLAYPAL | Credit | Reversed; counts toward the cancellation flag |
| Neither | CANCELLED / no_show_both | Credit | Reversed |
Blocking either way ends a running session between the two (ended_by_block, earning released) and cancels booked ones with a credit (cancelled_by_block, earning reversed).
SessionEvent is the append-only audit trail (a trigger refuses updates): join_token, joined, left, ended, no_show_user / no_show_playpal / no_show_both, blocked, screenshot, reported, with the actor and metadata. Admins read it; nothing edits it.
App (components/room/SessionRoom.tsx, route session-room/[sessionId]): a 1:1 video layout with a self view, the countdown with warnings at 5 and 1 minutes, mute, camera (off until turned on), flip, an End button that ends it for both without a confirmation, and report / block from the "⋯" menu. A dropped connection shows "Reconnecting…" and fetches a fresh token; the server deleting the room shows "Session ended". Android blocks screenshots and recording (FLAG_SECURE); iOS can't, so a screenshot warns the other person over the data channel and is logged. There is no recording or egress.
Prices
PUT /studio/offerings (PlayPals only) is the Studio's pricing save for one kind:
{
kind: "ONE_ON_ONE" | "EDATE",
enabled?: boolean, // left out: unchanged; can't be true with no lengths
acceptMinors?: boolean, // 1-on-1 only; e-dates are always 18+
offerings: { durationMinutes: number, productId: string | null }[], // replaces the lengths
}The list replaces the kind's lengths (up to 3 of 15, 30, 45, 60, 90, 120, no repeats), each priced at a SESSION or EDATE tier or null (unpriced, not bookable). An empty list turns the kind off. Hours are untouched. GET /playpals/:id/booking-options lists only lengths with a price on sale, as lengths: [{ durationMinutes, productId, priceCents }].
Availability
PlayPals publish when they can be booked, separately for 1-on-1 sessions (ONE_ON_ONE) and e-dates (EDATE). Hours are wall-clock times in the PlayPal's time zone (CreatorBookingSettings.timezone, falling back to the time zone their device reports as User.timezone); with neither, they have no bookable time.
Slots and booking options
| Method | Path | Purpose |
|---|---|---|
GET | /playpals/:id/booking-options | { timezone, kinds: [{ kind, durations }] }, enabled kinds only |
GET | /playpals/:id/slots?kind&duration&from&to | Open slots as UTC instants: { timezone, durationMinutes, slots: [{ startAt, endAt }] } |
from and to are ISO instants (default: now to 14 days on; at most 62 days). Errors: 409 SESSION_TYPE_DISABLED, 400 DURATION_NOT_OFFERED, 404 PLAYPAL_NOT_FOUND. The app shows the instants in the viewer's own time zone.
Slots come from the slot engine (apps/api/src/modules/booking/slots.ts, pure and unit-tested):
- each local date's windows are made instants with that date's offset, so DST moves instants, never the PlayPal's hours; a time in the spring-forward gap moves to the first real time, and a repeated fall-back time uses its first occurrence;
- windows that meet (Friday 22:00–24:00, Saturday 00:00–02:00) merge, so slots can run past midnight;
- lengths are real minutes; starts step every 30 minutes (or the length, if shorter) from each window's start;
- a date override replaces that date's weekly hours; a blackout removes the date;
- slots respect the minimum notice and booking horizon, and keep clear of the PlayPal's other sessions (in either role, either kind) plus their buffer after each.
Creator Studio
PlayPals only (403 PLAYPAL_ONLY otherwise).
| Method | Path | Purpose |
|---|---|---|
GET | /studio/availability?kind=ONE_ON_ONE | One kind's whole setup |
PUT | /studio/availability | Replace it |
{
kind: "ONE_ON_ONE" | "EDATE",
timezone: string, // IANA; shared by both kinds
enabled?: boolean, // left out: unchanged (the pricing save sets it)
bufferMinutes: number, // 0–240, kept free after each booking
minNoticeMinutes: number, // 0–43200
maxAdvanceDays: number, // 1–365
durations?: number[], // left out: unchanged; up to 3 of 15, 30, 45, 60, 90, 120
weekly: { dayOfWeek: 0-6, start: "HH:MM", end: "HH:MM" }[], // 0 = Monday, end may be "24:00"
overrides: { date: "YYYY-MM-DD", windows: { start, end }[] }[], // empty windows = day off
blackouts: "YYYY-MM-DD"[],
}The app sends the device's time zone as PUT /user { timezone } once per launch. The app's availability screen sends hours and rules only; on/off, lengths and prices go through PUT /studio/offerings from the pricing screen. A PlayPal's bookings are GET /sessions/mine?role=playpal.
App (me/studio/, PlayPals only, from Settings → Creator Studio): index (can fans buy and book, next bookings), pricing (VIP pass price; per kind on/off, under-18s, lengths and prices), availability (time zone, hours, exceptions, buffer, notice, horizon), bookings (upcoming and past, cancel), vip-posts (your VIP posts; "⋯" makes one public), earnings (USD by stage and source, what comes off hold when, coins from tips) and stats (7/30/90 days; see Creator Studio earnings and stats). edit/subscription-price and edit/working-hours redirect into the Studio.
Working hours (older endpoints)
These predate kinds and work on 1-on-1 hours only (they never read or change e-date hours).
| Method | Path | Purpose |
|---|---|---|
GET | /availability | Your working hours, formatted |
GET | /availability/raw | Raw rows |
PUT | /availability | Replace the whole schedule |
PATCH | /availability/day | Update one day |
Blocked dates
| Method | Path | Purpose |
|---|---|---|
GET | /availability/blocked-dates | List blocked dates |
POST | /availability/block-date | Block a specific date |
DELETE | /availability/block-date | Unblock |
Recurring hours and one-off blocked dates share the Availability model — recurring rows use dayOfWeek + startTime + endTime, blocked rows use date + blocked: true.
Checking availability
| Method | Path | Purpose |
|---|---|---|
GET | /availability/check | Is you free at a time? |
GET | /users/:userId/availability | Another user's public availability |
GET | /users/:userId/availability/check | Is they free at a time? |
The booking flow calls /users/:userId/availability to render the calendar, then /users/:userId/availability/check before submitting.
/availability/check and /users/:userId/availability/check now read the hours in the PlayPal's time zone, and POST /sessions checks the requested time with the slot engine.
Related
- Livestreaming & Voice
- Messaging & Channels — room tokens and access rules
- Monetization — how session revenue reaches creators
