Pubfuse SDK Documentation

Complete guide to integrating Pubfuse's live streaming platform into your applications

Getting Started

What is the Pubfuse SDK?

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.

Key Features:
  • Live Streaming: RTMP ingest with HLS playback
  • Real-time Chat & History: WebSocket plus persisted chat via REST (streams and broadcasts)
  • User Management: Complete authentication system
  • Monetization: Tokens, diamonds, and subscriptions
  • Analytics: Real-time metrics and insights
Multi-tenant Architecture

Each SDK Client operates in isolation with their own users, sessions, and data. Perfect for white-label solutions.

Secure API Keys

Industry-standard API key authentication with optional HMAC signature verification for enhanced security.

Chat Persistence

Stream Chat (Persisted)

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


Broadcast Chat (Persisted)

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

SDK Registration

Step 1: Register Your SDK Client

First, you need to register your application to get API credentials.

POST /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
}
Response:
{
  "success": true,
  "apiKey": "pk_5951C5196A6C47EDA12D41B9A050AC5C",
  "secretKey": "sk_8510CDE5A7ED4E749D15E1008FBD7B7E",
  "clientId": "550e8400-e29b-41d4-a716-446655440000",
  "message": "SDK Client registered successfully"
}

User Profile Management

Endpoints

  • GET /api/users/profile/full – Full profile with follower/following counts
  • PUT /api/users/profile – Update profile fields
  • DELETE /api/users/profile – Delete current user
  • POST /api/users/change-password – Change password
  • DELETE /api/users/follow/:id – Unfollow user by id
  • DELETE /api/contacts/:id/follow – Unfollow by contact id
  • PUT /api/contacts/:id – Update a stored contact
Swift (iOS) Examples
// 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)
}
Quick Tests (curl)
# 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."}'

Music User Linking

How It Works

  1. User logs into pubfuse.com → Receives JWT token
  2. User calls authentication endpoint → Server forwards token to pubfu.se
  3. pubfu.se validates token → Returns music user info + token
  4. Server creates/updates link → Stores relationship in database
  5. User can now manage music profiles → Using the linked account

API Endpoints

  • 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 status
  • POST /api/users/music/link – Create or update link (alternative to authenticate)
  • PUT /api/users/music/link – Update link metadata
  • DELETE /api/users/music/link – Delete link
  • POST /api/users/music/refresh-token – Refresh pubfu.se token
  • GET /api/users/music/search?q={query}NEW: Search for existing pubfu.se users by email or nickname
  • POST /api/users/music/link-existingNEW: Link to an existing pubfu.se user account
Swift (iOS) Examples
// 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
    )
}
Quick Tests (curl)
# 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"}'

Proxy Endpoint - Use Any pubfu.se API

✅ NEW: The proxy endpoint allows you to call any pubfu.se API through pubfuse.com!

📚 Music API Documentation: View pubfu.se Music API Documentation

ALL METHODS /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
}

Authentication

API Key Authentication

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"

HMAC Signature Authentication (Recommended)

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 Endpoints

Stream Management

POST /api/v1/sessions

Create a new streaming session

GET /api/v1/sessions

List all sessions for your SDK client

PATCH /api/v1/sessions/{id}

Update session status and metadata

User Management

POST /api/users/signup

Register a new user

✅ WORKING
POST /api/users/login

Authenticate a user

✅ WORKING
GET /api/v1/users

List users for your SDK client

Real-time Features

WebSocket /ws/streams/{id}/chat

Real-time chat connection

POST /api/v1/sessions/{id}/reactions

Send reactions to a stream

LiveKit SFU Integration

LiveKit Integration Benefits

  • Scalability: Support for 100+ concurrent participants
  • Recording: Built-in stream recording capabilities with customizable layouts and dimensions
  • Adaptive Streaming: Automatic quality adjustment
  • Professional Grade: Enterprise-ready infrastructure
  • Fallback Support: Automatic fallback to WebRTC if needed

Quick sanity-check tool: /livekit-test (connect → publish → remote subscribe)

Stream Recording with Custom Layouts

Overview

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.

