Article 2026-04-18

How Agency Phone Handles Contact Sync & Data

TDYSKY

TDYSKY

Founder & Lead Developer at Agency Scripts

The Challenge of Phone Data in FiveM

Building a phone system for FiveM involves far more than rendering a pretty UI on the player's screen. The real engineering challenge is managing persistent data across sessions: contacts, message threads, call logs, photos, and app settings all need to survive server restarts, character switches, and multi-character environments. Agency Phone was designed from the ground up with data integrity as a core principle. Every piece of data flows through a server-authoritative pipeline where the client requests actions and the server validates, processes, and persists them before confirming back to the client. This article pulls back the curtain on how Agency Phone handles contact synchronization, message storage, photo sharing, call logging, and privacy by design so that developers and server owners understand the architecture behind the product.

Contact Storage Architecture

Contacts in Agency Phone are stored per-character, not per-player. This distinction matters because a player might have three characters on the same server, each with completely different social circles. The contact table uses a composite key of the owner's phone number and the contact's phone number, with additional fields for the display name, avatar URL, and a favorite flag. When a player opens their contacts app, the client sends a single request to the server, which queries the database and returns the full contact list in one batch. This avoids the waterfall pattern where each contact triggers its own database query, which would be devastating on a server with players who have hundreds of contacts.

-- How contacts are structured internally
-- Each contact belongs to a specific phone number (character)
local contactSchema = {
    owner_number = 'string',    -- the character's phone number
    contact_number = 'string',  -- the saved contact's number
    display_name = 'string',    -- custom name set by player
    avatar = 'string|nil',      -- optional avatar URL
    is_favorite = 'boolean',    -- pinned to top of list
    created_at = 'timestamp',   -- when contact was added
}

-- Server: fetch all contacts for a character
lib.callback.register('phone:contacts:getAll', function(source)
    local phoneNumber = GetPlayerPhoneNumber(source)
    if not phoneNumber then return {} end

    local contacts = MySQL.query.await([[
        SELECT contact_number, display_name, avatar, is_favorite
        FROM phone_contacts
        WHERE owner_number = ?
        ORDER BY is_favorite DESC, display_name ASC
    ]], { phoneNumber })

    return contacts or {}
end)

Real-Time Contact Sync

When a player adds, edits, or deletes a contact, the change needs to be reflected immediately on their device and persisted to the database. Agency Phone uses an optimistic update pattern: the client immediately updates its local state to give instant feedback, while simultaneously sending the mutation to the server. If the server rejects the change due to validation failure, the client rolls back to the previous state and displays an error. This creates a responsive user experience that feels native while maintaining server authority over the data. The server also broadcasts relevant changes to other connected clients when necessary, such as when a player updates their own profile name that appears in other players' contact lists.

-- Server: add a new contact with validation
lib.callback.register('phone:contacts:add', function(source, data)
    local phoneNumber = GetPlayerPhoneNumber(source)
    if not phoneNumber then return { success = false, error = 'NO_PHONE' } end

    -- Validate the contact number exists in the system
    local numberExists = MySQL.scalar.await(
        'SELECT COUNT(*) FROM phone_numbers WHERE number = ?',
        { data.contact_number }
    )
    if numberExists == 0 then
        return { success = false, error = 'NUMBER_NOT_FOUND' }
    end

    -- Prevent duplicate contacts
    local existing = MySQL.scalar.await(
        'SELECT COUNT(*) FROM phone_contacts WHERE owner_number = ? AND contact_number = ?',
        { phoneNumber, data.contact_number }
    )
    if existing > 0 then
        return { success = false, error = 'ALREADY_EXISTS' }
    end

    -- Insert the contact
    MySQL.insert.await([[
        INSERT INTO phone_contacts (owner_number, contact_number, display_name, avatar)
        VALUES (?, ?, ?, ?)
    ]], { phoneNumber, data.contact_number, data.display_name, data.avatar })

    return { success = true }
end)

Message Storage and Threading

Messages are the most data-intensive feature of any phone system. Agency Phone organizes messages into conversation threads identified by a sorted pair of phone numbers. This means the conversation between number A and number B always maps to the same thread regardless of who initiated it. Messages within a thread are stored chronologically with sender identification, read status, and optional attachments. The threading model also supports group messages where three or more numbers participate in a shared conversation. Group threads use a separate identifier generated when the group is created, and each member maintains their own read pointer so unread counts are accurate per participant.

-- Message thread resolution
-- Ensures A->B and B->A map to the same conversation
local function GetThreadId(number1, number2)
    -- Sort numbers to create a deterministic thread ID
    local sorted = { number1, number2 }
    table.sort(sorted)
    return sorted[1] .. ':' .. sorted[2]
end

-- Server: send a message
lib.callback.register('phone:messages:send', function(source, data)
    local senderNumber = GetPlayerPhoneNumber(source)
    if not senderNumber then return { success = false } end

    local threadId = GetThreadId(senderNumber, data.to)

    local messageId = MySQL.insert.await([[
        INSERT INTO phone_messages (thread_id, sender_number, recipient_number, content, attachment, sent_at)
        VALUES (?, ?, ?, ?, ?, NOW())
    ]], { threadId, senderNumber, data.to, data.content, data.attachment })

    -- Notify recipient if online
    local recipientSource = GetPlayerByPhoneNumber(data.to)
    if recipientSource then
        TriggerClientEvent('phone:messages:receive', recipientSource, {
            id = messageId,
            thread_id = threadId,
            sender = senderNumber,
            sender_name = GetContactName(data.to, senderNumber),
            content = data.content,
            attachment = data.attachment,
            sent_at = os.time()
        })
    end

    return { success = true, id = messageId }
end)

