Skip to content

Server Exports

The phone provides server-side exports for other resources: resolving phone numbers, pushing notifications, sending messages and mail, starting calls, and reading contacts, groups, and app data. Exports that act on a player's behalf take the acting player's source and walk the exact same validation as the phone's own UI, so a sloppy caller cannot corrupt a mailbox, mint a payment card, or plant an invalid contact.

INFO

Several mutating exports return the phone's standard envelope shape: { success = boolean, message = string?, data = table? }. Where a return below says envelope, this is that shape.

The first group of exports resolves phone numbers, owners, and connectivity. Every character has exactly one number.

Numbers and identity

getPhoneNumber

Get a player's phone number by server ID, assigning one on first access.

Syntax

lua
local number = exports['sd-phone']:getPhoneNumber(source)
ParameterTypeDescription
sourcenumberThe player's server ID
ReturnTypeDescription
numberstring?The raw-digit phone number, or nil when the source does not resolve to a loaded character

Example

lua
RegisterCommand('mynumber', function(source)
    local number = exports['sd-phone']:getPhoneNumber(source)
    if number then
        TriggerClientEvent('chat:addMessage', source, { args = { 'Phone', 'Your number is ' .. number } })
    end
end)

getPhoneNumberByIdentifier

Get a character's phone number straight from a citizenid, for resources that hold identifiers rather than server IDs. Works for offline characters.

Syntax

lua
local number = exports['sd-phone']:getPhoneNumberByIdentifier(citizenid, ensure)
ParameterTypeDescription
citizenidstringThe character's framework identifier
ensureboolean?Pass true to assign a number on first access, the way getPhoneNumber does. Otherwise a never-assigned character yields nil
ReturnTypeDescription
numberstring?The raw-digit phone number, or nil for a malformed citizenid or a never-assigned character without ensure

Example

lua
-- Look up an offline employee's number before texting them
local number = exports['sd-phone']:getPhoneNumberByIdentifier(citizenid)

getIdentifierByNumber

Get the citizenid that owns a phone number. Both sides are digit-normalized, so any formatting matches.

Syntax

lua
local citizenid = exports['sd-phone']:getIdentifierByNumber(number)
ParameterTypeDescription
numberstringA phone number in any formatting
ReturnTypeDescription
citizenidstring?The owning character's citizenid, or nil when the number is unassigned

Example

lua
local citizenid = exports['sd-phone']:getIdentifierByNumber('555-0142')
if citizenid then
    -- pay the number's owner even while they are offline
end

getSourceByNumber

Get the connected server ID of the player that owns a phone number.

Syntax

lua
local playerId = exports['sd-phone']:getSourceByNumber(number)
ParameterTypeDescription
numberstringA phone number in any formatting
ReturnTypeDescription
playerIdnumber?The owner's server ID, or nil when the number is unassigned or its owner is offline

Example

lua
local playerId = exports['sd-phone']:getSourceByNumber(customerNumber)
if playerId then
    -- the customer is online, deliver the order in person
end

isNumberInService

Check whether a phone number is assigned to any character. Useful for validating user-supplied numbers before sending anything at them.

Syntax

lua
local inService = exports['sd-phone']:isNumberInService(number)
ParameterTypeDescription
numberstringA phone number in any formatting
ReturnTypeDescription
inServicebooleantrue when the number is assigned to a character. Empty or digitless input is false

Example

lua
if not exports['sd-phone']:isNumberInService(input) then
    return notifyPlayer(source, 'That number is not in service.')
end

isAirplaneMode

Check whether a player currently has airplane mode switched on.

Syntax

lua
local on = exports['sd-phone']:isAirplaneMode(source)
ParameterTypeDescription
sourcenumberThe player's server ID
ReturnTypeDescription
onbooleantrue when airplane mode is on. An unresolvable source reads as false

INFO

The value is served from an in-memory cache after the first read, so it is cheap enough to call once per routed message or call.

Example

lua
if not exports['sd-phone']:isAirplaneMode(target) then
    -- safe to ring them
end

The notification exports push iOS-style banners onto a player's phone.

Cell service

Cell service reflects where a player is standing relative to the configured masts in configs/celltowers.lua. The level is recomputed from the server's own view of the player each time you ask, so these readings are authoritative rather than client-reported. When the tower system is switched off every reading reports full service.

getServiceLevel

A player's current cell service, from 0.0 (dead zone) to 1.0 (at a mast).

Syntax

lua
exports['sd-phone']:getServiceLevel(source)

Parameters

ParameterTypeDescription
sourcenumberPlayer server ID

Returns

FieldTypeDescription
levelnumber0.0 to 1.0. 1.0 when no masts are configured, or when the player cannot be resolved

Example

lua
local level = exports['sd-phone']:getServiceLevel(source)
if level == 0.0 then
    print('player is in a dead zone')
end

hasService

Whether a player can currently use a capability.

Syntax

lua
exports['sd-phone']:hasService(source, capability)

Parameters

ParameterTypeDescription
sourcenumberPlayer server ID
capabilitystring?'text', 'call' or 'data'. Defaults to 'data'

Returns

FieldTypeDescription
allowedbooleantrue when the player's signal clears that capability's threshold

Example

lua
if not exports['sd-phone']:hasService(source, 'call') then
    return 'They have no signal out there.'
end

TIP

The phone already applies this to its own calls and texts. Reach for it when your own script wants to behave differently in a dead zone, such as a dispatch that refuses a callout.

getCellTowers

Every configured mast with its coverage radius.

Syntax

lua
exports['sd-phone']:getCellTowers()

Returns

FieldTypeDescription
towerstable[]{ tower = vector3, range = number }, mirroring configs/celltowers.lua

Empty while the system is switched off. The table is rebuilt on every call, so mutating it never touches the running config.

INFO

The lb-phone compatibility export GetCellTowers returns bare vector3 values with no ranges, matching lb-phone's own config shape. This one keeps the ranges.

Wi-Fi

Wi-Fi covers the local networks in configs/wifi.lua, which carry data where the masts do not reach. Every export here re-derives the player's position from the server's own view before it answers, and clears a connection the player has walked out of, so a reading is never something a client asserted. When the system is switched off nobody is ever connected.

isOnWifi

Whether a player is on a Wi-Fi network right now.

Syntax

lua
exports['sd-phone']:isOnWifi(source)

Parameters

ParameterTypeDescription
sourcenumberPlayer server ID

Returns

FieldTypeDescription
connectedbooleantrue while the player is joined to a network and still inside it

getWifi

The network a player is connected to.

Syntax

lua
exports['sd-phone']:getWifi(source)

Parameters

ParameterTypeDescription
sourcenumberPlayer server ID

Returns

FieldTypeDescription
idstring?Network id from configs/wifi.lua, nil when on none

hasWifiAccess

Whether a player is connected to that network specifically, range included.

Syntax

lua
exports['sd-phone']:hasWifiAccess(source, id)

Parameters

ParameterTypeDescription
sourcenumberPlayer server ID
idstringNetwork id from configs/wifi.lua

Returns

FieldTypeDescription
allowedbooleantrue only when that player is on that network

Example

lua
if not exports['sd-phone']:hasWifiAccess(source, 'mazebank') then
    return 'You have to be on the bank network to do that.'
end

TIP

This is the check that backs Wi-Fi-locked app downloads, and it is the right call for anything of your own that should only work inside one building. The player's position is re-read server-side on every call, so a modified client cannot talk its way past it.

getWifiNetworks

Every configured network with its position and radius.

Syntax

lua
exports['sd-phone']:getWifiNetworks()

Returns

FieldTypeDescription
networkstable[]{ id, ssid, coords, range, secured }, mirroring configs/wifi.lua

Empty while the system is switched off. The tables are rebuilt on every call, so mutating the result never touches the running config.

WARNING

secured is the only password-derived value here. A network's password stays inside the Wi-Fi module, is compared only against a player the server has itself placed inside the radius, and is never part of any export or any message to a client.

INFO

The matching client exports answer from the phone's own scan and are meant for UI. These are the authoritative ones. Gate on the server exports and use the client ones to describe the situation to the player.

Bluetooth

Bluetooth is a registry rather than a config: another resource declares a device, and phones nearby can pair with it. A boombox, a car stereo, a headset and a smartwatch are all the same thing here, so what a connection means is entirely the owning resource's to decide. The phone only answers who is connected to what.

A device belongs to the resource that registered it. Stopping that resource unregisters its devices and disconnects everyone on them, so nothing is ever left pointing at a script that is gone.

Positions are re-derived from the server's own view of the player, never asserted by a client.

registerBluetoothDevice

Registers a device phones can pair with.

Syntax

lua
exports['sd-phone']:registerBluetoothDevice(device)

Device