Recording URL Parameters

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"
Layout Styles

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 tile
  • grid4x4: 4x4 grid layout with host as one tile
  • battle: Host and first participant side-by-side, others on sides
  • onebig: Host large rectangle, participants in strip below
  • active: Active speaker layout with dynamic highlighting
Example URLs
# 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
Features
  • Reactions Display: Shows all viewer reactions (❤️, ✨, 👍, 😮) during recording
  • Participant Tiles: All co-hosts and participants visible with proper layout
  • Layout Matching: Layout styles match iOS LiveStreamLayoutStyle for consistency
  • Frame Pinning: All elements pinned to window frame for consistent recording
  • Default Values: Uses system settings with fallback to hardcoded defaults
  • Real-time Updates: Layout refreshes when participants join/leave
  • Callback Handling: Proper handling of participant join/leave, track mute/unmute, and layout updates
Usage in Recording Service

The 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
💡 Automatic Usage

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.

Quick Start with LiveKit

// 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);
}

iOS LiveKit Integration

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)
        }
    }
}

API Endpoints for LiveKit ✅ UPDATED

  • GET /api/streaming/providers - Get available streaming providers
  • POST /api/streaming/sessions - Create streaming session
  • GET /api/streaming/sessions/{id} - Get streaming session details
  • POST /api/streaming/sessions/{id}/token - Generate LiveKit JWT access token
    • Session ID Support: Accepts broadcast session ID (UUID), short IDs (e.g., "r5"), or LiveKit room name (UUID format)
    • For Scheduled Events: Can accept room name directly - server checks scheduled event's livekit_room_name and broadcast's stream_id if broadcast session ID not found
    • Automatic Resolution: Server automatically resolves room names for scheduled events and broadcasts
  • GET /api/streaming/ice-config - Get ICE server configuration
  • GET /api/livekit/health - LiveKit server health check
  • GET /api/files/{id}/public - Public file access (for LiveKit Egress)
  • GET /egress-player - Egress player page for scheduled playback
  • GET /streams/{id}/record - Stream recording page with customizable layouts
    • Supports query parameters: width, height, resolution, orientation, layout
    • Used by LiveKit WebEgress for recording streams with participants and reactions
    • See Stream Recording section below for details
🔑 JWT Token Generation

Tokens are LiveKit-compatible and include:

  • Epoch timestamps: nbf, iat, exp as integers
  • Grants: roomJoin, room, canSubscribe, optional publish/data grants
  • Room-scoped: Tied to a specific session/room
  • API key required: Provide X-API-Key header

Server 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.

📁 Public File Access for LiveKit

LiveKit Egress and Ingress services need to access media files without authentication. The public file endpoint provides this capability.

Public File Endpoint
GET /api/files/{id}/public

Access files without authentication. Designed for LiveKit and external services.

No authentication required. Includes CORS headers for cross-origin access.
Usage in iOS SDK
// 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
⚠️ Security Considerations

The public file endpoint allows unauthenticated access. Use it only for:

  • Files intended for public sharing
  • Media files for scheduled playback
  • Files that LiveKit Egress/Ingress need to access

For sensitive files, use the authenticated /download endpoint instead.

🎬 Egress Player for Scheduled Playback

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 Endpoint
GET /egress-player

Serves HTML page that auto-plays media files for LiveKit Egress.

Query parameters: file (required, file URL), type (required, "audio" or "video")
How It Works
  1. LiveKit Egress opens a headless browser to /egress-player?file={fileURL}&type=video
  2. The HTML page auto-plays the media file in a loop
  3. LiveKit captures the rendered page as a video track
  4. The video track is published to the LiveKit room
  5. All users joining the room receive the synchronized stream
Example URL
https://www.pubfuse.com/egress-player?file=https://www.pubfuse.com/api/files/550e8400-e29b-41d4-a716-446655440000/public&type=video
💡 File URL Requirements

The file parameter must be a publicly accessible HTTP/HTTPS URL:

  • https://www.pubfuse.com/api/files/{id}/public
  • https://www.pubfuse.com/userfiles/{userId}/video.mp4
  • /api/files/{id}/download (requires authentication)

🔄 Retry Logic for File Access

Both server and client include retry logic to handle intermittent file access issues:

