Skip to main content

Overview

RoadPhone can load your own web app inside the phone and exposes a global window.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:
  1. Registering your app in config.json
  2. How players install it via the App Shop
  3. The full JavaScript API available to your app
  4. 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 in public/static/config/config.json in the AppStore section. AppStore is an array of app entries — add one entry per custom app:
You can add as many custom apps as you want — one array entry per app, each with a unique custom_app_id. The admin menu (App settings tab) can also edit these entries in-game.

App Shop listing (AppInfos)

Apps with default: 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 touch config.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.
Where the install state lives:
  • 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 localStorage per client.
An installed app only appears if it still exists in 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:
window.roadphone is undefined inside your iframe — always use window.parent.roadphone. (The code samples below say window.roadphone for brevity; in a custom app that means the parent reference.)
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 otherwise

getPhoneNumber()

Returns the current phone number.
string
The player’s phone number (e.g., "1234567")

getPlayerName()

Returns the character name of the phone holder, or null 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)
Treat this as display-only. Anything that matters (money, items, permissions) must be resolved server-side from source — never trust an identifier sent up from the UI.

getBrightness()

Returns the current screen brightness level.
number
Brightness value between 10 and 100

isFlightMode()

Returns whether flight mode is enabled.
boolean
true if flight mode is on, false otherwise

getLanguage()

Returns the player’s currently selected phone language.
string
Locale code (e.g., "en_US", "de_DE", "fr_FR"). Falls back to "en_US".
Pair with the languageChanged event to re-render your UI in the new locale without forcing a reload.

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 the customAppRpc bridge built on top of this.
string
required
The NUI callback name to trigger
object
Optional data to send with the event
Always unwrap the response before reading fields. When the Lua callback replies with a table, the payload lands on res.data instead of res itself (when it replies with a JSON string, res IS the payload). Reading res.ok off the raw return value is the single most common custom-app bug:
Also stay defensive: in dev mode post() returns the string 'ok', and on network errors it returns undefined.

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.
The phone opens with 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.
Delegate on the document instead of wiring every field — it covers inputs that are rendered later, too:
Always pair it. Leaving focus on after blur means the player cannot move until they close the phone.

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.
After capture, the phone automatically routes back to your custom app — no manual navigation needed. The returned url is a public URL hosted on the configured upload provider (fivemanage by default).
In a custom app, the awaited Promise never resolves. Routing to the camera unmounts your iframe, so the realm that called takePhoto() is destroyed and your app reboots from scratch when it comes back. Do not await it — save the state you’d lose, fire the call, and reclaim the shot on boot with claimPhoto().

claimPhoto()

Picks up a photo captured via takePhoto() 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.
localStorage is keyed by origin and survives the iframe teardown, so it’s the natural place to stash the in-progress view and any form draft. Stamp it with a timestamp and ignore anything older than a minute or two — otherwise a cancelled camera trip resurrects a stale view on the next open.

showBottomSheet(options)

Displays an iOS-style action sheet that slides up from the bottom of the phone with a backdrop scrim. Resolves with the key of the row the user picked, or null if they dismissed the sheet by tapping the scrim.
object
required
Sheet configuration object
Row config (each entry in group.rows):
Promise<string | null>
Resolves with the picked row’s key, or null if dismissed via scrim.
The sheet auto-adapts to the phone’s dark mode — no need to pass theme info. It is also your confirm() replacement: window.alert/confirm/prompt have no implementation in FiveM’s CEF and crash the game — never call them.

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. '😀').
The picker auto-adapts to the phone’s current dark/light theme. No setup needed — just call and await.

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.
Custom apps can declare a stable identity that the Phone uses for two things: the storage namespace (so two apps can’t trample each other’s data) and the permission scope (so the user’s “Allow / Deny” decision is remembered per app, not globally). Set this once at startup before touching any storage or permission API.

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.
To stay forward-compatible across Phone updates, custom apps can probe the API they’re running against before calling features that may not exist on older Phones.