FieldTypeDescription
idstringUnique id, up to 64 characters. Registering an id twice is refused rather than overwritten
namestringName the phone shows in its device list
kindstring?vehicle, audio, headset, wearable or device. Picks the icon; defaults to device
coordsvector3?Where the device sits. Required unless entity is given
entitynumber?Network id of an entity the device follows, for a prop or a vehicle. Falls back to coords when the entity has despawned
rangenumber?Metres it reaches, defaulting to 10.0. A true sphere, so height counts
maxConnectionsnumber?Phones it accepts at once, defaulting to 1. 0 means unlimited
onConnectfunction?(source, deviceId) when a phone connects
onDisconnectfunction?(source, deviceId, reason) when one goes away

Returns

FieldTypeDescription
okbooleanfalse when the registration was rejected
errstring?Why it was rejected

Example

lua
exports['sd-phone']:registerBluetoothDevice({
    id             = 'boombox_pier',
    name           = 'Pier Boombox',
    kind           = 'audio',
    coords         = vector3(-1850.2, -1230.7, 13.0),
    range          = 18.0,
    maxConnections = 0,
    onConnect      = function(source, deviceId)
        TriggerClientEvent('my-radio:client:takeControl', source, deviceId)
    end,
    onDisconnect   = function(source, deviceId, reason)
        TriggerClientEvent('my-radio:client:releaseControl', source, deviceId, reason)
    end,
})

INFO

A registration is rejected rather than repaired. A missing name, a zero range, a negative connection cap or neither coords nor entity all come back as false, err, so a script learns its device is wrong instead of finding it silently unreachable.

updateBluetoothDevice

Patches a registered device in place. Renaming or moving a device this way keeps everyone connected, where unregistering and re-registering would drop them all first.

Syntax

lua
exports['sd-phone']:updateBluetoothDevice(deviceId, patch)

Parameters

ParameterTypeDescription
deviceIdstringThe device to patch
patchtableAny registration field except id. Omitted keys keep their current value

Returns

FieldTypeDescription
okbooleanfalse when the patch was refused
errstring?Why it was refused

Example

lua
exports['sd-phone']:updateBluetoothDevice('boombox_pier', { name = 'Beach Boombox', range = 25.0 })

WARNING

Only the resource that registered a device may patch it. The merged result still has to pass the same validation as a fresh registration, so a patch cannot install a broken device.

Lowering maxConnections below the number of phones already on a device does not kick anyone. The device simply refuses new connections until it drains.

unregisterBluetoothDevice

Removes a device, disconnecting everyone on it first with reason unregistered.

Syntax

lua
exports['sd-phone']:unregisterBluetoothDevice(deviceId)

Returns

FieldTypeDescription
removedbooleanfalse when no such device was registered

You rarely need this on shutdown: a resource stopping already takes its own devices with it.

getBluetoothDevices

Every registered device, as identity only.

Syntax

lua
exports['sd-phone']:getBluetoothDevices()

Returns

FieldTypeDescription
devicestable[]{ id, name, kind, owner }, where owner is the resource that registered it

Positions, ranges and callbacks are deliberately absent. The tables are rebuilt on every call, so mutating the result never reaches into the registry.

getBluetoothDevice

One registered device, as identity only.

Syntax

lua
exports['sd-phone']:getBluetoothDevice(deviceId)

Returns

FieldTypeDescription
devicetable?{ id, name, kind, owner }, or nil when nothing is registered under that id

isBluetoothConnected

Whether a player is connected to a device right now.

Syntax

lua
exports['sd-phone']:isBluetoothConnected(source, deviceId)

Parameters

ParameterTypeDescription
sourcenumberPlayer server ID
deviceIdstringThe device to check

Returns

FieldTypeDescription
connectedbooleantrue while the player holds a live connection to it

Example

lua
RegisterCommand('playtrack', function(source)
    if not exports['sd-phone']:isBluetoothConnected(source, 'boombox_pier') then
        return TriggerClientEvent('chat:addMessage', source, { args = { 'Radio', 'Connect to the boombox first.' } })
    end
    -- play the track
end)

getBluetoothConnections

Every player connected to a device.

Syntax

lua
exports['sd-phone']:getBluetoothConnections(deviceId)

Returns

FieldTypeDescription
sourcesnumber[]Server ids, empty when nobody is on the device

Example

lua
for _, src in ipairs(exports['sd-phone']:getBluetoothConnections('boombox_pier')) do
    TriggerClientEvent('my-radio:client:sync', src, currentTrack)
end

getConnectedDevices

Every device a player is connected to.

Syntax

lua
exports['sd-phone']:getConnectedDevices(source)

Returns

FieldTypeDescription
idsstring[]Device ids, empty when the player is connected to nothing

isBluetoothEnabled

Whether a player's Bluetooth radio is switched on. This is the per-character setting behind the toggle in Settings, persisted across sessions.

Syntax

lua
exports['sd-phone']:isBluetoothEnabled(source)

Returns

FieldTypeDescription
enabledbooleanfalse when the radio is off, or when the source has no loaded character

This is what tells "the radio is off" apart from "the device is out of range". Both look identical through isBluetoothConnected alone, which is worth checking before you tell a player their gear is broken.

connectBluetooth

Connects a player to a device from script, without them pairing through the phone.

Syntax

lua
exports['sd-phone']:connectBluetooth(source, deviceId)

Returns

FieldTypeDescription
okbooleanfalse when the connection was refused
errstring?no character, bluetooth is off, unknown device or device is full

Example — issuing a headset when a shift starts

lua
AddEventHandler('my-job:server:clockOn', function(source)
    local ok, err = exports['sd-phone']:connectBluetooth(source, 'dispatch_headset')
    if not ok then print('headset refused: ' .. err) end
end)

INFO

This honours the player's radio switch and the device's connection limit, but not its range, which is the point of connecting by hand. It does not pair: the device is not added to the player's saved list, so nothing reconnects it later. A connection to a device the player has paired is still subject to the reconnect sweep, which drops it once they walk out of range.

disconnectBluetooth

Disconnects a player from a device, firing the owning script's onDisconnect with reason kicked.

Syntax

lua
exports['sd-phone']:disconnectBluetooth(source, deviceId)

Returns

FieldTypeDescription
disconnectedbooleanfalse when the player was not connected to it

The pairing survives, so the phone reconnects on its own while the player stays in range. To stop that, unregister the device or move it out of reach.

INFO

Every connection and disconnection also fires a server event, which is how a resource that does not own a device can still watch it. See Bluetooth events. The matching client exports answer for the local player without a round trip.

Notifications

notify

Send a phone notification banner to a player by server ID.

Syntax

lua
local sent = exports['sd-phone']:notify(source, data)
ParameterTypeDescription
sourcenumberThe player's server ID
datatableNotification payload (see below)
FieldTypeDescription
titlestringRequired banner title
appstring?App-icon ID (e.g. "messages")
imagestring?Custom icon URL, overrides app
bodystring?Banner body text
timestring?Display time string (e.g. "now")
appIdstring?The app opened when the banner is tapped
ReturnTypeDescription
sentbooleanfalse on a non-number source or a payload without a string title

Example

lua
exports['sd-phone']:notify(source, {
    app   = 'mail',
    title = 'City Hall',
    body  = 'Your business licence has been approved.',
    time  = 'now',
    appId = 'mail',
})

notifyNumber

Send the same notification banner addressed by phone number instead of server ID. The number is digit-normalized before lookup, so any formatting matches.

Syntax

lua
local sent = exports['sd-phone']:notifyNumber(number, data)
ParameterTypeDescription
numberstringThe recipient's phone number in any formatting
datatableNotification payload, same contract as notify
ReturnTypeDescription
sentbooleanfalse for a digitless number, an unassigned number, or an offline owner

Example

lua
-- A dispatch script updating the caller that reported the incident
exports['sd-phone']:notifyNumber(reporterNumber, {
    app   = 'phone',
    title = 'Dispatch',
    body  = 'Officers have been dispatched to your location.',
    appId = 'phone',
})

emergencyAlert

Send an emergency alert: the same banner funnel as notify, given a distinct treatment the player cannot mistake for an ordinary notification. The card carries a red EMERGENCY ALERT label and a warning glyph, and it stays on screen until the player dismisses it instead of fading after a few seconds.

Pass -1 as the source to reach every online player at once, which is the usual case for a dispatch or city-wide alert.

Syntax

lua
local sent = exports['sd-phone']:emergencyAlert(source, data)
ParameterTypeDescription
sourcenumberThe player's server ID, or -1 for every online player
datatableNotification payload, same contract as notify
ReturnTypeDescription
sentbooleanfalse on a non-number source or a payload without a string title

Icons

app and image are accepted but not used for the alert's icon: an emergency alert always shows the warning glyph, so alerts stay recognizable at a glance whatever the calling resource passes.

Example

lua
-- A bank robbery script alerting every player in the city
exports['sd-phone']:emergencyAlert(-1, {
    title = 'Bank robbery in progress',
    body  = 'Pacific Standard, Vinewood Blvd. All units respond.',
})

lb-phone