Server-Side Retry
  • Retries up to 3 times if file is missing on disk
  • Exponential backoff: 100ms, 200ms, 300ms delays
  • Handles file system race conditions and slow propagation
Client-Side Retry (iOS)
  • Retries network requests up to 3 times
  • Handles 404 (Not Found) errors specifically
  • Exponential backoff with cancellation error handling
  • Built into 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
    }
}

iOS Co-Host SDK Integration

Co-Host Features

  • Join as Co-Host: Viewers can request to become co-hosts
  • Multi-Host Grid: Automatic video tile management
  • Real-time Updates: Live participant management
  • Permission Control: Granular co-host capabilities
  • LiveKit Integration: Full SFU support with 100+ participants
  • Grid Layouts: Configurable video arrangements
  • Chat & Reactions: Real-time interaction
  • Analytics: Co-host performance metrics

Basic Co-Host Setup

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
    }
}

Co-Host Grid Management

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")
            }
        }
    }
}

LiveKit Room Delegate for Co-Hosts

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
        }
    }
}

Co-Host API Endpoints

POST /api/streaming/sessions/{id}/join-cohost

Request to join as co-host

GET /api/streaming/sessions/{id}/multihost

Get multi-host session information

PUT /api/streaming/sessions/{id}/cohosts/{coHostId}

Update co-host permissions

DELETE /api/streaming/sessions/{id}/cohosts/{coHostId}

Remove co-host from session

GET /api/streaming/sessions/{id}/metrics

Get multi-host session metrics

Data Models

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?
}

Complete Integration Example

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)
        }
    }
}

iOS Broadcast Lifecycle

Complete Broadcast Flow

  1. Create Broadcast - Creates database entry with status: pending
  2. Start LiveKit Publishing - Connects to LiveKit and starts streaming
  3. Set Status to Active - Updates broadcast to active (makes it discoverable)
  4. Send Heartbeats - Keep session alive every 30 seconds
  5. Update Viewer Count - Report viewer count every 5 seconds
  6. Set Status to Completed - When broadcast ends

Step 1: Create Broadcast

Create the broadcast entry in the database. This does NOT make it live yet.

📱 Device Information: When creating a broadcast, include 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.
POST /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
}

Step 2: Start LiveKit Publishing

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")
}

Step 3: Set Broadcast to Active ⚠️ CRITICAL

This is the step most iOS apps miss! Without this, your broadcast won't appear in the live streams list.

POST /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"
}

Step 4: Send Heartbeats

Keep the session alive by sending heartbeats every 30 seconds.

POST /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
}

Step 5: Update Viewer Count

Report viewer count every 5 seconds for analytics.

POST /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
}

Step 6: End Broadcast

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")
}

Complete Broadcast Manager Implementation

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)")
    }
}

iOS Broadcast Creation - How URLs Are Generated

How It Works on the Server (Unified Logic)

When you call POST /api/broadcasts, the server uses unified logic that is also used by scheduled events:

  1. Creates a Stream via StreamService.create():
    • Generates a new streamId (UUID) - This is the LiveKit room name
    • Generates a streamKey (UUID without dashes) for RTMP ingest
    • Creates rtmpUrl: Uses INGEST_RTMP env var
    • Creates hlsUrl: {PLAYBACK_HLS_BASE}/{streamId}/index.m3u8
  2. Creates a Broadcast Session in the database:
    • Saves streamId to broadcast_sessions.stream_id - This is critical!
    • The stream_id column stores the LiveKit room name
    • Links to a user (creates guest user if needed)
    • Sets initial status to "created"
    • Stores all URLs (rtmp_url, hls_url, watch_url)
  3. Returns Complete Response with all fields populated, including streamId

What You Get from 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"

Complete iOS Implementation

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
}

Complete Workflow Example

// 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")")
}

API Sequence Diagram

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  |
   |                                 |

Contacts & Follow Features

Contact Sync

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);

Smart Following

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;
};

Enhanced User Profiles

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
    };
};
Available Endpoints
  • POST /api/contacts/sync - Sync device contacts
  • GET /api/contacts - Get user's contacts
  • GET /api/contacts/:id - Get specific contact
  • POST /api/contacts/:id/follow - Follow contact
  • GET /api/users/profile/full - Full profile
  • GET /api/users/search - Search users