Photo Sharing and Media Handling

Photo sharing in a FiveM phone requires a different approach than traditional web applications because you cannot directly access the player's filesystem. Agency Phone handles photos through two mechanisms: in-game screenshots captured using the GTA screenshot functionality, and URL-based images that players paste from external image hosting services. In-game screenshots are taken using the native screenshot API, converted to a data URL, and uploaded to a configurable storage backend. The server validates file size limits and content type before persisting the URL. When photos are shared in messages, only the URL reference is stored in the message record, keeping the messages table lean. The actual image data lives in the media storage backend, which can be configured to use local disk, S3-compatible storage, or an external image CDN depending on the server owner's infrastructure.

-- Server: handle photo upload from in-game camera
lib.callback.register('phone:photos:upload', function(source, imageData)
    local phoneNumber = GetPlayerPhoneNumber(source)
    if not phoneNumber then return { success = false } end

    -- Validate size (max 2MB base64)
    if #imageData > 2 * 1024 * 1024 * 1.37 then
        return { success = false, error = 'FILE_TOO_LARGE' }
    end

    -- Generate unique filename
    local filename = ('%s_%s.jpg'):format(phoneNumber, os.time())

    -- Store via configured backend (webhook, local, S3)
    local url = StorageBackend:upload(filename, imageData)
    if not url then
        return { success = false, error = 'UPLOAD_FAILED' }
    end

    -- Save photo reference in gallery
    MySQL.insert.await([[
        INSERT INTO phone_photos (owner_number, url, created_at)
        VALUES (?, ?, NOW())
    ]], { phoneNumber, url })

    return { success = true, url = url }
end)

Call Logs and History

Call logs record every incoming, outgoing, and missed call with timestamps and duration. When a player initiates a call, a call record is created with a status of "dialing". If the recipient answers, the status updates to "active" and a start timestamp is recorded. When the call ends, the duration is calculated and the record is finalized. Missed calls occur when the recipient does not answer within the timeout period or explicitly declines. The call log is displayed in the phone's recents tab with visual indicators for call direction and status. Players can tap a missed call entry to immediately call back, or long-press to add the number to contacts. The server prunes call logs older than a configurable retention period, defaulting to 30 days, to prevent the table from growing indefinitely on long-running servers.

-- Server: create and manage call records
local activeCalls = {}

function StartCallRecord(callerNumber, receiverNumber)
    local callId = MySQL.insert.await([[
        INSERT INTO phone_calls (caller_number, receiver_number, status, started_at)
        VALUES (?, ?, 'dialing', NOW())
    ]], { callerNumber, receiverNumber })

    activeCalls[callId] = {
        caller = callerNumber,
        receiver = receiverNumber,
        answeredAt = nil
    }
    return callId
end

function AnswerCall(callId)
    if not activeCalls[callId] then return end
    activeCalls[callId].answeredAt = os.time()
    MySQL.update.await(
        'UPDATE phone_calls SET status = ?, answered_at = NOW() WHERE id = ?',
        { 'active', callId }
    )
end

function EndCall(callId)
    local call = activeCalls[callId]
    if not call then return end

    local duration = call.answeredAt and (os.time() - call.answeredAt) or 0
    local status = call.answeredAt and 'completed' or 'missed'

    MySQL.update.await(
        'UPDATE phone_calls SET status = ?, duration = ?, ended_at = NOW() WHERE id = ?',
        { status, duration, callId }
    )
    activeCalls[callId] = nil
end

Privacy by Design

Agency Phone follows privacy-by-design principles throughout its architecture. Phone numbers are generated randomly and are not tied to any real-world identifier. Message content is stored in the database but is only accessible to the sender and recipient through validated server callbacks. There is no global message search that an admin could use to read private conversations without explicit database access. Contact lists are strictly per-character with no cross-character data leakage. When a character is deleted, all associated phone data including contacts, messages, call logs, and photos are cascade-deleted from the database, ensuring no orphaned personal data remains. The photo upload system strips EXIF metadata before storage to prevent unintentional location or device information leaks, though this is more of a best practice than a practical concern in a game environment.

Performance at Scale

Agency Phone is tested and optimized for servers with 200 or more concurrent players. The key performance strategies include lazy loading message threads so only the most recent conversations are fetched on phone open, with older threads loaded on scroll. Contact lists are cached client-side after the initial fetch and only refreshed when a mutation occurs. Database queries use proper indexes on phone numbers and timestamps to ensure sub-millisecond lookups even on tables with millions of rows. The server maintains an in-memory map of online player phone numbers for instant recipient lookups without database hits. All NUI communication is batched where possible, so opening the messages app triggers one server request that returns threads with their latest message preview rather than making separate requests for each thread. These optimizations ensure the phone remains responsive even during peak server hours when dozens of players are simultaneously sending messages and making calls.

Share this article

Ready to upgrade your server?

Check out our premium FiveM scripts in the Agency Scripts store or join our Discord community for support and updates.