Installation
Download
There are two ways to get the phone. Pick one. If you are not sure, use the Release.
Recommended: the Release (already built, ready to drop in)
This is what almost everyone should use. It is the phone fully built, with nothing left to compile. Just download, unzip, and drop it in.
- Go to the Releases page.
- Open the latest release, expand Assets, and download the file named
sd-phone-<version>.zip(for examplesd-phone-v1.0.0.zip). - Unzip it. You get a folder called
sd-phone. Drop that folder into your server'sresources. - Skip the rest of this section and continue with Dependencies below.
Download the sd-phone-...zip, NOT "Source code (zip)"
Every release also shows a Source code (zip) / Source code (tar.gz) link. That one is not built and the phone will open to a blank black screen if you use it. Only download the asset named sd-phone-<version>.zip. If you genuinely want to edit the code, read the next part.
Building from source (only if you want to edit the code)
The Source code download and git clone give you the raw project. They do not include the built phone screen (the web/build folder), so if you drop them in as-is the phone opens to a blank screen. You have to build it once yourself. It is easy, just follow these steps exactly.
First, install Node.js. Download it from nodejs.org and install the LTS version (the big green button). This gives you the npm command used below. After installing, close and reopen any terminal you had open.
Then build the phone:
Get the source: either download the ZIP from the repo (green Code button > Download ZIP) and unzip it, or run
git clone https://github.com/Samuels-Development/sd-phone.git.If you downloaded the ZIP, the folder is named
sd-phone-main. Rename it tosd-phone, or the resource will not start in-game.Open a terminal inside the
webfolder of the resource. On Windows, any of these works, pick whatever is easiest:- Copy the path and
cdto it (easiest): find thewebfolder insidesd-phone, right-click it and choose Copy as path (this copies the path with quotes already added). Open Command Prompt or PowerShell, typecd(with a space after it), paste the path, and press Enter. Example:cd "C:\FiveM\resources\sd-phone\web". - Or open the
webfolder in File Explorer, click the address bar, typecmd, and press Enter. - Or right-click inside the
webfolder and choose Open in Terminal.
You are in the right place when the terminal line ends with
...\sd-phone\web>.- Copy the path and
Run these two commands, one at a time, waiting for each to finish:
npm install
npm run build- When the second command prints something like
built in 6.07s, you are done. A newweb/buildfolder has appeared. That is the compiled phone. - Drop the whole
sd-phonefolder into your server'sresourcesand continue with Dependencies.
Edited the code later? Rebuild.
Every time you change the phone's files, run npm run build again inside the web folder. The server loads the compiled web/build folder, not your raw source edits, so nothing changes in-game until you rebuild.
Phone screen is blank or black?
This almost always means the web/build folder is missing, because the build was skipped or the "Source code" zip was used. Build it with the steps above, or just download the Release zip instead.
Dependencies
Hard requirements, both must start before the phone:
| Resource | Purpose |
|---|---|
| ox_lib | require loader, callbacks, notifications |
| oxmysql | database access |
| sd-phone-props | streams the in-hand phone models, one per frame colour |
The phone auto-detects the running framework (qb-core, qbx_core, ESX, ox_core, ND) and whichever inventory, banking, housing, garage, and voice resources are installed; there is nothing to configure for the common setups, ND included. Only ox_core needs a step: map your group types to jobs and gangs in configs/framework.lua. Calls and the Radio app carry audio over pma-voice.
1 Add the Resource
Place sd-phone and sd-phone-props in your resources folder and ensure them after the dependencies:
ensure ox_lib
ensure oxmysql
ensure sd-phone-props
ensure sd-phoneNo SQL to import
Database tables create themselves on first boot. The only SQL on this page is the ESX item list below, and that is for your inventory, not the phone.
2 Add Items
Register 8 items, one per frame colour. Each item maps to a frame colour and its matching in-hand prop.
['phone_black'] = {
label = 'Phone',
weight = 190,
stack = false,
consume = 0,
server = { export = 'sd-phone.usePhone_black' }
},
['phone_blue'] = {
label = 'Blue Phone',
weight = 190,
stack = false,
consume = 0,
server = { export = 'sd-phone.usePhone_blue' }
},
['phone_green'] = {
label = 'Green Phone',
weight = 190,
stack = false,
consume = 0,
server = { export = 'sd-phone.usePhone_green' }
},
['phone_orange'] = {
label = 'Orange Phone',
weight = 190,
stack = false,
consume = 0,
server = { export = 'sd-phone.usePhone_orange' }
},
['phone_pink'] = {
label = 'Pink Phone',
weight = 190,
stack = false,
consume = 0,
server = { export = 'sd-phone.usePhone_pink' }
},
['phone_purple'] = {
label = 'Purple Phone',
weight = 190,
stack = false,
consume = 0,
server = { export = 'sd-phone.usePhone_purple' }
},
['phone_red'] = {
label = 'Red Phone',
weight = 190,
stack = false,
consume = 0,
server = { export = 'sd-phone.usePhone_red' }
},
['phone_yellow'] = {
label = 'Yellow Phone',
weight = 190,
stack = false,
consume = 0,
server = { export = 'sd-phone.usePhone_yellow' }
},['phone_black'] = { name = 'phone_black', label = 'Phone', weight = 190, type = 'item', image = 'phone_black.png', unique = true, useable = true, shouldClose = true, description = 'A smartphone.' },
['phone_blue'] = { name = 'phone_blue', label = 'Blue Phone', weight = 190, type = 'item', image = 'phone_blue.png', unique = true, useable = true, shouldClose = true, description = 'A smartphone.' },
['phone_green'] = { name = 'phone_green', label = 'Green Phone', weight = 190, type = 'item', image = 'phone_green.png', unique = true, useable = true, shouldClose = true, description = 'A smartphone.' },
['phone_orange'] = { name = 'phone_orange', label = 'Orange Phone', weight = 190, type = 'item', image = 'phone_orange.png', unique = true, useable = true, shouldClose = true, description = 'A smartphone.' },
['phone_pink'] = { name = 'phone_pink', label = 'Pink Phone', weight = 190, type = 'item', image = 'phone_pink.png', unique = true, useable = true, shouldClose = true, description = 'A smartphone.' },
['phone_purple'] = { name = 'phone_purple', label = 'Purple Phone', weight = 190, type = 'item', image = 'phone_purple.png', unique = true, useable = true, shouldClose = true, description = 'A smartphone.' },
['phone_red'] = { name = 'phone_red', label = 'Red Phone', weight = 190, type = 'item', image = 'phone_red.png', unique = true, useable = true, shouldClose = true, description = 'A smartphone.' },
['phone_yellow'] = { name = 'phone_yellow', label = 'Yellow Phone', weight = 190, type = 'item', image = 'phone_yellow.png', unique = true, useable = true, shouldClose = true, description = 'A smartphone.' },INSERT INTO `items` (`name`, `label`, `weight`, `rare`, `can_remove`) VALUES
('phone_black', 'Phone', 190, 0, 1),
('phone_blue', 'Blue Phone', 190, 0, 1),
('phone_green', 'Green Phone', 190, 0, 1),
('phone_orange', 'Orange Phone', 190, 0, 1),
('phone_pink', 'Pink Phone', 190, 0, 1),
('phone_purple', 'Purple Phone', 190, 0, 1),
('phone_red', 'Red Phone', 190, 0, 1),
('phone_yellow', 'Yellow Phone', 190, 0, 1);3 Add Item Images
Copy the item icons from sd-phone's images/ folder into your inventory's image folder (ox_inventory/web/images/, qb-inventory/html/images/, and so on). You can also download them directly from the container below.