Privacy & Security
  • Contacts are private to each user
  • JWT authentication required
  • Phone numbers normalized securely
  • No cross-user data sharing
  • Auto-follow can be disabled

File Management

File Upload & Management

Upload files with automatic size variants and rich metadata. Files are organized by user, category, and tags for easy retrieval.

Key Features
  • Multiple Sizes: Automatic thumbnail, small, medium, large, and original versions
  • Rich Metadata: Name, description, tags, location, category
  • Contextual Files: Link files to users, contacts, messages, calls, or broadcasts
  • MIME Type Support: Works with images, videos, audio, documents
  • Quick Access: Specialized endpoints for profile images and contact photos
Privacy & Security
  • User-owned files (isolated by user ID)
  • JWT authentication required
  • Cascade delete on user removal
  • Optional contact-linked files
  • Flexible categorization and tagging

JavaScript/Node.js Examples

// 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 Endpoints

POST /api/files

Upload/create a new file record

GET /api/files

Get all files for authenticated user

GET /api/files/foruser/{userId}

Get files for a user with optional category/tag filters

GET /api/files/profileimage/foruser/{userId}

Quick access to user's profile image

GET /api/files/contactimage/foruser/{userId}

Quick access to contact's image

GET /api/files/forcontact/{contactId}

Get all files for a contact

GET /api/files/formessage/{messageId}

Get all files for a message

GET /api/files/forcall/{callId}

Get all files for a call

GET /api/files/forbroadcast/{broadcastId}

Get all files for a broadcast

PUT /api/files/{id}

Update file metadata

DELETE /api/files/{id}

Delete a file

Scheduled Events

Key Features

  • Schedule Content: Schedule video or audio content to play at specific start and end times
  • Automatic Playback: Events automatically start and stop based on their schedule
  • Repeat Modes: Support for none, until_end, daily, weekly, and hourly repeat modes
  • Content Upload: Upload video or audio files to be played during the event
  • Sync Playback: Synchronized playback across all viewers using server-side sync
  • Notifications: Automatic push notifications to followers when events start
  • Broadcast Sessions: Events create broadcast sessions when they start

iOS SDK Usage

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 Endpoints

GET /api/events

Get all scheduled events (public)

Query params: limit, offset, status
GET /api/events/:id

Get a specific event by ID (public)

GET /api/events/:id/sync

Get event sync information for synchronized playback (public)

POST /api/events/schedule

Schedule a new event (requires authentication)

PUT /api/events/:id

Update an existing event (requires authentication, owner only)

DELETE /api/events/:id

Delete an event (requires authentication, owner only)

POST /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.
POST /api/events/:id/start-video

Manually start event playback (requires authentication, owner only)

Creates broadcast session, sends notifications, and starts LiveKit Egress.
POST /api/events/:id/takeover

Take over an active event stream (requires authentication, owner only)

Event Status

  • scheduled: Event is scheduled but not yet started
  • active: Event is currently playing
  • completed: Event has finished
  • cancelled: Event was cancelled

Repeat Modes

  • none: Event plays once and stops
  • until_end: Content loops until scheduled end time
  • daily: Event repeats daily at the same time
  • weekly: Event repeats weekly on the same day
  • hourly: Event repeats every hour

repeatUntilEnd: When true and repeatMode is "until_end", the content will loop continuously until the scheduled end time is reached.

Synchronized Playback

Scheduled events support synchronized playback across all viewers. The server tracks the current playback position and all clients sync to this position.

Synchronization Algorithm

The sync algorithm ensures all viewers see the same content at the same time:

  1. Server Position Calculation: The server calculates the current playback position using:
    currentPosition = initialPosition + (now - playbackStartedAt)
  2. Client Sync: Clients poll the sync endpoint every 2 seconds to get the server's current position
  3. Position Adjustment: Clients compare their local position with the server position:
    • Small drift (< 0.5s): Ignored (within acceptable range)
    • Medium drift (0.5-2s): Smooth adjustment with tolerance for gradual seek
    • Large drift (> 2s): Immediate sync with zero tolerance for instant seek
  4. Network Latency: The algorithm accounts for network latency by using tolerance-based seeking for smaller adjustments
  5. Clock Drift: Regular polling (every 2 seconds) helps compensate for clock drift between devices