exports['lb-phone']:EmergencyNotification(source, data) maps onto this export, so a resource written for lb-phone produces the same alert with no changes. It takes lb's field names (content and icon rather than body and image), defaults a missing title to "Emergency" instead of refusing the call, and returns nil rather than a boolean, matching lb's own contract.

Racing

importRaceTrack

Import one race track, or a pack of them, into the Racing app. Every entry goes through the same validation the in-world gate creator uses, so an import cannot save a track the creator would refuse: the name, the gate count against Creator.MinGates/MaxGates, and two finite 3D points per gate.

A damaged entry is skipped and reported rather than failing the batch, so a pack with one bad track still lands the rest.

Syntax

lua
local result = exports['sd-phone']:importRaceTrack(data, authorName)
ParameterTypeDescription
datatableOne track table, or an array of them
authorNamestring?Credited author, defaults to "Imported"
Track fieldTypeDescription
namestringTrack name
modestring?"sprint" or "circuit" (default)
gatestableGate list, each { {ax,ay,az}, {bx,by,bz} }
ReturnTypeDescription
result.importedintegerTracks saved
result.failedtable[]{ index, name, reason } per skipped entry

Example

lua
local result = exports['sd-phone']:importRaceTrack({
    name  = 'Vinewood Sprint',
    mode  = 'sprint',
    gates = {
        { { 100.0, 200.0, 30.0 }, { 110.0, 200.0, 30.0 } },
        { { 150.0, 250.0, 31.5 }, { 160.0, 250.0, 31.5 } },
    },
}, 'Track Pack')

