Skip to main content

Stories & Live — Technical Reference

← Back to Stories & Live

Where this lives

Backend

  • apps/backend/managers/live-stream-managers/live-stream.manager.js — implements the business logic for every live-stream operation (createLiveStream, startLiveStream, endLiveStream, cancelLiveStream, deleteLiveStream, joinLiveStream, leaveLiveStream, banViewerFromLive/unbanViewerFromLive, sendLiveComment/deleteLiveComment/pinLiveComment/unpinLiveComment, reactToLiveStream/removeReaction, getCurrentlyLive, getTrendingLiveStreams, getScheduledStreams, getUserLiveStreams, getLiveStreamStats, LiveKit token issuance, plus the more recently added sendGift/getGiftHistory/getLiveGiftCatalog, requestToSpeak/respondToSpeakerRequest/getSpeakerRequests/getMySpeakerStatus, and createLivePromotionPost). Corrected — this is now fully wired to GraphQL. A prior pass of this doc said the broadcasting half of this manager had no resolver; that was accurate at the time but is no longer true. Auditing this manager before wiring it up also surfaced a systemic bug: nearly every .create()/.update()/.increment() call and instance-property read across this manager, its 4 access-services, and the 4 underlying Sequelize models used snake_case keys (user_id, live_stream_id, is_currently_watching, peak_viewer_count, ...) against models whose real attributes are camelCase — Sequelize silently drops unrecognized keys on writes rather than erroring, so writes like banViewer's is_banned: true never actually persisted, updatePeakViewerCount was dead code (undefined comparison), endLiveStream's duration always came out NaN, and joinLiveStream's own validator call was miskeyed so it always threw. On top of that, all 4 LiveStream-family Sequelize models (LiveStream, LiveStreamComment, LiveStreamViewer, LiveStreamInteraction) had empty associate() methods despite every access-service method eager-loading {model: User, as: 'user'} — Sequelize throws EagerLoadingError for an include alias with no defined association, so essentially every read in this feature would have failed at runtime. All of this was fixed (camelCase throughout, associations added, plus a missing getLiveStream/joinLiveStream visibility-enforcement gap for private/followers_only/close_friends streams) before the GraphQL layer below was built on top of it.
  • apps/backend/graphql/types/live-stream.type.js and resolvers/live-stream.resolver.js — the full broadcasting GraphQL API: every query/mutation listed under GraphQL API below.
  • apps/backend/graphql/types/live-stream-archive.type.js and resolvers/live-stream-archive.resolver.js — a thin, read-only passthrough exposing liveArchive(limit, offset): the caller's own ended live streams, backed by live-stream.manager.js#getUserLiveStreams scoped to status: 'ended'.
  • apps/backend/data-access-services/live-stream/live-stream.access-service.js, live-stream-viewer.access-service.js, live-stream-comment.access-service.js, live-stream-interaction.access-service.js — database layer used by the manager above
  • apps/backend/managers/call-managers/speaker-request.manager.js and data-access-services/call/speaker-request.access-service.jsCorrected — moved and no longer the whole story. This page previously cited these at their old paths (managers/speaker-request.manager.js, data-access-services/speaker-request.access-service.js) and said the "request to speak" flow was wired only to group Calls. Both are still true of this pair of files (they back pendingSpeakerRequests/mySpeakerRequests/cancelSpeakerRequest in call.resolver.js/call.type.js, for Calls only) — but live streams now have their own, separate speaker-request implementation that doesn't go through this manager at all: requestToSpeak/respondToSpeakerRequest/getSpeakerRequests/getMySpeakerStatus built directly into live-stream.manager.js, gating a speakerStatus column on LiveStreamViewer. See the Speaker requests section below.
  • apps/backend/services/livekit.service.js and livekit-monitor.service.js — LiveKit room provisioning, tokens, and stream monitoring
  • Corrected — Stories are implemented, but not via Tale. This page previously stated Stories have no backend implementation at all, citing a tale.access-service.js stub ("No DB model exists yet; returns safe empty values") as the only story-related code. That stub (along with its blast/article siblings under data-access-services/admin/) has since been deleted entirely - it was dead code kept alive only so admin moderation flows didn't crash when a report/removal request named one of these never-shipped content types; there was never a Tale model to route to. Stories actually live as Post rows with type: 'story' (parallel to how Clips are type: 'clip'), reusing the Post table's expires_at column instead of a dedicated stories table. A stale docblock comment on Post.type in post.type.js ("'story' is reserved for the upcoming Stories feature and isn't creatable yet") is itself wrong and contradicted by the createStory mutation two dozen lines below it in the same file — don't trust that comment.
  • apps/backend/graphql/types/post.type.jshomeStories, userStories, hasActiveStory, storyViewers, storyViewerCount queries and the createStory(input: StoryCreateInput!) mutation; StoryCreateInput.audience picks the story's PostVisibility (this doc previously said there was no audience field at all — corrected)
  • apps/backend/graphql/resolvers/post.resolver.js — resolves all operations above via postManager
  • apps/backend/managers/post-managers/post.manager.jscreateStory (sets expiresAt = now + STORY_DURATION_MS, visibility: validated.audience || 'followers'), getHomeStories, getUserStories, hasActiveStory. PostManager still has no getStoryViewers/getStoryViewerCount methods — that's fine now, because fixed this session, the fix went into the resolver instead of the manager: post.resolver.js's storyViewers/storyViewerCount no longer call postManager at all. They now delegate straight to watch-history.manager.js#getPostViewers/#getPostViewersCount — the same real, owner-only implementation already backing postViewers/postViewersCount in watch-history.type.js / watch-history.resolver.js. This page previously said both storyViewers/storyViewerCount threw TypeError: ... is not a function at runtime because they called nonexistent PostManager methods — that's no longer true.
  • apps/backend/validators/post.validator.jsvalidateStoryInput validates audience against the shared validateVisibility whitelist (now includes close_friends), defaulting to followers when omitted
  • Fixed this session — now enforced. This page previously said the story-reading path never checked audience back: getPost had no close_friends branch, and getHomeStories/getUserStories applied no visibility filter at all. That's fixed. getPost (~line 643) gained a close_friends branch alongside its existing private/followers/subscribers checks, gated on userCloseFriendAccessService.isCloseFriend(post.userId, viewerId). The story-listing paths are gated too: getHomeStories filters out close_friends stories from any followed user the viewer isn't on the close-friends list of (resolved once per distinct owner, not per story, to avoid a lookup per story), and getUserStories runs every non-owner viewer through a public/followers/close_friends switch (isFollowing/isCloseFriend), defaulting closed for any other value. StoryCreateInput.audience is now genuinely enforced on read, not just captured and stored. (close-friends.manager.js#isCloseFriend was already used for real for close-friends live streams in live-stream.manager.js — it's now used for stories too.)

Frontend

  • apps/frontend-nextjs/src/page-components/settings/StoryLivePage.tsx (routed at apps/frontend-nextjs/src/app/settings/story-live) — a settings toggle screen (default story audience / share stories / save to archive / location), not the actual stories or live-stream experience. Corrected — these toggles are wired, not placeholders. This page previously said the audience/toggle controls weren't wired to any GraphQL operation; they are — defaultStoryAudience, allowStorySharing, saveStoryToArchive, and shareLocation are all persisted through privacySettings/updatePrivacySettings (the same PrivacySettings/PrivacySettingsInput types AccountPrivacyPage.tsx uses). The "Hide story from" section is a separate, also-real and wired feature — see below.
  • apps/frontend-nextjs/src/page-components/settings/ArchivePage.tsx (routed at apps/frontend-nextjs/src/app/settings/archive) — has a "Live" tab that queries liveArchive and lists the user's own ended streams (thumbnail, duration, view/like/comment counts). This is the one live-stream screen confirmed to exist in frontend-nextjs.
  • apps/frontend-nextjs/src/components/stories/StoryRail.tsx — the horizontal story-avatar rail, rendered on HomePage.tsx. Queries homeStories, groups the flat post list into one avatar per user, and opens CreateStoryModal (for your own ring, if you have no active story) or StoryViewer (otherwise)
  • apps/frontend-nextjs/src/components/stories/StoryViewer.tsx — fullscreen story playback: per-story progress bars, tap-left/right and arrow-key navigation, auto-advance (5s for images, on ended for video), calls incrementPostViews (aggregate counter) and recordPostView (per-viewer log, skipped on your own story) once per story, a 5-emoji quick-reaction bar calling likePost, a comment box calling createComment, and (owner-only, via an eye icon) a "who viewed my story" bottom sheet backed by storyViewers
  • apps/frontend-nextjs/src/components/stories/CreateStoryModal.tsx — the composer; calls createStory with an audience picker (Everyone/Followers/Close friends). Accepts both images and video (accept="image/*,video/mp4,video/quicktime,video/x-msvideo,video/webm", matching the backend's /upload allowlist) - this doc previously said video was blocked; the backend allowlist already included video/* mimetypes (see api/server.js), this modal's client-side file picker was the only remaining restriction — corrected
  • hasActiveStory is wired into the profile-picture ring on both ProfilePage.tsx and PublicProfilePage.tsx
  • apps/frontend-nextjs/src/page-components/LiveDiscoveryPage.tsx (routed at /live) — currently-live / trending / scheduled grids (currentlyLive, trendingLiveStreams, scheduledLiveStreams), each card linking to /live/[id]; a "Go live" button (signed-in users only) opens GoLiveModal
  • apps/frontend-nextjs/src/components/live/GoLiveModal.tsx — the stream-creation form (title, description, visibility, allow comments); calls createLiveStream and navigates to /live/{id} on success, where the owner starts the actual broadcast
  • apps/frontend-nextjs/src/page-components/LiveRoomPage.tsx (routed at /live/[id]) — the room itself, with distinct owner/viewer/scheduled/ended states. Owner: startLiveStreamliveStreamerToken → connects to LiveKit as a publisher (camera + mic, toggleable) via livekit-client's Room/createLocalTracks; endLiveStream on stop. Viewer: joinLiveStream (returns a LiveKitConnectionInfo: token + url + roomName in one call) → connects as a subscriber, attaching subscribed video/audio tracks; leaveLiveStream on unmount/navigation-away. Both: a live comment feed (liveStreamComments, sendLiveComment, pin/unpin and delete for the host), and a 6-emoji reaction bar (reactToLiveStream, live counts via liveStreamReactionCounts). Enforces the stream's visibility server-side (see the manager correction above) — a private/followers-only/close-friends stream can't be joined just by knowing its ID.
  • apps/frontend-nextjs/src/components/Navigation.tsx — a "Live" entry in the "More" menu, linking to /live
  • Dependency note: livekit-client was added to apps/frontend-nextjs/package.json but could not be installed in the environment this was built in (no network access to the npm registry from that sandbox) — run npm install in apps/frontend-nextjs before building/running; until then livekit-client imports will fail to resolve.

Story privacy — hidden users

query StoryHiddenUsers { storyHiddenUsers { id username profilePicture } }
mutation UpdateStoryHiddenUsers($userIds: [ID!]!) { updateStoryHiddenUsers(userIds: $userIds) { id username } }

Backed by graphql/types/extended-privacy-settings.type.js + privacy-settings.manager.js (getStoryHiddenUsers/updateStoryHiddenUsers), fully wired end to end. updateStoryHiddenUsers replaces the full exclude-list in one call; the backend filters the caller's own ID out of the input so a user can never hide their story from themselves. This is a real, working privacy control layered on top of the real Stories feature documented below (previously this page described it as controlling a preference for a feature that didn't exist yet — corrected).

Stories GraphQL API

mutation CreateStory($input: StoryCreateInput!) {
createStory(input: $input) { id createdAt user { username } media { mediaUrl mediaType } }
}

# Own + followed users' active (non-expired) stories — home rail
query HomeStories { homeStories { id createdAt user { id username profilePicture } } }

# A single user's active stories, oldest first — the viewer's per-user story list
query UserStories($userId: ID!) {
userStories(userId: $userId) { id text createdAt media { mediaUrl mediaType } }
}

# Profile-picture ring indicator
query HasActiveStory($userId: ID!) { hasActiveStory(userId: $userId) }

# Viewed + commented the same way as a regular post
mutation IncrementStoryViews($postId: ID!) { incrementPostViews(postId: $postId) { id } }
mutation CommentOnStory($postId: ID!, $text: String!) {
createComment(input: { postId: $postId, text: $text }) { id }
}

StoryCreateInput.audience accepts public, followers, or close_friends (any other PostVisibility value is technically accepted by the shared validator but doesn't make sense for a story) and defaults to followers when omitted, matching the previous hardcoded behavior. The Close Friends list itself (Settings → Close Friends) is now wireable to a story's audience.

Enforced on read. audience is validated, stored, and — fixed this session — now checked back on every read path too. See the "Fixed this session — now enforced" correction under Where this lives above.

mutation RecordStoryView($postId: ID!) { recordPostView(postId: $postId) }
mutation ReactToStory($postId: ID!, $interactionType: InteractionType) {
likePost(postId: $postId, interactionType: $interactionType) { id reactionType }
}

# Owner-only - throws for anyone else
query StoryViewers($postId: ID!, $limit: Int) {
storyViewers(postId: $postId, limit: $limit) { viewedAt viewer { id username profilePicture } }
}
query StoryViewerCount($postId: ID!) { storyViewerCount(postId: $postId) }

Fixed this session — works now. Both resolvers delegate straight to watch-history.manager.js#getPostViewers/#getPostViewersCount — see the correction under Where this lives above. StoryViewer.tsx's "who viewed my story" sheet calls storyViewers, which now returns real data instead of throwing.

Technical implementation checklist

  • createStory — wired end-to-end: post.resolver.js + CreateStoryModal.tsx (images only — see Frontend note above)
  • homeStories / StoryRail.tsx — wired end-to-end
  • userStories / hasActiveStory / StoryViewer.tsx — wired end-to-end, including the profile-picture ring on ProfilePage.tsx and PublicProfilePage.tsx
  • Story view counting (incrementPostViews) — wired, aggregate count only
  • Commenting on a story (createComment) — wired, reuses the regular post comment mutation
  • Per-story audience selection (Everyone / Followers / Close Friends) — StoryCreateInput.audience is captured and stored on creation; fixed this session: it's now enforced on every read path too (getPost's new close_friends branch, plus visibility filtering in getHomeStories/getUserStories) — see the correction above
  • Video stories — CreateStoryModal.tsx's file picker now accepts video/*, matching the backend allowlist that already existed
  • "Who viewed my story" — fixed this session: storyViewers/storyViewerCount now delegate straight to watch-history.manager.js#getPostViewers/#getPostViewersCount (owner-only), the same real implementation already backing postViewers/postViewersCount — see the correction above. StoryViewer.tsx's "who viewed my story" sheet works end to end now.
  • React to a story with an emoji — a 5-emoji quick-reaction bar in StoryViewer.tsx, reusing the generic likePost/InteractionType system regular posts already had
  • createLiveStream / startLiveStream / endLiveStream / cancelLiveStream / deleteLiveStream — wired end-to-end: live-stream.resolver.js + GoLiveModal.tsx + LiveRoomPage.tsx (owner controls)
  • joinLiveStream / leaveLiveStream — wired end-to-end: LiveRoomPage.tsx (viewer), returns a LiveKitConnectionInfo (token + url + roomName); visibility-enforced server-side
  • banLiveViewer / unbanLiveViewer — resolver + manager wired (the manager-level persistence bug is fixed — see the backend correction above); no dedicated moderation UI in LiveRoomPage.tsx yet, callable via GraphQL only
  • sendLiveComment / pinLiveComment / unpinLiveComment / deleteLiveComment — wired end-to-end: live-stream.resolver.js + LiveRoomPage.tsx's comment sidebar (pin/unpin gated to the host; delete allowed for the comment's own author or the host)
  • reactToLiveStream / removeLiveStreamReaction — wired end-to-end: live-stream.resolver.js + LiveRoomPage.tsx's reaction bar, with live counts via liveStreamReactionCounts
  • currentlyLive / trendingLiveStreams / scheduledLiveStreams — wired end-to-end: live-stream.resolver.js + LiveDiscoveryPage.tsx (/live)
  • Live coin gifts — wired end-to-end: live-stream-gift.type.js/live-stream-gift.resolver.js (sendLiveGift, liveGiftCatalog, liveStreamGifts) + a gift picker in LiveRoomPage.tsx; coins move viewer → streamer through the coin ledger (sendTip), backed by the live_stream_gift table
  • Live-stream speaker requests — requestLiveSpeaker/liveStreamSpeakerRequests/myLiveSpeakerStatus/respondToLiveSpeakerRequest, wired end-to-end: live-stream.resolver.js (implemented directly in live-stream.manager.js, independent of the Call-only speaker-request.manager.js) + LiveSpeakerControls.tsx. Approval flips LiveStreamViewer.speakerStatus to approved; the viewer must re-join to actually receive a LiveKit publish token (no auto-reconnect)
  • StoryLivePage.tsx (/settings/story-live) — the default-audience select and the three toggles (share stories / save to archive / location) are wired to privacySettings/updatePrivacySettings; "Hide story from" is wired separately via storyHiddenUsers/updateStoryHiddenUsers
  • promoteLiveStream — wired end-to-end: live-stream.resolver.js + a "Promote" button in LiveRoomPage.tsx; creates/reuses a companion announcement Post linking back to the live, which then runs through the normal post-promotion flow (lives aren't Posts themselves)
  • liveArchive — wired end-to-end: live-stream-archive.resolver.js + ArchivePage.tsx's Live tab

Settings

Story and live preferences are managed under Settings → Story & Live (/settings/story-live).

Stories

Features

  • Upload a photo or video story that expires after 24 hours
  • Choose an audience per story: Everyone, Followers, or Close Friends
  • Comment on a story (reuses the regular post comment box)
  • React with one of 5 quick emoji (reuses the regular post reaction system)
  • Owners can see an aggregate view count, plus a "who viewed my story" list (storyViewers) — both wired end to end, owner-only, since this session's fix — see the Technical implementation checklist above

Story viewer

A fullscreen overlay (StoryViewer.tsx) with tap-to-advance navigation, per-story progress bars, and auto-advance (5s per image, or on video end).

Close Friends stories

The Close Friends list (Settings → Close Friends, add/remove people) can now be selected as a story's audience via StoryCreateInput.audience: close_friends. As noted above, this selection is now enforced when the story is viewed — fixed this session — only the owner and people on their close-friends list can see it.

Live

Features

  • Start a live video broadcast (GoLiveModal.tsxcreateLiveStream, then startLiveStream from the room page)
  • Viewers join and watch over LiveKit WebRTC, and send real-time comments and emoji reactions
  • Host sees a live viewer count, comment feed, and reaction totals; can pin/delete comments and toggle their own mic/camera
  • Broadcast ends when the host calls endLiveStream (or disconnects — LiveKit-side, not yet backed by a server-side auto-end on disconnect)

Live reactions

Viewers can send one of 6 emoji reactions (👍❤️😂😮😢😡) via reactToLiveStream; calling it again with a different type just changes the caller's existing reaction, and removeLiveStreamReaction clears it. liveStreamReactionCounts returns live per-type totals.

Live coin gifts

Viewers send coin-priced gifts from a picker in the live room: liveGiftCatalog lists the sendable gifts and prices, sendLiveGift transfers coins from viewer → streamer (server-authoritative pricing, balance-checked through the coin ledger), and liveStreamGifts lists a stream's received gifts. Backed by the live_stream_gift table. See Stories & Live and the separate live-stream-gift.type.js.

Permissions

SettingOptions
Who can see/join your livePublic / Private / Followers only / Close friends — LiveStreamVisibility, enforced server-side in both getLiveStream and joinLiveStream
Live commentsallowComments — enabled/disabled per stream
SharingallowSharing — enabled/disabled per stream
Coin giftsImplemented — sendLiveGift / liveGiftCatalog / liveStreamGifts (server-priced, coin-ledger backed)

Speaker requests (bring a viewer on)

A viewer can raise a hand to co-host; the streamer approves and the approved viewer gets a LiveKit publish token.

OperationPurpose
requestLiveSpeaker(liveStreamId)Viewer asks to speak — sets speakerStatus to requested
liveStreamSpeakerRequests(liveStreamId, limit, offset)Host lists pending (requested) hand-raises
myLiveSpeakerStatus(liveStreamId)The caller's own speaker state in this stream (none / requested / approved)
respondToLiveSpeakerRequest(liveStreamId, userId, approve)Host approves/denies; approval sets speakerStatus to approved, upgrading the viewer's next publish token

This is a separate, live-stream-only implementation in live-stream.manager.js — it does not share code with the group-Call speaker-request flow (pendingSpeakerRequests/mySpeakerRequests/cancelSpeakerRequest in call.resolver.js/call.type.js). Unlike that flow, there's no cancelSpeakerRequest equivalent here — a viewer can't withdraw a pending live-stream request once sent.

Viewer & session helpers

  • liveStreamViewers(liveStreamId, limit, offset) — the current/past viewer roster.
  • liveStreamerToken(liveStreamId) — LiveKit publish token for the host; guestLiveViewerToken(liveStreamId) — subscribe token for a viewer (incl. logged-out guests where allowed).
  • saveLiveRecording(liveStreamId, recordingUrl) — attach a recording URL to an ended stream (feeds the Live Archive).

Real-time subscriptions

Live UIs stay current over graphql-ws subscriptions rather than polling:

SubscriptionFires when
liveStreamCommentAdded(liveStreamId)A new live comment is posted
liveStreamReactionsUpdated(liveStreamId)Reaction totals change
liveStreamUpdated(liveStreamId)Stream state changes (viewer count, status, …)
liveStreamsChangedThe set of currently-live streams changes (for the /live discovery rail)
liveSpeakerRequestsChanged(liveStreamId)A speaker request is raised/withdrawn (host UI)
liveSpeakerStatusChanged(liveStreamId)The caller's own speaker status changes (approved/denied)

Privacy

Both Stories and Live respect the private account setting. If your account is private, only approved followers can see your content regardless of individual story settings.

Live stream data model

FieldDescription
title / descriptionStream metadata
streamKeyUnique key for RTMP ingest
streamUrl / playbackUrlSource and HLS playback URLs
thumbnailUrlPreview thumbnail
statusscheduled, live, ended, cancelled
visibilitypublic, private, followers_only, close_friends
scheduledStartTime / actualStartTime / endTimeTiming
durationSecondsTotal stream duration
viewerCount / peakViewerCount / totalViewsAudience stats
likesCount / commentsCount / sharesCountEngagement stats
isRecorded / recordingUrlVOD recording
allowComments / allowSharingStream controls
isMonetizedFlag on the model/schema; not wired to the coin-gift feature (sendLiveGift works regardless of this flag)

GraphQL API

Defined in graphql/types/live-stream.type.js, resolved in graphql/resolvers/live-stream.resolver.js.

createLiveStream provisions a LiveKit room and returns a streamKey/playbackUrl, without going live yet. Call startLiveStream when the host is ready — this is the moment viewers can join. updateLiveStream edits metadata (title/description/thumbnail/visibility/allowComments/allowSharing) on a stream that hasn't ended.

endLiveStream terminates the broadcast and returns the final durationSeconds. cancelLiveStream removes a still-scheduled stream before it starts. deleteLiveStream removes a stream record outright. promoteLiveStream (owner-only) creates — or reuses, if already created — a companion announcement Post linking back to /live/{id}, since a LiveStream isn't itself a Post and can't be run through the normal post-promotion/ads flow directly; the returned Post can then be promoted like any other post. Wired to a "Promote" button in LiveRoomPage.tsx.

joinLiveStream records the caller as a viewer and returns their LiveKit connection info (token, url, roomName) in a single call — the resolver combines liveStreamManager.joinLiveStream and .getViewerToken internally. leaveLiveStream disconnects the viewer. banLiveViewer / unbanLiveViewer (owner-only) moderate the audience. liveStreamerToken is the owner-only equivalent used to preview/publish camera+mic.

sendLiveComment posts a real-time comment. pinLiveComment / unpinLiveComment (owner-only) highlight/unhighlight a comment at the top of the feed. deleteLiveComment removes a comment (the comment's own author, or the stream's host — checked in live-stream.manager.js#deleteLiveComment; there's no separate site-admin bypass here). reactToLiveStream sets the caller's emoji reaction (like/love/wow/haha/sad/angry — calling again with a different type just changes it); removeLiveStreamReaction clears it; liveStreamReactionCounts returns live per-type totals.

currentlyLive returns streams live right now, sorted by viewerCount. trendingLiveStreams applies a recency-weighted ranking over a timeframe window (hours, default 24). scheduledLiveStreams shows upcoming streams. userLiveStreams returns a user's stream history. liveStreamStats returns aggregate + viewer-stats + reaction-count metrics for a stream.

# Create and schedule a stream — provisions the LiveKit room, doesn't go live yet
mutation CreateLiveStream($input: LiveStreamCreateInput!) {
createLiveStream(input: $input) { id title playbackUrl status scheduledStartTime }
}

# Go live — viewers can join after this
mutation StartLiveStream($id: ID!) { startLiveStream(id: $id) { id status actualStartTime } }

# End the broadcast — records final duration
mutation EndLiveStream($id: ID!) { endLiveStream(id: $id) { id status durationSeconds } }

mutation CancelLiveStream($id: ID!) { cancelLiveStream(id: $id) { id status } }
mutation DeleteLiveStream($id: ID!) { deleteLiveStream(id: $id) }

# Viewer joins — returns everything livekit-client needs to connect
mutation JoinLiveStream($id: ID!) {
joinLiveStream(id: $id) { token url roomName }
}
mutation LeaveLiveStream($id: ID!) { leaveLiveStream(id: $id) }

mutation BanLiveViewer($liveStreamId: ID!, $userId: ID!) { banLiveViewer(liveStreamId: $liveStreamId, userId: $userId) }
mutation UnbanLiveViewer($liveStreamId: ID!, $userId: ID!) { unbanLiveViewer(liveStreamId: $liveStreamId, userId: $userId) }

mutation SendLiveComment($liveStreamId: ID!, $commentText: String!) {
sendLiveComment(liveStreamId: $liveStreamId, commentText: $commentText) { id commentText createdAt user { username } }
}
mutation PinLiveComment($commentId: ID!) { pinLiveComment(commentId: $commentId) { id isPinned } }
mutation UnpinLiveComment($commentId: ID!) { unpinLiveComment(commentId: $commentId) { id isPinned } }
mutation DeleteLiveComment($commentId: ID!) { deleteLiveComment(commentId: $commentId) }

# interactionType: like | love | wow | haha | sad | angry
mutation ReactToLiveStream($liveStreamId: ID!, $interactionType: String!) {
reactToLiveStream(liveStreamId: $liveStreamId, interactionType: $interactionType) { id interactionType }
}
mutation RemoveLiveStreamReaction($liveStreamId: ID!) { removeLiveStreamReaction(liveStreamId: $liveStreamId) }

# Discovery — currently broadcasting, sorted by viewer count
query CurrentlyLive($limit: Int) { currentlyLive(limit: $limit) { id title viewerCount thumbnailUrl user { username profilePicture isVerified } } }

# Trending — recency-weighted ranking
query TrendingLiveStreams($limit: Int) { trendingLiveStreams(limit: $limit) { id title viewerCount likesCount } }

# Upcoming scheduled streams
query ScheduledLiveStreams($limit: Int) { scheduledLiveStreams(limit: $limit) { id title scheduledStartTime user { username } } }

# A user's stream history
query UserLiveStreams($userId: ID!) { userLiveStreams(userId: $userId) { id title status durationSeconds totalViews } }

# Live metrics for an active stream
query LiveStreamStats($id: ID!) {
liveStreamStats(liveStreamId: $id) {
viewerCount peakViewerCount totalViews likesCount commentsCount
viewerStats { currentViewers totalViewers avgWatchTimeSeconds }
reactionCounts { like love wow haha sad angry total }
}
}

# Owner-only — publish token to preview camera / go live
query LiveStreamerToken($id: ID!) { liveStreamerToken(liveStreamId: $id) { token url roomName } }

Live archive

query LiveArchive($limit: Int, $offset: Int) {
liveArchive(limit: $limit, offset: $offset) {
id
title
thumbnailUrl
status
durationSeconds
viewerCount
peakViewerCount
totalViews
likesCount
commentsCount
isRecorded
recordingUrl
createdAt
}
}

liveArchive is scoped server-side to the caller's own status: 'ended' streams — there's no userId argument, unlike userLiveStreams in the (unexposed) broader Live API above. It backs the "Live" tab in Settings → Archive, alongside the existing Posts/Stories/Instants archive tabs.

Infrastructure

Live streams are powered by LiveKit (WebRTC SFU). When a stream is created, the backend provisions a LiveKit room (up to 10,000 participants). Hosts and viewers receive signed JWT access tokens that the frontend uses to connect directly to LiveKit.

Backend data models

Live stream activity is tracked across several data-access services:

ServiceDescription
live-stream.access-service.jsLive stream sessions
live-stream-viewer.access-service.jsViewer tracking
live-stream-comment.access-service.jsReal-time comments
live-stream-interaction.access-service.jsEmoji reactions
live-stream-gift.access-service.jsCoin gift records (live_stream_gift table)