Skip to content

Installation

Download

There are two ways to get the phone. Pick one. If you are not sure, use the Release.

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.

  1. Go to the Releases page.
  2. Open the latest release, expand Assets, and download the file named sd-phone-<version>.zip (for example sd-phone-v1.0.0.zip).
  3. Unzip it. You get a folder called sd-phone. Drop that folder into your server's resources.
  4. 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:

  1. 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.

  2. If you downloaded the ZIP, the folder is named sd-phone-main. Rename it to sd-phone, or the resource will not start in-game.

  3. Open a terminal inside the web folder of the resource. On Windows, any of these works, pick whatever is easiest:

    • Copy the path and cd to it (easiest): find the web folder inside sd-phone, right-click it and choose Copy as path (this copies the path with quotes already added). Open Command Prompt or PowerShell, type cd (with a space after it), paste the path, and press Enter. Example: cd "C:\FiveM\resources\sd-phone\web".
    • Or open the web folder in File Explorer, click the address bar, type cmd, and press Enter.
    • Or right-click inside the web folder and choose Open in Terminal.

    You are in the right place when the terminal line ends with ...\sd-phone\web>.

  4. Run these two commands, one at a time, waiting for each to finish:

sh
npm install
npm run build
  1. When the second command prints something like built in 6.07s, you are done. A new web/build folder has appeared. That is the compiled phone.
  2. Drop the whole sd-phone folder into your server's resources and 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:

ResourcePurpose
ox_librequire loader, callbacks, notifications
oxmysqldatabase access
sd-phone-propsstreams 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:

cfg
ensure ox_lib
ensure oxmysql
ensure sd-phone-props
ensure sd-phone

No 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.

lua
['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' }
},
lua
['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.' },
sql
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.

Phone Item Images9 images
Phone
phone_black.png
Blue Phone
phone_blue.png
Green Phone
phone_green.png
Orange Phone
phone_orange.png
Pink Phone
phone_pink.png
Purple Phone
phone_purple.png
Red Phone
phone_red.png
Yellow Phone
phone_yellow.png
SIM Card
sim_card.png

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:

KeyPurpose
GiphyThe Messages GIF picker. Free key from developers.giphy.com; left blank the picker shows a setup hint
FivemanageMediaRequired 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
QboxCdnOnly 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.

Fivemanage

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.

Get started with Fivemanage

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.

lua
-- configs/photos.lua
Provider = 'fivemanage',   -- 'fivemanage' (default) or 'qbox'
lua
-- 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.

  1. Sign in at dash.cloudflare.com (a free account is fine).
  2. In the sidebar open Realtime, then the TURN tab.
  3. Click Create TURN key and give it any name, for example sd-phone.
  4. Cloudflare shows a Turn Token ID and a API Token. Copy both now; the API token is only shown once.
  5. Put them in your server.cfg and restart:
cfg
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:

cfg
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):

cfg
refresh
ensure sd-phone

Optional 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:

lua
buttons = {
    { label = 'SIM Tray', action = function(slot) exports['sd-phone']:openSimTray(slot) end },
},

Which gives you, for example:

lua
['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:

lua
['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' }
},
lua
['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.' },
sql
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

ConvarDefaultPurpose
sd_fivemanage_keyemptyLegacy location for the Fivemanage media token; prefer configs/server/apikeys.lua
sd_qbox_cdn_keyemptyThe Qbox CDN token, when Provider = 'qbox'; prefer configs/server/apikeys.lua
sd_phone_lbcompattrueThe lb-phone compatibility layer; set false to disable
sd_cf_turn_token_id
sd_cf_turn_api_token
emptyCloudflare TURN, used by every WebRTC feature: video calls, nearby-voice capture, Photogram Live and bodycams. See step 5
sd_phone_turn_url
sd_phone_turn_username
sd_phone_turn_credential
emptyA 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-guarded

Third-party lb-phone integrations keep working through the compatibility layer without edits.