print(('imported %d, skipped %d'):format(result.imported, #result.failed))

Bulk import from a file

An owner seeding a track pack does not need a script. Drop the JSON into the sd-phone folder and run this from the server console:

importtracks tracks.json

The path is relative to the resource folder. Each track saved and each entry skipped is printed with its reason. At most 50 tracks are taken from one call.

In the app

Players can do the same by hand: Copy JSON on any track's detail page puts it on the clipboard, and the import button on the Tracks list takes a pasted track or list. That route is held to the track creator's permission and rate limits, since importing a track and recording one both add a row.

The message exports send SMS on a player's behalf or as a service.

Messages

sendMessage

Send a message on a player's behalf. Mirrors the phone's own composer payload and walks the full composer validation (kind whitelist, length caps, banking-validated money), so a caller cannot move unchecked funds.

Syntax

lua
local result = exports['sd-phone']:sendMessage(source, payload)
ParameterTypeDescription
sourcenumberThe acting player's server ID; the sender's identity resolves from it
payloadtableComposer payload (see below)
FieldTypeDescription
conversationstringA phone number, or "g-<groupId>" for a group thread
bodystringMessage body
kindstring?Optional bubble kind, same whitelist as the composer
gifUrlstring?Media URL for image and gif kinds
amountnumber?Amount for money kinds, banking-validated
durationnumber?Duration for voice-note kinds
wpCodestring?Waypoint code for the location kind
wpSubstring?Location label for the location kind
ReturnTypeDescription
resulttableThe standard envelope

Example

lua
exports['sd-phone']:sendMessage(source, {
    conversation = targetNumber,
    body         = 'Package delivered to the drop-off.',
})

sendSystemMessage

Deliver a one-way service-to-player SMS from a short code, without any player acting as the sender. No sender mailbox copy is stored and the recipient's block list is bypassed. A recipient in airplane mode has the message withheld until they switch it off.

Syntax

lua
local delivered = exports['sd-phone']:sendSystemMessage(senderNumber, senderName, targetNumber, body, opts)
ParameterTypeDescription
senderNumberstringService short code the recipient's thread files under, capped at 32 characters
senderNamestringDisplay name for the banner and thread header, capped at 64 characters
targetNumberstringThe recipient's phone number in any formatting
bodystringMessage body, capped at the configured maximum length
optstable?Optional presentation kind (see below)
FieldTypeDescription
kindstring?"image", "gif", or "location". Anything outside the whitelist is delivered as plain text
gifUrlstring?Media URL for the image and gif kinds
wpCodestring?Waypoint code for the location kind
wpSubstring?Location label for the location kind
ReturnTypeDescription
deliveredbooleanfalse on blank numbers, missing content for the kind, or a target number not in service

INFO

Money kinds are never accepted on this path, so a service message can never mint a payment card.

Example

lua
-- A taxi script letting the customer know their ride is outside
exports['sd-phone']:sendSystemMessage('8294', 'Downtown Cab Co.', customerNumber,
    'Your taxi has arrived. Look for the yellow Washington.')

The call exports start, inspect, and end phone calls on a player's behalf.

Calls

startCall

Start a 1:1 call on a player's behalf. The full player-originated validation applies: already-on-a-call and airplane checks, digit normalization, self-call guard, number-in-service, callee reachability and busy.

Syntax

lua
local result = exports['sd-phone']:startCall(source, number)
ParameterTypeDescription
sourcenumberThe acting caller's server ID
numberstringThe number to dial, any formatting
ReturnTypeDescription
resulttable{ success = true, data = { channel } } on success, { success = false, message } otherwise. channel is the voice call channel

Example

lua
local result = exports['sd-phone']:startCall(source, '555-0142')
if not result.success then
    print('Call failed: ' .. (result.message or 'unknown'))
end

startGroupCall

Ring several players at once on a caller's behalf, for example a dispatch line ringing every on-duty unit.

Syntax

lua
local result = exports['sd-phone']:startGroupCall(source, targetSources, displayName, displayNumber)
ParameterTypeDescription
sourcenumberThe acting caller's server ID
targetSourcestableArray of recipient server IDs. Unresolvable entries are dropped and the scan is bounded at 64
displayNamestringWhat the caller sees they are calling (e.g. "Police"). Required non-empty, capped at 40 characters
displayNumberstring?Optional display number shown to recipients
ReturnTypeDescription
resulttableSame envelope as startCall. Recipients who are the caller, busy, or in airplane mode are filtered out

Example

lua
local onDuty = getOnDutyOfficers() -- your own list of server IDs
exports['sd-phone']:startGroupCall(source, onDuty, 'Dispatch', '911')

getCurrentCall

Read a player's live call from their own perspective. Read-only.

Syntax

lua
local call = exports['sd-phone']:getCurrentCall(source)
ParameterTypeDescription
sourcenumberThe player's server ID
ReturnTypeDescription
calltable?{ channel, phase, number, name, elapsed } where phase is "outgoing", "incoming", or "active". nil when the player is not in a call or pending ring

Example

lua
local call = exports['sd-phone']:getCurrentCall(source)
if call and call.phase == 'active' then
    print(('On a call with %s for %ds'):format(call.name or call.number, call.elapsed))
end

isInCall

Check whether a player is currently in a call or pending ring. Boolean shorthand over getCurrentCall.

Syntax

lua
local inCall = exports['sd-phone']:isInCall(source)
ParameterTypeDescription
sourcenumberThe player's server ID
ReturnTypeDescription
inCallbooleantrue while the player is in a call or being rung

Example

lua
if exports['sd-phone']:isInCall(source) then
    return notifyPlayer(source, 'Finish your call first.')
end

endCallFor

End whatever call a player is in, on their behalf. The player's own channel is resolved internally, no raw channel argument is accepted, so a caller can never end someone else's call.

Syntax

lua
local result = exports['sd-phone']:endCallFor(source)
ParameterTypeDescription
sourcenumberThe player's server ID
ReturnTypeDescription
resulttable{ success = boolean, message = string? }. Idempotent: a player not in any call returns success

Example

lua
-- Cut the line when the player is downed
exports['sd-phone']:endCallFor(source)

The contact exports read and mutate a player's contacts, recents, and block list.

logCall

Log a call into a player's recents, for external calling systems. Every field is re-validated regardless of caller.

Syntax

lua
local result = exports['sd-phone']:logCall(source, payload)
ParameterTypeDescription
sourcenumberThe player's server ID
payloadtable{ number, name?, direction?, duration? }
ReturnTypeDescription
resulttableThe standard envelope

Example

lua
exports['sd-phone']:logCall(source, {
    number    = '911',
    name      = 'Emergency Services',
    direction = 'outgoing',
    duration  = 45,
})

Contacts

getContacts

Read a player's contacts, already serialized to the shape the app renders. Read-only.

Syntax

lua
local contacts = exports['sd-phone']:getContacts(source)
ParameterTypeDescription
sourcenumberThe player's server ID
ReturnTypeDescription
contactstable?Array of contact tables, or nil when the player cannot be resolved

Example

lua
local contacts = exports['sd-phone']:getContacts(source)
print(('Player has %d contacts'):format(contacts and #contacts or 0))

addContact

Create a contact for a player. Walks the exact same validation as the app's own add flow: the number must be in service, not the player's own, not a duplicate, and under the per-player cap. On success the new card is pushed live to the player's open phone.

Syntax

lua
local result = exports['sd-phone']:addContact(source, fields)
ParameterTypeDescription
sourcenumberThe acting player's server ID
fieldstableContact fields (see below)
FieldTypeDescription
phonestringThe contact's phone number, any formatting
namestring?Display name
emailstring?Email address
addressstring?Street address
avatarstring?Avatar image URL
ReturnTypeDescription
resulttableThe standard envelope

Example

lua
-- Hand the player a quest giver's number
exports['sd-phone']:addContact(source, {
    name  = 'Lester',
    phone = '5550187',
})

removeContactByNumber

Remove every contact matching a number from a player's list. The number is accepted in any format and digit-normalized before matching.

Syntax

lua
local result = exports['sd-phone']:removeContactByNumber(source, number)
ParameterTypeDescription
sourcenumberThe acting player's server ID
numberstringThe number to remove, any formatting
ReturnTypeDescription
resulttable{ success, data = { removed = n } }. A number matching nothing still succeeds with removed = 0

Example

lua
exports['sd-phone']:removeContactByNumber(source, '5550187')

getContactByNumber

Look up one of a player's own contacts by number, serialized to the shape the app renders. Read-only.

Syntax

lua
local contact = exports['sd-phone']:getContactByNumber(source, number)
ParameterTypeDescription
sourcenumberThe acting player's server ID
numberstringThe number to look up, any formatting
ReturnTypeDescription
contacttable?The contact table, or nil when the player, the digits, or a matching contact cannot be resolved

Example

lua
local contact = exports['sd-phone']:getContactByNumber(source, callerNumber)
local display = contact and contact.name or callerNumber

isNumberBlocked

Check whether a player has a number on their block list, for example a calling system deciding whether to ring them. Read-only; garbage input answers false.

Syntax

lua
local blocked = exports['sd-phone']:isNumberBlocked(source, number)
ParameterTypeDescription
sourcenumberThe player whose block list is checked
numberstringThe number to check, any formatting
ReturnTypeDescription
blockedbooleantrue when the number is on the player's block list

Example

lua
-- A taxi script skipping customers who blocked the taxi line
local customer = exports['sd-phone']:getSourceByNumber(customerNumber)
if customer and not exports['sd-phone']:isNumberBlocked(customer, TAXI_NUMBER) then
    exports['sd-phone']:startCall(driverSource, customerNumber)
end

The mail exports deliver and read mailbox data.

Mail

sendMail

Send mail as the system sender, for automated senders like payroll, city hall, or a job script.

Syntax

lua
local result = exports['sd-phone']:sendMail(mail)
ParameterTypeDescription
mailtableMail payload (see below)
FieldTypeDescription
tostring|string[]One address or a list. Deduped and capped at 20 recipients
subjectstring?Truncated to the compose cap
bodystring?Truncated to the compose cap
fromtable?{ name?, email? }. Display-only, never resolved to an account; defaults to the System sender
attachments(string|table)[]?Up to 5 attachments (see below); extras and malformed entries are dropped server-side

Each attachment is either a plain URL string, shorthand for a photo, or a tagged table:

ShapeRenders as
'https://...'Photo (same as the tagged photo shape)
{ kind = 'photo', url }Tappable image with a fullscreen viewer; the recipient can save it to their Photos
{ kind = 'audio', url, name?, duration? }Inline audio player; savable to Voice Memos. duration is seconds
{ kind = 'note', title?, body? }Readable note card; savable to Notes. At least one of title/body required
ReturnTypeDescription
resulttable{ success = boolean, delivered = number }. Unregistered addresses are silently skipped; delivered counts the ones that existed

Example

lua
-- A job script mailing a payslip at the end of a shift
local addresses = exports['sd-phone']:getMailAddresses(citizenid)
if addresses[1] then
    exports['sd-phone']:sendMail({
        to      = addresses[1].email,
        subject = 'Payslip - Week 32',
        body    = ('Hours worked: %d\nTotal pay: $%d'):format(hours, pay),
        from    = { name = 'Los Santos Customs', email = 'payroll@lscustoms.com' },
        attachments = {
            'https://cdn.example.com/payslips/week32.png',
            { kind = 'note', title = 'Overtime policy', body = 'Overtime pays 1.5x after 40 hours.' },
        },
    })
end

sendMailFromPlayer

Send mail on a player's behalf, as if they composed it themselves. The player must be signed into fromEmail, the From header is rebuilt from the account row, and the full compose validation applies.

Syntax

lua
local result = exports['sd-phone']:sendMailFromPlayer(source, payload)
ParameterTypeDescription
sourcenumberThe acting player's server ID; the sender's identity resolves from it
payloadtable{ fromEmail, to = string[], subject?, body?, attachments? }. Attachments use the tagged-table shapes documented under sendMail (the plain-string photo shorthand applies to sendMail only)
ReturnTypeDescription
resulttableThe standard envelope; data.sent is the serialized sent copy on success

Example

lua
exports['sd-phone']:sendMailFromPlayer(source, {
    fromEmail = playerEmail,
    to        = { 'applications@lspd.gov' },
    subject   = 'Job application',
    body      = 'I would like to apply for the open position.',
})

getMailAccounts

Get every mail account a player is signed into, in creation order. Never carries password hashes.

Syntax

lua
local accounts = exports['sd-phone']:getMailAccounts(source)
ParameterTypeDescription
sourcenumberThe player's server ID
ReturnTypeDescription
accountstable[]{ id, name, email } per account. Empty when the source is offline or signed into nothing

Example

lua
local accounts = exports['sd-phone']:getMailAccounts(source)
for _, acc in ipairs(accounts) do
    print(acc.email)
end

getMailAddresses

Get the same account shape keyed by citizenid instead of a live source. Works for offline players.

Syntax

lua
local accounts = exports['sd-phone']:getMailAddresses(citizenid)
ParameterTypeDescription
citizenidstringThe character's framework identifier
ReturnTypeDescription
accountstable[]{ id, name, email } per account, {} when none exist

INFO

The citizenid must be a non-empty string without % or _ characters; the session lookup treats both as wildcards, so patterned input returns {} instead of matching other citizens.

Example

lua
local addresses = exports['sd-phone']:getMailAddresses(citizenid)

mailAddressExists

Check whether a mail address resolves to a registered account. The address is trimmed and lowercased before the lookup.

Syntax

lua
local exists = exports['sd-phone']:mailAddressExists(email)
ParameterTypeDescription
emailstringThe address to check
ReturnTypeDescription
existsbooleantrue when a registered account owns the address. Non-string or empty input is false

Example

lua
if exports['sd-phone']:mailAddressExists('payroll@lscustoms.com') then
    -- safe to reference in a reply
end

getMailbox

Read a mailbox's messages, in the same serialized shape the app renders.

Syntax

lua
local messages = exports['sd-phone']:getMailbox(email, folder)
ParameterTypeDescription
emailstringThe account address
folderstring?One of inbox, drafts, sent, spam, bin. Omit for every message in the account
ReturnTypeDescription
messagestable[]?Serialized mail messages, or nil when the account does not exist or the folder is invalid

INFO

flagged is a virtual view in the app, not a real folder, so it is not a valid folder argument here.

Example

lua
local inbox = exports['sd-phone']:getMailbox('payroll@lscustoms.com', 'inbox')

The banking exports write and read the Wallet app's transaction log.

Banking

addBankTransaction

Append a transaction row to a character's Wallet list. Log-only: it does NOT move money; the calling resource owns the actual credit or debit. Works for offline characters.

Syntax

lua
local ok = exports['sd-phone']:addBankTransaction(identifier, data)
ParameterTypeDescription
identifierstringThe recipient character's citizenid
datatableTransaction fields (see below)
FieldTypeDescription
labelstringTransaction label shown in the Wallet list
amountnumberSigned amount: positive = money in, negative = money out
categorystring?Optional category
counterpartystring?Who the money came from or went to
notifyboolean|string?Set only for incoming payments the player did not initiate. true pops a default "You received $X" banner, a string pops that exact line
ReturnTypeDescription
okbooleantrue when the row was accepted

INFO

A server-side event alternative exists for resources that prefer TriggerEvent: TriggerEvent('sd-phone:bank:addTransaction', citizenid, data). It is a plain event handler, so only server code can raise it.

Example

lua
-- Log a paycheck after the framework pays it out
exports['sd-phone']:addBankTransaction(citizenid, {
    label        = 'Paycheck',
    amount       = 500,
    category     = 'income',
    counterparty = 'LSPD',
    notify       = true,
})

getBankTransactions

Read a character's Wallet transaction log, newest first. Read-only.

Syntax

lua
local rows = exports['sd-phone']:getBankTransactions(citizenid, limit)
ParameterTypeDescription
citizenidstringThe owning character's citizenid
limitnumber?Row cap, defaults to the configured transaction limit, floored and clamped to 1..100
ReturnTypeDescription
rowstable[]?Raw rows (id, citizenid, label, amount signed, category, counterparty, created_at unix seconds), {} when none. nil on a malformed call (empty citizenid, non-finite limit)

Example

lua
local rows = exports['sd-phone']:getBankTransactions(citizenid, 10)
for _, row in ipairs(rows or {}) do
    print(row.label, row.amount)
end

The badge exports drive the home-screen unread counters.

Badges

pushBadges

Recompute and push a player's home-screen badge counts. Call after mutating anything the counts derive from.

Syntax

lua
exports['sd-phone']:pushBadges(source)
ParameterTypeDescription
sourcenumberThe player's server ID. A non-number source is a silent no-op

Example

lua
-- After marking custom rows read outside the phone's own flows
exports['sd-phone']:pushBadges(source)

getBadgeCounts

Read a player's current per-app unread counts without pushing them.

Syntax

lua
local counts = exports['sd-phone']:getBadgeCounts(source)
ParameterTypeDescription
sourcenumberThe player's server ID
ReturnTypeDescription
countstable?{ messages, phone, mail, groups, photogram }, or nil when the source does not resolve to a loaded character, so "no player" is distinguishable from all-zero counts

Example

lua
local counts = exports['sd-phone']:getBadgeCounts(source)
if counts and counts.messages > 0 then
    -- they have unread texts
end

The photo exports save and host media for the Photos app.

Photos and media

getPhotos

Read a player's gallery, newest first, for a photo picker in another resource: a vehicle listing, an evidence board, a print shop. Read-only, and only ever that player's own photos.

Syntax

lua
local photos = exports['sd-phone']:getPhotos(source, opts)
ParameterTypeDescription
sourcenumberThe acting player's server ID; the gallery owner resolves from it
optstable?{ limit = number?, filter = 'favorites'|'videos'|nil }
ReturnTypeDescription
photostable[]Always an array, empty when nothing resolves

Each entry carries:

FieldTypeDescription
idstringPhoto row ID
urlstringHosted media URL
isVideobooleanWhether the URL points at a video, read off the extension
favoritebooleanWhether the owner starred it
timestampnumberCapture time as a unix integer, in seconds

limit defaults to 200 and is clamped to 200: this is one bounded page, not a paged read. Use the app itself for a full library.

Example

lua
local photos = exports['sd-phone']:getPhotos(source, { limit = 24 })
for _, photo in ipairs(photos) do
    if not photo.isVideo then
        print(photo.id, photo.url)
    end
end

getPhotosByIdentifier

The same read keyed by owner identifier rather than a live server ID, for offline owners and for callers holding a phone number.

Syntax

lua
local photos = exports['sd-phone']:getPhotosByIdentifier(citizenid, opts)
ParameterTypeDescription
citizenidstringThe owner's framework per-character identifier
optstable?Same shape and defaults as getPhotos
ReturnTypeDescription
photostable[]Same entry shape as getPhotos

Example

Starting from a phone number, resolve the owner with getIdentifierByNumber first:

lua
local citizenid = exports['sd-phone']:getIdentifierByNumber(number)
local photos = citizenid and exports['sd-phone']:getPhotosByIdentifier(citizenid, { limit = 24 }) or {}

INFO

Photos are stored per character, keyed by identifier, not per phone number. A player who swaps SIM cards keeps the same gallery, so a number is a way in rather than the owner itself.

addPhoto

Save an already-hosted http(s) media URL into a player's gallery. The URL walks the same validation as the app's own save path; on success the photo is pushed live so an open Photos app updates.

Syntax

lua
local result = exports['sd-phone']:addPhoto(source, url)
ParameterTypeDescription
sourcenumberThe acting player's server ID; the gallery owner resolves from it
urlstringAn http(s) URL of the hosted media, capped at 512 bytes
ReturnTypeDescription
resulttable{ success = boolean, photo = table? }

Example

lua
exports['sd-phone']:addPhoto(source, 'https://cdn.example.com/photos/race-finish.jpg')

uploadMedia

Asynchronously upload a base64 data: URL to the configured media host and hand the hosted CDN URL to a callback.

Syntax

lua
local accepted = exports['sd-phone']:uploadMedia(dataUrl, filename, cb)
ParameterTypeDescription
dataUrlstringThe media as a base64 data-URL (data:image/... or data:video/...)
filenamestring?Suggested filename stored alongside the upload
cbfunctionfunction(url, err) called exactly once: url is the hosted URL on success, err a reason string on failure
ReturnTypeDescription
acceptedbooleanfalse when the callback or payload shape is unusable, before any quota is spent

INFO

Every accepted call spends the server's Fivemanage quota, so the payload must be a data: URL and fit the per-kind byte cap before the upload starts. Upload only: nothing lands in a gallery; pair with addPhoto when it should.

Example

lua
exports['sd-phone']:uploadMedia(screenshotDataUrl, 'mugshot.jpg', function(url, err)
    if url then
        exports['sd-phone']:addPhoto(source, url)
    else
        print('Upload failed: ' .. tostring(err))
    end
end)

The account exports query the shared login engine behind the social apps. The account apps are photogram, cherry, vibez, birdy, mail, and ryde.

App accounts

accountExists

Check whether an account exists for an app. The username is trimmed and lowercased before the lookup, matching how accounts register.

Syntax

lua
local exists = exports['sd-phone']:accountExists(app, username)
ParameterTypeDescription
appstringOne of the account app keys
usernamestringThe account username
ReturnTypeDescription
existsbooleantrue when the account exists. Non-string or blank arguments return false

Example

lua
if exports['sd-phone']:accountExists('birdy', 'weazelnews') then
    -- the handle is taken
end

getAppAccount

Get one account in its public shape. Never returns the password hash. Read-only.

Syntax

lua
local account = exports['sd-phone']:getAppAccount(app, username)
ParameterTypeDescription
appstringOne of the account app keys
usernamestringThe account username
ReturnTypeDescription
accounttable?{ username, name, email, phone }, or nil on an unknown app, malformed arguments, or no such account

Example

lua
local account = exports['sd-phone']:getAppAccount('photogram', 'lifeinvader')

getSessionAccount

Get the account a citizen is currently signed into for an app. Read-only.

Syntax

lua
local account = exports['sd-phone']:getSessionAccount(app, citizenid)
ParameterTypeDescription
appstringOne of the account app keys
citizenidstringThe character's framework identifier
ReturnTypeDescription
accounttable?Same public shape as getAppAccount. nil means "not signed in", not an error

Example

lua
local account = exports['sd-phone']:getSessionAccount('ryde', citizenid)
if account then
    print('Signed into Ryde as ' .. account.username)
end

The group exports read the Groups app's membership state. The export view shape is { id, name, color, avatar, leaderCitizenid, members } where each member is { citizenid, name, source? } (source present only while that member is online).

INFO

These exports return real citizenids, so they are for trusted server callers only.

Groups

getActiveGroup

Get a player's active group as the full export view.

Syntax

lua
local group = exports['sd-phone']:getActiveGroup(source)
ParameterTypeDescription
sourcenumberThe player's server ID
ReturnTypeDescription
grouptable?The export view, or nil when the player is not connected, has no active group, or the group has been disbanded

Example

lua
-- Gate a heist start on group size
local group = exports['sd-phone']:getActiveGroup(source)
if not group or #group.members < 2 then
    return notifyPlayer(source, 'You need a group of at least 2.')
end

getActiveGroupId

Get just a player's active group ID. A cheap one-row read, useful as a precheck before pulling the full view.

Syntax

lua
local groupId = exports['sd-phone']:getActiveGroupId(source)
ParameterTypeDescription
sourcenumberThe player's server ID
ReturnTypeDescription
groupIdstring?The active group's ID, or nil when no active group is set

Example

lua
local groupId = exports['sd-phone']:getActiveGroupId(source)

getGroup

Get the export view of a specific group by ID.

Syntax

lua
local group = exports['sd-phone']:getGroup(groupId)
ParameterTypeDescription
groupIdstringThe group's ID
ReturnTypeDescription
grouptable?The export view, or nil when the group does not exist

Example

lua
local group = exports['sd-phone']:getGroup(heist.groupId)
for _, member in ipairs(group and group.members or {}) do
    if member.source then
        -- the member is online
    end
end

The services exports integrate with the Services app's company directory.

Services directory

getCompanyDirectory

Get the configured company directory, the same rows the app's Companies tab lists. Pure config read; a fresh array each call, safe for the caller to mutate.

Syntax

lua
local companies = exports['sd-phone']:getCompanyDirectory()
ReturnTypeDescription
companiestable[]{ id, name, location, color, emoji, canCall, callNumber, coords } per company, in config order

Example

lua
for _, company in ipairs(exports['sd-phone']:getCompanyDirectory()) do
    print(company.name, company.callNumber)
end

messageCompany

Send a customer message to a configured company on a player's behalf. The payload walks the full validation (directory whitelist, kind whitelist, length caps); on-duty staff get the same banner and live inbox push as the app's own path.

Syntax

lua
local result = exports['sd-phone']:messageCompany(source, payload)
ParameterTypeDescription
sourcenumberThe acting player's server ID; the sender's identity resolves from it
payloadtable{ job, kind?, body, mediaUrl?, wpCode?, wpSub? }
ReturnTypeDescription
resulttable{ success = boolean, message = string? }

Example

lua
exports['sd-phone']:messageCompany(source, {
    job  = 'mechanic',
    body = 'My car broke down on Route 68, can someone come take a look?',
})

The Weazel News exports publish to the news app as a trusted caller.

Weazel News

postArticle

Publish an article from another resource. Only the staff boss-gate is skipped; every clamp still applies (category whitelist, required headline, length caps). Timestamps are server-stamped and a featured article demotes every other hero.

Syntax

lua
local articleId, reason = exports['sd-phone']:postArticle(article)
ParameterTypeDescription
articletableArticle draft (see below)
FieldTypeDescription
categorystringMust be one of the configured categories
headlinestringRequired
dekstring?Subheadline
bodystring|string[]A single string or a paragraph array
imagestring?Header image URL
featuredboolean?true makes this the hero article
authorstring?Byline, defaults to Weazel News
ReturnTypeDescription
articleIdinteger?The new article's ID, or nil on a validation failure
reasonstring?Failure reason, only present when articleId is nil

INFO

There is no live push: the article appears the next time a player opens the app.

Example

lua
-- Automated coverage when the vault is hit
local id, reason = exports['sd-phone']:postArticle({
    category = 'crime',
    headline = 'Pacific Standard hit in broad daylight',
    body     = { 'Masked suspects fled the scene minutes before police arrived.' },
    featured = true,
})
if not id then print('Article rejected: ' .. reason) end

setBreakingTicker

Replace the breaking-news ticker. Lines are trimmed, non-strings and empties dropped, each line capped and at most the configured number of lines kept, in order.

Syntax

lua
local replaced = exports['sd-phone']:setBreakingTicker(lines)
ParameterTypeDescription
linesstring[]Ticker lines in display order. An empty array clears the ticker
ReturnTypeDescription
replacedbooleanfalse for a non-table argument, which leaves the ticker untouched

Example

lua
exports['sd-phone']:setBreakingTicker({
    'BREAKING: Pacific Standard bank robbed',
    'Police pursuit ongoing on the Del Perro Freeway',
})

The music export delivers tracks into a player's library.

Music

giveTrack

Give a track straight to a player's music library, skipping the AirShare nearby-consent handshake entirely: the caller vouches for the delivery (a quest reward, a purchased song). The track merges into the recipient's library even while the Music app is closed.

Syntax

lua
local delivered = exports['sd-phone']:giveTrack(source, track)
ParameterTypeDescription
sourcenumberThe recipient's server ID, must be an online player
tracktableTrack fields (see below). Extra fields ride along untouched
FieldTypeDescription
titlestringRequired, non-empty
urlstringRequired, non-empty audio URL
artiststring?Artist name
artworkstring?Cover art URL
durationnumber?Track length in seconds
ReturnTypeDescription
deliveredbooleanfalse for an offline source or a malformed track

Example

lua
-- Reward a record-store quest with a song
exports['sd-phone']:giveTrack(source, {
    title  = 'Sleepwalking',
    artist = 'The Chain Gang of 1974',
    url    = 'https://cdn.example.com/music/sleepwalking.mp3',
})

The item exports tie the phone to its inventory items.

Phone items and SIM

hasPhone

Check whether a player owns any configured phone item, answered by the same authoritative inventory check the keybind gate uses.

Syntax

lua
local color = exports['sd-phone']:hasPhone(source)
ParameterTypeDescription
sourcenumberThe player's server ID
ReturnTypeDescription
colorstring?The frame colour of the first owned variant in config order (black, blue, green, orange, pink, purple, red, yellow by default), or nil when no phone item is owned

Example

lua
if not exports['sd-phone']:hasPhone(source) then
    return notifyPlayer(source, 'You need a phone for this job.')
end

usePhone

The usable-item export family behind the phone items. ox_inventory dispatches item use to a per-item export on the owning resource, and the export name is auto-derived from the item key (use plus the item name with its first letter uppercased). With the default items that registers usePhone for phone, plus usePhone_blue, usePhone_green, usePhone_orange, usePhone_pink, usePhone_purple, usePhone_red, and usePhone_yellow for the coloured variants. Only the usingItem phase acts, opening the phone in that variant's frame colour.

Syntax

lua
exports['sd-phone']:usePhone(event, item, inv, slot, data)
ParameterTypeDescription
eventstringox_inventory dispatch phase; only usingItem acts
itemtableItem data from ox_inventory
invtableThe holder's inventory; inv.id is the acting player
slotnumberThe item's slot
datatableExtra dispatch data

INFO

These exports are registered only when ox_inventory is the active inventory, and they exist for ox_inventory's item-use dispatcher, not for manual calls. Other inventories register the phone items through their own CreateUsableItem style APIs and expose no export.

The SIM exports manage the unique-phones SIM system: creating SIM card items, reading a player's active number, and assigning custom numbers. Numbers are bare digit strings; formatting in inputs is stripped.

giveSimCard

Create a pre-provisioned SIM card and put it in a player's inventory. With opts.citizenid the SIM is character-bound and carries that character's existing number and data; with opts.number it carries a specific hardcoded number; with neither it is simply a pre-activated SIM with a fresh number.

You usually don't need this

Blank sim_card items activate themselves on first use — a fresh number is minted and registered on the spot — so shops, loot tables and admin spawns can hand out the raw item with zero integration. Reach for this export only when the SIM must carry a specific identity (character-bound, or a hardcoded number). Setting ActivateBlankSims = false in configs/uniqueandsim.lua disables self-activation for servers that want every SIM created through this export.

Syntax

lua
local number = exports['sd-phone']:giveSimCard(source, opts)
ParameterTypeDescription
sourcenumberThe receiving player's server ID
optstable?{ number?, citizenid? }. number requests a specific number and fails if it is taken
ReturnTypeDescription
numberstring?The SIM's bare-digit number, or nil when creation or the inventory give failed

Example

lua
-- Onboarding: hand a new character a SIM bound to them (their number and data carry over)
local number = exports['sd-phone']:giveSimCard(source, { citizenid = cid })

-- A quest reward carrying a memorable hardcoded number (nil if the number is taken)
local number = exports['sd-phone']:giveSimCard(source, { number = '2085550777' })
if number then
    TriggerClientEvent('shop:notify', source, ('Your new number is %s'):format(number))
end

getSimNumber

Get the SIM number installed in a player's active phone.

Syntax

lua
local number = exports['sd-phone']:getSimNumber(source)
ParameterTypeDescription
sourcenumberThe player's server ID
ReturnTypeDescription
numberstring?Bare-digit SIM number, or nil without an active SIM

hasSim

Whether the player's active phone has a SIM installed.

Syntax

lua
local installed = exports['sd-phone']:hasSim(source)
ParameterTypeDescription
sourcenumberThe player's server ID
ReturnTypeDescription
installedbooleantrue when a SIM is installed

isSimModeActive

Whether unique phones / SIM mode is live, meaning the config is on and the active inventory backend supports it.

Syntax

lua
local active = exports['sd-phone']:isSimModeActive()
ReturnTypeDescription
activebooleantrue while SIM mode is running

isNumberAvailable

Whether a phone number is free to assign: not on any SIM and not held by a legacy character assignment.

Syntax

lua
local free = exports['sd-phone']:isNumberAvailable(number)
ParameterTypeDescription
numberstringPhone number in any formatting
ReturnTypeDescription
freebooleantrue when the number can be assigned

setSimNumber

Assign a specific number to the SIM in a player's active phone, keeping its identity and data. This is the hook for server-owned "buy a custom number" implementations.

Syntax

lua
local ok, err = exports['sd-phone']:setSimNumber(source, number)
ParameterTypeDescription
sourcenumberThe player's server ID
numberstringRequested number; digits are kept, 3 to 15 of them
ReturnTypeDescription
okbooleantrue on success
errstring?On failure: 'invalid' (bad input or SIM mode off), 'no_sim', or 'taken'

Example

lua
-- A custom-number storefront
local free = exports['sd-phone']:isNumberAvailable(wanted)
if free and chargePlayer(source, price) then
    local ok, err = exports['sd-phone']:setSimNumber(source, wanted)
    if not ok then refundPlayer(source, price) end
end

Documents

The Documents exports put files on players' phones from any resource — the paperwork layer of the city. Citations from an MDT, contracts from a dealership, licenses from city hall: one call creates the document, files it into a named folder (auto-created), updates the owner's open phone live, and shows a notification banner. Documents marked locked are read-only for the player — no editing, renaming, moving, or sharing — though they may still discard their copy: the lock freezes a document's content and identity, not the owner's right to throw it away. For the rare record that must persist, pass deletable = false too. The issuing resource can always revoke its own documents either way.

A single text document can mix paragraphs and pictures: in the phone's read-only view (locked and signed documents), any line that is exactly one http(s) URL renders as an inline image — dossiers with surveillance stills, deeds with property photos, contracts with condition documentation. And players can sign text documents with a hand-drawn signature that the server records and verifies; getDocumentSignatures reads those signatures back, so your script can confirm who signed before acting.

Everything resolves through the phone's identity layer, so the same call works untouched on stock servers and under every unique-phones mode; documents and their signatures ride cloud backups and admin wipes automatically.

createDocument

Create a document on an online player's phone. Validates every field and applies the same caps as the app (configs/documents.lua).

Syntax

lua
local docId, err = exports['sd-phone']:createDocument(source, opts)
ParameterTypeDescription
sourcenumberThe receiving player's server ID
optstableSee fields below; name is required
opts fieldTypeDescription
namestringDisplay name (max length per config)
kindstring?'text' (default), 'image', or 'file'
contentstring?Body for text documents. Lines that are exactly one http(s) URL render as inline images in the read-only view
urlstring?http(s) URL for image/file documents
folderstring?Root folder name — resolved case-insensitively, created if absent
lockedboolean?Read-only for the player — no editing, renaming, moving, or sharing (deleting stays allowed unless deletable = false)
signableboolean?Pass false to forbid signing this document (default signable; see Document signatures)
deletableboolean?Pass false to forbid the player deleting this document (default deletable). Deleting a folder around such a document moves it to the Files root instead of destroying it
notifyboolean?Notification banner on delivery (default true)
ReturnTypeDescription
docIdstring?The new document's id, or nil on refusal
errstring?Refusal message when docId is nil (caps hit, bad input, …)

Example

lua
-- An MDT files a citation the player can read but never delete
local docId = exports['sd-phone']:createDocument(source, {
    name    = ('Citation #%d'):format(citationId),
    folder  = 'LSPD',
    content = citationText,
    locked  = true,
})

-- Paying the fine revokes it
if paid and docId then
    exports['sd-phone']:deleteDocumentById(source, docId)
end

Example — one document mixing paragraphs and inline images

lua
-- An illustrated case dossier: URL-only lines become pictures in the read view
exports['sd-phone']:createDocument(source, {
    name   = 'Case Dossier #204',
    folder = 'LSPD',
    locked = true,
    content = table.concat({
        'CASE DOSSIER #204 — CONFIDENTIAL',
        '',
        'Subject observed leaving the premises at 23:41. Surveillance still, camera 2:',
        'https://your-cdn.example.com/stills/case204-cam2.jpg',
        'The vehicle matched a stolen Sultan reported earlier the same evening:',
        'https://your-cdn.example.com/stills/case204-plate.jpg',
        '',
        'Filed by Officer J. Marsh. Issued by the LSPD; read-only on the receiving phone.',
    }, '\n'),
})

The illustrated document format

The content string is line-based, which makes long structured documents trivial to build with table.concat:

  • Every line is plain text; blank lines ('') create paragraph spacing.
  • A line that is exactly one http(s) URL renders as an inline picture in the read-only view — put a caption on the line above it. A URL that fails to load falls back to visible text, so a broken CDN link never blanks a section.
  • The images render everywhere: in the read-only view of locked and signed documents, and inline while a player edits their own document — the editor works on the same format, with an image button that inserts gallery photos at the cursor. A document a player composes this way is byte-compatible with one your script issues.
  • MaxTextLength (25,000 characters by default) is the only budget — enough for dozens of sections and images in one document.

Example — a long, fully structured report built section by section

lua
-- A vehicle inspection report a mechanic script issues after service: many text
-- sections with a photo documenting each finding, assembled programmatically.
local sections = {
    'BENNYS ORIGINAL MOTOR WORKS — INSPECTION REPORT',
    ('Vehicle: %s   Plate: %s   Odometer: %d mi'):format(vehicleLabel, plate, mileage),
    ('Inspector: %s   Date: %s'):format(mechanicName, os.date('%Y-%m-%d')),
    '',
}

for _, finding in ipairs(findings) do
    sections[#sections + 1] = ('%d. %s — %s'):format(finding.no, finding.part, finding.verdict)
    sections[#sections + 1] = finding.notes
    if finding.photoUrl then sections[#sections + 1] = finding.photoUrl end
    sections[#sections + 1] = ''
end

sections[#sections + 1] = ('Estimated repair total: $%d. This report is valid for 30 days.'):format(total)

exports['sd-phone']:createDocument(source, {
    name    = ('Inspection — %s'):format(plate),
    folder  = 'Bennys',
    locked  = true,
    content = table.concat(sections, '\n'),
})

createDocumentForNumber

The same as createDocument, addressed by phone number instead of server ID. The number is resolved to its owner; when they are online, delivery is pushed live.

Syntax

lua
local docId, err = exports['sd-phone']:createDocumentForNumber(number, opts)
ParameterTypeDescription
numberstringPhone number in any formatting
optstableIdentical to createDocument
ReturnTypeDescription
docIdstring?The new document's id, or nil ('Number not in service', …)
errstring?Refusal message when docId is nil

getPlayerDocuments

Read a player's documents, optionally scoped to one root folder by name. Read-only; content is deliberately excluded — fetch it per document with getDocumentContent.

Syntax

lua
local docs = exports['sd-phone']:getPlayerDocuments(source, folderName)
ParameterTypeDescription
sourcenumberThe player's server ID
folderNamestring?Optional root folder name filter (case-insensitive)
ReturnTypeDescription
docstable[]Document rows { id, name, kind, folderId, size, locked, createdAt, updatedAt, url? }; always an array, empty when nothing resolves

getDocumentContent

Read one document's raw text content.

Syntax

lua
local content = exports['sd-phone']:getDocumentContent(source, docId)
ParameterTypeDescription
sourcenumberThe player's server ID
docIdstringThe document id
ReturnTypeDescription
contentstring?The document body, or nil when it doesn't exist or isn't theirs

Example

lua
-- A court script pulls the statement the player wrote in their Files app
local statement = exports['sd-phone']:getDocumentContent(source, docId)
if statement then fileEvidence(caseId, statement) end

deleteDocumentById

Delete one of a player's documents. Deliberately bypasses the player-side guards, so the resource that issued a document can revoke it — including documents the player cannot delete themselves (deletable = false).

Syntax

lua
local removed = exports['sd-phone']:deleteDocumentById(source, docId)
ParameterTypeDescription
sourcenumberThe player's server ID
docIdstringThe document id
ReturnTypeDescription
removedbooleantrue when a document was removed

Document signatures

Players sign text documents in the Files app: they draw a personal signature once (saved to their phone), then sign any document with one tap. Signatures are server-authoritative rows, not marks in the text — each carries the signer's identity, their display name frozen at signing time, an image snapshot of the drawn signature (redrawing later never rewrites old documents), and the signing timestamp. The verified badge the phone shows renders from those rows, never from document content, so a signature cannot be forged by typing a name.

Signing freezes the document — the phone refuses further edits and renames — while delete, move, duplicate, and AirShare stay available. AirShared and mailed copies carry their signature rows, so a signed contract stays verifiably signed on the recipient's phone. Signing a locked document you issued is allowed by design: it adds signature rows without touching your content, which is exactly the contract flow — issue a locked agreement, then verify the player signed it.

A document takes any number of signatures — one per signer. A signed document still offers Sign to a player who hasn't signed it yet, so a copy gathers signatures as it passes from phone to phone, each new signature stacking below the earlier ones. And for proper two-party contracts there are signature requests: after signing your own document, its menu offers Request Signature, which asks a nearby player over AirShare. They review the full document — inline images and existing signatures included — and sign or decline. On signing, their signature lands on your original, your open phone updates live, and they automatically receive a completed copy carrying every signature, so both parties end up holding identical, fully executed paper.

Every text document is signable by default. When a signature makes no sense on your document — a citation, a report, a license — issue it with signable = false: the phone hides the Sign button and the server refuses signing outright. AirShared copies keep the restriction.

Deletion follows the same per-document pattern. Every document is deletable by its owner by default — even locked ones, since a player throwing away their copy of a citation is legitimate roleplay while the police record lives in your MDT, not their phone. When a document genuinely must persist — a court record, an active loan contract — issue it with deletable = false: the phone offers no Delete and the server refuses one, and deleting a folder around it re-parents the document to the Files root rather than destroying it. One consequence to design around: deleting a document also removes its signature rows, so if your script needs durable proof that something was signed, either record the verification when it matters (for example when the deal closes) or issue the contract with deletable = false — don't count on the player's copy existing forever.

isDocumentSigned

The quick boolean gate: true when the document carries at least one signature. Use it when you don't need the signer list — for the full rows, use getDocumentSignatures.

Syntax

lua
local signed = exports['sd-phone']:isDocumentSigned(source, docId)
ParameterTypeDescription
sourcenumberThe player's server ID
docIdstringThe document id
ReturnTypeDescription
signedbooleantrue when at least one signature exists; false for an unsigned, missing, or not-theirs document

Example

lua
-- Gate a rental handover on the signed agreement
if not exports['sd-phone']:isDocumentSigned(source, rentalDocId) then
    return notify(source, 'Sign the rental agreement in your Files app first.')
end
handOverKeys(source)

getDocumentSignatures

Read a document's signatures — the verification half of the contract flow.

Syntax

lua
local sigs = exports['sd-phone']:getDocumentSignatures(source, docId)
ParameterTypeDescription
sourcenumberThe player's server ID
docIdstringThe document id
ReturnTypeDescription
sigstable[]Signature rows { id, signer, image, signedAt }; always an array, empty when the document doesn't exist, isn't theirs, or is unsigned
sigs entry fieldTypeDescription
signerstringThe signer's display name, frozen at signing time
imagestring?PNG data-URL snapshot of the drawn signature
signedAtnumberEpoch seconds of the signing moment

Example — a dealership that releases the keys once the buyer signs

lua
local pendingSales = {}

-- 1) Issue the locked agreement (an inline photo documents the vehicle's condition)
RegisterCommand('sellblista', function(source)
    local docId = exports['sd-phone']:createDocument(source, {
        name   = 'Vehicle Purchase Agreement',
        folder = 'Dinoco',
        locked = true,
        content = table.concat({
            'VEHICLE PURCHASE AGREEMENT',
            '',
            'The buyer agrees to purchase one (1) used Blista for $12,500.',
            'Condition at handover:',
            'https://your-cdn.example.com/lot/blista-2041.jpg',
            '',
            'Sign this document in the Files app to accept.',
        }, '\n'),
    })
    if docId then pendingSales[source] = docId end
end)

-- 2) Release the keys only when the buyer has actually signed
RegisterCommand('collectkeys', function(source)
    local docId = pendingSales[source]
    if not docId then return end
    local sigs = exports['sd-phone']:getDocumentSignatures(source, docId)
    if #sigs == 0 then
        -- not signed yet: point them at the Files app
        return
    end
    print(('Contract signed by %s at %s'):format(sigs[1].signer, os.date('%Y-%m-%d %H:%M', sigs[1].signedAt)))
    pendingSales[source] = nil
    -- hand over the vehicle here
end)

Example — a court verifying every party signed a settlement

lua
local sigs = exports['sd-phone']:getDocumentSignatures(source, settlementDocId)
local names = {}
for i = 1, #sigs do names[#names + 1] = sigs[i].signer end
if #sigs >= 2 then
    print('Settlement executed by: ' .. table.concat(names, ', '))
end

INFO

Every export-created document fires the sd-phone:server:documents:created event, carrying the creating resource's name.

App unlocks

Apps gated with requires = { consume = true } are unlocked permanently for a character rather than checked live. These three exports are how that unlock is handed out, taken back, and read.

The unlock is stored against the character and survives relogs, resource restarts and an empty inventory. It is scoped per character, so a second character on the same account does not inherit it.

See Custom Apps for the gate itself, and configs/apps.lua for gating a built-in app.

unlockApp

Grants a permanent app unlock. Idempotent — granting one the character already has changes nothing.

Syntax

lua
exports['sd-phone']:unlockApp(source, appId)

Parameters

FieldTypeDescription
sourcenumberPlayer server id
appIdstringApp identifier, as configs/apps.lua or addCustomApp names it

Returns

FieldTypeDescription
okbooleanfalse when the app id is empty or the player has no loaded character

The player's phone is told immediately, so a gated app appears without waiting for a reopen.

Example — a heist payout that hands over a hidden app

lua
if lootTier >= 3 then
    exports['sd-phone']:unlockApp(source, 'darkchat')
end

revokeApp

Takes a permanent unlock back.

Syntax

lua
exports['sd-phone']:revokeApp(source, appId)

Returns

FieldTypeDescription
removedbooleanfalse when the character did not have that unlock

hasAppUnlock

Whether a character currently holds a permanent unlock. Read-only.

Syntax

lua
exports['sd-phone']:hasAppUnlock(source, appId)

Returns

FieldTypeDescription
unlockedbooleanfalse when the player has no loaded character

An unlock is not a permission

hasAppUnlock tells you whether the phone will draw the icon. It does not stop a player calling your resource's events directly, so keep checking whatever actually matters server-side.

INFO

/appunlock grant|revoke <app> [target] does the same thing by hand. Acting on another player needs the phone's admin aces.

MDT firearms registry

The Weapons section of the MDT is a serial-number registry: police look a firearm up by the serial stamped on its frame and see who it is registered to, what state it is in, and every note an officer has left on it. These exports are how a firearm gets onto that registry in the first place, so the record exists before an officer ever runs the serial.

The obvious caller is a gun shop, at the moment it hands the weapon over. A crafting bench, an evidence locker or an admin script that spawns a weapon are the same shape.

Every export here is server-side, and every one is safe to call on a server with the MDT switched off: they refuse quietly rather than erroring, so a resource that supports sd-phone optionally does not need to branch.

mdtRegisterWeapon

Files a firearm on the registry. Omit serial and one is minted and handed back, which is what a shop wants: it knows the weapon and the buyer, but has no serial until one is issued.

Syntax

lua
local serial, message = exports['sd-phone']:mdtRegisterWeapon(data)
ParameterTypeDescription
datatableThe firearm (see below)
FieldTypeDescription
namestringDisplay name of the firearm, for example Combat Pistol. Required
serialstring?The serial on the frame. Omit it and a unique one is minted and returned
classstring?One of pistol, smg, rifle, shotgun, sniper, melee, other. Defaults to other
ownerstring?Citizenid the firearm is registered to. Omit for an unregistered frame
notesstring?Free text shown on the record, for example where it was sold
registeredBystring?Who to record as having filed it. A citizenid, or a marker such as SHOP
ReturnTypeDescription
serialstring|falseThe serial it was filed under, or false on refusal
messagestring?Reason when serial is false

Example

lua
-- At the point of sale, after the weapon is actually given
local serial, err = exports['sd-phone']:mdtRegisterWeapon({
    name         = 'Combat Pistol',
    class        = 'pistol',
    owner        = citizenid,
    notes        = 'Sold at Ammu-Nation Sandy Shores',
    registeredBy = 'SHOP',
})

if serial then
    -- Stamp the same serial on the item so the frame and the registry agree
    item.metadata.serial = serial
else
    print(('registry refused the sale: %s'):format(err))
end

Refusals are all validation, and every one is worth handling: an unnamed firearm, a class outside the list above, an owner that no citizen holds, or a serial already on the registry.

Stamp the serial back onto the item

The registry keys on the serial and nothing else. If the number in the weapon's metadata and the number on the record ever disagree, an officer running the frame finds nothing. Take the serial this returns and write it onto the item rather than generating your own.

mdtGetWeapon

Reads one record by serial. The owner's display name is resolved live, so a citizen who changed their name reads correctly here.

Syntax

lua
local weapon = exports['sd-phone']:mdtGetWeapon(serial)
ReturnTypeDescription
weapontable|nilnil when no firearm is on file under that serial

mdtGetWeaponsByOwner

Every firearm registered to a citizen, newest first. The lookup a licence check or a warrant application wants.

Syntax

lua
local list = exports['sd-phone']:mdtGetWeaponsByOwner(citizenid)
ReturnTypeDescription
listtable[]Empty when the citizen has nothing registered

mdtSetWeaponStatus

Moves a firearm to another registry state. This is the hook for the script that seized it into evidence, destroyed it, or logged it stolen.

Syntax

lua
local ok, message = exports['sd-phone']:mdtSetWeaponStatus(serial, status, byCitizenid)
ParameterTypeDescription
serialstringThe serial on the frame
statusstringOne of registered, stolen, seized, destroyed
byCitizenidstring?Who to record as having changed it
ReturnTypeDescription
okbooleanfalse when the serial is not on file or the status is not one of the four
messagestring?Reason when ok is false

Example

lua
-- A player reports a burglary
exports['sd-phone']:mdtSetWeaponStatus(serial, 'stolen', citizenid)

mdtIsWanted

Whether a citizen has an active warrant. The cheap predicate plate readers and NPC patrols read.

Syntax

lua
local wanted = exports['sd-phone']:mdtIsWanted(citizenid)
ReturnTypeDescription
wantedbooleanfalse when the MDT is disabled

These bypass the terminal's permissions on purpose

Everything on this page is server-side and already trusted, so none of it checks a police permission the way the terminal does. Do not expose any of it through a client event a player can trigger, or you have handed them write access to the firearms registry.

TIP

Client-side exports for opening the phone, launching apps, and showing local notifications are documented on the Client Exports page.