Implementation Details
  • Use GET /api/events/:id/sync to get the current sync position
  • Poll every 2 seconds to stay in sync
  • Adjust player position if it drifts more than 0.5 seconds from server position
  • Sync position is calculated based on playbackStartedAt and currentPlaybackPosition
  • For late joiners, the player automatically seeks to the current server position when ready

Repeat Modes - Detailed Explanation

Scheduled events support multiple repeat modes for flexible scheduling:

Repeat Mode Types
  • none: Event plays once and stops at the scheduled end time. No repetition.
  • until_end: Content loops continuously from the beginning when it ends, continuing until the scheduled end time is reached. Requires repeatUntilEnd: true to enable looping.
  • daily: Event repeats daily at the same time. A new event instance is created for each day.
  • weekly: Event repeats weekly on the same day and time. A new event instance is created for each week.
  • hourly: Event repeats every hour at the same minute. A new event instance is created for each hour.
Repeat Behavior
  • Content Looping: When 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.
  • Event Rescheduling: For daily, weekly, and hourly modes, the scheduler automatically creates new event instances for future occurrences.
  • Timezone Handling: All repeat calculations respect the event's original timezone and handle daylight saving time transitions.

Automatic Notifications

When an event starts (either automatically or manually), the system will:

  • Create a broadcast session for the event
  • Send push notifications to all followers of the event creator
  • Start LiveKit Egress for server-side video streaming

How Scheduled Playback Works

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.

Architecture Overview

The scheduled playback system uses LiveKit Egress to stream media files into LiveKit rooms:

  1. File Upload: Upload your video/audio file using POST /api/events/:id/upload
  2. Event Scheduling: Create a scheduled event with start and end times
  3. Automatic Start: When the scheduled time arrives, the system:
    • Creates a broadcast session
    • Starts LiveKit Egress to stream the file
    • Publishes the stream to a LiveKit room (format: event-{eventId})
    • Sends notifications to followers
  4. User Joining: Users can join the LiveKit room to watch the synchronized playback
  5. Egress Player: LiveKit uses the /egress-player endpoint to render and capture the media file
LiveKit Egress Integration

When playback starts, LiveKit Egress:

  • Opens a headless browser to the egress player page: /egress-player?file={fileURL}&type={contentType}
  • The egress player page auto-plays the media file in a loop
  • LiveKit captures the rendered page and publishes it as a video track to the room
  • All users joining the room receive the synchronized video stream
File Access Requirements

For LiveKit Egress to access your files, they must be publicly accessible. Use one of these methods:

  • Public File Endpoint: GET /api/files/{id}/public - No authentication required
  • Direct Userfiles URL: Files in /userfiles/{userId}/ are served publicly
  • Authenticated Download: GET /api/files/{id}/download - Requires JWT token (not suitable for Egress)
⚠️ Important: File Accessibility

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.

Example: Complete Scheduled Playback Flow
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)
Egress Player Endpoint

The /egress-player endpoint serves an HTML page that plays media files for LiveKit WebEgress:

  • URL Format: /egress-player?file={fileURL}&type={contentType}
  • Parameters:
    • file (required): HTTP/HTTPS URL of the media file
    • type (required): Content type - "audio" or "video"
  • Features:
    • Auto-plays the media file
    • Loops continuously
    • Muted by default (required for autoplay)
    • Includes CORS headers for LiveKit access
💡 Pro Tip: Testing Scheduled Playback

To test scheduled playback immediately, you can:

  1. Create an event with a future start time
  2. Update the event's scheduledStartTime to the current time
  3. Call POST /api/events/:id/start-video to manually start playback

Code Examples

JavaScript/Node.js

class 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));

Python

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)

API Integration (Backend)

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);
  }
});

Watch View Flow (LiveKit)

