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
local number = exports['sd-phone']:getPhoneNumber(source)| Parameter | Type | Description |
|---|---|---|
source | number | The player's server ID |
| Return | Type | Description |
|---|---|---|
number | string? | The raw-digit phone number, or nil when the source does not resolve to a loaded character |
Example
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
local number = exports['sd-phone']:getPhoneNumberByIdentifier(citizenid, ensure)| Parameter | Type | Description |
|---|---|---|
citizenid | string | The character's framework identifier |
ensure | boolean? | Pass true to assign a number on first access, the way getPhoneNumber does. Otherwise a never-assigned character yields nil |
| Return | Type | Description |
|---|---|---|
number | string? | The raw-digit phone number, or nil for a malformed citizenid or a never-assigned character without ensure |
Example
-- 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
local citizenid = exports['sd-phone']:getIdentifierByNumber(number)| Parameter | Type | Description |
|---|---|---|
number | string | A phone number in any formatting |
| Return | Type | Description |
|---|---|---|
citizenid | string? | The owning character's citizenid, or nil when the number is unassigned |
Example
local citizenid = exports['sd-phone']:getIdentifierByNumber('555-0142')
if citizenid then
-- pay the number's owner even while they are offline
endgetSourceByNumber
Get the connected server ID of the player that owns a phone number.
Syntax
local playerId = exports['sd-phone']:getSourceByNumber(number)| Parameter | Type | Description |
|---|---|---|
number | string | A phone number in any formatting |
| Return | Type | Description |
|---|---|---|
playerId | number? | The owner's server ID, or nil when the number is unassigned or its owner is offline |
Example
local playerId = exports['sd-phone']:getSourceByNumber(customerNumber)
if playerId then
-- the customer is online, deliver the order in person
endisNumberInService
Check whether a phone number is assigned to any character. Useful for validating user-supplied numbers before sending anything at them.
Syntax
local inService = exports['sd-phone']:isNumberInService(number)| Parameter | Type | Description |
|---|---|---|
number | string | A phone number in any formatting |
| Return | Type | Description |
|---|---|---|
inService | boolean | true when the number is assigned to a character. Empty or digitless input is false |
Example
if not exports['sd-phone']:isNumberInService(input) then
return notifyPlayer(source, 'That number is not in service.')
endisAirplaneMode
Check whether a player currently has airplane mode switched on.
Syntax
local on = exports['sd-phone']:isAirplaneMode(source)| Parameter | Type | Description |
|---|---|---|
source | number | The player's server ID |
| Return | Type | Description |
|---|---|---|
on | boolean | true 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
if not exports['sd-phone']:isAirplaneMode(target) then
-- safe to ring them
endThe 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
exports['sd-phone']:getServiceLevel(source)Parameters
| Parameter | Type | Description |
|---|---|---|
source | number | Player server ID |
Returns
| Field | Type | Description |
|---|---|---|
level | number | 0.0 to 1.0. 1.0 when no masts are configured, or when the player cannot be resolved |
Example
local level = exports['sd-phone']:getServiceLevel(source)
if level == 0.0 then
print('player is in a dead zone')
endhasService
Whether a player can currently use a capability.
Syntax
exports['sd-phone']:hasService(source, capability)Parameters
| Parameter | Type | Description |
|---|---|---|
source | number | Player server ID |
capability | string? | 'text', 'call' or 'data'. Defaults to 'data' |
Returns
| Field | Type | Description |
|---|---|---|
allowed | boolean | true when the player's signal clears that capability's threshold |
Example
if not exports['sd-phone']:hasService(source, 'call') then
return 'They have no signal out there.'
endTIP
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
exports['sd-phone']:getCellTowers()Returns
| Field | Type | Description |
|---|---|---|
towers | table[] | { 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
exports['sd-phone']:isOnWifi(source)Parameters
| Parameter | Type | Description |
|---|---|---|
source | number | Player server ID |
Returns
| Field | Type | Description |
|---|---|---|
connected | boolean | true while the player is joined to a network and still inside it |
getWifi
The network a player is connected to.
Syntax
exports['sd-phone']:getWifi(source)Parameters
| Parameter | Type | Description |
|---|---|---|
source | number | Player server ID |
Returns
| Field | Type | Description |
|---|---|---|
id | string? | Network id from configs/wifi.lua, nil when on none |
hasWifiAccess
Whether a player is connected to that network specifically, range included.
Syntax
exports['sd-phone']:hasWifiAccess(source, id)Parameters
| Parameter | Type | Description |
|---|---|---|
source | number | Player server ID |
id | string | Network id from configs/wifi.lua |
Returns
| Field | Type | Description |
|---|---|---|
allowed | boolean | true only when that player is on that network |
Example
if not exports['sd-phone']:hasWifiAccess(source, 'mazebank') then
return 'You have to be on the bank network to do that.'
endTIP
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
exports['sd-phone']:getWifiNetworks()Returns
| Field | Type | Description |
|---|---|---|
networks | table[] | { 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
exports['sd-phone']:registerBluetoothDevice(device)Device
| Field | Type | Description |
|---|---|---|
id | string | Unique id, up to 64 characters. Registering an id twice is refused rather than overwritten |
name | string | Name the phone shows in its device list |
kind | string? | vehicle, audio, headset, wearable or device. Picks the icon; defaults to device |
coords | vector3? | Where the device sits. Required unless entity is given |
entity | number? | Network id of an entity the device follows, for a prop or a vehicle. Falls back to coords when the entity has despawned |
range | number? | Metres it reaches, defaulting to 10.0. A true sphere, so height counts |
maxConnections | number? | Phones it accepts at once, defaulting to 1. 0 means unlimited |
onConnect | function? | (source, deviceId) when a phone connects |
onDisconnect | function? | (source, deviceId, reason) when one goes away |
Returns
| Field | Type | Description |
|---|---|---|
ok | boolean | false when the registration was rejected |
err | string? | Why it was rejected |
Example
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
exports['sd-phone']:updateBluetoothDevice(deviceId, patch)Parameters
| Parameter | Type | Description |
|---|---|---|
deviceId | string | The device to patch |
patch | table | Any registration field except id. Omitted keys keep their current value |
Returns
| Field | Type | Description |
|---|---|---|
ok | boolean | false when the patch was refused |
err | string? | Why it was refused |
Example
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
exports['sd-phone']:unregisterBluetoothDevice(deviceId)Returns
| Field | Type | Description |
|---|---|---|
removed | boolean | false 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
exports['sd-phone']:getBluetoothDevices()Returns
| Field | Type | Description |
|---|---|---|
devices | table[] | { 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
exports['sd-phone']:getBluetoothDevice(deviceId)Returns
| Field | Type | Description |
|---|---|---|
device | table? | { 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
exports['sd-phone']:isBluetoothConnected(source, deviceId)Parameters
| Parameter | Type | Description |
|---|---|---|
source | number | Player server ID |
deviceId | string | The device to check |
Returns
| Field | Type | Description |
|---|---|---|
connected | boolean | true while the player holds a live connection to it |
Example
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
exports['sd-phone']:getBluetoothConnections(deviceId)Returns
| Field | Type | Description |
|---|---|---|
sources | number[] | Server ids, empty when nobody is on the device |
Example
for _, src in ipairs(exports['sd-phone']:getBluetoothConnections('boombox_pier')) do
TriggerClientEvent('my-radio:client:sync', src, currentTrack)
endgetConnectedDevices
Every device a player is connected to.
Syntax
exports['sd-phone']:getConnectedDevices(source)Returns
| Field | Type | Description |
|---|---|---|
ids | string[] | 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
exports['sd-phone']:isBluetoothEnabled(source)Returns
| Field | Type | Description |
|---|---|---|
enabled | boolean | false 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
exports['sd-phone']:connectBluetooth(source, deviceId)Returns
| Field | Type | Description |
|---|---|---|
ok | boolean | false when the connection was refused |
err | string? | no character, bluetooth is off, unknown device or device is full |
Example — issuing a headset when a shift starts
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
exports['sd-phone']:disconnectBluetooth(source, deviceId)Returns
| Field | Type | Description |
|---|---|---|
disconnected | boolean | false 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
local sent = exports['sd-phone']:notify(source, data)| Parameter | Type | Description |
|---|---|---|
source | number | The player's server ID |
data | table | Notification payload (see below) |
| Field | Type | Description |
|---|---|---|
title | string | Required banner title |
app | string? | App-icon ID (e.g. "messages") |
image | string? | Custom icon URL, overrides app |
body | string? | Banner body text |
time | string? | Display time string (e.g. "now") |
appId | string? | The app opened when the banner is tapped |
| Return | Type | Description |
|---|---|---|
sent | boolean | false on a non-number source or a payload without a string title |
Example
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
local sent = exports['sd-phone']:notifyNumber(number, data)| Parameter | Type | Description |
|---|---|---|
number | string | The recipient's phone number in any formatting |
data | table | Notification payload, same contract as notify |
| Return | Type | Description |
|---|---|---|
sent | boolean | false for a digitless number, an unassigned number, or an offline owner |
Example
-- 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
local sent = exports['sd-phone']:emergencyAlert(source, data)| Parameter | Type | Description |
|---|---|---|
source | number | The player's server ID, or -1 for every online player |
data | table | Notification payload, same contract as notify |
| Return | Type | Description |
|---|---|---|
sent | boolean | false 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
-- 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
local result = exports['sd-phone']:importRaceTrack(data, authorName)| Parameter | Type | Description |
|---|---|---|
data | table | One track table, or an array of them |
authorName | string? | Credited author, defaults to "Imported" |
| Track field | Type | Description |
|---|---|---|
name | string | Track name |
mode | string? | "sprint" or "circuit" (default) |
gates | table | Gate list, each { {ax,ay,az}, {bx,by,bz} } |
| Return | Type | Description |
|---|---|---|
result.imported | integer | Tracks saved |
result.failed | table[] | { index, name, reason } per skipped entry |
Example
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.jsonThe 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
local result = exports['sd-phone']:sendMessage(source, payload)| Parameter | Type | Description |
|---|---|---|
source | number | The acting player's server ID; the sender's identity resolves from it |
payload | table | Composer payload (see below) |
| Field | Type | Description |
|---|---|---|
conversation | string | A phone number, or "g-<groupId>" for a group thread |
body | string | Message body |
kind | string? | Optional bubble kind, same whitelist as the composer |
gifUrl | string? | Media URL for image and gif kinds |
amount | number? | Amount for money kinds, banking-validated |
duration | number? | Duration for voice-note kinds |
wpCode | string? | Waypoint code for the location kind |
wpSub | string? | Location label for the location kind |
| Return | Type | Description |
|---|---|---|
result | table | The standard envelope |
Example
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
local delivered = exports['sd-phone']:sendSystemMessage(senderNumber, senderName, targetNumber, body, opts)| Parameter | Type | Description |
|---|---|---|
senderNumber | string | Service short code the recipient's thread files under, capped at 32 characters |
senderName | string | Display name for the banner and thread header, capped at 64 characters |
targetNumber | string | The recipient's phone number in any formatting |
body | string | Message body, capped at the configured maximum length |
opts | table? | Optional presentation kind (see below) |
| Field | Type | Description |
|---|---|---|
kind | string? | "image", "gif", or "location". Anything outside the whitelist is delivered as plain text |
gifUrl | string? | Media URL for the image and gif kinds |
wpCode | string? | Waypoint code for the location kind |
wpSub | string? | Location label for the location kind |
| Return | Type | Description |
|---|---|---|
delivered | boolean | false 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
-- 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
local result = exports['sd-phone']:startCall(source, number)| Parameter | Type | Description |
|---|---|---|
source | number | The acting caller's server ID |
number | string | The number to dial, any formatting |
| Return | Type | Description |
|---|---|---|
result | table | { success = true, data = { channel } } on success, { success = false, message } otherwise. channel is the voice call channel |
Example
local result = exports['sd-phone']:startCall(source, '555-0142')
if not result.success then
print('Call failed: ' .. (result.message or 'unknown'))
endstartGroupCall
Ring several players at once on a caller's behalf, for example a dispatch line ringing every on-duty unit.
Syntax
local result = exports['sd-phone']:startGroupCall(source, targetSources, displayName, displayNumber)| Parameter | Type | Description |
|---|---|---|
source | number | The acting caller's server ID |
targetSources | table | Array of recipient server IDs. Unresolvable entries are dropped and the scan is bounded at 64 |
displayName | string | What the caller sees they are calling (e.g. "Police"). Required non-empty, capped at 40 characters |
displayNumber | string? | Optional display number shown to recipients |
| Return | Type | Description |
|---|---|---|
result | table | Same envelope as startCall. Recipients who are the caller, busy, or in airplane mode are filtered out |
Example
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
local call = exports['sd-phone']:getCurrentCall(source)| Parameter | Type | Description |
|---|---|---|
source | number | The player's server ID |
| Return | Type | Description |
|---|---|---|
call | table? | { 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
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))
endisInCall
Check whether a player is currently in a call or pending ring. Boolean shorthand over getCurrentCall.
Syntax
local inCall = exports['sd-phone']:isInCall(source)| Parameter | Type | Description |
|---|---|---|
source | number | The player's server ID |
| Return | Type | Description |
|---|---|---|
inCall | boolean | true while the player is in a call or being rung |
Example
if exports['sd-phone']:isInCall(source) then
return notifyPlayer(source, 'Finish your call first.')
endendCallFor
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
local result = exports['sd-phone']:endCallFor(source)| Parameter | Type | Description |
|---|---|---|
source | number | The player's server ID |
| Return | Type | Description |
|---|---|---|
result | table | { success = boolean, message = string? }. Idempotent: a player not in any call returns success |
Example
-- 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
local result = exports['sd-phone']:logCall(source, payload)| Parameter | Type | Description |
|---|---|---|
source | number | The player's server ID |
payload | table | { number, name?, direction?, duration? } |
| Return | Type | Description |
|---|---|---|
result | table | The standard envelope |
Example
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
local contacts = exports['sd-phone']:getContacts(source)| Parameter | Type | Description |
|---|---|---|
source | number | The player's server ID |
| Return | Type | Description |
|---|---|---|
contacts | table? | Array of contact tables, or nil when the player cannot be resolved |
Example
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
local result = exports['sd-phone']:addContact(source, fields)| Parameter | Type | Description |
|---|---|---|
source | number | The acting player's server ID |
fields | table | Contact fields (see below) |
| Field | Type | Description |
|---|---|---|
phone | string | The contact's phone number, any formatting |
name | string? | Display name |
email | string? | Email address |
address | string? | Street address |
avatar | string? | Avatar image URL |
| Return | Type | Description |
|---|---|---|
result | table | The standard envelope |
Example
-- 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
local result = exports['sd-phone']:removeContactByNumber(source, number)| Parameter | Type | Description |
|---|---|---|
source | number | The acting player's server ID |
number | string | The number to remove, any formatting |
| Return | Type | Description |
|---|---|---|
result | table | { success, data = { removed = n } }. A number matching nothing still succeeds with removed = 0 |
Example
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
local contact = exports['sd-phone']:getContactByNumber(source, number)| Parameter | Type | Description |
|---|---|---|
source | number | The acting player's server ID |
number | string | The number to look up, any formatting |
| Return | Type | Description |
|---|---|---|
contact | table? | The contact table, or nil when the player, the digits, or a matching contact cannot be resolved |
Example
local contact = exports['sd-phone']:getContactByNumber(source, callerNumber)
local display = contact and contact.name or callerNumberisNumberBlocked
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
local blocked = exports['sd-phone']:isNumberBlocked(source, number)| Parameter | Type | Description |
|---|---|---|
source | number | The player whose block list is checked |
number | string | The number to check, any formatting |
| Return | Type | Description |
|---|---|---|
blocked | boolean | true when the number is on the player's block list |
Example
-- 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)
endThe 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
local result = exports['sd-phone']:sendMail(mail)| Parameter | Type | Description |
|---|---|---|
mail | table | Mail payload (see below) |
| Field | Type | Description |
|---|---|---|
to | string|string[] | One address or a list. Deduped and capped at 20 recipients |
subject | string? | Truncated to the compose cap |
body | string? | Truncated to the compose cap |
from | table? | { 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:
| Shape | Renders 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 |
| Return | Type | Description |
|---|---|---|
result | table | { success = boolean, delivered = number }. Unregistered addresses are silently skipped; delivered counts the ones that existed |
Example
-- 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.' },
},
})
endsendMailFromPlayer
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
local result = exports['sd-phone']:sendMailFromPlayer(source, payload)| Parameter | Type | Description |
|---|---|---|
source | number | The acting player's server ID; the sender's identity resolves from it |
payload | table | { fromEmail, to = string[], subject?, body?, attachments? }. Attachments use the tagged-table shapes documented under sendMail (the plain-string photo shorthand applies to sendMail only) |
| Return | Type | Description |
|---|---|---|
result | table | The standard envelope; data.sent is the serialized sent copy on success |
Example
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
local accounts = exports['sd-phone']:getMailAccounts(source)| Parameter | Type | Description |
|---|---|---|
source | number | The player's server ID |
| Return | Type | Description |
|---|---|---|
accounts | table[] | { id, name, email } per account. Empty when the source is offline or signed into nothing |
Example
local accounts = exports['sd-phone']:getMailAccounts(source)
for _, acc in ipairs(accounts) do
print(acc.email)
endgetMailAddresses
Get the same account shape keyed by citizenid instead of a live source. Works for offline players.
Syntax
local accounts = exports['sd-phone']:getMailAddresses(citizenid)| Parameter | Type | Description |
|---|---|---|
citizenid | string | The character's framework identifier |
| Return | Type | Description |
|---|---|---|
accounts | table[] | { 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
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
local exists = exports['sd-phone']:mailAddressExists(email)| Parameter | Type | Description |
|---|---|---|
email | string | The address to check |
| Return | Type | Description |
|---|---|---|
exists | boolean | true when a registered account owns the address. Non-string or empty input is false |
Example
if exports['sd-phone']:mailAddressExists('payroll@lscustoms.com') then
-- safe to reference in a reply
endgetMailbox
Read a mailbox's messages, in the same serialized shape the app renders.
Syntax
local messages = exports['sd-phone']:getMailbox(email, folder)| Parameter | Type | Description |
|---|---|---|
email | string | The account address |
folder | string? | One of inbox, drafts, sent, spam, bin. Omit for every message in the account |
| Return | Type | Description |
|---|---|---|
messages | table[]? | 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
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
local ok = exports['sd-phone']:addBankTransaction(identifier, data)| Parameter | Type | Description |
|---|---|---|
identifier | string | The recipient character's citizenid |
data | table | Transaction fields (see below) |
| Field | Type | Description |
|---|---|---|
label | string | Transaction label shown in the Wallet list |
amount | number | Signed amount: positive = money in, negative = money out |
category | string? | Optional category |
counterparty | string? | Who the money came from or went to |
notify | boolean|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 |
| Return | Type | Description |
|---|---|---|
ok | boolean | true 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
-- 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
local rows = exports['sd-phone']:getBankTransactions(citizenid, limit)| Parameter | Type | Description |
|---|---|---|
citizenid | string | The owning character's citizenid |
limit | number? | Row cap, defaults to the configured transaction limit, floored and clamped to 1..100 |
| Return | Type | Description |
|---|---|---|
rows | table[]? | 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
local rows = exports['sd-phone']:getBankTransactions(citizenid, 10)
for _, row in ipairs(rows or {}) do
print(row.label, row.amount)
endThe 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
exports['sd-phone']:pushBadges(source)| Parameter | Type | Description |
|---|---|---|
source | number | The player's server ID. A non-number source is a silent no-op |
Example
-- 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
local counts = exports['sd-phone']:getBadgeCounts(source)| Parameter | Type | Description |
|---|---|---|
source | number | The player's server ID |
| Return | Type | Description |
|---|---|---|
counts | table? | { 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
local counts = exports['sd-phone']:getBadgeCounts(source)
if counts and counts.messages > 0 then
-- they have unread texts
endThe 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
local photos = exports['sd-phone']:getPhotos(source, opts)| Parameter | Type | Description |
|---|---|---|
source | number | The acting player's server ID; the gallery owner resolves from it |
opts | table? | { limit = number?, filter = 'favorites'|'videos'|nil } |
| Return | Type | Description |
|---|---|---|
photos | table[] | Always an array, empty when nothing resolves |
Each entry carries:
| Field | Type | Description |
|---|---|---|
id | string | Photo row ID |
url | string | Hosted media URL |
isVideo | boolean | Whether the URL points at a video, read off the extension |
favorite | boolean | Whether the owner starred it |
timestamp | number | Capture 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
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
endgetPhotosByIdentifier
The same read keyed by owner identifier rather than a live server ID, for offline owners and for callers holding a phone number.
Syntax
local photos = exports['sd-phone']:getPhotosByIdentifier(citizenid, opts)| Parameter | Type | Description |
|---|---|---|
citizenid | string | The owner's framework per-character identifier |
opts | table? | Same shape and defaults as getPhotos |
| Return | Type | Description |
|---|---|---|
photos | table[] | Same entry shape as getPhotos |
Example
Starting from a phone number, resolve the owner with getIdentifierByNumber first:
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
local result = exports['sd-phone']:addPhoto(source, url)| Parameter | Type | Description |
|---|---|---|
source | number | The acting player's server ID; the gallery owner resolves from it |
url | string | An http(s) URL of the hosted media, capped at 512 bytes |
| Return | Type | Description |
|---|---|---|
result | table | { success = boolean, photo = table? } |
Example
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
local accepted = exports['sd-phone']:uploadMedia(dataUrl, filename, cb)| Parameter | Type | Description |
|---|---|---|
dataUrl | string | The media as a base64 data-URL (data:image/... or data:video/...) |
filename | string? | Suggested filename stored alongside the upload |
cb | function | function(url, err) called exactly once: url is the hosted URL on success, err a reason string on failure |
| Return | Type | Description |
|---|---|---|
accepted | boolean | false 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
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
local exists = exports['sd-phone']:accountExists(app, username)| Parameter | Type | Description |
|---|---|---|
app | string | One of the account app keys |
username | string | The account username |
| Return | Type | Description |
|---|---|---|
exists | boolean | true when the account exists. Non-string or blank arguments return false |
Example
if exports['sd-phone']:accountExists('birdy', 'weazelnews') then
-- the handle is taken
endgetAppAccount
Get one account in its public shape. Never returns the password hash. Read-only.
Syntax
local account = exports['sd-phone']:getAppAccount(app, username)| Parameter | Type | Description |
|---|---|---|
app | string | One of the account app keys |
username | string | The account username |
| Return | Type | Description |
|---|---|---|
account | table? | { username, name, email, phone }, or nil on an unknown app, malformed arguments, or no such account |
Example
local account = exports['sd-phone']:getAppAccount('photogram', 'lifeinvader')getSessionAccount
Get the account a citizen is currently signed into for an app. Read-only.
Syntax
local account = exports['sd-phone']:getSessionAccount(app, citizenid)| Parameter | Type | Description |
|---|---|---|
app | string | One of the account app keys |
citizenid | string | The character's framework identifier |
| Return | Type | Description |
|---|---|---|
account | table? | Same public shape as getAppAccount. nil means "not signed in", not an error |
Example
local account = exports['sd-phone']:getSessionAccount('ryde', citizenid)
if account then
print('Signed into Ryde as ' .. account.username)
endThe 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
local group = exports['sd-phone']:getActiveGroup(source)| Parameter | Type | Description |
|---|---|---|
source | number | The player's server ID |
| Return | Type | Description |
|---|---|---|
group | table? | The export view, or nil when the player is not connected, has no active group, or the group has been disbanded |
Example
-- 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.')
endgetActiveGroupId
Get just a player's active group ID. A cheap one-row read, useful as a precheck before pulling the full view.
Syntax
local groupId = exports['sd-phone']:getActiveGroupId(source)| Parameter | Type | Description |
|---|---|---|
source | number | The player's server ID |
| Return | Type | Description |
|---|---|---|
groupId | string? | The active group's ID, or nil when no active group is set |
Example
local groupId = exports['sd-phone']:getActiveGroupId(source)getGroup
Get the export view of a specific group by ID.
Syntax
local group = exports['sd-phone']:getGroup(groupId)| Parameter | Type | Description |
|---|---|---|
groupId | string | The group's ID |
| Return | Type | Description |
|---|---|---|
group | table? | The export view, or nil when the group does not exist |
Example
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
endThe 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
local companies = exports['sd-phone']:getCompanyDirectory()| Return | Type | Description |
|---|---|---|
companies | table[] | { id, name, location, color, emoji, canCall, callNumber, coords } per company, in config order |
Example
for _, company in ipairs(exports['sd-phone']:getCompanyDirectory()) do
print(company.name, company.callNumber)
endmessageCompany
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
local result = exports['sd-phone']:messageCompany(source, payload)| Parameter | Type | Description |
|---|---|---|
source | number | The acting player's server ID; the sender's identity resolves from it |
payload | table | { job, kind?, body, mediaUrl?, wpCode?, wpSub? } |
| Return | Type | Description |
|---|---|---|
result | table | { success = boolean, message = string? } |
Example
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
local articleId, reason = exports['sd-phone']:postArticle(article)| Parameter | Type | Description |
|---|---|---|
article | table | Article draft (see below) |
| Field | Type | Description |
|---|---|---|
category | string | Must be one of the configured categories |
headline | string | Required |
dek | string? | Subheadline |
body | string|string[] | A single string or a paragraph array |
image | string? | Header image URL |
featured | boolean? | true makes this the hero article |
author | string? | Byline, defaults to Weazel News |
| Return | Type | Description |
|---|---|---|
articleId | integer? | The new article's ID, or nil on a validation failure |
reason | string? | 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
-- 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) endsetBreakingTicker
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
local replaced = exports['sd-phone']:setBreakingTicker(lines)| Parameter | Type | Description |
|---|---|---|
lines | string[] | Ticker lines in display order. An empty array clears the ticker |
| Return | Type | Description |
|---|---|---|
replaced | boolean | false for a non-table argument, which leaves the ticker untouched |
Example
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
local delivered = exports['sd-phone']:giveTrack(source, track)| Parameter | Type | Description |
|---|---|---|
source | number | The recipient's server ID, must be an online player |
track | table | Track fields (see below). Extra fields ride along untouched |
| Field | Type | Description |
|---|---|---|
title | string | Required, non-empty |
url | string | Required, non-empty audio URL |
artist | string? | Artist name |
artwork | string? | Cover art URL |
duration | number? | Track length in seconds |
| Return | Type | Description |
|---|---|---|
delivered | boolean | false for an offline source or a malformed track |
Example
-- 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
local color = exports['sd-phone']:hasPhone(source)| Parameter | Type | Description |
|---|---|---|
source | number | The player's server ID |
| Return | Type | Description |
|---|---|---|
color | string? | 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
if not exports['sd-phone']:hasPhone(source) then
return notifyPlayer(source, 'You need a phone for this job.')
endusePhone
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
exports['sd-phone']:usePhone(event, item, inv, slot, data)| Parameter | Type | Description |
|---|---|---|
event | string | ox_inventory dispatch phase; only usingItem acts |
item | table | Item data from ox_inventory |
inv | table | The holder's inventory; inv.id is the acting player |
slot | number | The item's slot |
data | table | Extra 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
local number = exports['sd-phone']:giveSimCard(source, opts)| Parameter | Type | Description |
|---|---|---|
source | number | The receiving player's server ID |
opts | table? | { number?, citizenid? }. number requests a specific number and fails if it is taken |
| Return | Type | Description |
|---|---|---|
number | string? | The SIM's bare-digit number, or nil when creation or the inventory give failed |
Example
-- 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))
endgetSimNumber
Get the SIM number installed in a player's active phone.
Syntax
local number = exports['sd-phone']:getSimNumber(source)| Parameter | Type | Description |
|---|---|---|
source | number | The player's server ID |
| Return | Type | Description |
|---|---|---|
number | string? | Bare-digit SIM number, or nil without an active SIM |
hasSim
Whether the player's active phone has a SIM installed.
Syntax
local installed = exports['sd-phone']:hasSim(source)| Parameter | Type | Description |
|---|---|---|
source | number | The player's server ID |
| Return | Type | Description |
|---|---|---|
installed | boolean | true 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
local active = exports['sd-phone']:isSimModeActive()| Return | Type | Description |
|---|---|---|
active | boolean | true 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
local free = exports['sd-phone']:isNumberAvailable(number)| Parameter | Type | Description |
|---|---|---|
number | string | Phone number in any formatting |
| Return | Type | Description |
|---|---|---|
free | boolean | true 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
local ok, err = exports['sd-phone']:setSimNumber(source, number)| Parameter | Type | Description |
|---|---|---|
source | number | The player's server ID |
number | string | Requested number; digits are kept, 3 to 15 of them |
| Return | Type | Description |
|---|---|---|
ok | boolean | true on success |
err | string? | On failure: 'invalid' (bad input or SIM mode off), 'no_sim', or 'taken' |
Example
-- 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
endDocuments
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
local docId, err = exports['sd-phone']:createDocument(source, opts)| Parameter | Type | Description |
|---|---|---|
source | number | The receiving player's server ID |
opts | table | See fields below; name is required |
opts field | Type | Description |
|---|---|---|
name | string | Display name (max length per config) |
kind | string? | 'text' (default), 'image', or 'file' |
content | string? | Body for text documents. Lines that are exactly one http(s) URL render as inline images in the read-only view |
url | string? | http(s) URL for image/file documents |
folder | string? | Root folder name — resolved case-insensitively, created if absent |
locked | boolean? | Read-only for the player — no editing, renaming, moving, or sharing (deleting stays allowed unless deletable = false) |
signable | boolean? | Pass false to forbid signing this document (default signable; see Document signatures) |
deletable | boolean? | 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 |
notify | boolean? | Notification banner on delivery (default true) |
| Return | Type | Description |
|---|---|---|
docId | string? | The new document's id, or nil on refusal |
err | string? | Refusal message when docId is nil (caps hit, bad input, …) |
Example
-- 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)
endExample — one document mixing paragraphs and inline images
-- 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
lockedand 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
-- 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
local docId, err = exports['sd-phone']:createDocumentForNumber(number, opts)| Parameter | Type | Description |
|---|---|---|
number | string | Phone number in any formatting |
opts | table | Identical to createDocument |
| Return | Type | Description |
|---|---|---|
docId | string? | The new document's id, or nil ('Number not in service', …) |
err | string? | 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
local docs = exports['sd-phone']:getPlayerDocuments(source, folderName)| Parameter | Type | Description |
|---|---|---|
source | number | The player's server ID |
folderName | string? | Optional root folder name filter (case-insensitive) |
| Return | Type | Description |
|---|---|---|
docs | table[] | 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
local content = exports['sd-phone']:getDocumentContent(source, docId)| Parameter | Type | Description |
|---|---|---|
source | number | The player's server ID |
docId | string | The document id |
| Return | Type | Description |
|---|---|---|
content | string? | The document body, or nil when it doesn't exist or isn't theirs |
Example
-- 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) enddeleteDocumentById
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
local removed = exports['sd-phone']:deleteDocumentById(source, docId)| Parameter | Type | Description |
|---|---|---|
source | number | The player's server ID |
docId | string | The document id |
| Return | Type | Description |
|---|---|---|
removed | boolean | true 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
local signed = exports['sd-phone']:isDocumentSigned(source, docId)| Parameter | Type | Description |
|---|---|---|
source | number | The player's server ID |
docId | string | The document id |
| Return | Type | Description |
|---|---|---|
signed | boolean | true when at least one signature exists; false for an unsigned, missing, or not-theirs document |
Example
-- 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
local sigs = exports['sd-phone']:getDocumentSignatures(source, docId)| Parameter | Type | Description |
|---|---|---|
source | number | The player's server ID |
docId | string | The document id |
| Return | Type | Description |
|---|---|---|
sigs | table[] | Signature rows { id, signer, image, signedAt }; always an array, empty when the document doesn't exist, isn't theirs, or is unsigned |
sigs entry field | Type | Description |
|---|---|---|
signer | string | The signer's display name, frozen at signing time |
image | string? | PNG data-URL snapshot of the drawn signature |
signedAt | number | Epoch seconds of the signing moment |
Example — a dealership that releases the keys once the buyer signs
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
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, ', '))
endINFO
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
exports['sd-phone']:unlockApp(source, appId)Parameters
| Field | Type | Description |
|---|---|---|
source | number | Player server id |
appId | string | App identifier, as configs/apps.lua or addCustomApp names it |
Returns
| Field | Type | Description |
|---|---|---|
ok | boolean | false 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
if lootTier >= 3 then
exports['sd-phone']:unlockApp(source, 'darkchat')
endrevokeApp
Takes a permanent unlock back.
Syntax
exports['sd-phone']:revokeApp(source, appId)Returns
| Field | Type | Description |
|---|---|---|
removed | boolean | false when the character did not have that unlock |
hasAppUnlock
Whether a character currently holds a permanent unlock. Read-only.
Syntax
exports['sd-phone']:hasAppUnlock(source, appId)Returns
| Field | Type | Description |
|---|---|---|
unlocked | boolean | false 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
local serial, message = exports['sd-phone']:mdtRegisterWeapon(data)| Parameter | Type | Description |
|---|---|---|
data | table | The firearm (see below) |
| Field | Type | Description |
|---|---|---|
name | string | Display name of the firearm, for example Combat Pistol. Required |
serial | string? | The serial on the frame. Omit it and a unique one is minted and returned |
class | string? | One of pistol, smg, rifle, shotgun, sniper, melee, other. Defaults to other |
owner | string? | Citizenid the firearm is registered to. Omit for an unregistered frame |
notes | string? | Free text shown on the record, for example where it was sold |
registeredBy | string? | Who to record as having filed it. A citizenid, or a marker such as SHOP |
| Return | Type | Description |
|---|---|---|
serial | string|false | The serial it was filed under, or false on refusal |
message | string? | Reason when serial is false |
Example
-- 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))
endRefusals 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
local weapon = exports['sd-phone']:mdtGetWeapon(serial)| Return | Type | Description |
|---|---|---|
weapon | table|nil | nil 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
local list = exports['sd-phone']:mdtGetWeaponsByOwner(citizenid)| Return | Type | Description |
|---|---|---|
list | table[] | 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
local ok, message = exports['sd-phone']:mdtSetWeaponStatus(serial, status, byCitizenid)| Parameter | Type | Description |
|---|---|---|
serial | string | The serial on the frame |
status | string | One of registered, stolen, seized, destroyed |
byCitizenid | string? | Who to record as having changed it |
| Return | Type | Description |
|---|---|---|
ok | boolean | false when the serial is not on file or the status is not one of the four |
message | string? | Reason when ok is false |
Example
-- 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
local wanted = exports['sd-phone']:mdtIsWanted(citizenid)| Return | Type | Description |
|---|---|---|
wanted | boolean | false 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.
