Complete guide to integrating Pubfuse's live streaming platform into your applications
The Pubfuse SDK allows you to integrate live streaming capabilities into your applications. With our API-first approach, you can build custom streaming experiences while leveraging Pubfuse's robust infrastructure.
Each SDK Client operates in isolation with their own users, sessions, and data. Perfect for white-label solutions.
Industry-standard API key authentication with optional HMAC signature verification for enhanced security.
Chat messages for a stream are stored in contact_history with relatedStreamId. Live WebSocket chat is also persisted automatically on the server; use these endpoints to fetch/send for completed or ongoing streams.
// Fetch chat history for a stream
let messages = try await pubfuseSDK.streamChatService.getStreamChat(
streamId: "stream-uuid",
limit: 200,
offset: 0
)
// Send a chat message to a stream (authenticated user as sender)
let sent = try await pubfuseSDK.streamChatService.postStreamChat(
streamId: "stream-uuid",
message: "Hello everyone!"
)
Endpoints:
GET /api/streams/{id}/chat
POST /api/streams/{id}/chat
Chat for broadcast sessions is stored in contact_history with relatedBroadcastId (and also surfaces any messages stored under the broadcast’s streamId). Use these endpoints to fetch/send chat tied to a broadcast.
# Fetch chat history for a broadcast
curl -s -H "Authorization: Bearer <JWT>" \
"https://www.pubfuse.com/api/broadcasts/<broadcast-id>/chat?limit=200&offset=0"
# Post a chat message to a broadcast
curl -s -X POST -H "Authorization: Bearer <JWT>" \
-H "Content-Type: application/json" \
-d '{"message":"Hello broadcast!"}' \
"https://www.pubfuse.com/api/broadcasts/<broadcast-id>/chat"
Endpoints:
GET /api/broadcasts/{id}/chat
POST /api/broadcasts/{id}/chat
First, you need to register your application to get API credentials.
/api/admin/sdk-clients
{
"name": "My Streaming App",
"website": "https://myapp.com",
"description": "My awesome streaming application",
"contactName": "John Developer",
"contactTitle": "Lead Developer",
"contactEmail": "[email protected]",
"contactPhone": "+1234567890",
"expectedApps": "1-5",
"useCase": "Live streaming for events",
"expectedUsers": "100-1000",
"agreeTerms": true,
"agreeMarketing": false,
"agreeDataProcessing": true
}
{
"success": true,
"apiKey": "pk_5951C5196A6C47EDA12D41B9A050AC5C",
"secretKey": "sk_8510CDE5A7ED4E749D15E1008FBD7B7E",
"clientId": "550e8400-e29b-41d4-a716-446655440000",
"message": "SDK Client registered successfully"
}
GET /api/users/profile/full – Full profile with follower/following countsPUT /api/users/profile – Update profile fieldsDELETE /api/users/profile – Delete current userPOST /api/users/change-password – Change passwordDELETE /api/users/follow/:id – Unfollow user by idDELETE /api/contacts/:id/follow – Unfollow by contact idPUT /api/contacts/:id – Update a stored contact// Update profile
struct UpdateProfileRequest: Codable {
let username: String?
let firstName: String?
let lastName: String?
let avatarUrl: String?
let email: String?
let phoneNumber: String?
}
func updateProfile(token: String, body: UpdateProfileRequest) async throws {
var req = URLRequest(url: URL(string: "${baseUrl}/api/users/profile")!)
req.httpMethod = "PUT"
req.addValue("application/json", forHTTPHeaderField: "Content-Type")
req.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
req.httpBody = try JSONEncoder().encode(body)
let _ = try await URLSession.shared.data(for: req)
}
// Delete profile
func deleteProfile(token: String) async throws {
var req = URLRequest(url: URL(string: "${baseUrl}/api/users/profile")!)
req.httpMethod = "DELETE"
req.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
let _ = try await URLSession.shared.data(for: req)
}
// Change password
struct ChangePasswordRequest: Codable { let currentPassword: String; let newPassword: String }
func changePassword(token: String, body: ChangePasswordRequest) async throws {
var req = URLRequest(url: URL(string: "${baseUrl}/api/users/change-password")!)
req.httpMethod = "POST"
req.addValue("application/json", forHTTPHeaderField: "Content-Type")
req.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
req.httpBody = try JSONEncoder().encode(body)
let _ = try await URLSession.shared.data(for: req)
}
// Unfollow a user by user id
func unfollowUser(token: String, userId: String) async throws {
var req = URLRequest(url: URL(string: "${baseUrl}/api/users/follow/\(userId)")!)
req.httpMethod = "DELETE"
req.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
let _ = try await URLSession.shared.data(for: req)
}
// Update a contact
struct ContactUpdate: Codable { let phoneNumber: String?; let displayName: String?; let firstName: String?; let lastName: String?; let email: String? }
func updateContact(token: String, contactId: String, body: ContactUpdate) async throws {
var req = URLRequest(url: URL(string: "${baseUrl}/api/contacts/\(contactId)")!)
req.httpMethod = "PUT"
req.addValue("application/json", forHTTPHeaderField: "Content-Type")
req.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
req.httpBody = try JSONEncoder().encode(body)
let _ = try await URLSession.shared.data(for: req)
}
# Update profile
curl -X PUT "$BASE/api/users/profile" -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"username":"newname","firstName":"New","lastName":"Name","email":"[email protected]","phoneNumber":"+1234567890"}'
# Delete profile
curl -X DELETE "$BASE/api/users/profile" -H "Authorization: Bearer $TOKEN"
# Change password
curl -X POST "$BASE/api/users/change-password" -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"currentPassword":"oldpass","newPassword":"newpass123"}'
# Unfollow by user id
curl -X DELETE "$BASE/api/users/follow/USER_ID" -H "Authorization: Bearer $TOKEN"
# Update a contact
curl -X PUT "$BASE/api/contacts/CONTACT_ID" -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"displayName":"Jane S."}'
This feature allows pubfuse.com users to seamlessly manage music creator profiles on pubfu.se as if they were native pubfu.se users. The linking system creates a bridge between the two platforms, enabling single sign-on and unified user management.
🔗 Quick Links:
POST /api/users/music/authenticate – Authenticate with pubfu.se and create/update link (creates new account if needed)GET /api/users/music/link – Get current user's link statusPOST /api/users/music/link – Create or update link (alternative to authenticate)PUT /api/users/music/link – Update link metadataDELETE /api/users/music/link – Delete linkPOST /api/users/music/refresh-token – Refresh pubfu.se tokenGET /api/users/music/search?q={query} – NEW: Search for existing pubfu.se users by email or nicknamePOST /api/users/music/link-existing – NEW: Link to an existing pubfu.se user accountLinking to Existing Accounts:
POST /gettoken/:email/:pass/ endpoint. If a valid token is returned, the password is correct and linking proceeds. If verification fails, the link request is rejected with a 401 Unauthorized errormusicUserEmail field can be provided from search results to optimize the verification process// After logging into pubfuse.com, authenticate with pubfu.se
func linkMusicAccount(pubfuseToken: String) async throws {
guard let url = URL(string: "https://www.pubfuse.com/api/users/music/authenticate") else {
throw NSError(domain: "Invalid URL", code: -1)
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("Bearer \(pubfuseToken)", forHTTPHeaderField: "Authorization")
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw NSError(domain: "Authentication failed", code: -1)
}
struct MusicLinkResponse: Codable {
let id: String
let pubfuseUserId: String
let pubfuseUserEmail: String
let pubfuseMusicUserId: String?
let isActive: Bool
let linkedAt: String?
}
let linkResponse = try JSONDecoder().decode(MusicLinkResponse.self, from: data)
print("✅ Linked to music account: \(linkResponse.pubfuseMusicUserId ?? "pending")")
}
// Get link status
func getMusicLinkStatus(pubfuseToken: String) async throws -> MusicLinkResponse? {
guard let url = URL(string: "https://www.pubfuse.com/api/users/music/link") else {
throw NSError(domain: "Invalid URL", code: -1)
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue("Bearer \(pubfuseToken)", forHTTPHeaderField: "Authorization")
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw NSError(domain: "Invalid response", code: -1)
}
if httpResponse.statusCode == 404 {
return nil // No link exists
}
guard httpResponse.statusCode == 200 else {
throw NSError(domain: "Failed to get link", code: -1)
}
return try JSONDecoder().decode(MusicLinkResponse.self, from: data)
}
// Using PubfuseSDK (recommended)
let sdk = PubfuseSDK(configuration: config)
// After login
try await sdk.authenticateMusicWithPubfuseToken()
// Check if linked
if sdk.isMusicAuthenticated {
print("✅ Music account linked")
}
// NEW: Search for existing pubfu.se users
func searchMusicUsers(query: String, pubfuseToken: String) async throws -> [MusicUserSearchResult] {
guard let url = URL(string: "https://www.pubfuse.com/api/users/music/search?q=\(query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? query)") else {
throw NSError(domain: "Invalid URL", code: -1)
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue("Bearer \(pubfuseToken)", forHTTPHeaderField: "Authorization")
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw NSError(domain: "Search failed", code: -1)
}
struct MusicUserSearchResult: Codable {
let id: String
let email: String
let nickname: String?
let activated: Bool
}
return try JSONDecoder().decode([MusicUserSearchResult].self, from: data)
}
// NEW: Link to an existing pubfu.se user
func linkToExistingMusicUser(musicUserId: String, musicUserEmail: String?, password: String?, pubfuseToken: String) async throws {
guard let url = URL(string: "https://www.pubfuse.com/api/users/music/link-existing") else {
throw NSError(domain: "Invalid URL", code: -1)
}
struct LinkRequest: Codable {
let musicUserId: String
let musicUserEmail: String? // Optional: email from search result (optimizes verification)
let password: String? // Required if emails don't match
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("Bearer \(pubfuseToken)", forHTTPHeaderField: "Authorization")
request.httpBody = try JSONEncoder().encode(LinkRequest(
musicUserId: musicUserId,
musicUserEmail: musicUserEmail,
password: password
))
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
let errorMessage = String(data: data, encoding: .utf8) ?? "Link failed"
throw NSError(domain: errorMessage, code: httpResponse.statusCode)
}
// Link created successfully
print("✅ Linked to existing music account: \(musicUserId)")
}
// Using PubfuseSDK for search and link-existing
// Search for users
let results = try await sdk.searchMusicUsers(query: "[email protected]")
// Check if email matches (no password needed)
let currentEmail = sdk.currentUser?.email ?? ""
let targetUser = results.first!
if targetUser.email.lowercased() == currentEmail.lowercased() {
// Emails match - link without password
try await sdk.linkToExistingMusicUser(
musicUserId: targetUser.id,
musicUserEmail: targetUser.email
)
} else {
// Emails don't match - password required
// Password is verified using pubfu.se's /gettoken/:email/:pass/ endpoint
let password = "user_password_here"
try await sdk.linkToExistingMusicUser(
musicUserId: targetUser.id,
musicUserEmail: targetUser.email, // Provide email from search result
password: password
)
}
# Authenticate with pubfu.se and create link
curl -X POST "https://www.pubfuse.com/api/users/music/authenticate" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json"
# Get link status
curl -X GET "https://www.pubfuse.com/api/users/music/link" \
-H "Authorization: Bearer $TOKEN"
# Refresh pubfu.se token
curl -X POST "https://www.pubfuse.com/api/users/music/refresh-token" \
-H "Authorization: Bearer $TOKEN"
# Delete link
curl -X DELETE "https://www.pubfuse.com/api/users/music/link" \
-H "Authorization: Bearer $TOKEN"
# NEW: Search for existing pubfu.se users
curl -X GET "https://www.pubfuse.com/api/users/music/[email protected]" \
-H "Authorization: Bearer $TOKEN"
# NEW: Link to existing pubfu.se user (email matches - no password)
curl -X POST "https://www.pubfuse.com/api/users/music/link-existing" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"musicUserId":"uuid-of-pubfu-se-user","musicUserEmail":"[email protected]"}'
# NEW: Link to existing pubfu.se user (email doesn't match - password required)
# Password is verified using pubfu.se's POST /gettoken/:email/:pass/ endpoint
curl -X POST "https://www.pubfuse.com/api/users/music/link-existing" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"musicUserId":"uuid-of-pubfu-se-user","musicUserEmail":"[email protected]","password":"pubfu-se-password"}'
✅ NEW: The proxy endpoint allows you to call any pubfu.se API through pubfuse.com!
📚 Music API Documentation: View pubfu.se Music API Documentation
/api/users/music/proxy/*
Proxies any request to pubfu.se API with automatic token management
// Call any pubfu.se API through pubfuse.com proxy
func getArtistFromPubfuSe(artistId: String, pubfuseToken: String) async throws {
// The proxy automatically uses your stored pubfu.se token
guard let url = URL(string: "https://www.pubfuse.com/api/users/music/proxy/api/v1/music/artist/\(artistId)") else {
throw NSError(domain: "Invalid URL", code: -1)
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue("Bearer \(pubfuseToken)", forHTTPHeaderField: "Authorization")
let (data, response) = try await URLSession.shared.data(for: request)
// Response from pubfu.se API
}
// Create an album on pubfu.se
func createAlbum(name: String, pubfuseToken: String) async throws {
guard let url = URL(string: "https://www.pubfuse.com/api/users/music/proxy/api/v1/music/album") else {
throw NSError(domain: "Invalid URL", code: -1)
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("Bearer \(pubfuseToken)", forHTTPHeaderField: "Authorization")
let albumData = ["name": name, "artist": "Artist Name"]
request.httpBody = try JSONEncoder().encode(albumData)
let (data, response) = try await URLSession.shared.data(for: request)
// Response from pubfu.se API
}
pubfuse.com/api/users/music/proxy/{pubfu.se_path} with a valid pubfuse.com JWT tokenpubfu.se/{pubfu.se_path} with the pubfu.se token✅ Key Benefit: No pre-linking required! Users with valid pubfuse.com tokens can immediately use any pubfu.se API through the proxy.
Ready to link your account? You can do it from multiple places:
All API requests require authentication using your API key. Include it in the X-API-Key header.
curl -X GET https://api.pubfuse.com/api/v1/sessions \
-H "X-API-Key: pk_5951C5196A6C47EDA12D41B9A050AC5C" \
-H "Content-Type: application/json"
For enhanced security, use HMAC signature authentication with your secret key.
const crypto = require('crypto');
function generateSignature(method, path, body, timestamp, secretKey) {
const payload = `${method}${path}${body}${timestamp}`;
return crypto.createHmac('sha256', secretKey)
.update(payload)
.digest('hex');
}
const method = 'POST';
const path = '/api/v1/sessions';
const body = JSON.stringify({ title: 'My Stream' });
const timestamp = Math.floor(Date.now() / 1000).toString();
const signature = generateSignature(method, path, body, timestamp, secretKey);
fetch('https://api.pubfuse.com/api/v1/sessions', {
method: 'POST',
headers: {
'X-API-Key': apiKey,
'X-Signature': signature,
'X-Timestamp': timestamp,
'Content-Type': 'application/json'
},
body: body
});
/api/v1/sessions
Create a new streaming session
/api/v1/sessions
List all sessions for your SDK client
/api/v1/sessions/{id}
Update session status and metadata
/api/users/signup
Register a new user
✅ WORKING/api/users/login
Authenticate a user
✅ WORKING/api/v1/users
List users for your SDK client
/ws/streams/{id}/chat
Real-time chat connection
/api/v1/sessions/{id}/reactions
Send reactions to a stream
Pubfuse now supports LiveKit SFU streaming for better scalability and performance. LiveKit supports 100+ concurrent participants with built-in recording capabilities.
See our LiveKit Setup Guide for complete integration instructions.
Quick sanity-check tool: /livekit-test (connect → publish → remote subscribe)
Pubfuse supports advanced stream recording with customizable dimensions, orientations, and layout styles. The recording page (/streams/{id}/record) captures the viewer experience including all participants, reactions, and UI elements.
The recording endpoint accepts the following query parameters to customize the recording output:
| Parameter | Type | Description | Default |
|---|---|---|---|
width |
integer | Recording width in pixels | 1080 (portrait) or 1920 (landscape) |
height |
integer | Recording height in pixels | 1920 (portrait) or 1080 (landscape) |
resolution |
string | Resolution preset: "720p" or "1080p" |
From system settings or "1080p" |
orientation |
string | Recording orientation: "portrait" or "landscape" |
"portrait" |
layout |
string | Layout style (see below) | "default" |
The layout parameter controls how participant tiles are arranged:
default: Horizontal strip of participants at bottom (iOS portrait style)grid3x3: 3x3 grid layout with host as one tilegrid4x4: 4x4 grid layout with host as one tilebattle: Host and first participant side-by-side, others on sidesonebig: Host large rectangle, participants in strip belowactive: Active speaker layout with dynamic highlighting# Default recording (portrait, 1080p, default layout)
https://www.pubfuse.com/streams/{streamId}/record
# Landscape recording with 3x3 grid layout
https://www.pubfuse.com/streams/{streamId}/record?width=1920&height=1080&orientation=landscape&layout=grid3x3
# Portrait recording with battle layout
https://www.pubfuse.com/streams/{streamId}/record?orientation=portrait&layout=battle
# Mobile default dimensions (390x644, portrait)
https://www.pubfuse.com/streams/{streamId}/record?orientation=portrait&layout=default&width=390&height=644
# Phone layout in portrait (768x1024, battle layout)
https://www.pubfuse.com/streams/DE5BA844-C5CC-4965-92BA-11BCF32AAE5B/record?orientation=portrait&layout=battle&width=768&height=1024
# Custom dimensions with active speaker layout
https://www.pubfuse.com/streams/{streamId}/record?width=1280&height=720&resolution=720p&layout=active
# 4x4 grid layout in landscape mode
https://www.pubfuse.com/streams/{streamId}/record?width=1920&height=1080&orientation=landscape&layout=grid4x4
LiveStreamLayoutStyle for consistencyThe recording service automatically constructs the recording URL with parameters when starting WebEgress:
// Recording service automatically includes parameters in URL
let recordingService = LiveKitRecordingService(app: app)
let egressId = try await recordingService.startRecording(
roomName: streamId.uuidString,
broadcastId: broadcastId,
baseURL: baseURL,
width: 1920, // Optional
height: 1080, // Optional
resolution: "1080p", // Optional
orientation: "landscape", // Optional
layout: "grid3x3" // Optional
)
// The service constructs: {baseURL}/streams/{streamId}/record?width=1920&height=1080&orientation=landscape&layout=grid3x3
The recording page is automatically used by LiveKit WebEgress when recording is enabled. Parameters can be passed via the recording service API or configured in system settings.
When parameters are not provided, the system uses defaults from system settings or hardcoded fallback values.
// 1. Get streaming provider configuration
const providers = await fetch('/api/streaming/providers').then(r => r.json());
const activeProvider = providers.find(p => p.isConfigured);
// 2. Create streaming session
const session = await fetch('/api/streaming/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: 'My Live Stream',
visibility: 'public',
maxParticipants: 1000
})
}).then(r => r.json());
// 3. Generate LiveKit JWT access token ✅ **UPDATED**
const token = await fetch(`/api/streaming/sessions/${session.id}/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': 'your_pubfuse_api_key' // ✅ Required
},
body: JSON.stringify({
userId: 'user-123',
role: 'publisher'
})
}).then(r => r.json());
// 4. Connect to LiveKit (if provider is LiveKit)
if (activeProvider.provider.rawValue === 'livekit') {
// Load LiveKit SDK
const script = document.createElement('script');
script.src = 'https://unpkg.com/livekit-client@latest/dist/livekit-client.umd.js';
document.head.appendChild(script);
// Connect to room
const room = new LiveKit.Room();
await room.connect(token.serverUrl, token.token);
}
import Foundation
import LiveKit
class PubfuseStreamingSDK {
private var room: Room?
func connect(to streamId: String) async throws {
// Get LiveKit access token
let tokenResponse = try await getLiveKitToken(streamId: streamId)
// Create LiveKit room
let room = Room()
room.delegate = self
// Connect to LiveKit room
try await room.connect(
url: tokenResponse.serverUrl,
token: tokenResponse.token,
connectOptions: ConnectOptions(
autoManageVideo: true,
autoManageAudio: true
)
)
self.room = room
}
private func getLiveKitToken(streamId: String) async throws -> LiveKitTokenResponse {
guard let url = URL(string: "\(baseURL)/api/streaming/sessions/\(streamId)/token") else {
throw PubfuseError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("your_pubfuse_api_key", forHTTPHeaderField: "X-API-Key") // ✅ Required
let tokenRequest = LiveKitTokenRequest(
userId: UUID().uuidString,
role: "subscriber"
)
request.httpBody = try JSONEncoder().encode(tokenRequest)
let (data, _) = try await URLSession.shared.data(for: request)
return try JSONDecoder().decode(LiveKitTokenResponse.self, from: data)
}
}
extension PubfuseStreamingSDK: RoomDelegate {
func room(_ room: Room, didConnect isReconnect: Bool) {
print("✅ Connected to LiveKit room")
}
func room(_ room: Room, participant: RemoteParticipant, didSubscribeTo publication: RemoteTrackPublication) {
if let videoTrack = publication.track as? VideoTrack {
videoTrack.addRenderer(videoView)
}
}
}
GET /api/streaming/providers - Get available streaming providersPOST /api/streaming/sessions - Create streaming sessionGET /api/streaming/sessions/{id} - Get streaming session detailsPOST /api/streaming/sessions/{id}/token - Generate LiveKit JWT access token
livekit_room_name and broadcast's stream_id if broadcast session ID not foundGET /api/streaming/ice-config - Get ICE server configurationGET /api/livekit/health - LiveKit server health checkGET /api/files/{id}/public - Public file access (for LiveKit Egress)GET /egress-player - Egress player page for scheduled playbackGET /streams/{id}/record - Stream recording page with customizable layouts
width, height, resolution, orientation, layoutTokens are LiveKit-compatible and include:
nbf, iat, exp as integersroomJoin, room, canSubscribe, optional publish/data grantsX-API-Key headerServer returns token prefixed with livekit_; pass it unchanged to the SDK.
Short IDs (e.g., r5) are accepted and resolved to UUIDs.
For Scheduled Events: Room names (UUID format) are accepted directly - server automatically looks up the room in scheduled events and broadcasts.
LiveKit Egress and Ingress services need to access media files without authentication. The public file endpoint provides this capability.
/api/files/{id}/public
Access files without authentication. Designed for LiveKit and external services.
No authentication required. Includes CORS headers for cross-origin access.// Get public URL for a file (for LiveKit Egress)
let file = PFFile(id: fileId, userID: userId, name: "video.mp4",
mimeType: "video/mp4", filePathOriginal: "", fileSizeBytes: 0)
// Public URL (no authentication required)
let publicURL = sdk.fileService.publicURLString(for: file)
// Returns: https://www.pubfuse.com/api/files/{id}/public
// Authenticated download URL (requires JWT token)
let downloadURL = sdk.fileService.downloadURLString(for: file)
// Returns: https://www.pubfuse.com/api/files/{id}/download
// Use public URL for scheduled playback
let event = try await sdk.eventsService.scheduleEvent(request)
// The system automatically uses public URLs for LiveKit Egress
The public file endpoint allows unauthenticated access. Use it only for:
For sensitive files, use the authenticated /download endpoint instead.
The egress player endpoint serves an HTML page that plays media files for LiveKit WebEgress. This enables scheduled playback of pre-recorded content.
/egress-player
Serves HTML page that auto-plays media files for LiveKit Egress.
Query parameters:file (required, file URL), type (required, "audio" or "video")
/egress-player?file={fileURL}&type=videohttps://www.pubfuse.com/egress-player?file=https://www.pubfuse.com/api/files/550e8400-e29b-41d4-a716-446655440000/public&type=video
The file parameter must be a publicly accessible HTTP/HTTPS URL:
https://www.pubfuse.com/api/files/{id}/publichttps://www.pubfuse.com/userfiles/{userId}/video.mp4/api/files/{id}/download (requires authentication)Both server and client include retry logic to handle intermittent file access issues:
ImagePreviewView and ChatVideoPlayerView// Retry logic is built into the SDK
// Example: Loading a video with automatic retries
let maxRetries = 3
for attempt in 1...maxRetries {
do {
let (data, response) = try await URLSession.shared.data(for: request)
if let httpResponse = response as? HTTPURLResponse,
(200..<400).contains(httpResponse.statusCode) {
// Success - use the data
break
} else if httpResponse.statusCode == 404, attempt < maxRetries {
// Retry on 404
let delay = UInt64(100_000_000 * attempt) // Exponential backoff
try? await Task.sleep(nanoseconds: delay)
continue
}
} catch {
if attempt < maxRetries {
let delay = UInt64(100_000_000 * attempt)
try? await Task.sleep(nanoseconds: delay)
continue
}
throw error
}
}
Pubfuse now supports co-host functionality, allowing viewers to join live streams as additional broadcasters. Create engaging multi-host sessions with grid-based video layouts and real-time participant management.
Perfect for interviews, panel discussions, interactive shows, and collaborative streaming experiences.
import Foundation
import LiveKit
import UIKit
class PubfuseCoHostSDK {
private var room: Room?
private let clientId = UUID().uuidString
private var streamId: String?
private let baseURL: String
private var isCoHost = false
init(baseURL: String = "https://www.pubfuse.com") {
self.baseURL = baseURL
}
/// Request to join as co-host
func requestCoHostAccess(streamId: String, userName: String) async throws -> CoHostResponse {
guard let url = URL(string: "\(baseURL)/api/streaming/sessions/\(streamId)/join-cohost") else {
throw PubfuseError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let joinRequest = JoinCoHostRequest(
userName: userName,
permissions: CoHostPermissions.default
)
request.httpBody = try JSONEncoder().encode(joinRequest)
let (data, _) = try await URLSession.shared.data(for: request)
let response = try JSONDecoder().decode(CoHostResponse.self, from: data)
if response.success {
self.isCoHost = true
}
return response
}
/// Join as co-host with LiveKit
private func connectAsCoHost(streamId: String, userName: String) async throws {
// Get LiveKit token with publisher permissions
let tokenResponse = try await getCoHostToken(streamId: streamId, userName: userName)
// Create LiveKit room
let room = Room()
room.delegate = self
// Connect with publisher permissions
try await room.connect(
url: tokenResponse.serverUrl,
token: tokenResponse.token,
connectOptions: ConnectOptions(
autoManageVideo: true,
autoManageAudio: true,
publishDefaults: PublishDefaults(
video: true,
audio: true,
videoCodec: .h264,
audioCodec: .opus
)
)
)
self.room = room
self.isCoHost = true
}
}
class CoHostGridViewController: UIViewController {
private let streamingSDK: PubfuseCoHostSDK
private var coHostViews: [String: UIView] = [:]
private var gridLayout: GridLayoutConfig
private var streamId: String
private lazy var gridContainer: UIStackView = {
let stackView = UIStackView()
stackView.axis = .vertical
stackView.distribution = .fillEqually
stackView.spacing = 8
stackView.translatesAutoresizingMaskIntoConstraints = false
return stackView
}()
override func viewDidLoad() {
super.viewDidLoad()
setupGridLayout()
setupCoHostButton()
}
private func setupGridLayout() {
view.addSubview(gridContainer)
NSLayoutConstraint.activate([
gridContainer.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
gridContainer.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
gridContainer.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -100),
gridContainer.heightAnchor.constraint(equalToConstant: 200)
])
// Create grid rows
for row in 0..<gridLayout.gridRows {
let rowStackView = createGridRow()
gridContainer.addArrangedSubview(rowStackView)
}
}
@objc private func joinAsCoHostTapped() {
Task {
do {
let response = try await streamingSDK.requestCoHostAccess(
streamId: streamId,
userName: "iOS User"
)
if response.success {
print("✅ Co-host access granted: \(response.message)")
updateUIForCoHostMode(true)
} else {
print("❌ Co-host access denied: \(response.message)")
showAlert(message: response.message)
}
} catch {
print("❌ Error requesting co-host access: \(error)")
showAlert(message: "Failed to request co-host access")
}
}
}
}
extension PubfuseCoHostSDK: RoomDelegate {
func room(_ room: Room, didConnect isReconnect: Bool) {
print("✅ Connected to LiveKit room as co-host")
if isCoHost {
// Start publishing video and audio
Task {
await room.localParticipant?.setCameraEnabled(true)
await room.localParticipant?.setMicrophoneEnabled(true)
}
}
}
func room(_ room: Room, participant: RemoteParticipant, didSubscribeTo publication: RemoteTrackPublication) {
print("📹 Subscribed to track: \(publication.track?.kind.rawValue ?? "unknown") from \(participant.identity)")
// Handle co-host video tracks
if let videoTrack = publication.track as? VideoTrack {
handleCoHostVideoTrack(videoTrack, participant: participant)
}
// Handle co-host audio tracks
if let audioTrack = publication.track as? AudioTrack {
handleCoHostAudioTrack(audioTrack, participant: participant)
}
}
func room(_ room: Room, participant: RemoteParticipant, didConnect isReconnect: Bool) {
print("👋 Co-host connected: \(participant.identity)")
// Check if this is a co-host (not main broadcaster)
if isCoHostParticipant(participant) {
addCoHostToGrid(participant: participant)
}
}
func room(_ room: Room, participant: RemoteParticipant, didDisconnect error: Error?) {
print("👋 Co-host disconnected: \(participant.identity)")
removeCoHostFromGrid(participantId: participant.identity)
}
private func isCoHostParticipant(_ participant: RemoteParticipant) -> Bool {
// Check participant metadata to determine if it's a co-host
guard let metadata = participant.metadata else { return false }
do {
let metadataDict = try JSONSerialization.jsonObject(with: metadata.data(using: .utf8) ?? Data()) as? [String: Any]
let role = metadataDict?["role"] as? String
return role == "co_host" || role == "publisher"
} catch {
// Fallback: check if participant name indicates co-host
return participant.name?.contains("Co-Host") == true ||
participant.name?.contains("User") == true
}
}
}
/api/streaming/sessions/{id}/join-cohost
Request to join as co-host
/api/streaming/sessions/{id}/multihost
Get multi-host session information
/api/streaming/sessions/{id}/cohosts/{coHostId}
Update co-host permissions
/api/streaming/sessions/{id}/cohosts/{coHostId}
Remove co-host from session
/api/streaming/sessions/{id}/metrics
Get multi-host session metrics
struct CoHostPermissions: Codable {
let canInviteOthers: Bool
let canRemoveOthers: Bool
let canControlLayout: Bool
let canModerateChat: Bool
let canTriggerAds: Bool
let canAccessAnalytics: Bool
static let `default` = CoHostPermissions(
canInviteOthers: false,
canRemoveOthers: false,
canControlLayout: false,
canModerateChat: false,
canTriggerAds: false,
canAccessAnalytics: false
)
static let moderator = CoHostPermissions(
canInviteOthers: true,
canRemoveOthers: true,
canControlLayout: true,
canModerateChat: true,
canTriggerAds: false,
canAccessAnalytics: true
)
}
struct GridLayoutConfig: Codable {
let maxHosts: Int
let gridColumns: Int
let gridRows: Int
let aspectRatio: String
let showNames: Bool
let showControls: Bool
static let `default` = GridLayoutConfig(
maxHosts: 4,
gridColumns: 2,
gridRows: 2,
aspectRatio: "16:9",
showNames: true,
showControls: true
)
}
struct CoHostResponse: Codable {
let success: Bool
let message: String
let coHost: CoHost?
let inviteToken: String?
}
import UIKit
import LiveKit
class LiveStreamViewController: UIViewController {
private let streamingSDK = PubfuseCoHostSDK()
private var coHostGrid: CoHostGridView?
private var streamId: String = ""
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
}
private func setupUI() {
view.backgroundColor = .black
// Setup co-host grid
coHostGrid = CoHostGridView()
coHostGrid?.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(coHostGrid!)
NSLayoutConstraint.activate([
coHostGrid!.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
coHostGrid!.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
coHostGrid!.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -100),
coHostGrid!.heightAnchor.constraint(equalToConstant: 200)
])
// Setup join co-host button
let joinButton = UIButton(type: .system)
joinButton.setTitle("Join as Co-Host", for: .normal)
joinButton.backgroundColor = .systemBlue
joinButton.setTitleColor(.white, for: .normal)
joinButton.layer.cornerRadius = 8
joinButton.translatesAutoresizingMaskIntoConstraints = false
joinButton.addTarget(self, action: #selector, for: .touchUpInside)
view.addSubview(joinButton)
NSLayoutConstraint.activate([
joinButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
joinButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -20),
joinButton.widthAnchor.constraint(equalToConstant: 200),
joinButton.heightAnchor.constraint(equalToConstant: 44)
])
}
func joinStream(streamId: String) {
self.streamId = streamId
Task {
do {
// Connect as viewer first
try await streamingSDK.connect(to: streamId)
print("✅ Connected to stream as viewer")
} catch {
print("❌ Failed to connect to stream: \(error)")
}
}
}
@objc private func joinAsCoHostTapped() {
Task {
do {
let response = try await streamingSDK.requestCoHostAccess(
streamId: streamId,
userName: "iOS User"
)
if response.success {
print("✅ Co-host access granted")
updateUIForCoHostMode(true)
} else {
print("❌ Co-host access denied: \(response.message)")
showAlert(message: response.message)
}
} catch {
print("❌ Error requesting co-host access: \(error)")
showAlert(message: "Failed to request co-host access")
}
}
}
private func updateUIForCoHostMode(_ isCoHost: Bool) {
DispatchQueue.main.async {
// Update button
if let button = self.view.subviews.first(where: { $0 is UIButton }) as? UIButton {
button.setTitle(isCoHost ? "Leave Co-Host" : "Join as Co-Host", for: .normal)
button.backgroundColor = isCoHost ? .systemRed : .systemBlue
}
}
}
private func showAlert(message: String) {
DispatchQueue.main.async {
let alert = UIAlertController(title: "Co-Host", message: message, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "OK", style: .default))
self.present(alert, animated: true)
}
}
}
CoHostPermissions.default for basic co-hosts, CoHostPermissions.moderator for enhanced capabilitiesGridLayoutConfig to match your app's design requirementsCreating a broadcast and starting it are separate API calls. Many iOS apps only create the broadcast but forget to set it to active status, causing it to not appear in live streams.
pendingactive (makes it discoverable)Create the broadcast entry in the database. This does NOT make it live yet.
deviceWidth, deviceHeight, deviceType, and operatingSystem in your request. These values are used to automatically set the recording dimensions when recording is enabled. If not provided, the system will use default mobile dimensions (390x644) for portrait recordings.
/api/broadcasts
Creates a new broadcast session
func createBroadcast(title: String) async throws -> BroadcastResponse {
guard let url = URL(string: "\(baseURL)/api/broadcasts") else {
throw PubfuseError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
// Get device information for recording dimensions
let screenSize = UIScreen.main.bounds.size
let deviceWidth = Int(screenSize.width * UIScreen.main.scale)
let deviceHeight = Int(screenSize.height * UIScreen.main.scale)
let deviceType: String = UIDevice.current.userInterfaceIdiom == .phone ? "mobile" : "tablet"
let operatingSystem = "iOS"
let broadcastRequest = CreateBroadcastRequest(
title: title,
visibility: "public",
tags: ["live", "broadcast"],
metadata: [
"createdBy": "iOS-App",
"clientId": UUID().uuidString,
"deviceModel": UIDevice.current.model
],
deviceWidth: deviceWidth,
deviceHeight: deviceHeight,
deviceType: deviceType,
operatingSystem: operatingSystem
)
request.httpBody = try JSONEncoder().encode(broadcastRequest)
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw PubfuseError.broadcastCreationFailed
}
let broadcastData = try JSONDecoder().decode(BroadcastResponse.self, from: data)
// ⚠️ At this point, broadcast is created but NOT active
// Status is: "pending"
return broadcastData
}
// Response contains:
struct BroadcastResponse: Codable {
let id: String // broadcastSessionId
let streamId: String // for LiveKit room
let title: String
let status: String // "pending"
let createdAt: String
let watchUrl: String
}
Connect to LiveKit and start publishing video/audio.
func startLiveKitPublishing(streamId: String) async throws {
// Get LiveKit token with publisher permissions
let tokenResponse = try await getLiveKitToken(
streamId: streamId,
role: "publisher"
)
// Create and connect to LiveKit room
let room = Room()
room.delegate = self
try await room.connect(
url: tokenResponse.serverUrl,
token: tokenResponse.token,
connectOptions: ConnectOptions(
autoManageVideo: true,
autoManageAudio: true,
publishDefaults: PublishDefaults(
video: true,
audio: true,
videoCodec: .h264,
audioCodec: .opus
)
)
)
self.room = room
// Start camera and microphone
await room.localParticipant?.setCameraEnabled(true)
await room.localParticipant?.setMicrophoneEnabled(true)
print("✅ LiveKit publishing started")
}
This is the step most iOS apps miss! Without this, your broadcast won't appear in the live streams list.
/api/broadcasts/{id}/status
Updates broadcast status to active
func setBroadcastActive(broadcastId: String) async throws {
guard let url = URL(string: "\(baseURL)/api/broadcasts/\(broadcastId)/status") else {
throw PubfuseError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let statusUpdate = StatusUpdateRequest(status: "active")
request.httpBody = try JSONEncoder().encode(statusUpdate)
let (_, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw PubfuseError.statusUpdateFailed
}
print("✅ Broadcast set to ACTIVE - now discoverable!")
}
struct StatusUpdateRequest: Codable {
let status: String // "active", "completed", "error"
}
Keep the session alive by sending heartbeats every 30 seconds.
/api/sessions/{id}/heartbeat
Sends heartbeat to keep session alive
private var heartbeatTimer: Timer?
func startHeartbeat(broadcastId: String) {
heartbeatTimer = Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) { [weak self] _ in
Task {
try? await self?.sendHeartbeat(broadcastId: broadcastId)
}
}
}
func sendHeartbeat(broadcastId: String) async throws {
guard let url = URL(string: "\(baseURL)/api/sessions/\(broadcastId)/heartbeat") else {
throw PubfuseError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let (_, _) = try await URLSession.shared.data(for: request)
print("💓 Heartbeat sent")
}
func stopHeartbeat() {
heartbeatTimer?.invalidate()
heartbeatTimer = nil
}
Report viewer count every 5 seconds for analytics.
/api/broadcasts/{id}/viewer-count
Updates current viewer count
private var viewerCountTimer: Timer?
private var currentViewerCount: Int = 0
func startViewerCountTracking(broadcastId: String) {
viewerCountTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in
Task {
try? await self?.updateViewerCount(broadcastId: broadcastId)
}
}
}
func updateViewerCount(broadcastId: String) async throws {
guard let url = URL(string: "\(baseURL)/api/broadcasts/\(broadcastId)/viewer-count") else {
throw PubfuseError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let viewerUpdate = ViewerCountUpdate(viewerCount: currentViewerCount)
request.httpBody = try JSONEncoder().encode(viewerUpdate)
let (_, _) = try await URLSession.shared.data(for: request)
}
struct ViewerCountUpdate: Codable {
let viewerCount: Int
}
func stopViewerCountTracking() {
viewerCountTimer?.invalidate()
viewerCountTimer = nil
}
When stopping the broadcast, set status to completed.
func stopBroadcast(broadcastId: String) async throws {
// Stop LiveKit publishing
await room?.localParticipant?.setCameraEnabled(false)
await room?.localParticipant?.setMicrophoneEnabled(false)
await room?.disconnect()
room = nil
// Stop timers
stopHeartbeat()
stopViewerCountTracking()
// Set broadcast status to completed
guard let url = URL(string: "\(baseURL)/api/broadcasts/\(broadcastId)/status") else {
throw PubfuseError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let statusUpdate = StatusUpdateRequest(status: "completed")
request.httpBody = try JSONEncoder().encode(statusUpdate)
let (_, _) = try await URLSession.shared.data(for: request)
print("✅ Broadcast ended and marked as completed")
}
Full implementation combining all steps:
import Foundation
import LiveKit
class PubfuseBroadcastManager {
private let baseURL: String
private var room: Room?
private var broadcastId: String?
private var streamId: String?
private var isActive = false
private var heartbeatTimer: Timer?
private var viewerCountTimer: Timer?
private var currentViewerCount: Int = 0
init(baseURL: String = "https://www.pubfuse.com") {
self.baseURL = baseURL
}
// MARK: - Main Broadcast Flow
/// Complete flow: Create → Start Publishing → Activate
func startBroadcast(title: String) async throws {
// Step 1: Create broadcast
print("📝 Step 1: Creating broadcast...")
let broadcast = try await createBroadcast(title: title)
self.broadcastId = broadcast.id
self.streamId = broadcast.streamId
print("✅ Broadcast created: \(broadcast.id)")
// Step 2: Start LiveKit publishing
print("📹 Step 2: Starting LiveKit publishing...")
try await startLiveKitPublishing(streamId: broadcast.streamId)
print("✅ LiveKit publishing started")
// Step 3: Set broadcast to active ⚠️ CRITICAL
print("🚀 Step 3: Activating broadcast...")
try await setBroadcastActive(broadcastId: broadcast.id)
self.isActive = true
print("✅ Broadcast is now ACTIVE and discoverable!")
// Step 4: Start heartbeat and viewer tracking
print("💓 Step 4: Starting heartbeat and viewer tracking...")
startHeartbeat(broadcastId: broadcast.id)
startViewerCountTracking(broadcastId: broadcast.id)
print("✅ All systems running!")
}
/// Stop broadcast and cleanup
func stopBroadcast() async throws {
guard let broadcastId = broadcastId else { return }
print("⏹️ Stopping broadcast...")
// Stop LiveKit
await room?.localParticipant?.setCameraEnabled(false)
await room?.localParticipant?.setMicrophoneEnabled(false)
await room?.disconnect()
room = nil
// Stop timers
stopHeartbeat()
stopViewerCountTracking()
// Set to completed
try await setBroadcastCompleted(broadcastId: broadcastId)
self.isActive = false
print("✅ Broadcast stopped")
}
// MARK: - API Methods
private func createBroadcast(title: String) async throws -> BroadcastResponse {
guard let url = URL(string: "\(baseURL)/api/broadcasts") else {
throw PubfuseError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
// Get device information for recording dimensions
let screenSize = UIScreen.main.bounds.size
let deviceWidth = Int(screenSize.width * UIScreen.main.scale)
let deviceHeight = Int(screenSize.height * UIScreen.main.scale)
let deviceType: String = UIDevice.current.userInterfaceIdiom == .phone ? "mobile" : "tablet"
let operatingSystem = "iOS"
let broadcastRequest = CreateBroadcastRequest(
title: title,
visibility: "public",
tags: ["live", "broadcast"],
metadata: [
"createdBy": "iOS-App",
"deviceModel": UIDevice.current.model
],
deviceWidth: deviceWidth,
deviceHeight: deviceHeight,
deviceType: deviceType,
operatingSystem: operatingSystem
)
request.httpBody = try JSONEncoder().encode(broadcastRequest)
let (data, _) = try await URLSession.shared.data(for: request)
return try JSONDecoder().decode(BroadcastResponse.self, from: data)
}
private func startLiveKitPublishing(streamId: String) async throws {
let tokenResponse = try await getLiveKitToken(streamId: streamId, role: "publisher")
let room = Room()
room.delegate = self
try await room.connect(
url: tokenResponse.serverUrl,
token: tokenResponse.token,
connectOptions: ConnectOptions(
autoManageVideo: true,
autoManageAudio: true
)
)
self.room = room
await room.localParticipant?.setCameraEnabled(true)
await room.localParticipant?.setMicrophoneEnabled(true)
}
private func setBroadcastActive(broadcastId: String) async throws {
try await updateBroadcastStatus(broadcastId: broadcastId, status: "active")
}
private func setBroadcastCompleted(broadcastId: String) async throws {
try await updateBroadcastStatus(broadcastId: broadcastId, status: "completed")
}
private func updateBroadcastStatus(broadcastId: String, status: String) async throws {
guard let url = URL(string: "\(baseURL)/api/broadcasts/\(broadcastId)/status") else {
throw PubfuseError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let statusUpdate = StatusUpdateRequest(status: status)
request.httpBody = try JSONEncoder().encode(statusUpdate)
let (_, _) = try await URLSession.shared.data(for: request)
}
private func startHeartbeat(broadcastId: String) {
heartbeatTimer = Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) { [weak self] _ in
Task {
try? await self?.sendHeartbeat(broadcastId: broadcastId)
}
}
}
private func sendHeartbeat(broadcastId: String) async throws {
guard let url = URL(string: "\(baseURL)/api/sessions/\(broadcastId)/heartbeat") else {
throw PubfuseError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let (_, _) = try await URLSession.shared.data(for: request)
}
private func stopHeartbeat() {
heartbeatTimer?.invalidate()
heartbeatTimer = nil
}
private func startViewerCountTracking(broadcastId: String) {
viewerCountTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in
Task {
try? await self?.updateViewerCount(broadcastId: broadcastId)
}
}
}
private func updateViewerCount(broadcastId: String) async throws {
guard let url = URL(string: "\(baseURL)/api/broadcasts/\(broadcastId)/viewer-count") else {
throw PubfuseError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let viewerUpdate = ViewerCountUpdate(viewerCount: currentViewerCount)
request.httpBody = try JSONEncoder().encode(viewerUpdate)
let (_, _) = try await URLSession.shared.data(for: request)
}
private func stopViewerCountTracking() {
viewerCountTimer?.invalidate()
viewerCountTimer = nil
}
}
// MARK: - LiveKit Delegate
extension PubfuseBroadcastManager: RoomDelegate {
func room(_ room: Room, didConnect isReconnect: Bool) {
print("✅ Connected to LiveKit room")
}
func room(_ room: Room, didDisconnect error: Error?) {
print("❌ Disconnected from LiveKit room")
}
func room(_ room: Room, participant: RemoteParticipant, didConnect isReconnect: Bool) {
currentViewerCount += 1
print("👋 Viewer joined - count: \(currentViewerCount)")
}
func room(_ room: Room, participant: RemoteParticipant, didDisconnect error: Error?) {
currentViewerCount = max(0, currentViewerCount - 1)
print("👋 Viewer left - count: \(currentViewerCount)")
}
}
POST /api/broadcasts/{id}/status with status: "active"active AFTER starting LiveKit publishing, not beforecompleted when ending to update analyticsPOST /api/broadcastsPOST /api/broadcasts/{id}/statusThe server automatically generates streamId, streamKey, rtmpUrl, and hlsUrl when you create a broadcast.
You don't need separate API calls. The POST /api/broadcasts endpoint does everything in one go!
When you call POST /api/broadcasts, the server uses unified logic that is also used by scheduled events:
StreamService.create():
streamId (UUID) - This is the LiveKit room namestreamKey (UUID without dashes) for RTMP ingestrtmpUrl: Uses INGEST_RTMP env varhlsUrl: {PLAYBACK_HLS_BASE}/{streamId}/index.m3u8streamId to broadcast_sessions.stream_id - This is critical!stream_id column stores the LiveKit room name"created"rtmp_url, hls_url, watch_url)streamIdstartBroadcast functionalityImportant: The streamId in the response is the LiveKit room name. It's stored in broadcast_sessions.stream_id and should be used for all LiveKit connections.
POST /api/broadcasts| Field | Type | Description | Usage |
|---|---|---|---|
id |
String (UUID) | Broadcast session ID | Use for status updates, heartbeats, viewer counts |
streamId |
String (UUID) | LiveKit Room Name - Stored in broadcast_sessions.stream_id |
Use this to connect to LiveKit room! This is the room name for WebRTC connections. |
streamKey |
String | Stream key | For RTMP ingest (if using external encoder) |
rtmpUrl |
String | RTMP ingest URL | For RTMP streaming setup |
hlsUrl |
String | HLS playback URL | For HLS playback testing |
watchUrl |
String | Web watch URL | Share this link with viewers |
status |
String | Broadcast status | Initially "created", then "active", "completed" |
import Foundation
class PubfuseBroadcastManager {
private let baseURL: String
private var broadcastId: String?
private var streamId: String?
init(baseURL: String = "https://www.pubfuse.com") {
self.baseURL = baseURL
}
/// Create broadcast - This returns EVERYTHING you need
func createBroadcast(title: String) async throws -> BroadcastData {
guard let url = URL(string: "\(baseURL)/api/broadcasts") else {
throw PubfuseError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let createRequest = CreateBroadcastRequest(
title: title,
visibility: "public",
tags: ["live", "broadcast"],
metadata: [
"createdBy": "iOS-App",
"deviceModel": UIDevice.current.model,
"appVersion": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0"
]
)
request.httpBody = try JSONEncoder().encode(createRequest)
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw PubfuseError.broadcastCreationFailed
}
let broadcastData = try JSONDecoder().decode(BroadcastData.self, from: data)
// Store for later use
self.broadcastId = broadcastData.id
self.streamId = broadcastData.streamId
print("✅ Broadcast created successfully!")
print(" Broadcast ID: \(broadcastData.id)")
print(" Stream ID: \(broadcastData.streamId ?? "nil")")
print(" Stream Key: \(broadcastData.streamKey ?? "nil")")
print(" RTMP URL: \(broadcastData.rtmpUrl ?? "nil")")
print(" HLS URL: \(broadcastData.hlsUrl ?? "nil")")
print(" Watch URL: \(broadcastData.watchUrl ?? "nil")")
return broadcastData
}
}
// MARK: - Data Models
struct CreateBroadcastRequest: Codable {
let title: String
let description: String?
let visibility: String
let tags: [String]?
let metadata: [String: String]?
}
struct BroadcastData: Codable {
let id: String // Broadcast session ID
let streamId: String? // Stream ID (LiveKit room name) ⚠️ USE THIS!
let title: String
let description: String?
let status: String
let streamKey: String? // Stream key for RTMP
let rtmpUrl: String? // RTMP ingest URL
let hlsUrl: String? // HLS playback URL
let watchUrl: String? // Web watch URL
let createdAt: String
}
id ≠ streamId: These are different UUIDs!
id: The broadcast session database record ID (stored in broadcast_sessions.id)streamId: The LiveKit room name (stored in broadcast_sessions.stream_id) - this is what you use to connect!streamId or streamKey yourselfPOST /api/broadcasts responsestreamId for LiveKit: When connecting to LiveKit, use the streamId from the responsestartBroadcast functionalitystream_id is always correctly saved to the database.
// Complete flow from creation to live streaming
func startCompleteBroadcast(title: String) async throws {
// Step 1: Create broadcast (gets ALL URLs and IDs)
print("📝 Step 1: Creating broadcast...")
let broadcast = try await createBroadcast(title: title)
guard let streamId = broadcast.streamId else {
throw PubfuseError.missingStreamId
}
print("✅ Broadcast created with all data:")
print(" 🆔 Broadcast ID: \(broadcast.id)")
print(" 📹 Stream ID: \(streamId)")
print(" 🔑 Stream Key: \(broadcast.streamKey ?? "nil")")
print(" 📡 RTMP URL: \(broadcast.rtmpUrl ?? "nil")")
print(" 🎬 HLS URL: \(broadcast.hlsUrl ?? "nil")")
print(" 🔗 Watch URL: \(broadcast.watchUrl ?? "nil")")
// Step 2: Start LiveKit publishing (using streamId!)
print("\n📹 Step 2: Starting LiveKit publishing...")
try await startLiveKitPublishing(streamId: streamId)
print("✅ LiveKit publishing started")
// Step 3: Set broadcast to active
print("\n🚀 Step 3: Setting broadcast to ACTIVE...")
try await setBroadcastActive(broadcastId: broadcast.id)
print("✅ Broadcast is now ACTIVE and discoverable!")
// Step 4: Start maintenance tasks
print("\n💓 Step 4: Starting heartbeat and viewer tracking...")
startHeartbeat(broadcastId: broadcast.id)
startViewerCountTracking(broadcastId: broadcast.id)
print("✅ All systems running!")
print("\n🎉 Broadcast is LIVE!")
print("🔗 Watch at: \(broadcast.watchUrl ?? "N/A")")
}
iOS App Server
| |
| POST /api/broadcasts |
| { title: "..." } |
| -----------------------------> |
| |
| Creates Stream (streamId, |
| streamKey, URLs) |
| Creates Broadcast Session |
| (id, links to streamId) |
| |
| <---------------------------- |
| { id, streamId, streamKey, |
| rtmpUrl, hlsUrl, watchUrl } |
| |
| POST /api/streaming/sessions/ |
| {streamId}/token |
| { userId, role: "publisher" } |
| -----------------------------> |
| |
| <---------------------------- |
| { token, serverUrl, room } |
| |
| Connect to LiveKit |
| (using streamId as room) |
| -----------------------------> |
| |
| POST /api/broadcasts/{id}/ |
| status |
| { status: "active" } |
| -----------------------------> |
| |
| Broadcast is now LIVE! |
| Viewers can watch at watchUrl |
| |
The watch URL returned by the server follows this pattern:
https://www.pubfuse.com/streams/{streamId}/watch
Or for the simplified version: https://www.pubfuse.com/watch/{streamId}
Sync your device contacts and automatically discover which contacts are already using Pubfuse. Follow them with one tap!
Upload and sync contacts from your device to discover which contacts are already on Pubfuse.
// Sync contacts from device
const syncContacts = async (contacts) => {
const response = await fetch('/api/contacts/sync', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
contacts: contacts.map(contact => ({
phoneNumber: contact.phoneNumber,
displayName: contact.displayName,
firstName: contact.firstName,
lastName: contact.lastName,
email: contact.email
}))
})
});
const result = await response.json();
console.log(`Synced ${result.totalContacts} contacts, ${result.pubfuseUsers} are on Pubfuse`);
return result;
};
// Example usage
const deviceContacts = [
{
phoneNumber: "+1234567890",
displayName: "John Doe",
firstName: "John",
lastName: "Doe",
email: "[email protected]"
}
];
const syncResult = await syncContacts(deviceContacts);
Automatically follow contacts who are already on Pubfuse, or follow them manually from your contacts list.
// Follow a contact who's on Pubfuse
const followContact = async (contactId) => {
const response = await fetch(`/api/contacts/${contactId}/follow`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
console.log('Contact followed successfully');
}
};
// Get user's contacts with Pubfuse status
const getContacts = async () => {
const response = await fetch('/api/contacts', {
headers: {
'Authorization': `Bearer ${token}`
}
});
const contacts = await response.json();
// Filter contacts who are on Pubfuse
const pubfuseContacts = contacts.filter(contact => contact.isPubfuseUser);
console.log(`${pubfuseContacts.length} of your contacts are on Pubfuse`);
return contacts;
};
Get complete user profiles with follower/following counts and social metrics.
// Get full user profile with social metrics
const getFullProfile = async () => {
const response = await fetch('/api/users/profile/full', {
headers: {
'Authorization': `Bearer ${token}`
}
});
const profile = await response.json();
console.log(`Profile: ${profile.username} has ${profile.followerCount} followers`);
return profile;
};
// Search for users
const searchUsers = async (query) => {
const response = await fetch(`/api/users/search?q=${encodeURIComponent(query)}`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
const users = await response.json();
return users;
};
// Get follower/following counts
const getSocialCounts = async () => {
const [followersRes, followingRes] = await Promise.all([
fetch('/api/users/followers/count', {
headers: { 'Authorization': `Bearer ${token}` }
}),
fetch('/api/users/following/count', {
headers: { 'Authorization': `Bearer ${token}` }
})
]);
const followers = await followersRes.json();
const following = await followingRes.json();
return {
followers: followers.count,
following: following.count
};
};
All phone numbers are automatically normalized for consistent matching. Supports US/Canada (+1) and international formats.
POST /api/contacts/sync - Sync device contactsGET /api/contacts - Get user's contactsGET /api/contacts/:id - Get specific contactPOST /api/contacts/:id/follow - Follow contactGET /api/users/profile/full - Full profileGET /api/users/search - Search usersUpload and manage files with automatic thumbnail generation, multiple sizes, and rich metadata. Perfect for profile images, attachments, media files, and more!
Upload files with automatic size variants and rich metadata. Files are organized by user, category, and tags for easy retrieval.
// Upload a file
const uploadFile = async (fileData) => {
const response = await fetch('/api/files', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: 'profile-photo.jpg',
description: 'My profile picture',
mimeType: 'image/jpeg',
filePathOriginal: '/uploads/original/profile-photo.jpg',
filePathThumbnail: '/uploads/thumb/profile-photo.jpg',
filePathSmall: '/uploads/small/profile-photo.jpg',
filePathMedium: '/uploads/medium/profile-photo.jpg',
filePathLarge: '/uploads/large/profile-photo.jpg',
fileSizeBytes: 245890,
categoryName: 'profile',
tags: ['profilepic', 'avatar'],
location: 'San Francisco, CA'
})
});
return await response.json();
};
// Get user's profile image
const getProfileImage = async (userId) => {
const response = await fetch(`/api/files/profileimage/foruser/${userId}`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
return await response.json();
};
// Get all files for a user with optional filtering
const getUserFiles = async (userId, category = null, tag = null) => {
let url = `/api/files/foruser/${userId}`;
const params = new URLSearchParams();
if (category) params.append('category', category);
if (tag) params.append('tag', tag);
if (params.toString()) url += `?${params.toString()}`;
const response = await fetch(url, {
headers: {
'Authorization': `Bearer ${token}`
}
});
return await response.json();
};
// Update file metadata
const updateFile = async (fileId, updates) => {
const response = await fetch(`/api/files/${fileId}`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(updates)
});
return await response.json();
};
// Delete a file
const deleteFile = async (fileId) => {
const response = await fetch(`/api/files/${fileId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`
}
});
return response.status === 204; // No Content on success
};
// Get files for a broadcast
const getBroadcastFiles = async (broadcastId) => {
const response = await fetch(`/api/files/forbroadcast/${broadcastId}`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
return await response.json();
};
// Get files for a contact
const getContactFiles = async (contactId) => {
const response = await fetch(`/api/files/forcontact/${contactId}`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
return await response.json();
};
/api/files
Upload/create a new file record
/api/files
Get all files for authenticated user
/api/files/foruser/{userId}
Get files for a user with optional category/tag filters
/api/files/profileimage/foruser/{userId}
Quick access to user's profile image
/api/files/contactimage/foruser/{userId}
Quick access to contact's image
/api/files/forcontact/{contactId}
Get all files for a contact
/api/files/formessage/{messageId}
Get all files for a message
/api/files/forcall/{callId}
Get all files for a call
/api/files/forbroadcast/{broadcastId}
Get all files for a broadcast
/api/files/{id}
Update file metadata
/api/files/{id}
Delete a file
When uploading files, you can provide paths for multiple size variants. The system supports: original, thumbnail, small, medium, and large. Use the appropriate variant based on your UI context for optimal performance.
Scheduled events use the same unified logic as createBroadcast:
StreamService.create()streamId (UUID) is saved to broadcast_sessions.stream_idlivekit_room_name in scheduled_events is set to match streamIdstream_id, the scheduler updates itstream_id and livekit_room_name always matchImportant: Use livekit_room_name from the scheduled event response to connect to LiveKit rooms. This value matches the stream_id in the associated broadcast session.
For scheduled events, always use the event's livekitRoomName as the sessionId when creating a stream:
// ✅ CORRECT: Use event's livekitRoomName
let stream = PFStream(
// ... other fields ...
sessionId: event.livekitRoomName, // ✅ Use event's room name
isPhoneSession: false,
isMessageSession: false
)
// ❌ WRONG: Don't use broadcastSessionId
sessionId: event.broadcastSessionId // ❌ This is wrong!
The server's token endpoint automatically resolves room names for scheduled events. See Troubleshooting section for more details.
import PubfuseSDK
// Get SDK instance
guard let sdk = appViewModel.pubfuseSDK else { return }
// Schedule a new event
let startTime = Date().addingTimeInterval(3600) // 1 hour from now
let endTime = startTime.addingTimeInterval(1800) // 30 minutes duration
let request = PFScheduleEventRequest(
scheduledStartTime: startTime,
scheduledEndTime: endTime,
title: "My Scheduled Event",
description: "Event description",
repeatMode: "none",
repeatUntilEnd: false
)
do {
let event = try await sdk.eventsService.scheduleEvent(request)
print("Event scheduled: \(event.id)")
} catch {
print("Error scheduling event: \(error)")
}
// Get all events
let events = try await sdk.eventsService.getEvents()
// Get a specific event
let event = try await sdk.eventsService.getEvent(eventId: eventId)
// Update an event
let updateRequest = PFUpdateEventRequest(
scheduledStartTime: newStartTime,
scheduledEndTime: newEndTime,
title: "Updated Title",
description: "Updated description",
repeatMode: "daily",
repeatUntilEnd: true
)
let updatedEvent = try await sdk.eventsService.updateEvent(
eventId: eventId,
updateRequest
)
// Upload content for an event
let fileData = try Data(contentsOf: fileURL)
let uploadedEvent = try await sdk.eventsService.uploadEventContent(
eventId: eventId,
fileData: fileData,
mimeType: "video/mp4",
filename: "video.mp4"
)
// Manually start event playback
let startedEvent = try await sdk.eventsService.startVideoPlayback(eventId: eventId)
// Get event sync information (for synchronized playback)
let sync = try await sdk.eventsService.getEventSync(eventId: eventId)
print("Current position: \(sync.currentPosition)")
print("Playback started at: \(sync.playbackStartedAt)")
// Delete an event
let response = try await sdk.eventsService.deleteEvent(eventId: eventId)
print("Event deleted: \(response.success)")
/api/events
Get all scheduled events (public)
Query params: limit, offset, status/api/events/:id
Get a specific event by ID (public)
/api/events/:id/sync
Get event sync information for synchronized playback (public)
/api/events/schedule
Schedule a new event (requires authentication)
/api/events/:id
Update an existing event (requires authentication, owner only)
/api/events/:id
Delete an event (requires authentication, owner only)
/api/events/:id/upload
Upload content file for an event (requires authentication, owner only)
Multipart form data with file field. Supports video (mp4, mov, webm) and audio (mp3, wav, m4a, aac) files up to 1GB./api/events/:id/start-video
Manually start event playback (requires authentication, owner only)
Creates broadcast session, sends notifications, and starts LiveKit Egress./api/events/:id/takeover
Take over an active event stream (requires authentication, owner only)
repeatUntilEnd: When true and repeatMode is "until_end", the content will loop continuously until the scheduled end time is reached.
Scheduled events support synchronized playback across all viewers. The server tracks the current playback position and all clients sync to this position.
The sync algorithm ensures all viewers see the same content at the same time:
currentPosition = initialPosition + (now - playbackStartedAt)
GET /api/events/:id/sync to get the current sync positionplaybackStartedAt and currentPlaybackPositionScheduled events support multiple repeat modes for flexible scheduling:
repeatUntilEnd: true to enable looping.repeatMode is "until_end" and repeatUntilEnd is true, the content file will loop from the beginning when it reaches the end, continuing until the scheduled end time.When an event starts (either automatically or manually), the system will:
Scheduled playback allows you to play pre-recorded video or audio files through a LiveKit session that users can join, creating a synchronized viewing experience.
The scheduled playback system uses LiveKit Egress to stream media files into LiveKit rooms:
POST /api/events/:id/uploadevent-{eventId})/egress-player endpoint to render and capture the media fileWhen playback starts, LiveKit Egress:
/egress-player?file={fileURL}&type={contentType}For LiveKit Egress to access your files, they must be publicly accessible. Use one of these methods:
GET /api/files/{id}/public - No authentication required/userfiles/{userId}/ are served publiclyGET /api/files/{id}/download - Requires JWT token (not suitable for Egress)Files used for scheduled playback must be accessible from the public internet. LiveKit Egress cannot authenticate, so use the public file endpoint or ensure files are in the public userfiles directory.
import PubfuseSDK
import Foundation
class ScheduledPlaybackManager {
private let sdk: PubfuseSDK
init(sdk: PubfuseSDK) {
self.sdk = sdk
}
// Step 1: Upload video file for scheduled playback
func uploadVideoForEvent(eventId: String, videoURL: URL) async throws {
let fileData = try Data(contentsOf: videoURL)
let mimeType = "video/mp4"
let filename = videoURL.lastPathComponent
let event = try await sdk.eventsService.uploadEventContent(
eventId: eventId,
fileData: fileData,
mimeType: mimeType,
filename: filename
)
print("✅ Video uploaded for event: \(event.id)")
print("📁 File URL: \(event.contentFileUrl ?? "nil")")
}
// Step 2: Create scheduled event
func createScheduledEvent(
title: String,
description: String,
startTime: Date,
endTime: Date,
videoURL: URL
) async throws -> ScheduledEvent {
// First, upload the video
let fileData = try Data(contentsOf: videoURL)
let tempEvent = try await sdk.eventsService.scheduleEvent(
PFScheduleEventRequest(
scheduledStartTime: startTime,
scheduledEndTime: endTime,
title: title,
description: description,
repeatMode: "none",
repeatUntilEnd: false
)
)
// Upload video to the event
let event = try await sdk.eventsService.uploadEventContent(
eventId: tempEvent.id,
fileData: fileData,
mimeType: "video/mp4",
filename: videoURL.lastPathComponent
)
print("✅ Scheduled event created: \(event.id)")
print("⏰ Starts at: \(event.scheduledStartTime)")
print("⏰ Ends at: \(event.scheduledEndTime)")
return event
}
// Step 3: Manually start playback (or wait for automatic start)
func startPlayback(eventId: String) async throws {
let event = try await sdk.eventsService.startVideoPlayback(eventId: eventId)
print("🎬 Playback started for event: \(event.id)")
print("📺 Broadcast session: \(event.broadcastSessionId ?? "nil")")
print("🏠 LiveKit room: \(event.livekitRoomName ?? "nil")")
// Users can now join the LiveKit room to watch
// Room name format: event-{eventId}
}
// Step 4: Join the playback session as a viewer
func joinPlaybackSession(eventId: String) async throws {
// Get the event to find the LiveKit room
let event = try await sdk.eventsService.getEvent(eventId: eventId)
guard let roomName = event.livekitRoomName else {
throw NSError(domain: "ScheduledPlayback", code: -1,
userInfo: [NSLocalizedDescriptionKey: "Event not started yet"])
}
// Generate LiveKit token for joining
let tokenResponse = try await sdk.streamingService.generateLiveKitToken(
streamId: event.broadcastSessionId ?? eventId,
userId: sdk.currentUser?.id ?? UUID().uuidString,
role: "subscriber"
)
// Connect to LiveKit room
let room = Room()
try await room.connect(
url: tokenResponse.serverUrl,
token: tokenResponse.token
)
print("✅ Joined playback session: \(roomName)")
// Set up video rendering
room.delegate = self
}
// Step 5: Stop playback
func stopPlayback(eventId: String) async throws {
let event = try await sdk.eventsService.stopVideoPlayback(eventId: eventId)
print("🛑 Playback stopped for event: \(event.id)")
}
}
// Usage example
let manager = ScheduledPlaybackManager(sdk: pubfuseSDK)
// Schedule a video to play tomorrow at 2 PM
let tomorrow = Calendar.current.date(byAdding: .day, value: 1, to: Date())!
let startTime = Calendar.current.date(bySettingHour: 14, minute: 0, second: 0, of: tomorrow)!
let endTime = startTime.addingTimeInterval(3600) // 1 hour duration
let event = try await manager.createScheduledEvent(
title: "Scheduled Movie Night",
description: "Watch our latest video together",
startTime: startTime,
endTime: endTime,
videoURL: videoFileURL
)
// The event will automatically start at the scheduled time
// Users can join using:
try await manager.joinPlaybackSession(eventId: event.id)
The /egress-player endpoint serves an HTML page that plays media files for LiveKit WebEgress:
/egress-player?file={fileURL}&type={contentType}file (required): HTTP/HTTPS URL of the media filetype (required): Content type - "audio" or "video"To test scheduled playback immediately, you can:
scheduledStartTime to the current timePOST /api/events/:id/start-video to manually start playbackclass PubfuseSDK {
constructor(apiKey, secretKey, baseUrl = 'https://api.pubfuse.com') {
this.apiKey = apiKey;
this.secretKey = secretKey;
this.baseUrl = baseUrl;
}
async makeRequest(method, path, data = null) {
const timestamp = Math.floor(Date.now() / 1000).toString();
const body = data ? JSON.stringify(data) : '';
const signature = this.generateSignature(method, path, body, timestamp);
const response = await fetch(`${this.baseUrl}${path}`, {
method,
headers: {
'X-API-Key': this.apiKey,
'X-Signature': signature,
'X-Timestamp': timestamp,
'Content-Type': 'application/json'
},
body: data ? body : undefined
});
return response.json();
}
generateSignature(method, path, body, timestamp) {
const crypto = require('crypto');
const payload = `${method}${path}${body}${timestamp}`;
return crypto.createHmac('sha256', this.secretKey)
.update(payload)
.digest('hex');
}
// Stream Management
async createSession(title, description = '') {
return this.makeRequest('POST', '/api/v1/sessions', {
title,
description
});
}
async getSessions() {
return this.makeRequest('GET', '/api/v1/sessions');
}
// User Management
async registerUser(userData) {
return this.makeRequest('POST', '/api/users/signup', userData);
}
async loginUser(email, password) {
return this.makeRequest('POST', '/api/users/login', {
email,
password
});
}
}
// Usage
const sdk = new PubfuseSDK('pk_your_api_key', 'sk_your_secret_key');
// Create a session
sdk.createSession('My Live Stream', 'Welcome to my stream!')
.then(session => console.log('Session created:', session))
.catch(error => console.error('Error:', error));
import requests
import hmac
import hashlib
import time
from typing import Dict, Any, Optional
class PubfuseSDK:
def __init__(self, api_key: str, secret_key: str, base_url: str = 'https://api.pubfuse.com'):
self.api_key = api_key
self.secret_key = secret_key
self.base_url = base_url
def _generate_signature(self, method: str, path: str, body: str, timestamp: str) -> str:
payload = f"{method}{path}{body}{timestamp}"
return hmac.new(
self.secret_key.encode(),
payload.encode(),
hashlib.sha256
).hexdigest()
def _make_request(self, method: str, path: str, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
timestamp = str(int(time.time()))
body = json.dumps(data) if data else ''
signature = self._generate_signature(method, path, body, timestamp)
headers = {
'X-API-Key': self.api_key,
'X-Signature': signature,
'X-Timestamp': timestamp,
'Content-Type': 'application/json'
}
response = requests.request(
method,
f"{self.base_url}{path}",
headers=headers,
json=data
)
return response.json()
def create_session(self, title: str, description: str = '') -> Dict[str, Any]:
return self._make_request('POST', '/api/v1/sessions', {
'title': title,
'description': description
})
def get_sessions(self) -> Dict[str, Any]:
return self._make_request('GET', '/api/v1/sessions')
def register_user(self, user_data: Dict[str, Any]) -> Dict[str, Any]:
return self._make_request('POST', '/api/users/signup', user_data)
def login_user(self, email: str, password: str) -> Dict[str, Any]:
return self._make_request('POST', '/api/users/login', {
'email': email,
'password': password
})
# Usage
sdk = PubfuseSDK('pk_your_api_key', 'sk_your_secret_key')
# Create a session
try:
session = sdk.create_session('My Live Stream', 'Welcome to my stream!')
print('Session created:', session)
except Exception as e:
print('Error:', e)
Generate a LiveKit token from your server, then pass it unchanged (including the livekit_ prefix) to the client SDK.
# Get LiveKit token for a session (UUID or short ID like r5)
curl -s -X POST "http://127.0.0.1:8080/api/streaming/sessions/SESSION_ID/token" \
-H "Content-Type: application/json" \
-H "X-API-Key: <your_api_key>" \
-d '{"userId":"550e8400-e29b-41d4-a716-446655440000","role":"subscriber"}' | jq .
Minimal LiveKit Web client usage:
// tokenResponse: { token, room, serverUrl }
const room = new LiveKit.Room();
await room.connect(tokenResponse.serverUrl, tokenResponse.token, {
autoManageVideo: true,
autoManageAudio: true
});
room.on(LiveKit.RoomEvent.TrackSubscribed, (track, pub, participant) => {
if (track.kind === 'video') {
const el = document.getElementById('remoteVideo');
track.attach(el);
}
});
This is what the web watch page does at runtime. Mirror this sequence for mobile clients.
r5).window.LiveKit is available.GET /api/streaming/providers and select id === "livekit".POST /api/streaming/sessions/{id}/token with X-API-Key, role subscriber, and a UUID userId.livekit_ prefix) to the SDK Room.connect().TrackSubscribed, attach remote video/audio to the player.// Pseudocode that mirrors watch.leaf
const sessionId = getSessionIdFromUrl();
// 1) Provider
const providers = await fetch('/api/streaming/providers').then(r => r.json());
const livekit = providers.find(p => p.id === 'livekit' && p.isConfigured);
// 2) Token (server returns: { token, room, serverUrl, expiresAt })
const tRes = await fetch(`/api/streaming/sessions/${sessionId}/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-API-Key': API_KEY },
body: JSON.stringify({ userId: USER_UUID, role: 'subscriber' })
});
const t = await tRes.json();
// 3) Connect
const room = new LiveKit.Room();
await room.connect(t.serverUrl || livekit.configuration.url, t.token);
// 4) Track handling
room.on(LiveKit.RoomEvent.TrackSubscribed, (track, pub, participant) => {
if (track.kind === 'video') track.attach(document.getElementById('remoteVideo'));
});
// 5) Reactions/chat (data)
function sendReaction(kind) {
// send via your server or LiveKit data channel depending on your design
}
livekit_ prefix; integer nbf/iat/exp; grants include roomJoin and room.Use your backend to mint the token, then connect the SDK on device.
import LiveKit
let room = Room()
// tokenResponse.serverUrl (wss://...), tokenResponse.token (starts with livekit_)
try await room.connect(tokenResponse.serverUrl, tokenResponse.token)
room.on(.trackSubscribed) { track, pub, participant in
if let video = track as? RemoteVideoTrack {
// Attach to your LKVideoView / UIView
}
}
import io.livekit.android.Room
val room = Room.getInstance(applicationContext)
// tokenResponse.serverUrl (wss://...), tokenResponse.token (starts with livekit_)
room.connect(tokenResponse.serverUrl, tokenResponse.token)
room.onTrackSubscribed = { track, publication, participant ->
// Attach video track to SurfaceViewRenderer
}
livekit_ prefix from the token. The server includes integer nbf/iat/exp and grants (e.g., roomJoin, room, canSubscribe).
import Foundation
import CryptoKit
class PubfuseSDK {
private let apiKey: String
private let secretKey: String
private let baseURL: String
init(apiKey: String, secretKey: String, baseURL: String = "https://api.pubfuse.com") {
self.apiKey = apiKey
self.secretKey = secretKey
self.baseURL = baseURL
}
private func generateSignature(method: String, path: String, body: String, timestamp: String) -> String {
let payload = "\(method)\(path)\(body)\(timestamp)"
let key = SymmetricKey(data: secretKey.data(using: .utf8)!)
let signature = HMAC.authenticationCode(for: payload.data(using: .utf8)!, using: key)
return Data(signature).map { String(format: "%02hhx", $0) }.joined()
}
func makeRequest(method: String, path: String, data: T? = nil) async throws -> Data {
let timestamp = String(Int(Date().timeIntervalSince1970))
let body = data != nil ? try JSONEncoder().encode(data) : Data()
let bodyString = String(data: body, encoding: .utf8) ?? ""
let signature = generateSignature(method: method, path: path, body: bodyString, timestamp: timestamp)
var request = URLRequest(url: URL(string: "\(baseURL)\(path)")!)
request.httpMethod = method
request.setValue(apiKey, forHTTPHeaderField: "X-API-Key")
request.setValue(signature, forHTTPHeaderField: "X-Signature")
request.setValue(timestamp, forHTTPHeaderField: "X-Timestamp")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
if data != nil {
request.httpBody = body
}
let (data, _) = try await URLSession.shared.data(for: request)
return data
}
// Stream Management
func createSession(title: String, description: String = "") async throws -> SessionResponse {
let request = CreateSessionRequest(title: title, description: description)
let data = try await makeRequest(method: "POST", path: "/api/v1/sessions", data: request)
return try JSONDecoder().decode(SessionResponse.self, from: data)
}
func getSessions() async throws -> [SessionResponse] {
let data = try await makeRequest(method: "GET", path: "/api/v1/sessions")
return try JSONDecoder().decode([SessionResponse].self, from: data)
}
}
// Usage
let sdk = PubfuseSDK(apiKey: "pk_your_api_key", secretKey: "sk_your_secret_key")
Task {
do {
let session = try await sdk.createSession(title: "My Live Stream", description: "Welcome!")
print("Session created: \(session)")
} catch {
print("Error: \(error)")
}
}
Each SDK Client has a default rate limit of 1000 requests per hour. Monitor your usage and contact support if you need higher limits.
// Implement exponential backoff for rate limiting
async function makeRequestWithRetry(requestFn, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
try {
return await requestFn();
} catch (error) {
if (error.status === 429 && i < maxRetries - 1) {
const delay = Math.pow(2, i) * 1000; // Exponential backoff
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}
throw error;
}
}
}
Complete implementation examples for iOS apps using the Pubfuse contacts and follow features.
Implement contact synchronization in your iOS app to discover Pubfuse users.
import Foundation
import Contacts
class PubfuseContactsManager: ObservableObject {
private let apiBaseURL = "https://www.pubfuse.com/api"
private var authToken: String?
// MARK: - Contact Sync
func syncContacts() async throws -> ContactsSyncResponse {
let contacts = try await fetchDeviceContacts()
let syncRequest = ContactsSyncRequest(
contacts: contacts.map { contact in
ContactSyncRequest(
phoneNumber: contact.phoneNumber ?? "",
displayName: contact.displayName,
firstName: contact.firstName,
lastName: contact.lastName,
email: contact.email
)
}
)
let url = URL(string: "\(apiBaseURL)/contacts/sync")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("Bearer \(authToken ?? "")", forHTTPHeaderField: "Authorization")
request.httpBody = try JSONEncoder().encode(syncRequest)
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw PubfuseError.networkError("Failed to sync contacts")
}
return try JSONDecoder().decode(ContactsSyncResponse.self, from: data)
}
private func fetchDeviceContacts() async throws -> [DeviceContact] {
let store = CNContactStore()
guard try await store.requestAccess(for: .contacts) else {
throw PubfuseError.permissionDenied
}
let keys = [CNContactGivenNameKey, CNContactFamilyNameKey,
CNContactPhoneNumbersKey, CNContactEmailAddressesKey] as [CNKeyDescriptor]
let request = CNContactFetchRequest(keysToFetch: keys)
var contacts: [DeviceContact] = []
try store.enumerateContacts(with: request) { contact, _ in
let phoneNumber = contact.phoneNumbers.first?.value.stringValue
let email = contact.emailAddresses.first?.value as String?
let deviceContact = DeviceContact(
firstName: contact.givenName,
lastName: contact.familyName,
displayName: CNContactFormatter.string(from: contact, style: .fullName),
phoneNumber: phoneNumber,
email: email
)
contacts.append(deviceContact)
}
return contacts
}
}
// MARK: - Data Models
struct ContactsSyncRequest: Codable {
let contacts: [ContactSyncRequest]
}
struct ContactSyncRequest: Codable {
let phoneNumber: String
let displayName: String?
let firstName: String?
let lastName: String?
let email: String?
}
struct ContactsSyncResponse: Codable {
let success: Bool
let message: String
let contacts: [ContactResponse]
let totalContacts: Int
let pubfuseUsers: Int
}
struct ContactResponse: Codable {
let id: UUID
let phoneNumber: String
let displayName: String?
let firstName: String?
let lastName: String?
let email: String?
let isPubfuseUser: Bool
let pubfuseUserId: UUID?
let isFollowing: Bool
let isFollowedBy: Bool
let createdAt: Date
let updatedAt: Date
}
struct DeviceContact {
let firstName: String
let lastName: String
let displayName: String?
let phoneNumber: String?
let email: String?
}
enum PubfuseError: Error {
case networkError(String)
case permissionDenied
case invalidToken
}
Implement following and unfollowing of contacts who are on Pubfuse.
// MARK: - Follow Management
func followContact(contactId: UUID) async throws {
let url = URL(string: "\(apiBaseURL)/contacts/\(contactId)/follow")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("Bearer \(authToken ?? "")", forHTTPHeaderField: "Authorization")
let (_, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw PubfuseError.networkError("Invalid response")
}
switch httpResponse.statusCode {
case 200:
// Successfully followed
break
case 400:
throw PubfuseError.networkError("Contact is not a Pubfuse user")
case 409:
throw PubfuseError.networkError("Already following this user")
default:
throw PubfuseError.networkError("Failed to follow contact")
}
}
func unfollowContact(contactId: UUID) async throws {
let url = URL(string: "\(apiBaseURL)/contacts/\(contactId)/follow")!
var request = URLRequest(url: url)
request.httpMethod = "DELETE"
request.setValue("Bearer \(authToken ?? "")", forHTTPHeaderField: "Authorization")
let (_, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw PubfuseError.networkError("Failed to unfollow contact")
}
}
// MARK: - User Profile Enhancement
func getFullProfile() async throws -> FullUserProfileResponse {
let url = URL(string: "\(apiBaseURL)/users/profile/full")!
var request = URLRequest(url: url)
request.setValue("Bearer \(authToken ?? "")", forHTTPHeaderField: "Authorization")
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw PubfuseError.networkError("Failed to get profile")
}
return try JSONDecoder().decode(FullUserProfileResponse.self, from: data)
}
struct FullUserProfileResponse: Codable {
let id: UUID
let username: String
let email: String
let phoneNumber: String
let firstName: String?
let lastName: String?
let avatarUrl: String?
let isActive: Bool
let emailVerified: Bool
let phoneVerified: Bool
let appIdName: String
let followerCount: Int
let followingCount: Int
let connectionCount: Int
let createdAt: Date
let updatedAt: Date
}
Implement user search functionality to find and connect with other Pubfuse users.
// MARK: - User Search
func searchUsers(query: String, limit: Int = 20) async throws -> [UserSearchResponse] {
let encodedQuery = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? ""
let url = URL(string: "\(apiBaseURL)/users/search?q=\(encodedQuery)&limit=\(limit)")!
var request = URLRequest(url: url)
request.setValue("Bearer \(authToken ?? "")", forHTTPHeaderField: "Authorization")
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw PubfuseError.networkError("Failed to search users")
}
return try JSONDecoder().decode([UserSearchResponse].self, from: data)
}
struct UserSearchResponse: Codable {
let id: UUID
let username: String
let email: String
let firstName: String?
let lastName: String?
let avatarUrl: String?
let isFollowing: Bool
}
Upload and retrieve poster images for broadcast sessions. Posters are displayed in broadcast lists and serve as preview thumbnails.
// MARK: - Broadcast Poster Images
// Get the file service
guard let sdk = pubfuseSDK else { return }
let fileService = PFFileService(networkService: sdk.networkService)
// Upload a poster image for a broadcast
func uploadBroadcastPoster(broadcastId: String, image: UIImage) async {
do {
let posterFile = try await fileService.uploadBroadcastPosterImage(
image: image,
broadcastId: broadcastId,
description: "Poster for my live stream"
)
print("✅ Poster uploaded: \(posterFile.id ?? "unknown")")
print(" Thumbnail: \(posterFile.filePathThumbnail ?? "N/A")")
print(" Small: \(posterFile.filePathSmall ?? "N/A")")
print(" Original: \(posterFile.filePathOriginal ?? "N/A")")
} catch {
print("❌ Failed to upload poster: \(error)")
}
}
// Get poster image for a broadcast
func loadBroadcastPoster(broadcastId: String) async {
do {
guard let posterFile = try await fileService.getBroadcastPosterImage(for: broadcastId) else {
print("ℹ️ No poster image found for broadcast")
return
}
// Get the image URL (prefer smallest size for list views)
let imagePath = posterFile.filePathThumbnail ??
posterFile.filePathSmall ??
posterFile.filePathMedium ??
posterFile.filePathOriginal
let config = pubfuseSDK?.configuration
let baseURL = config?.baseURL ?? "http://localhost:8080"
let imageURL = "\(baseURL)/\(imagePath)"
print("✅ Poster image URL: \(imageURL)")
// Use imageURL with CachedAsyncImage in SwiftUI
} catch {
print("❌ Failed to load poster: \(error)")
}
}
// Display poster in SwiftUI view
struct BroadcastPosterView: View {
let broadcastId: String
@State private var posterImageUrl: String?
@EnvironmentObject var appViewModel: AppViewModel
var body: some View {
ZStack {
// Placeholder
RoundedRectangle(cornerRadius: 10)
.fill(Color.gray.opacity(0.3))
// Poster image
if let posterImageUrl = posterImageUrl, !posterImageUrl.isEmpty {
CachedAsyncImage(url: posterImageUrl) {
RoundedRectangle(cornerRadius: 10)
.fill(Color.gray.opacity(0.3))
}
.aspectRatio(contentMode: .fill)
.frame(width: 100, height: 75)
.clipShape(RoundedRectangle(cornerRadius: 10))
.id(posterImageUrl) // Force re-render when URL changes
}
}
.onAppear {
loadPosterImage()
}
}
private func loadPosterImage() {
guard let fileService = appViewModel.pubfuseSDK?.fileService else { return }
Task {
do {
let posterFile = try await fileService.getBroadcastPosterImage(for: broadcastId)
await MainActor.run {
if let imageFile = posterFile {
let imagePath = imageFile.filePathThumbnail ??
imageFile.filePathSmall ??
imageFile.filePathMedium ??
imageFile.filePathOriginal
let config = appViewModel.pubfuseSDK?.configuration
let baseURL = config?.baseURL ?? "http://localhost:8080"
self.posterImageUrl = "\(baseURL)/\(imagePath)"
}
}
} catch {
print("Failed to load poster: \(error)")
}
}
}
}
filePathThumbnail or filePathSmall for list views (faster loading)filePathMedium or filePathLarge for detail viewsCachedAsyncImage with .id() modifier for proper refresh behaviorforBroadcastIdAllow users to upload a poster image before starting a broadcast:
struct GoLiveView: View {
@State private var selectedPosterImage: UIImage?
@State private var showingImagePicker = false
@State private var fileService: PFFileService?
var body: some View {
VStack {
// Poster upload section
if let posterImage = selectedPosterImage {
HStack {
Image(uiImage: posterImage)
.resizable()
.frame(width: 80, height: 60)
.clipShape(RoundedRectangle(cornerRadius: 8))
Button("Remove") {
selectedPosterImage = nil
}
}
} else {
Button("Upload Poster Image") {
showingImagePicker = true
}
}
// Start streaming button
Button("Start Streaming") {
startStreaming()
}
}
.sheet(isPresented: $showingImagePicker) {
ImagePicker(image: $selectedPosterImage)
}
}
private func startStreaming() async {
// Create broadcast
let broadcast = try await appViewModel.startLiveKitBroadcast(...)
// Upload poster if selected
if let posterImage = selectedPosterImage,
let fileService = fileService,
let broadcastId = broadcast.id {
do {
_ = try await fileService.uploadBroadcastPosterImage(
image: posterImage,
broadcastId: broadcastId,
description: "Poster for broadcast"
)
print("✅ Poster uploaded")
} catch {
print("⚠️ Failed to upload poster: \(error)")
}
}
}
}
Upload and manage files in your iOS app, including profile images and file attachments.
// MARK: - File Management
import SwiftUI
import PhotosUI
class PubfuseFileManager: ObservableObject {
private let apiBaseURL = "https://www.pubfuse.com/api"
private var authToken: String?
// Upload a file
func uploadFile(fileData: FileUploadData) async throws -> PFFile {
let createRequest = PFFileCreateRequest(
name: fileData.name,
description: fileData.description,
mimeType: fileData.mimeType,
filePathOriginal: fileData.pathOriginal,
forContactID: fileData.forContactID,
filePathThumbnail: fileData.pathThumbnail,
filePathSmall: fileData.pathSmall,
filePathMedium: fileData.pathMedium,
filePathLarge: fileData.pathLarge,
fileSizeBytes: fileData.sizeBytes,
categoryName: fileData.categoryName,
tags: fileData.tags,
location: fileData.location,
forMessageId: fileData.forMessageId,
forCallId: fileData.forCallId,
forBroadcastId: fileData.forBroadcastId
)
let url = URL(string: "\(apiBaseURL)/files")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("Bearer \(authToken ?? "")", forHTTPHeaderField: "Authorization")
request.httpBody = try JSONEncoder().encode(createRequest)
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw PubfuseError.networkError("Failed to upload file")
}
return try JSONDecoder().decode(PFFile.self, from: data)
}
// Upload profile image with multiple sizes
func uploadProfileImage(originalPath: String, thumbnailPath: String, sizeBytes: Int64) async throws -> PFFile {
let fileData = FileUploadData(
name: "profile-photo.jpg",
description: "Profile picture",
mimeType: "image/jpeg",
pathOriginal: originalPath,
pathThumbnail: thumbnailPath,
pathSmall: nil,
pathMedium: nil,
pathLarge: nil,
sizeBytes: sizeBytes,
categoryName: "profile",
tags: ["profilepic", "avatar"],
location: nil,
forContactID: nil,
forMessageId: nil,
forCallId: nil,
forBroadcastId: nil
)
return try await uploadFile(fileData: fileData)
}
// Get user's profile image
func getProfileImage(for userId: String) async throws -> PFFile {
let url = URL(string: "\(apiBaseURL)/files/profileimage/foruser/\(userId)")!
var request = URLRequest(url: url)
request.setValue("Bearer \(authToken ?? "")", forHTTPHeaderField: "Authorization")
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw PubfuseError.networkError("Profile image not found")
}
return try JSONDecoder().decode(PFFile.self, from: data)
}
// Get all user files with optional filters
func getUserFiles(userId: String, category: String? = nil, tag: String? = nil) async throws -> [PFFile] {
var urlString = "\(apiBaseURL)/files/foruser/\(userId)"
var queryParams: [String] = []
if let category = category {
queryParams.append("category=\(category)")
}
if let tag = tag {
queryParams.append("tag=\(tag)")
}
if !queryParams.isEmpty {
urlString += "?" + queryParams.joined(separator: "&")
}
let url = URL(string: urlString)!
var request = URLRequest(url: url)
request.setValue("Bearer \(authToken ?? "")", forHTTPHeaderField: "Authorization")
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw PubfuseError.networkError("Failed to get files")
}
return try JSONDecoder().decode([PFFile].self, from: data)
}
// Update file metadata
func updateFile(fileId: String, updates: PFFileUpdateRequest) async throws -> PFFile {
let url = URL(string: "\(apiBaseURL)/files/\(fileId)")!
var request = URLRequest(url: url)
request.httpMethod = "PUT"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("Bearer \(authToken ?? "")", forHTTPHeaderField: "Authorization")
request.httpBody = try JSONEncoder().encode(updates)
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw PubfuseError.networkError("Failed to update file")
}
return try JSONDecoder().decode(PFFile.self, from: data)
}
// Delete a file
func deleteFile(fileId: String) async throws {
let url = URL(string: "\(apiBaseURL)/files/\(fileId)")!
var request = URLRequest(url: url)
request.httpMethod = "DELETE"
request.setValue("Bearer \(authToken ?? "")", forHTTPHeaderField: "Authorization")
let (_, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 204 else {
throw PubfuseError.networkError("Failed to delete file")
}
}
}
// MARK: - File Models
struct PFFile: Codable {
let id: String
let userID: String
let forContactID: String?
let name: String
let description: String?
let mimeType: String
let filePathOriginal: String
let filePathThumbnail: String?
let filePathSmall: String?
let filePathMedium: String?
let filePathLarge: String?
let fileSizeBytes: Int64
let categoryName: String?
let tags: [String]?
let location: String?
let forMessageId: String?
let forCallId: String?
let forBroadcastId: String?
let createdAt: Date?
let updatedAt: Date?
}
struct PFFileCreateRequest: Codable {
let name: String
let description: String?
let mimeType: String
let filePathOriginal: String
let forContactID: String?
let filePathThumbnail: String?
let filePathSmall: String?
let filePathMedium: String?
let filePathLarge: String?
let fileSizeBytes: Int64
let categoryName: String?
let tags: [String]?
let location: String?
let forMessageId: String?
let forCallId: String?
let forBroadcastId: String?
}
struct PFFileUpdateRequest: Codable {
let name: String?
let description: String?
let tags: [String]?
let location: String?
let categoryName: String?
}
struct FileUploadData {
let name: String
let description: String?
let mimeType: String
let pathOriginal: String
let pathThumbnail: String?
let pathSmall: String?
let pathMedium: String?
let pathLarge: String?
let sizeBytes: Int64
let categoryName: String?
let tags: [String]?
let location: String?
let forContactID: String?
let forMessageId: String?
let forCallId: String?
let forBroadcastId: String?
}
// Example: SwiftUI view for uploading a profile image
struct ProfileImageUploadView: View {
@StateObject private var fileManager = PubfuseFileManager()
@State private var selectedImage: UIImage?
@State private var showingImagePicker = false
@State private var uploadProgress = false
@Environment(\.presentationMode) var presentationMode
var body: some View {
VStack(spacing: 20) {
if let image = selectedImage {
Image(uiImage: image)
.resizable()
.scaledToFill()
.frame(width: 200, height: 200)
.clipShape(Circle())
} else {
Image(systemName: "person.circle")
.resizable()
.scaledToFill()
.frame(width: 200, height: 200)
.foregroundColor(.gray)
}
Button("Select Photo") {
showingImagePicker = true
}
.buttonStyle(.bordered)
if uploadProgress {
ProgressView("Uploading...")
} else {
Button("Upload Profile Image") {
uploadProfileImage()
}
.buttonStyle(.borderedProminent)
.disabled(selectedImage == nil)
}
}
.sheet(isPresented: $showingImagePicker) {
ImagePicker(selectedImage: $selectedImage)
}
.padding()
}
private func uploadProfileImage() {
guard let image = selectedImage else { return }
uploadProgress = true
Task {
do {
// In a real implementation, you would:
// 1. Save the image to your server/storage (e.g., S3, local storage)
// 2. Get the storage paths for original and thumbnail
// 3. Calculate file size
// 4. Call uploadFile with the paths
// Example assuming you have the paths
let originalPath = "/storage/original/profile-\(UUID().uuidString).jpg"
let thumbnailPath = "/storage/thumb/profile-\(UUID().uuidString).jpg"
let fileSize: Int64 = 245890 // Actual file size
_ = try await fileManager.uploadProfileImage(
originalPath: originalPath,
thumbnailPath: thumbnailPath,
sizeBytes: fileSize
)
DispatchQueue.main.async {
uploadProgress = false
presentationMode.wrappedValue.dismiss()
}
} catch {
DispatchQueue.main.async {
uploadProgress = false
print("Upload failed: \(error)")
}
}
}
}
}
The Pubfuse MCP Server allows AI assistants (like Claude, ChatGPT) to interact with the Pubfuse API through the Model Context Protocol. The MCP server is integrated directly into the Vapor server - no separate process required!
The Model Context Protocol is an open standard that enables AI assistants to securely access external tools and data sources. Our MCP server exposes Pubfuse API functionality as tools and resources that AI assistants can use.
All MCP endpoints are available at:
http://localhost:8080/mcp
# or in production:
https://www.pubfuse.com/mcp
Initialize MCP connection. Returns server capabilities and version info.
List all available MCP tools (API operations).
curl http://localhost:8080/mcp/tools/list
Execute an MCP tool with parameters.
{
"params": {
"name": "get_user",
"arguments": {
"userId": "user-id-here"
}
}
}
List all available MCP resources (data sources).
Read a specific resource by URI.
The MCP server exposes the following tools (automatically generated from the controller):
<div class="table-responsive"> <table class="table table-striped"> <thead> <tr> <th>Tool Name</th> <th>Description</th> <th>Parameters</th> </tr> </thead> <tbody> <tr> <td><code>get_user</code></td> <td>Get user information by user ID</td> <td><code>userId</code> (string) <span class='badge bg-danger'>required</span> - The user ID to retrieve</td> </tr> <tr> <td><code>search_users</code></td> <td>Search for users by query string</td> <td><code>query</code> (string) <span class='badge bg-danger'>required</span> - Search query (username, email, etc.)<br><code>limit</code> (number) - Maximum number of results (default: 20)</td> </tr> <tr> <td><code>get_user_followers</code></td> <td>Get list of users following a specific user</td> <td><code>userId</code> (string) <span class='badge bg-danger'>required</span> - The user ID</td> </tr> <tr> <td><code>get_user_following</code></td> <td>Get list of users that a specific user is following</td> <td><code>userId</code> (string) <span class='badge bg-danger'>required</span> - The user ID</td> </tr> <tr> <td><code>get_stream</code></td> <td>Get stream details by stream ID</td> <td><code>streamId</code> (string) <span class='badge bg-danger'>required</span> - The stream/session ID</td> </tr> <tr> <td><code>list_streams</code></td> <td>List all active streams</td> <td><code>limit</code> (number) - Maximum number of results (default: 50)</td> </tr> <tr> <td><code>get_stream_viewers</code></td> <td>Get viewer count and list for a stream</td> <td><code>streamId</code> (string) <span class='badge bg-danger'>required</span> - The stream/session ID</td> </tr> <tr> <td><code>get_stream_chat</code></td> <td>Get chat messages for a stream</td> <td><code>limit</code> (number) - Maximum number of messages (default: 100)<br><code>streamId</code> (string) <span class='badge bg-danger'>required</span> - The stream/session ID</td> </tr> <tr> <td><code>get_broadcast_status</code></td> <td>Get the current status of a broadcast</td> <td><code>broadcastId</code> (string) <span class='badge bg-danger'>required</span> - The broadcast ID</td> </tr> <tr> <td><code>send_notification</code></td> <td>Send a push notification to a user</td> <td><code>userId</code> (string) <span class='badge bg-danger'>required</span> - The user ID to send notification to<br><code>data</code> (object) - Additional notification data<br><code>title</code> (string) <span class='badge bg-danger'>required</span> - Notification title<br><code>body</code> (string) <span class='badge bg-danger'>required</span> - Notification body text<br><code>type</code> (string) <span class='badge bg-danger'>required</span> - Notification type (e.g., 'stream_started', 'follow')</td> </tr> <tr> <td><code>get_notifications</code></td> <td>Get notifications for the current user (requires authentication via Authorization header or token parameter)</td> <td><code>limit</code> (number) - Maximum number of notifications (default: 50)<br><code>token</code> (string) - JWT authentication token (optional if Authorization header is provided)</td> </tr> <tr> <td><code>get_stream_metrics</code></td> <td>Get analytics/metrics for a stream</td> <td><code>streamId</code> (string) <span class='badge bg-danger'>required</span> - The stream/session ID</td> </tr> <tr> <td><code>get_user_dashboard</code></td> <td>Get user dashboard data (requires authentication via Authorization header or token parameter)</td> <td><code>token</code> (string) - JWT authentication token (optional if Authorization header is provided)</td> </tr> </tbody> </table> </div>get_user - Get user information by IDsearch_users - Search for users by queryget_user_followers - Get user's followersget_user_following - Get users a user followsget_stream - Get stream details by IDlist_streams - List all active streamsget_stream_viewers - Get stream viewer count and listget_stream_chat - Get chat messages for a streamget_broadcast_status - Get current broadcast statussend_notification - Send push notification to a userget_notifications - Get user notifications (requires auth token)get_stream_metrics - Get analytics/metrics for a streamget_user_dashboard - Get user dashboard data (requires auth token)MCP endpoints support JWT authentication from username/password login. You can authenticate in two ways:
Authorization: Bearer <token> headertoken parameter in the arguments# First, login to get a token
curl -X POST http://localhost:8080/api/users/login \
-H "Content-Type: application/json" \
-d '{
"email": "[email protected]",
"password": "your-password"
}'
# Response includes a token:
# {"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "user": {...}}
# Use the token in MCP calls via Authorization header
curl -X POST http://localhost:8080/mcp/tools/call \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-d '{
"params": {
"name": "get_notifications",
"arguments": {
"limit": 10
}
}
}'
# List all available tools
curl http://localhost:8080/mcp/tools/list
# Call a tool to get user info (no auth needed)
curl -X POST http://localhost:8080/mcp/tools/call \
-H "Content-Type: application/json" \
-d '{
"params": {
"name": "get_user",
"arguments": {
"userId": "123e4567-e89b-12d3-a456-426614174000"
}
}
}'
# List active streams (no auth needed)
curl -X POST http://localhost:8080/mcp/tools/call \
-H "Content-Type: application/json" \
-d '{
"params": {
"name": "list_streams",
"arguments": {
"limit": 10
}
}
}'
# Get user notifications (requires auth)
curl -X POST http://localhost:8080/mcp/tools/call \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-d '{
"params": {
"name": "get_notifications",
"arguments": {
"limit": 50
}
}
}'
# Get user dashboard (requires auth)
curl -X POST http://localhost:8080/mcp/tools/call \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-d '{
"params": {
"name": "get_user_dashboard"
}
}'
# Read a resource
curl "http://localhost:8080/mcp/resources/read?uri=pubfuse://active-streams"
The MCP tools list is automatically generated from the MCPController. When you add new tools to the controller, they automatically appear in the documentation!
Error: 401 Unauthorized - Invalid API key
Solution:
X-API-Key headerError: 429 Too Many Requests
Solution:
Error: 401 Unauthorized - Invalid signature
Solution:
Error: 404 Not Found - File missing on server (happens 2 out of 3 times)
Root Cause: Race conditions or file system delays when accessing files
Solution:
/api/files/{id}/publicImplementation: The retry logic is built into both the server-side file serving and client-side file loading.
Error: LiveKit Egress fails to access files for scheduled playback
Root Cause: LiveKit cannot authenticate to access protected file endpoints
Solution:
GET /api/files/{id}/public/userfiles/{userId}/ are also publicly accessible// Get public file URL for LiveKit
let publicURL = sdk.fileService.publicURLString(for: file)
// Use this URL in scheduled events instead of download URL
Error: NSURLErrorDomain Code=-1001 "The request timed out"
Solution:
let sdk = PubfuseSDK(configuration: config, debug: true)Issue: Scheduled event doesn't start automatically
Solution:
scheduledStartTime is in the future (or current time for immediate start)GET /api/events/:id/debug to check event status and LiveKit Egress statusPOST /api/events/:id/start-videoGET /api/livekit/healthError: 401 Unauthorized when requesting LiveKit token
Solution:
X-API-Key header is included in the requestuserId is a valid UUID formatrole is one of: "publisher", "subscriber", or "both"// Correct token request
var request = URLRequest(url: tokenURL)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("your_api_key", forHTTPHeaderField: "X-API-Key") // ✅ Required
let tokenRequest = [
"userId": userId.uuidString, // Must be valid UUID
"role": "subscriber" // Must be: publisher, subscriber, or both
]
request.httpBody = try JSONEncoder().encode(tokenRequest)
Scheduled events use livekitRoomName from the event, which may differ from the broadcast's stream_id. The mobile app must use the event's room name as sessionId.
// Get scheduled event
let event = try await sdk.eventsService.getEvent(id: eventId)
// CRITICAL: Use event's livekitRoomName as sessionId
let stream = PFStream(
id: event.broadcastSessionId ?? event.id,
title: event.title,
description: event.description,
thumbnailUrl: nil,
streamUrl: "https://www.pubfuse.com/streams/\(event.broadcastSessionId ?? event.id)/watch",
hlsUrl: "https://www.pubfuse.com/hls/\(event.broadcastSessionId ?? event.id).m3u8",
rtmpUrl: nil,
status: .active,
duration: nil,
createdAt: event.createdAt ?? Date(),
updatedAt: event.updatedAt ?? Date(),
owner: owner,
sessionId: event.livekitRoomName, // ✅ Use event's room name, NOT broadcast ID
isPhoneSession: false,
isMessageSession: false
)
// Cache the stream to preserve sessionId
sdk.cacheStream(stream: stream)
// Select stream (connection happens automatically in VideoStreamPlayerView)
appViewModel.selectStream(stream: stream)
event.broadcastSessionId as sessionId - This is wrong!sessionIdlivekitRoomName: GET /api/events/:id/debugsessionId matches event's livekitRoomNamestream_id to find the correct room.
Enable debug logging to troubleshoot API issues:
// Add debug logging to your SDK
class PubfuseSDK {
constructor(apiKey, secretKey, debug = false) {
this.apiKey = apiKey;
this.secretKey = secretKey;
this.debug = debug;
}
log(message, data = null) {
if (this.debug) {
console.log(`[PubfuseSDK] ${message}`, data);
}
}
async makeRequest(method, path, data = null) {
this.log(`Making request: ${method} ${path}`, data);
// ... rest of implementation
this.log('Response received:', response);
return response;
}
}
If you need assistance integrating the Pubfuse SDK, we're here to help!