This is what the web watch page does at runtime. Mirror this sequence for mobile clients.

  1. Extract session/stream ID: from the URL (UUID or short ID like r5).
  2. Load LiveKit SDK: wait until window.LiveKit is available.
  3. Choose provider: call GET /api/streaming/providers and select id === "livekit".
  4. Request token: POST /api/streaming/sessions/{id}/token with X-API-Key, role subscriber, and a UUID userId.
  5. Connect Room: pass serverUrl and the token (keep the livekit_ prefix) to the SDK Room.connect().
  6. Render tracks: on TrackSubscribed, attach remote video/audio to the player.
  7. Realtime events: reactions/chat are emitted over the data path; self-echo is ignored and duplicates are deduped on client.
// 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
}
  • Token: keep the livekit_ prefix; integer nbf/iat/exp; grants include roomJoin and room.
  • Provider: WebRTC fallback is disabled on watch; LiveKit is the primary.
  • Stability: client dedupes reactions and ignores self-echo; renegotiation is handled safely.

Mobile Implementation (iOS & Android)

Use your backend to mint the token, then connect the SDK on device.

iOS (Swift)
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
    }
}
Android (Kotlin)
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
}
Note: Do not strip the livekit_ prefix from the token. The server includes integer nbf/iat/exp and grants (e.g., roomJoin, room, canSubscribe).

Swift (iOS)

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)")
    }
}

Best Practices

Security
  • Never expose secret keys in client-side code
  • Use HTTPS for all API communications
  • Implement HMAC signature authentication
  • Rotate API keys periodically
  • Validate all input data
Performance
  • Implement proper error handling
  • Use connection pooling
  • Cache frequently accessed data
  • Implement retry logic with backoff
  • Monitor API usage and limits
Rate Limiting

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;
        }
    }
}

iOS Implementation Guide

Contacts Sync Implementation

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
}

Follow/Unfollow Implementation

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
}

User Search Implementation

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
}

File Management Implementation

📸 Broadcast Poster Images

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)")
            }
        }
    }
}
💡 Tips:
  • Use filePathThumbnail or filePathSmall for list views (faster loading)
  • Use filePathMedium or filePathLarge for detail views
  • Always use CachedAsyncImage with .id() modifier for proper refresh behavior
  • Poster images are automatically associated with broadcasts via forBroadcastId
  • Multiple image sizes are generated automatically on the server (thumbnail, small, medium, large, original)
📤 Uploading Poster from GoLiveView

Allow 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)")
                }
            }
        }
    }
}

Model Context Protocol (MCP) Server

🤖 AI Assistant Integration

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!

What is MCP?

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.

Base URL

All MCP endpoints are available at:

http://localhost:8080/mcp
# or in production:
https://www.pubfuse.com/mcp

Available Endpoints

POST /mcp/initialize

Initialize MCP connection. Returns server capabilities and version info.

GET /mcp/tools/list

List all available MCP tools (API operations).

curl http://localhost:8080/mcp/tools/list
POST /mcp/tools/call

Execute an MCP tool with parameters.

{
  "params": {
    "name": "get_user",
    "arguments": {
      "userId": "user-id-here"
    }
  }
}
GET /mcp/resources/list

List all available MCP resources (data sources).

GET /mcp/resources/read?uri=pubfuse://active-streams

Read a specific resource by URI.

Available Tools

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 ID
  • search_users - Search for users by query
  • get_user_followers - Get user's followers
  • get_user_following - Get users a user follows

  • get_stream - Get stream details by ID
  • list_streams - List all active streams
  • get_stream_viewers - Get stream viewer count and list
  • get_stream_chat - Get chat messages for a stream

  • get_broadcast_status - Get current broadcast status

  • send_notification - Send push notification to a user
  • get_notifications - Get user notifications (requires auth token)

  • get_stream_metrics - Get analytics/metrics for a stream
  • get_user_dashboard - Get user dashboard data (requires auth token)

Available Resources

