Overview
RoadPhone can load your own web app inside the phone and exposes a globalwindow.roadphone API (v1.3.0) with access to phone data, native UI,
storage, permissions, events, and a server RPC bridge.
This page covers the whole lifecycle:
- Registering your app in
config.json - How players install it via the App Shop
- The full JavaScript API available to your app
- Talking to Lua — RPC to your own server scripts
A complete, runnable example resource — roadphone-customapp-demo — ships
alongside RoadPhone. Every tab in it demonstrates one part of this API.
Install it and copy the parts you need.
Registering Your App (config.json)
Custom apps are declared inpublic/static/config/config.json in the
AppStore section. AppStore is an array of app entries — add one entry
per custom app:
App Shop listing (AppInfos)
Apps withdefault: false show up in the phone’s App Shop. The store page
(description, rating, developer, size…) comes from a matching entry in the
AppInfos section of the same config.json — matched by redirect and, for
custom apps, custom_app_id:
Installing Apps as a Player
Players never touchconfig.json — they install apps entirely in-game,
exactly like on a real phone:
1
Open the App Shop
The AppShop app is preinstalled on every phone. It lists every app the
server has configured with
default: false, split into Apps and
Games tabs, with daily-rotating Featured/Top sections and search.2
Tap GET
Tapping the install button plays a short download animation, then the app
appears on the home screen, ready to open.
3
Uninstall anytime
Apps can be removed from the App Shop’s manage list or straight from the
home screen (long-press → remove). Preinstalled (
default: true) apps
cannot be uninstalled.- Metadata mode (
Config.UseMetadata = true): the list of installed apps is stored on the phone item’s metadata (installed_apps, up to 50 apps per phone). Each phone keeps its own apps — trade or steal a phone and its installed apps travel with it. - Non-metadata mode: installed apps persist in the browser’s
localStorageper client.
config.json — if the
server removes an entry, it silently disappears from players’ phones.
Quick Start
Your app runs inside an iframe within the phone, so the API lives on the parent window:Recommended startup (since 1.3.0)
For any non-trivial app, declare your identity, version-gate, and pre-load saved state at boot:Getter Functions
isDarkMode()
Returns whether dark mode is currently enabled.boolean
true if dark mode is enabled, false otherwisegetPhoneNumber()
Returns the current phone number.string
The player’s phone number (e.g.,
"1234567")getPlayerName()
Returns the character name of the phone holder, ornull if not loaded yet.
string | null
The player’s name (e.g.,
"John Doe")getJob()
Returns the player’s current job.string
The job identifier (e.g.,
"police", "ambulance", "unemployed")getIdentifier()
Returns the player’s unique identifier.string
The player identifier (format depends on framework)
getBrightness()
Returns the current screen brightness level.number
Brightness value between
10 and 100isFlightMode()
Returns whether flight mode is enabled.boolean
true if flight mode is on, false otherwisegetLanguage()
Returns the player’s currently selected phone language.string
Locale code (e.g.,
"en_US", "de_DE", "fr_FR"). Falls back to "en_US".getConfig()
Returns the full phone configuration object.object
The complete config.json configuration
Utility Functions
copyToClipboard(text)
Copies text to the clipboard (uses a CEF-safe fallback chain internally —navigator.clipboard alone is blocked in FiveM NUI).
string
required
The text to copy
post(event, data)
Sends data to a RoadPhone NUI callback in the Lua backend and resolves with its response. For calling your own resource’s server code, use thecustomAppRpc bridge built on top of this.
string
required
The NUI callback name to trigger
object
Optional data to send with the event
inputFocus(focus)
Toggles NUI keyboard focus. Required for any text input in a custom app.boolean
required
true while an input is focused, false on blur.SetNuiFocusKeepInput(true) so movement keys still reach
the game while the phone is up. The side effect: keystrokes do not reach your
inputs by default. Until you call inputFocus(true), typing into a field does
nothing — and the keys drive the player instead.
showNotification(options)
Displays a phone notification.object
required
Notification configuration object
Notifications are silently dropped while the player is in a focus mode
(Sleep, Work, Personal, Do Not Disturb).
Native UI
Custom apps can pop the same native iOS-style camera and action sheets that the built-in apps use. Both APIs are Promise-based —await the result and you’ll
receive what the user picked, or null if they backed out.
takePhoto(options)
Opens the phone’s camera, waits for the user to take a single shot, then routes back to your app and resolves with the captured image URL.object
Optional configuration object
Promise<{ url: string, isVideo: boolean } | null>
Resolves with an object describing the captured media, or
null if the user
backed out of the camera without taking a shot.claimPhoto()
Picks up a photo captured viatakePhoto() after the custom-app iframe was
reloaded by the camera round-trip. Returns the shot once (then clears it).
{ url: string, isVideo: boolean } | null
The captured media, or
null if there is nothing to claim.showBottomSheet(options)
Displays an iOS-style action sheet that slides up from the bottom of the phone with a backdrop scrim. Resolves with thekey of the row the user picked, or
null if they dismissed the sheet by tapping the scrim.
object
required
Sheet configuration object
group.rows):
Promise<string | null>
Resolves with the picked row’s
key, or null if dismissed via scrim.pickEmoji()
Opens the system emoji picker (centered floating panel with backdrop scrim) and resolves with the user’s selection.Promise<EmojiObject | null>
Resolves with the picked emoji object, or
null if the user dismissed via
scrim. The emoji object follows emoji-mart’s shape: a record with
native, name, id, shortcodes, unified, keywords fields —
native is the Unicode glyph (e.g. '😀').Combine both: take photo via action sheet
A common pattern is letting the user choose between camera and gallery:App Identity
Available since API v1.3.0.
app.setName(name)
Sets the human-readable name shown in permission prompts and as the default notification app title.string
required
Display name (max 64 chars). Used in the user-facing permission prompt:
” wants: …“.
app.setNamespace(ns)
Sets the storage / permission namespace for this app. Only[a-z0-9_-] are
kept; everything else is replaced with _. Max 64 chars.
string
required
Stable identifier — pick something short and unique to your app, like
"weather-pro" or "team_radio". Once data is stored under one namespace,
changing it makes that data unreachable.app.getName() / app.getNamespace()
Read back the values you set.getName() falls back to the localized “Custom
App” string when nothing was set.
Version & Feature Negotiation
Available since API v1.3.0.
version
Current API version string, semver-style.features
A flat object of feature flags. UsehasFeature() rather than reading this
directly.
hasFeature(name)
string
required
Feature flag name. See table below.
boolean
true if the feature is supported by this Phone.requireFeature(name)
LikehasFeature() but throws if the feature is missing. Use at startup so
your app crashes loudly rather than silently misbehaving on older Phones.
minVersion(version)
Throws if the running Phone is older thanversion. Useful as a one-line
startup gate.
Permissions
Available since API v1.3.0.
Your App wants: Read contacts [ Allow ] [ Deny ]The user’s decision is persisted in
localStorage under
roadphone_customapp_perm:{namespace}:{scope}, so they’re prompted exactly
once per scope per app — even across phone reloads. Only the user can revoke;
your app can also revoke programmatically (see revoke() below).
If the user denies, the gated call rejects with
Error: Permission '<scope>' denied by user. Wrap calls in try/catch.
Available scopes
permissions.request(scope)
Explicitly request a permission. Returns immediately if already granted/denied.string
required
One of the scopes above.
Promise<boolean>
true if granted, false if denied (or scrim-dismissed).permissions.has(scope)
Synchronously check whether a scope is currently granted.boolean
true only if explicitly granted. Denied or never-asked → false.permissions.revoke(scope)
Forget the user’s decision for this scope. The next gated call will prompt again.permissions.list()
Returns all stored decisions for this app’s namespace.Storage
Available since API v1.3.0.
Both are automatically namespaced by
app.getNamespace() — two apps with
different namespaces cannot read each other’s keys.
storage.set(key, value)
Stores a JSON-serializable value. Returnstrue on success, false if it
couldn’t serialize.
storage.get(key)
Returns the deserialized value, ornull if not set.
storage.delete(key)
Removes a single key.storage.keys()
Returns all keys for this app’s namespace (without the namespace prefix).storage.clear()
Removes all keys for this app’s namespace. Other apps’ data is untouched.storage.metadata.*
Same shape, async, persists on the phone item’s metadata so the data follows the physical phone (phone trading, swapping characters with same phone, etc.). Requires thestorage.metadata permission on first use — the
user is prompted via the standard permission sheet.
Phone Data Access
Available since API v1.3.0. All functions in this section require a
permission — see Permissions. The first call auto-prompts
the user.
Contacts
Read-only access to the player’s contact list.Promise<Contact[]>
Returns a deep clone of the contact list — mutating the result has no effect.
Messages
Read messages and send new ones.messages.send throws when the target is the player’s own number — the
server drops self-addressed messages, so the API rejects them up-front.Bank
Read-only access to the player’s active bank account.Alarms
Read existing alarms and manage them.Event System
The API includes a powerful event system that allows your custom app to react to phone state changes in real-time.on(event, callback)
Subscribe to an event.string
required
The event name to listen for
function
required
Function to call when the event fires
off(event, callback)
Unsubscribe from an event.string
required
The event name to unsubscribe from
function
required
The same function reference used when subscribing
Available Events
phoneOpened
phoneOpened
Fired when the phone is opened.
phoneClosed
phoneClosed
Fired when the phone is closed.
darkModeChanged
darkModeChanged
Fired when dark mode is toggled.Payload:
boolean - true if dark mode is now enabledbrightnessChanged
brightnessChanged
Fired when screen brightness changes.Payload:
number - Brightness value (10-100)flightModeChanged
flightModeChanged
Fired when flight mode is toggled.Payload:
boolean - true if flight mode is now enabledlanguageChanged
languageChanged
Since 1.3.0. Fired when the player switches the phone language.Payload:
string - locale code (e.g. "de_DE")appOpened
appOpened
Since 1.3.0. Fired whenever the user navigates into a top-level app
(your custom app included).Payload:
{ app: string, path: string }appis a stable ID derived from the route ("messages","contacts","custom_app:<url>"for Custom Apps).pathis the full router path.
appClosed
appClosed
Since 1.3.0. Fired when the user navigates away from an app.Payload: same shape as
appOpened. Pair the two events to track
foreground/background lifecycle.incomingCall
incomingCall
Since 1.3.0. Fired when a call rings on the phone.Payload:
{ number: string, isAnonym: boolean }callEnded
callEnded
Since 1.3.0. Fired when any call (incoming, outgoing, active) ends.Payload:
{ number: string | null }notificationReceived
notificationReceived
Since 1.3.0. Fired whenever the phone shows a banner notification —
including notifications from other apps. Useful for activity dashboards or
“do not disturb” widgets.Payload:
{ appTitle: string, title: string, message: string, icon: string | null }Talking to Lua (Server RPC)
For simple one-way pings you can
post() to any RoadPhone NUI callback. This
section is about the real thing: calling your own resource’s server code
and getting a response back.ui_page, so FiveM does not reliably route
https://<your-resource>/… fetches from inside the phone’s iframe — your app
may not be able to reach its own NUI callbacks. RoadPhone therefore ships a
generic RPC relay:
1. UI side
2. Server side
Register one server callback namedroadphone:customApp:<your-resource>
in the same framework callback system RoadPhone’s bridge uses, and dispatch by
name:
- ESX:
ESX.RegisterServerCallback(name, fn) - QBCore:
QBCore.Functions.CreateCallback(name, fn) - Qbox:
lib.callback.register(name, fn)(requires@ox_lib/init.lua) - Standalone: RoadPhone’s event-based bridge pattern
Pushing from the server to your app
SendNUIMessage() from your resource has no document to deliver to (no
ui_page) and cannot reach the iframe. Server → player pushes go through
RoadPhone’s own NUI as a notification:
appOpened event or when the user switches to the relevant tab.
FiveM Resource Setup
If you want to ship your custom app as a FiveM resource, here’s the correctfxmanifest.lua:
url in config.json at the resource:
server.cfg after roadphone:
Common Pitfalls
Text input does nothing until you call inputFocus
Text input does nothing until you call inputFocus
The phone keeps game input active while open. Tap a field, type, nothing
happens — the keys steer the player instead. See
inputFocus(); delegate focusin/focusout on the
document once and every field works.The API lives on the parent window
The API lives on the parent window
Your app is an iframe inside the phone: use
window.parent.roadphone.
window.roadphone is undefined in your realm.alert() / confirm() / prompt() crash the game
alert() / confirm() / prompt() crash the game
FiveM’s CEF has no dialog implementation — these calls crash the client.
Use an in-app toast and
showBottomSheet() as
your confirm().Any route change reloads your iframe
Any route change reloads your iframe
Navigating away from your app (including to the camera via
takePhoto())
unmounts the iframe — all in-memory state is lost and your app boots
from scratch on return. Persist view state to localStorage (or
storage.*) and restore it on boot; reclaim camera shots with
claimPhoto().Size in vh, not px
Size in vh, not px
RoadPhone scales the phone with CSS
zoom, so the iframe’s viewport grows
and shrinks with the player’s phone-size setting. A px layout doesn’t
follow — at 150% the text stays small while the screen around it grows.
Use vh: inside the iframe, 1vh = 1% of the phone screen, whatever size
it currently is.Complete Example
Here’s a complete example of a custom app that displays player information. This app is designed to fit perfectly within the phone’s iframe.Demo Resource
roadphone-customapp-demo is a full working tour of this API — every tab is a runnable example:
Install it like any custom app (copy to
resources/, ensure it after
roadphone, point an AppStore entry’s url at
https://cfx-nui-roadphone-customapp-demo/html/index.html) and use it as the
starting point for your own app — it’s MIT licensed.
API Reference
Getters & utilities
Version & identity (1.3.0)
Permissions (1.3.0)
Storage (1.3.0)
Phone data (1.3.0, permission-gated)
Events
Version
Current API Version: 1.3.0Access via:
window.roadphone.versionChangelog- 1.3.0
- Added Version & feature negotiation:
version,hasFeature,requireFeature,minVersion - Added App identity:
app.setName/setNamespace/getName/getNamespace - Added Permissions:
permissions.request/has/revoke/listwith iOS-style consent sheet - Added Storage:
storage.*(localStorage) andstorage.metadata.*(phone-item-backed, survives trades) - Added Phone data read access:
contacts.*,messages.*,bank.*,alarms.*— all permission-gated - Added Lifecycle events:
appOpened,appClosed,incomingCall,callEnded,notificationReceived,languageChanged - Added
getLanguage() - Added the
customAppRpcserver RPC bridge and the roadphone-customapp-demo example resource
- Added Version & feature negotiation:
- 1.2.0 — Added
pickEmoji()for the system emoji picker - 1.1.0 — Added
takePhoto()andshowBottomSheet()for native UI access - 1.0.0 — Initial release