Skip to main content

Custom App RPC

A custom app is an iframe inside RoadPhone. It has no ui_page of its own, which decides everything on this page: how a request reaches your server, and why the answer can only ever come back as a reply to a request the UI made itself. Every request follows the same shape:
One server callback per resource — roadphone:customApp:<resource> — with the handler name as a string argument. Do not register one callback per action.

Picking a transport

There are two ways for the UI to reach your client, and both end at the same server callback.
The own-/rpc fetch depends on the server and the FiveM build. It works on most setups, but it has been observed failing with net::ERR_FAILED on others. Ship the dual path below and you never have to find out which kind of server your customer runs.

JavaScript side

One api() helper for the whole app. Everything else calls through it.
Always unwrap roadphone.post(). It parses the reply as JSON and returns the raw axios response when that parse fails — which is exactly what happens when your Lua returns a table. res.ok is undefined and the payload sits at res.data. Reading fields off res directly is the single most common bug in custom apps.

Errors are codes, not sentences

The server returns a code, the UI turns it into a translated string. That keeps player-facing text in your locale files and out of Lua.

Normalise empty lists

An empty Lua table serialises as {}, not []. Anything you iterate needs a guard:

Lua: client relay

The client does nothing but forward — unless the action is genuinely client-only (setting a waypoint, playing an animation), in which case it never has to leave the client at all.
Resolve cb inside this handler. Storing it and calling it later from a separate RegisterNetEvent does not reliably resolve the pending fetch. The reply has to come out of the server callback’s own result.

Lua: server dispatcher

One callback, a handler table, and a guard that cb fires exactly once.
The CreateThread is what lets a handler wait on a database query. Register the callback through whichever system your framework uses:
  • ESXESX.RegisterServerCallback(name, fn)
  • QBCoreQBCore.Functions.CreateCallback(name, fn)
  • Qboxlib.callback.register(name, fn) (needs @ox_lib/init.lua)
data comes from the UI. Treat every field as hostile: check types, clamp numbers, cut strings to length, and re-check permissions and prices on the server. The UI hiding a button is not a permission check.

Refetching: there is no push

SendNUIMessage from your resource has no document to deliver to and cannot reach the iframe. Nothing your server does can put data into the UI on its own — the UI has to ask. Four patterns cover it:
1

Refetch when a view opens

The default, and it covers more than it looks like. Leaving the app and coming back destroys and reloads the iframe — your boot code runs again from scratch, so there is nothing stale to refresh. Within a session, have every tab or view load its own data on mount, so switching away and back is a refresh. Ask the server for feature flags before drawing the tab bar, so no tab points at a disabled system.
2

Cache with a TTL, bust it on writes

For read-heavy apps, cache per handler name and drop the whole cache after any write — a write makes every cached read suspect. Never cache a reply that carries an error, and exclude handlers that must always be live.
3

Notify, then let the app refetch

Server-side events reach the player through RoadPhone’s own notification system, not through your iframe:
When the client already knows what happened, use the client export instead and skip the round trip: exports['roadphone']:sendNotification({ … }).The UI can listen for it and refresh:
4

Time out locally on a server-provided duration

For “this expires in N seconds”, have the server return the duration and run the countdown in the UI. Guard it with a token so a stale timer from an earlier run cannot overwrite a newer state.
Effects in the game world are not affected by any of this. Blips, waypoints, animations and props are plain client code: TriggerClientEvent from your server to a RegisterNetEvent in your client works normally, because it never touches NUI.

Host events worth listening to

appOpened fires for the app the player navigates to. Your own iframe is torn down when the player leaves your app, so use phoneOpened — not appOpened — as the “I am visible again” signal.

Checklist

  • One server callback per resource, dispatching by handler name.
  • cb is called on every branch, exactly once — early returns included.
  • cb is resolved inside the NUI callback, never from a later net event.
  • The UI unwraps roadphone.post() replies before reading any field.
  • Every fetch has a timeout; a dead server callback must not become a blank screen.
  • Empty Lua tables are normalised to arrays in JS.
  • data from the UI is validated server-side.
  • Views refetch on open; writes bust the cache.
  • No SendNUIMessage in a resource without a ui_page.