<div class="list-group"> <div class="list-group-item"> <h5><code>pubfuse://api-docs</code></h5> <p class="mb-1"><strong>Pubfuse API Documentation</strong></p> <p class="mb-0 text-muted">OpenAPI specification for Pubfuse API</p> <small class="text-muted">MIME Type: application/json</small> </div> <div class="list-group-item"> <h5><code>pubfuse://active-streams</code></h5> <p class="mb-1"><strong>Active Streams</strong></p> <p class="mb-0 text-muted">List of currently active streams</p> <small class="text-muted">MIME Type: application/json</small> </div> <div class="list-group-item"> <h5><code>pubfuse://system-status</code></h5> <p class="mb-1"><strong>System Status</strong></p> <p class="mb-0 text-muted">Current system health and status</p> <small class="text-muted">MIME Type: application/json</small> </div></div>

Authentication

MCP endpoints support JWT authentication from username/password login. You can authenticate in two ways:

  1. Authorization Header (Recommended): Include the JWT token in the Authorization: Bearer <token> header
  2. Token Parameter: Some tools accept a token 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
      }
    }
  }'

Example Usage

# 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"
✨ Auto-Generated Documentation

The MCP tools list is automatically generated from the MCPController. When you add new tools to the controller, they automatically appear in the documentation!

Troubleshooting

Common Issues

Error: 401 Unauthorized - Invalid API key

Solution:

  • Verify your API key is correct
  • Check that the key is properly included in the X-API-Key header
  • Ensure your SDK Client is active in the admin dashboard

Error: 429 Too Many Requests

Solution:

  • Implement exponential backoff retry logic
  • Cache responses when possible
  • Contact support for higher rate limits if needed

Error: 401 Unauthorized - Invalid signature

Solution:

  • Verify the signature generation algorithm
  • Check that the timestamp is within 5 minutes
  • Ensure the payload string matches exactly
  • Verify your secret key is correct

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:

  • The SDK now includes automatic retry logic with exponential backoff
  • Files are retried up to 3 times with delays of 100ms, 200ms, 300ms
  • Use the public file endpoint for LiveKit: /api/files/{id}/public
  • Ensure files are fully uploaded before accessing them

Implementation: 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:

  • Use the public file endpoint: GET /api/files/{id}/public
  • This endpoint doesn't require authentication and includes CORS headers
  • Files in /userfiles/{userId}/ are also publicly accessible
  • For scheduled playback, ensure files are uploaded to userfiles directory
// 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:

  • Verify the stream is actively broadcasting
  • Check network connectivity (WiFi/cellular)
  • SDK automatically falls back to HLS if WebSocket fails
  • Enable debug logging: let sdk = PubfuseSDK(configuration: config, debug: true)
  • Check server status and WebSocket endpoint availability

Issue: Scheduled event doesn't start automatically

Solution:

  • Verify the event has a content file uploaded
  • Check that the file URL is publicly accessible
  • Ensure scheduledStartTime is in the future (or current time for immediate start)
  • Use GET /api/events/:id/debug to check event status and LiveKit Egress status
  • Manually start playback: POST /api/events/:id/start-video
  • Check LiveKit server health: GET /api/livekit/health

Error: 401 Unauthorized when requesting LiveKit token

Solution:

  • Ensure X-API-Key header is included in the request
  • Verify your API key is correct and active
  • Check that the session ID exists and is valid
  • Verify userId is a valid UUID format
  • Ensure role 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)

Symptoms:
  • Mobile app shows "No room connected" for scheduled events
  • Web version connects successfully to the same event
  • Server logs show "Room not found" or "HTTP 404" errors
  • Different room names between mobile and web versions
Root Cause:

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.

Solution (iOS):
// 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)
Common Mistakes:
  • ❌ Using event.broadcastSessionId as sessionId - This is wrong!
  • ❌ Not caching the stream before connecting
  • ❌ Refetching stream without preserving original sessionId
Debug Steps:
  1. Check event's livekitRoomName: GET /api/events/:id/debug
  2. Verify stream's sessionId matches event's livekitRoomName
  3. Check server logs for token generation - should show room name being used
  4. Compare room names between mobile app debug info and web version
Note: The server automatically handles room name lookup. When requesting a token with a room name, it checks scheduled events and broadcast stream_id to find the correct room.

Debug Mode

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;
    }
}

Need Help?

If you need assistance integrating the Pubfuse SDK, we're here to help!

Email Support

[email protected]

API Documentation

Swagger API Docs

OpenAPI Spec

Download OpenAPI Spec