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: WebSocket-based messaging
  • 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.

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."}'

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

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
  • POST /api/streaming/sessions/{id}/token - Generate LiveKit JWT access token
  • GET /api/streaming/ice-config - Get ICE server configuration
  • GET /api/livekit/health - LiveKit server health check
🔑 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

Server returns token prefixed with livekit_; pass it unchanged to the SDK.

Short IDs (e.g., r5) are accepted and resolved to UUIDs.

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.

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")
    
    let broadcastRequest = CreateBroadcastRequest(
        title: title,
        visibility: "public",
        tags: ["live", "broadcast"],
        metadata: [
            "createdBy": "iOS-App",
            "clientId": UUID().uuidString,
            "deviceModel": UIDevice.current.model
        ]
    )
    
    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")
        
        let broadcastRequest = CreateBroadcastRequest(
            title: title,
            visibility: "public",
            tags: ["live", "broadcast"],
            metadata: [
                "createdBy": "iOS-App",
                "deviceModel": UIDevice.current.model
            ]
        )
        
        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

When you call POST /api/broadcasts, the server:

  1. Creates a Stream via StreamService.create():
    • Generates a new streamId (UUID)
    • Generates a streamKey (UUID without dashes)
    • Creates rtmpUrl: Uses INGEST_RTMP env var
    • Creates hlsUrl: {PLAYBACK_HLS_BASE}/{streamId}/index.m3u8
  2. Creates a Broadcast Session in the database:
    • Stores the streamId from step 1
    • Links to a user (creates guest user if needed)
    • Sets initial status to "created"
    • Stores all URLs
  3. Returns Complete Response with all fields populated

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) Stream/Room ID Use this to connect to LiveKit room!
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

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

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

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