X3D Phone
Free ESX smartphone: pma-voice calls, SMS with location sharing, mobile banking, a camera with self-hosted photos, Buzz and anonymous Dark Chat.
Buy / Get freeConfig builderInstallation
- Extract the zip and put the
x3d_phonefolder inresources/. Do not rename the folder: every export and event uses this name. - Run
install.sqlon your server database. It is safe to run again and keeps existing data. - If your server has no
phoneitem, runoptional/phone_item.sql. With ox_inventory, add the item toox_inventory/data/items.luainstead; the file has an example. - If your server has no
screenshot-basic, moveoptional/screenshot-basic/intoresources/. You can skip this; only the camera needs it. - Add these lines to
server.cfg, in this order:
ensure oxmysql
ensure es_extended
ensure pma-voice
ensure screenshot-basic
ensure x3d_phone
Players open the phone with F1, the /openphone command, or by using the phone item. While the phone is open, Alt switches to game controls; press it again to get the cursor back.
Requirements
| Resource | Required | Used for |
|---|---|---|
es_extended |
no | ESX Legacy. Without it the script runs standalone and you wire banking and billing yourself in addons/ (see Config.Framework) |
oxmysql |
yes | database |
pma-voice |
no | call audio; without it calls connect but carry no audio |
screenshot-basic |
no | the camera; a copy is bundled in optional/ |
chat (FiveM) |
no | post cards in chat |
No image host, no API key, and no other software to install.
All settings: config/config.lua
General
| Setting | Default | Meaning |
|---|---|---|
Config.Locale |
'th' |
Language of the phone and its messages: 'th' or 'en' |
Config.Framework |
'auto' |
'auto' (ESX if present, else standalone) | 'esx' | 'standalone' |
Config.Debug |
false |
true prints logs to the server console |
Config.OpenKey |
'F1' |
Key that opens the phone. Players can rebind it in Settings > Key Bindings > FiveM. This value only applies to a player's first join. |
Config.DefaultContacts |
{} |
Contacts added to every new number, e.g. { phone = '911', name = 'Police' } |
Phone item
| Setting | Default | Meaning |
|---|---|---|
Config.UseItem |
true |
The item must be in the inventory to open the phone, call, answer and get SMS alerts |
Config.PhoneItem |
'phone' |
Item name. You can use one your server already has, e.g. 'iphone' |
With UseItem on:
- No item: the phone does not open, and every server callback refuses. Triggering events by hand does not get past this.
- A player without the item cannot be called. The caller sees "unavailable" and the call is logged as missed.
- SMS are still stored, but no alert shows until the player has the item.
- If the item leaves the inventory while the phone is open (dropped or taken), the phone closes and any call ends.
Calls
| Setting | Default | Meaning |
|---|---|---|
Config.UseVoice |
true |
Use pma-voice for call audio |
Config.CallChannelStart |
1000 |
First pma-voice channel used for calls. Change it only if it collides with another script. |
Config.CallTimeout |
30 |
Seconds before an unanswered call ends |
Config.Ringtones |
5 sounds | Ringtones; each maps to a GTA V sound |
Config.NotificationSounds |
3 sounds | Message alert sounds |
Config.RingtonePreviewMs |
3000 |
Length of /testringtone and /testdialtone |
Config.Ringtones,Config.NotificationSoundsandConfig.Wallpapersare what the server checks a player's choice against. You can remove an option in the config. Adding a new option also needs a change to the phone's web UI.
Messages
Emoji work in every text box (SMS, Buzz, Dark Chat), with a picker holding all 3,600+ emoji in 9 groups plus search. Lengths are counted in real characters, so Thai text and emoji are not over-counted.
| Setting | Default | Meaning |
|---|---|---|
Config.MaxMessageLength |
500 |
Maximum length of an SMS or a Dark Chat message |
Buzz and post card skins
| Setting | Default | Meaning |
|---|---|---|
Config.Buzz.ToChat |
true |
New posts appear in everyone's in-game chat |
Config.Buzz.Themes |
all 5 | Skins on offer: default, halloween, christmas, songkran, night |
Config.Buzz.DefaultTheme |
'default' |
Skin for players who have not picked one |
Config.Buzz.PlayerTheme |
true |
true: players pick their skin in Settings. false: everyone uses DefaultTheme. |
Example: lock the whole server to Halloween for the event:
Config.Buzz = {
ToChat = true,
Themes = { 'halloween' },
DefaultTheme = 'halloween',
PlayerTheme = false,
}
Post cards use FiveM's standard chat resource. x3d_phone adds its own styles through chat_theme and leaves your chat theme alone. Post text is always escaped, so players cannot inject HTML into chat.
Service numbers (police / EMS / mechanic) and job scripts
| Setting | Default | Meaning |
|---|---|---|
Config.Dispatch.Numbers |
police, ambulance, mechanic | Service numbers: number = { label = 'Shown name', jobs = { 'job' }, coords = true } |
Config.Dispatch.OnDutyOnly |
false |
true delivers only to players on duty (ESX Legacy job.onDuty) |
- Service numbers appear as special contacts for everyone and cannot be edited or deleted.
- Texting
policedelivers[Police] ...to every online player with a job injobswho has a phone. It arrives from the sender's own number with their location, so a tap sets a GPS waypoint and a reply goes straight back to the sender. - The location comes from the character's position on the server, so it cannot be faked. One message per service every 20 seconds.
- If nobody from the service is online, the sender is told.
Hooking up job scripts: the police, ambulance and mechanic numbers already ship in the config, and your job scripts can add or remove numbers with these events.
| Event | What it does |
|---|---|
TriggerEvent('x3d_phone:registerNumber', number, label, isDispatch, hasCoords) (server) |
Adds a service number from a job script (an existing number is left alone) |
TriggerEvent('x3d_phone:removeNumber', number) (server) |
Removes a registered number (numbers in the config stay) |
TriggerServerEvent('x3d_phone:send', number, message, anonymous) |
Scripts that alert the police automatically (robberies etc.) send a message to a service number. With anonymous = true the message comes from the service number instead of the player's. |
Job scripts that call the old
esx_phone:*event names do not work with x3d_phone; point them at the events above.
Dispatch app
Responders (players with a job in jobs) get a Dispatch app. Each call shows a title, message, distance, age and the caller's number, plus a flashing map blip, a notification and a sound.
- Respond sets a GPS waypoint, tells the caller "X accepted and is on the way", and shows the other responders that someone has it.
- Close call removes it from every phone and map. Calls nobody closes expire after
AlertSeconds. - Calls come from texts to a service number, or from other scripts requesting a service.
| Setting | Default | Meaning |
|---|---|---|
Config.Dispatch.AlertSeconds |
600 |
How long a call stays in the app |
Config.Dispatch.CallNeedsPhone |
false |
Whether the caller needs the phone item (a "call EMS" button while downed should not) |
Numbers.<number>.request |
Call title when someone requests the service, e.g. 'Person injured, EMS needed' |
|
Numbers.<number>.blip |
{ sprite, color } of the responder's map blip |
Requesting a service from other scripts (e.g. a "call EMS" button on the death screen):
-- client
exports['x3d_phone']:RequestService('ambulance', 'Unconscious in the street')
-- or
TriggerServerEvent('x3d_phone:requestService', 'ambulance', 'Unconscious in the street')
-- server (set your own title / coords)
exports['x3d_phone']:SendAlert('police', { source = src, title = 'Bank robbery', message = 'Pacific Standard' })
exports['x3d_phone']:SendAlert('police', { coords = vector3(x, y, z), title = 'Alarm triggered' })
RequestService reads the location from the caller's character on the server, so it cannot be faked, and a player can call the same service once every 60 seconds.
Bills app
| Setting | Default | Meaning |
|---|---|---|
Config.Billing.Enabled |
true |
Shows the Bills app |
Config.Billing.AllowPersonal |
true |
Allows personal bills. The money goes to the biller's bank account, even when they are offline. |
Config.Billing.AllowDecline |
true |
The recipient can decline. With false they can only pay (the biller can still cancel). |
Config.Billing.MinAmount / MaxAmount |
1 / 1000000 |
Smallest / largest bill |
Config.Billing.MaxOpenPerTarget |
10 |
Most unpaid bills one biller can have open to one number (anti-spam) |
Config.Billing.PayFrom |
'bank' |
Which account the payer pays from: 'bank' or 'money' |
Config.Billing.UsersTable |
'users' |
ESX players table, used to credit a biller who is offline |
Config.Billing.Jobs |
police, ambulance, mechanic, taxi | Jobs that can bill on behalf of the business: jobname = { label = 'Shown name', minGrade = 0, locked = true } · locked = true = the biller cannot cancel and the recipient cannot decline; it is paid or an admin cancels it in x3d_adminpanel (default: police, ambulance) |
Config.Billing.Admin |
{ enabled = true, label = 'แอดมิน', groups = { 'admin', 'superadmin' }, locked = true } |
Admins can bill as "Admin" (e.g. fines); the money goes to Billing.PayAdmin at the end of addons/billing/server.lua, credited to nobody by default |
Locked bills can only be cancelled in x3d_adminpanel → Phone → Unpaid bills (search by number or description, hold to cancel, with a cancel log).
Business bills: by default there is no society account (Billing.PaySociety returns false), so the payer is not charged and the bill stays unpaid. Wire your server's society account (for example esx_addonaccount) in addons/billing/server.lua before you use business bills.
- The recipient gets an alert at once if online. If offline, the bill waits in the app.
- A bill can be paid once; tapping repeatedly never charges twice.
- One bill every 5 seconds per player, and descriptions go through
FilterMessagelike other messages.
Billing from other scripts (server):
local ok, idOrError = exports['x3d_phone']:CreateBill(source, '48213', 500, 'Parking fine', 'police')
-- issuer: 'personal', a job name in Config.Billing.Jobs (the biller must actually have that job) or 'admin' (the biller must be in Config.Billing.Admin.groups)
Images: camera, uploads and external links
| Setting | Default | Meaning |
|---|---|---|
Config.Uploads.Enabled |
true |
Enables the camera and wallpaper uploads from the PC |
Config.Uploads.BaseUrl |
'' |
Can stay empty; see below |
Config.Uploads.MaxBytes |
3 MB | Maximum size per image (hard ceiling 8 MB) |
Config.Uploads.PerPlayerPerHour |
30 |
Uploads allowed per player per hour |
Config.Uploads.Keep |
1000 |
How many recent images to keep. After that the oldest is overwritten, and its link stops working. |
Config.Uploads.Quality |
0.85 |
JPG quality of camera photos (0–1) |
Config.ImageHosts |
imgur, ibb | External hosts players may paste image links from (chat, posts, wallpaper). {} turns external links off. |
Config.AnyImageHost |
false |
true accepts https links from any site |
Where images are stored: in x3d_phone/data/uploads/ on your server. They are served through FXServer's own game port (30120), with no outside service. Worst-case disk use is Keep × MaxBytes, about 3 GB by default, but a camera photo is normally under 400 KB.
BaseUrl: when empty, image links are stored as /i/<token>.jpg. Each player's client adds the address of the server it is connected to, so this works with no setup. Set BaseUrl only when:
- you have an https domain pointing at the server (for example through a reverse proxy), and you want photos to show inside post cards in chat.
- Example:
BaseUrl = 'https://img.myserver.com/x3d_phone'
Why cdn.discordapp.com is not a default host: Discord file links expire after about 24 hours when opened outside Discord, so the image would be gone the next day.
⚠️
AnyImageHost = true: the image host sees the IP address of every player who views the image, and you do not control what the image shows. Prefer listing the hosts you trust inImageHosts.
Mobile banking
| Setting | Default | Meaning |
|---|---|---|
Config.Banking.Enabled |
true |
Shows the Banking app |
Config.Banking.MinTransfer |
1 |
Smallest transfer |
Config.Banking.MaxTransfer |
1000000 |
Largest transfer |
Transfers go to the phone number of an online player. They use the ESX bank account and are logged in the x3d_phone_bank_history table. A transfer cannot start while another one is still running, and a player can transfer once every 3 seconds.
Copies to Discord
Add these to server.cfg. The webhook URLs stay on the server; players never see them.
set x3d_phone_upload_webhook "https://discord.com/api/webhooks/..."
set x3d_phone_log_webhook "https://discord.com/api/webhooks/..."
| Convar | What it sends |
|---|---|
x3d_phone_upload_webhook |
A copy of every photo a player takes or uploads, with name, server id, identifier and type (messages / twitter / wallpaper) |
x3d_phone_log_webhook |
Logs of SMS, posts, Dark Chat and transfers (optional) |
- Mentions are off in every post, so a player named
@everyonepings nobody. - Posts are queued, one every 1.5 seconds, to stay under Discord's rate limit. When more than 20 are waiting, new ones are skipped.
- The photo itself stays on your server, so in-game links never expire.
- Only URLs that look like real Discord webhooks are accepted.
Fitting your server: addons/
Edit these files freely. Each one works out of the box.
| File | Functions | Default |
|---|---|---|
addons/notify/client.lua |
Notify(message, kind) |
esx_notify → ox_lib → ESX.ShowNotification |
addons/voice/client.lua |
Voice.Enabled(), Voice.SetCallChannel(ch), Voice.SetMuted(bool) |
pma-voice (mumble-voip example included) |
addons/upload/client.lua |
Upload.Screenshot(kind, cb), Upload.Target(kind, cb) |
screenshot-basic → stored on your server |
addons/framework/server.lua / client.lua |
The framework bridge (Framework.*), identical across every X3D product |
ESX if present, else standalone |
addons/billing/server.lua |
Billing.Charge, Billing.Refund, Billing.PaySociety, Billing.PayPerson, Billing.PayAdmin |
Charges / refunds and pays the biller's bank through Framework.*; PaySociety returns false, wire it yourself |
addons/banking/server.lua |
Banking.GetBalances, Banking.Transfer, Banking.History |
ESX bank account |
addons/filter/server.lua |
FilterMessage(src, kind, message) |
blocks words in Config.BannedWords |
addons/log/server.lua |
PhoneLog(src, kind, data), UploadCopy(p, file) |
Discord webhooks from convars |
addons/locale/shared.lua |
T(th, en) |
follows Config.Locale |
Example: to use your own banking system, change Banking.Transfer to call it and return true, or false, 'error message'. The server has already checked the amount, the recipient and the rate limit.
Example: banned words:
Config.BannedWords = { 'badword1', 'badword2' }
Exports (client)
exports['x3d_phone']:OpenPhone()
exports['x3d_phone']:ClosePhone()
exports['x3d_phone']:IsPhoneOpen() --> boolean
exports['x3d_phone']:GetPhoneNumber() --> string | nil
exports['x3d_phone']:IsInCall() --> boolean
exports['x3d_phone']:GetCallData() --> { phoneNumber, displayName, isOutgoing } | nil
Commands
| Command | What it does |
|---|---|
/openphone |
Open or close the phone |
/testringtone |
Play your selected ringtone |
/testnotification |
Play your selected alert sound |
/testdialtone |
Play the outgoing-call tone |
Troubleshooting
| Symptom | Cause / fix |
|---|---|
| F1 says "You don't have a phone" | Config.UseItem = true and the item is not in the inventory. Give the phone item, or set UseItem = false. |
| No camera button | screenshot-basic is not ensured, or Config.Uploads.Enabled = false |
A photo fails with upload_failed |
Check the console. If it says cannot write data/uploads/, create the data/uploads/ folder in the resource. |
| Calls connect but carry no audio | pma-voice is not installed, or is ensured after x3d_phone |
| Posts do not appear in chat | Config.Buzz.ToChat = false, or the server does not run FiveM's chat resource |
| A pasted image link will not send | The host is not in Config.ImageHosts, or the link does not start with https |