The files are named after the items, so ox_inventory picks them up automatically with no image field needed. sim_card.png is only needed if you turn on unique phones; see below.
4 Add Your API Keys
Third-party keys live in configs/server/apikeys.lua, which is deliberately excluded from the client download. Set them before you start the phone for the first time:
| Key | Purpose |
|---|---|
Giphy | The Messages GIF picker. Free key from developers.giphy.com; left blank the picker shows a setup hint |
FivemanageMedia | Required for the Camera, Photos and Voice Memos apps on the default provider. Photo, video, and voice-note uploads go through fivemanage.com. Use a Fivemanage token of type Media. Left blank, the uploader falls back to the legacy sd_fivemanage_key convar; with neither set, capture UI still opens but nothing uploads or saves |
QboxCdn | Only used when you switch the media provider to Qbox (see below). A token from the Qbox Dashboard CDN. Left blank, the uploader falls back to the sd_qbox_cdn_key convar |
The media apps need a media key
Camera photos and videos, Photos uploads, and Voice Memos all need somewhere to store their files. Without a key for whichever provider you are on, those apps open but captures never upload or save. On the default provider, create a free token in the Fivemanage dashboard: open the Tokens tab, click Create Token, and pick token type Media. The rest of the phone works fine without it.
Media hosting for the phone
Photos, camera clips and voice memos upload to Fivemanage and come back as fast CDN URLs, so you never run your own media server. Required for those apps: in the dashboard, open the Tokens tab, create a token of type Media, and drop it into FivemanageMedia.
Choosing a media provider
Uploads go to Fivemanage out of the box. If you already pay for a Qbox Dashboard plan, you can send them to the Qbox CDN instead — it is the same set of apps either way, only the destination changes.
-- configs/photos.lua
Provider = 'fivemanage', -- 'fivemanage' (default) or 'qbox'-- configs/server/apikeys.lua
FivemanageMedia = 'your-fivemanage-media-token',
QboxCdn = 'your-qbox-cdn-token',To get a Qbox token: sign in at dashboard.qbox.re with Discord, open CDN → API, and generate one. The free tier includes 2 GB of storage; paid tiers go up to 120 GB. Uploaded files come back as https://r2.qbox.re/... URLs, and a single file may be up to 100 MB.
Only 'qbox' switches it
Anything other than 'qbox' — including a typo — keeps uploads on Fivemanage, so your media never quietly moves somewhere you did not choose. Whichever provider is active, the server prints a startup warning naming it if its key is missing.
Each key is only read by its own provider, so it is safe to leave both filled in while you try one out. Every app that uploads — Camera, Photos, Voice Memos, bodycams, Photogram and Weazel News — follows the same switch.
5 Turn On Video Calls (TURN)
Video calls, Photogram Live and bodycams send their picture directly between the two players. That works out of the box when both are on the same network, which is why it usually looks fine while you test. Two players on different home connections need a relay server in the middle, called TURN.
Skip this step and the failure is easy to mistake for a bug: the call connects, the timer runs, the audio works, each player sees their own camera perfectly, and the other person's half of the screen stays black.
One setup covers video calls, nearby-voice capture in camera clips, Photogram Live and bodycams.
The easy way: Cloudflare (free)
Needs sd-phone v0.9.10 or newer
On v0.9.9 and older, video calls do not read the Cloudflare convars: they only accept a fixed relay through sd_phone_turn_*. If you are on an older build, either update, or use the fixed-relay option below. The Cloudflare pair still works on older builds for camera-clip audio, Photogram Live and bodycams, just not for calls.
sd-phone talks to Cloudflare's TURN service directly, so you only paste two values and it handles the rest, including refreshing credentials before they expire.
- Sign in at dash.cloudflare.com (a free account is fine).
- In the sidebar open Realtime, then the TURN tab.
- Click Create TURN key and give it any name, for example
sd-phone. - Cloudflare shows a Turn Token ID and a API Token. Copy both now; the API token is only shown once.
- Put them in your
server.cfgand restart:
set sd_cf_turn_token_id "paste-the-turn-token-id"
set sd_cf_turn_api_token "paste-the-api-token"That's it. The free tier covers a normal roleplay server, and configs/voice.lua already has Turn.Provider = 'cloudflare' switched on.
You set these once and never touch them again. The two values are a long-lived key, not a password that expires. The phone uses them to mint short-lived relay credentials automatically, re-minting shortly before each one lapses, which is roughly one request a day for the whole server. Nothing to rotate, nothing to renew.
How do I know it worked?
While no relay is configured, sd-phone prints a reminder in your server console at boot. Once the convars are set, that line disappears. To test properly you need two players on different internet connections, not two clients on one PC.
The alternative: your own TURN server
If you already run coturn, or use a provider that gives you a fixed username and password such as Metered, use these instead. They can also sit alongside the Cloudflare pair as an extra relay:
set sd_phone_turn_url "turn:turn.example.com:3478"
set sd_phone_turn_username "your-username"
set sd_phone_turn_credential "your-password"Do not paste a generated Cloudflare or Twilio credential here
These three convars are for a relay with a fixed password that you control, such as your own coturn box. Cloudflare and Twilio hand out credentials that expire within a day, and these convars have no way to renew one, so a pasted value would stop working overnight.
That is not a reason to avoid Cloudflare: use the sd_cf_turn_* pair in the section above instead, where the phone renews credentials for you and you never have to touch them.
6 Start the Resource
To load the resource, either restart your server entirely, or run the following in your server console (F8 or txAdmin live console):
refresh
ensure sd-phoneOptional item setup
SIM tray button (only for SimTray)
Skip this unless you are running unique phones with SimTray = true, where the SIM is a physical card dragged into the phone. In every other mode the phone item above is complete as-is.
In tray mode, using the phone opens the phone, so the tray needs its own right-click entry. Add a buttons field to each phone item:
buttons = {
{ label = 'SIM Tray', action = function(slot) exports['sd-phone']:openSimTray(slot) end },
},Which gives you, for example:
['phone_black'] = {
label = 'Phone',
weight = 190,
stack = false,
consume = 0,
server = { export = 'sd-phone.usePhone_black' },
buttons = {
{ label = 'SIM Tray', action = function(slot) exports['sd-phone']:openSimTray(slot) end },
}
},Note the comma added after server = { ... } once a field follows it. Repeat for every phone item you added; an item without the entry simply has no way to reach its tray.
buttons is an ox_inventory feature, which is why SIM trays are ox-only. Leaving the entry in place on a server that later switches away from tray mode is harmless, since openSimTray is a no-op outside it.
SIM card item (optional)
Only needed if you turn on unique phones in configs/uniqueandsim.lua (off by default), where phone numbers live on SIM items instead of characters. Not needed in the BuiltInNumbers variant, where phones mint their own numbers. Copy sim_card.png from sd-phone's images/ folder into ox_inventory/web/images/ like the phone icons, then add the item:
['sim_card'] = {
label = 'SIM Card',
weight = 5,
stack = false,
close = true,
consume = 0, -- required: sd-phone consumes the item itself on install
server = { export = 'sd-phone.useSim_card' }
},['sim_card'] = { name = 'sim_card', label = 'SIM Card', weight = 5, type = 'item', image = 'sim_card.png', unique = true, useable = true, shouldClose = true, description = 'A SIM card. Install it in a phone to get a number.' },INSERT INTO `items` (`name`, `label`, `weight`, `rare`, `can_remove`) VALUES
('sim_card', 'SIM Card', 5, 0, 1);That's the whole integration. Sell or spawn sim_card anywhere you like — an ox_inventory shop, a loot table, an admin give — and a blank card activates itself on first use, minting a fresh registered number on the spot. The giveSimCard export exists only for special cases (character-bound SIMs or hardcoded numbers), and ActivateBlankSims = false in configs/uniqueandsim.lua disables self-activation if you want every SIM to come through it.
Convars
| Convar | Default | Purpose |
|---|---|---|
sd_fivemanage_key | empty | Legacy location for the Fivemanage media token; prefer configs/server/apikeys.lua |
sd_qbox_cdn_key | empty | The Qbox CDN token, when Provider = 'qbox'; prefer configs/server/apikeys.lua |
sd_phone_lbcompat | true | The lb-phone compatibility layer; set false to disable |
sd_cf_turn_token_idsd_cf_turn_api_token | empty | Cloudflare TURN, used by every WebRTC feature: video calls, nearby-voice capture, Photogram Live and bodycams. See step 5 |
sd_phone_turn_urlsd_phone_turn_usernamesd_phone_turn_credential | empty | A fixed TURN server of your own (coturn, Metered), used in addition to the above |
Both are optional, and either one alone is enough. With neither, the phone falls back to public STUN: recordings still capture the player's own microphone, and video and nearby voices work only between players who can reach each other directly, which in practice means the same network.
Migrating from lb-phone
If the server previously ran lb-phone, the built-in importer carries player data over (numbers, passcodes, contacts, call history, blocked numbers, SMS including groups, photos, notes):
sdphone:migrate dry # preview, writes nothing
sdphone:migrate # import, idempotent and marker-guardedThird-party lb-phone integrations keep working through the compatibility layer without edits.