version

Current API version string, semver-style.

features

A flat object of feature flags. Use hasFeature() 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)

Like hasFeature() 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 than version. Useful as a one-line startup gate.
Available feature flags:

Permissions

Available since API v1.3.0.
Sensitive APIs (contacts, messages, bank, alarms, metadata storage) are gated behind a per-namespace permission. The first time your app calls a gated function, an iOS-style action sheet pops up with the prompt:
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).
You don’t usually need to call this directly — the gated APIs auto-prompt on first use. Call it explicitly only when you want to ask up-front (e.g., on app startup) instead of mid-flow.

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.
Two backends, same shape — pick based on what you want to persist. Both are automatically namespaced by app.getNamespace() — two apps with different namespaces cannot read each other’s keys.
Always call window.roadphone.app.setNamespace('your-app-id') at startup before using storage. Otherwise everything goes into the shared default namespace and may collide with other custom apps.

storage.set(key, value)

Stores a JSON-serializable value. Returns true on success, false if it couldn’t serialize.

storage.get(key)

Returns the deserialized value, or null 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 the storage.metadata permission on first use — the user is prompted via the standard permission sheet.
Use storage.* for cosmetic/UI state (theme, last-opened tab) and storage.metadata.* for data the user expects to follow their phone (saved cities, preferences, login tokens for your external service).

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.
Alarms created by your app get an id prefixed with customapp-{namespace}- so you can identify them later in list().

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

Fired when the phone is opened.
Fired when the phone is closed.
Fired when dark mode is toggled.Payload: boolean - true if dark mode is now enabled
Fired when screen brightness changes.Payload: number - Brightness value (10-100)
Fired when flight mode is toggled.Payload: boolean - true if flight mode is now enabled
Since 1.3.0. Fired when the player switches the phone language.Payload: string - locale code (e.g. "de_DE")
Since 1.3.0. Fired whenever the user navigates into a top-level app (your custom app included).Payload: { app: string, path: string }
  • app is a stable ID derived from the route ("messages", "contacts", "custom_app:<url>" for Custom Apps).
  • path is the full router path.
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.
Since 1.3.0. Fired when a call rings on the phone.Payload: { number: string, isAnonym: boolean }
Since 1.3.0. Fired when any call (incoming, outgoing, active) ends.Payload: { number: string | null }
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 }
Notifications are silently dropped while the player is in any focus mode (Sleep, Work, Personal, Do Not Disturb), so you won’t see them either.

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.
A custom app has no 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 named roadphone: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
The roadphone-customapp-demo resource ships a small framework-agnostic bridge plus a Handlers dispatcher with per-handler reply(), error pcall-guarding, and thread support for DB queries — copy that instead of hand-rolling it.

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:
For live data, have your app refetch when it becomes visible — e.g. on the 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 correct fxmanifest.lua:
Then point the app’s url in config.json at the resource:
Use https://cfx-nui-<resource>/…, not nui://<resource>/…. The nui:// form is the older scheme and will not load in the iframe.
Never use ui_page in your fxmanifest.lua! This will cause your app to render fullscreen instead of inside the phone. Only use files, which is what makes your HTML fetchable at https://cfx-nui-<resource>/….
Ensure the resource in server.cfg after roadphone:

Common Pitfalls

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.
Your app is an iframe inside the phone: use window.parent.roadphone. window.roadphone is undefined in your realm.
FiveM’s CEF has no dialog implementation — these calls crash the client. Use an in-app toast and showBottomSheet() as your confirm().
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().
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/list with iOS-style consent sheet
    • Added Storage: storage.* (localStorage) and storage.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 customAppRpc server RPC bridge and the roadphone-customapp-demo example resource
  • 1.2.0 — Added pickEmoji() for the system emoji picker
  • 1.1.0 — Added takePhoto() and showBottomSheet() for native UI access
  • 1.0.0 — Initial release