Skip to content

Live & Sessions ​

Livestreaming, and the booking flow for 1-on-1 sessions. All endpoints require authentication.

Livestreams ​

MethodPathPurpose
GET/livestreams/credentialsGet (or lazily create) your Mux stream credentials
POST/livestreams/credentials/regenerateRotate the stream key
POST/livestreamsCreate a livestream record
POST/livestreams/:id/enableEnable the Mux live stream
POST/livestreams/:id/endEnd it
GET/livestreams/:idFetch a livestream (with playback id)
GET/livestreams/:id/messagesChat history
POST/livestreams/:id/messagesSend 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.

json
{
  "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).

MethodPathPurpose
POST/sessions/holdsHold a slot and open its checkout
GET/sessions/mineYour bookings (role, status = upcoming | past, cursor, limit)
GET/sessions/:idOne booking
PATCH/sessions/:id/cancelCancel (policy below)
POST/sessions/:id/rescheduleMove it: { "startAt": "<ISO>" }
POST/sessionsRetired: 410 SESSION_FLOW_RETIRED (it booked without payment)

Booking ​

  1. POST /sessions/holds { playpalId, kind, durationMinutes, startAt, notes? } → 201 { session, checkout }. The slot is HELD for 10 minutes. checkout is a checkout for the length's product (kind: "SESSION" | "EDATE", with sessionId); if the booker has an unused credit for that product it's already fulfilled (paidWithCredit: true).
  2. The app pays it exactly like a VIP pass (checkout_intent_id attribute, purchaseStoreProduct, POST /checkout/intents/:id/complete, or the webhook).
  3. Paid → CONFIRMED: a private two-person ChannelRoom is created, and the PlayPal's earning is recorded in ESCROW until 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):

CodeMeaning
ELITE_REQUIREDBooking needs an Elite membership
PASS_REQUIREDAn active VIP pass for this PlayPal
SESSION_TYPE_DISABLEDThe PlayPal isn't taking this kind
BLOCKEDA block either way
BIRTHDATE_REQUIRED, MINORS_NOT_ACCEPTED1-on-1: under-18 bookers only if the PlayPal accepts them
AGE_VERIFICATION_*, UNDERAGE, PLAYPAL_NOT_VERIFIEDE-dates (see Age Verification)
DURATION_NOT_OFFERED, PRICE_UNAVAILABLEThe length isn't offered, or has no price on sale
SLOT_UNAVAILABLE, SLOT_TAKENNot 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, whenBooker getsPlayPal
Booker, 24+ hours aheadA credit for the same product (valid 365 days)Earning reversed
Booker, under 24 hoursNothing backPaid (earning starts its 30-day hold)
PlayPal, any timeA creditEarning reversed; 3 cancellations in 30 days raise a playpal_cancellations flag
A store refundSession cancelled, no creditEarning 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_REMINDER a 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 own User.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.

EndpointWhat it does
POST /sessions/:id/joinA LiveKit token for this session's room
POST /sessions/:id/endEither 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 joinedStatus / outcomeBookerPlayPal earning
PlayPal onlyNO_SHOW_USERNothing backReleased
Booker onlyNO_SHOW_PLAYPALCreditReversed; counts toward the cancellation flag
NeitherCANCELLED / no_show_bothCreditReversed

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:

ts
{
  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 ​

MethodPathPurpose
GET/playpals/:id/booking-options{ timezone, kinds: [{ kind, durations }] }, enabled kinds only
GET/playpals/:id/slots?kind&duration&from&toOpen 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).

MethodPathPurpose
GET/studio/availability?kind=ONE_ON_ONEOne kind's whole setup
PUT/studio/availabilityReplace it
ts
{
  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).

MethodPathPurpose
GET/availabilityYour working hours, formatted
GET/availability/rawRaw rows
PUT/availabilityReplace the whole schedule
PATCH/availability/dayUpdate one day

Blocked dates ​

MethodPathPurpose
GET/availability/blocked-datesList blocked dates
POST/availability/block-dateBlock a specific date
DELETE/availability/block-dateUnblock

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 ​

MethodPathPurpose
GET/availability/checkIs you free at a time?
GET/users/:userId/availabilityAnother user's public availability
GET/users/:userId/availability/checkIs 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.

Internal documentation — PlayPalz platform