Advanced integrations

Custom apps (SDK)

Optional developer reference for building custom phone apps and Browser sites.

How another FiveM resource adds an app, widgets, notifications, and deep links to NovaOS. The release ships two complete SDK resources, each as its own zip and both at version 1.0.1: nova_example_app, a small working example, and the Browser add-on (nova_browser), a larger one that also uses mobile data and per-phone storage. The example app is a how-to rather than a feature, so it ships unencrypted (its fxmanifest.lua lists '*.lua' under escrow_ignore): read it and copy from it.

How external apps work

text
your resource (server)                wd_phone (server)
  RegisterApp{ id, ui, permissions } ──► registry (memory; cleared when your resource stops)
                                          │ broadcast to every client
                                          ▼
player's phone (NUI) ── Nova Store ──► install / uninstall / update (phone_installed_apps)
  │
  └─ opens <iframe sandbox src="https://cfx-nui-<your_resource>/<ui>">
        │  window.postMessage  ◄──►  NovaSDK (sdk/nova-sdk.js)
        │                            origin + source checked on every message
        └─ fetch('https://<your_resource>/<callback>')  ──►  your client.lua NUI callbacks
  • Your UI is a page served by your own resource. It runs in a sandboxed iframe (allow-scripts allow-same-origin allow-forms) and cannot reach the phone's DOM, stores, or NUI callbacks.
  • Your UI talks to your own client scripts with normal NUI callbacks (https://<your_resource>/<name>), and to NovaOS only through the SDK.
  • Install state, permission grants, notification-channel preferences and app storage are per phone and persist across restarts. Your app definition does not persist: register it every time your resource (or Wolfden Phone) starts.

Registering an app

lua
-- server.lua in your resource
local function register()
    local ok, reason = exports.wd_phone:RegisterApp({
        id = 'novanews',                 -- letter first, then letters/digits/_ (2-32)
        label = 'Nova News',             -- 24 chars max
        icon = 'newspaper',              -- a registered icon name (see Icons)
        color = '#e5484d',
        version = '1.1.0',               -- MAJOR.MINOR.PATCH; raise it to offer an update
        category = 'news',               -- Nova Store grouping
        developer = 'Nova Example Co.',
        description = 'Headlines from around Los Santos.',
        ui = 'html/index.html',          -- relative .html/.htm path inside THIS resource
        permissions = { 'notifications', 'location' },
        channels = {
            { id = 'breaking', label = 'Breaking news',
              defaults = { enabled = true, banner = true, sound = true, lockscreen = true } },
        },
        deepLinks = { 'story/<id>' },    -- documentation shown to players/admins
        preinstalled = false,            -- true installs on currently open phones
    })
    if not ok then print('RegisterApp failed: ' .. tostring(reason)) end
end

AddEventHandler('onResourceStart', function(resource)
    if resource == GetCurrentResourceName() or resource == 'wd_phone' then
        SetTimeout(500, register)        -- wd_phone may (re)start after you
    end
end)

Your fxmanifest.lua must list the UI files:

lua
files { 'html/index.html', 'html/*.js', 'html/*.css' }

Rules enforced by RegisterApp (it returns false, reason otherwise):

FieldRule
idMust not collide with a built-in app. While one resource has it registered, no other resource can register it.
uiA relative path (128 characters max) without .., / prefix, or scheme. Only .html/.htm. Served from https://cfx-nui-<resource>/<ui>.
permissionsOnly notifications, location, contacts, media.
channelsUp to 8, ids match ^[a-z0-9_]{2,32}$, labels 40 characters. default always exists implicitly. defaults fields left out are on.
Other fieldsdescription 240 characters, category 24, developer 40 (default: your resource name), up to 16 deepLinks. An invalid version becomes 1.0.0, an invalid color #5e5ce6, an invalid icon app-window.
LimitsConfig.AppPlatform.external.maxApps (default 40); allowedResources can restrict who may register; enabled = false refuses every app; allowPreinstall = false ignores preinstalled.

Operators can also install or remove an app on a known device:

lua
exports.wd_phone:InstallApp(deviceId, 'novanews')   --> ok, errorCode
exports.wd_phone:UninstallApp(deviceId, 'novanews')

The phone model's apps whitelist (for example the Wolfden Phone Secure model) applies to external apps too. An app that is not on the whitelist is "Not on this model" in the Store.

Versions and updates

Raise version and re-register. Devices that installed an older version see Update in the Nova Store and a single "updates available" notification the next time the phone opens (Config.AppPlatform.notifyUpdates). The installed version is informational for your app; read it from NovaSDK.getContext().version if you need migrations.

The app page and the SDK

Include the SDK from Wolfden Phone:

html
<script src="https://cfx-nui-wd_phone/sdk/nova-sdk.js"></script>
<script>
  // Attach listeners first: the phone delivers a pending deep link right after
  // the ready() handshake, and pushes any later ones while the app is open.
  NovaSDK.on('context', (c) => { /* the phone's theme changed: same shape as getContext() */ });
  NovaSDK.on('deepLink', (route) => { /* { path: ['story','42'], query: {}, uri } */ });
  NovaSDK.on('message', (data) => { /* from exports.wd_phone:SendAppMessage */ });

  (async () => {
    const ctx = await NovaSDK.ready();          // { appId, version, locale, theme, ..., route }
    document.documentElement.dataset.theme = ctx.theme;
  })();
</script>
MethodPermissionResult
ready()noneHandshake. Context below plus route (the deep link that opened the app, or null). Call once, after attaching listeners.
getContext()none{ appId, version, locale, theme, reduceMotion, textSize, deviceModel, secureDevice, permissions }
getPermissions()none{ [permission]: 'granted' | 'denied' | 'ask' }
requestPermission(name)nonePrompts on the phone once; { granted }
notify({ title, content, channel, deepLink, persistent })notifications{ delivered }. Title 64 / content 256 characters. At most notifyMax (6) per notifyWindowSeconds (60) per player (Config.AppPlatform.sdk).
getContacts()contacts{ contacts: [{ displayName, phoneNumber, isFavorite }] } (the phone owner's contacts, max 200)
getLocation()location{ x, y, z, street, zone } of the player
pickMedia({ max })mediaOpens the gallery picker (max 1-4); { media: [{ mediaId, url, thumbnailUrl, width, height }] }
openDeepLink('nova://…')noneRoutes a validated link; { opened }
close()noneCloses the app
useData(kind)noneUses the phone's mobile data for one request / page / media; { online }. See below.
getItem(key)none{ key, value } from this phone's storage for your app (value is null when unset)
setItem(key, value)noneStores any JSON value; { key, stored }
removeItem(key)none{ key, removed }
on(event, fn) / off(event, fn)noneListen for context, deepLink and message. Returns NovaSDK.
activity()noneTells the phone the player is using your app, so Auto-Lock does not lock it. Clicks, keys, scrolling and mouse movement in your page are reported on their own; call it only for input your page cannot see, such as a nested frame.

Every call returns a Promise that rejects with an Error whose .code is one of PERMISSION_DENIED, PERMISSION_UNKNOWN, RATE_LIMITED, APP_NOT_INSTALLED, INVALID_LINK, INVALID_PAYLOAD, DATA_KIND_UNKNOWN, STORAGE_LIMIT, UNKNOWN_METHOD, TIMEOUT (15 s; requestPermission and pickMedia wait 2 minutes), NOT_EMBEDDED or INTERNAL, or (from useData) one of the offline reasons below. A call that reaches the server can also fail the phone's own checks (NO_SESSION, NOT_POSSESSED, DEVICE_LOCKED, DEVICE_LOST).

Messages are limited to 8 KB (Config.AppPlatform.sdk.maxMessageBytes); a larger one is dropped and the call times out.

Mobile data and app storage

useData(kind)

Anything your app fetches "from the internet" in roleplay terms (a feed, a web page, a photo) should cost the phone mobile data like the built-in apps. Call NovaSDK.useData(kind) before loading it:

js
try {
  await NovaSDK.useData('page');
  loadThePage();
} catch (e) {
  showOffline(e.message);   // e.code: AIRPLANE_MODE, DATA_OFF, NO_SIM, SIM_LOCKED,
}                            // LINE_SUSPENDED, NO_SERVICE, OUT_OF_DATA, NO_DATA, RATE_LIMITED
KindSize (KB, Config.MobileData.usage)
requestappRequest (10)
pageappPage (400)
mediaappMedia (600)

The server decides the size from its own config (Config.AppPlatform.sdk.dataKinds maps each kind to a usage key); your app never reports one. A joined Wi-Fi network in range is free, exactly as for built-in apps. Calls are rate limited per player (Config.RateLimits.platformData) and the app must be installed on the phone. e.message is already translated into the phone's language.

getItem / setItem / removeItem

A small key/value store per phone and per app (phone_app_storage), for data that should follow the phone item rather than the player's PC: history, drafts, preferences. Values are any JSON (objects, arrays, strings, numbers, booleans).

LimitDefault (Config.AppPlatform.sdk.storage)
Keys[A-Za-z0-9_.-], 1-64 characters
Keys per app per phonemaxKeys = 32
Value sizemaxValueBytes = 6144 bytes of JSON (SDK messages are 8 KB)

Storage is erased when the app is uninstalled from that phone and when the phone is erased. It is not included in Nova Cloud backups or phone transfers. Rate limit: Config.RateLimits.platformStorage. Never store secrets: the player's own client can read and write it through the SDK.

Permissions

  • Declare every permission you might need in RegisterApp. Undeclared permissions are always denied.
  • requestPermission shows a system prompt the first time; the answer is stored per device (phone_app_permissions). Players change it later in Settings → Apps → your app (allowing there shows the same prompt). The server raises the prompt, so an app frame cannot grant itself anything. A denied permission is not asked again by the app.
  • An app ID belongs to the resource that first registered it. If another resource later registers the same ID, every phone's grants, notification channels and app storage for it are reset.
  • Permission checks happen in the NUI and again on the server for anything that reads server data or delivers a notification.
  • Built-in NovaOS apps are system apps and are not permission-gated.

Notification channels

Every notification belongs to an app channel. Players toggle each channel (enabled / banners / sound / lock screen) in Settings → Apps.

From your app page, use NovaSDK.notify({ channel: 'breaking', ... }). From your server:

lua
exports.wd_phone:SendNotification(source, {
    app = 'novanews',          -- your app id
    channel = 'breaking',      -- unknown channels fall back to 'default'
    title = 'Breaking',
    content = 'Storm warning issued for Blaine County',
    persistent = true,
    data = { deepLink = 'nova://novanews/story/43' },   -- tap target (validated)
})

It goes to the player's open phone, else the phone they last looked at, else the first phone they carry (nil when they carry none). A notification is not delivered when the app is not installed, when an external app lacks the notifications permission, or when the channel is off. SendNotification then returns { suppressed = true }.

Built-in channels, for reference: phone/missed, phone/voicemail, messages/direct, messages/groups, bank/activity, bank/requests, social/interactions, social/follows, snapgram/activity, snapgram/messages, ember/likes, ember/matches, ember/messages, mail/inbox, services/requests, calendar/reminders, clock/alarms, clock/timers, marketplace/listings, business/orders, business/campaigns, simManager/billing, store/updates, store/available, findmy/security and findmy/accessories (Locate), news/breaking, garage/valet, homes/keys, shade/dms, shade/mentions, bites/orders, bites/couriers, rides/trips, rides/offers, racing/lobbies, racing/results, racing/crew, racing/bets, clips/activity, clips/follows, clips/live, clips/deals.

nova://<appId>/<segment>/<segment>?key=value

LimitValue
Length512 characters
Segments8 including the app id; each [A-Za-z0-9_.-], 64 max
Query12 parameters; keys [A-Za-z0-9_], values 128 max, no <, > or control characters

Built-in routes:

LinkOpens
nova://pay?account=<id> (legacy) / nova://bank/pay?account=<id> · nova://bank/request/<requestId>Nova Wallet pay screen, prefilled / a payment request
nova://social/post/<postId> · nova://social/profile/<handle>Echo post / profile
nova://snapgram/post/<id> · nova://snapgram/profile/<handle> · nova://snapgram/thread/<id> · nova://snapgram/activitySnapgram post / profile / DM thread / Activity
nova://ember/chat/<matchId> · nova://ember/matches · nova://ember/likesEmber chat / Matches / Likes
nova://news/article/<articleId>A News article
nova://shade/channel/<id> · nova://shade/dm/<threadId> · nova://shade/room/<roomId> · nova://shade/join/<code>Shade channel / DM / a room the phone belongs to / Rooms with the invite code filled in (joining is a tap)
nova://clips/clip/<id> · nova://clips/profile/<handle> · nova://clips/tag/<tag> · nova://clips/activity · nova://clips/requests · nova://clips/live/<id> · nova://clips/creator · nova://clips/deals · nova://clips/upload?caption=<text>Clips clip / profile / hashtag / Inbox / follow requests / a live stream / Creator tools (creator and deals open the same screen) / the new-clip composer with the caption filled in (the player still picks the video)
nova://contacts/searchContacts with search open
nova://music/nowMusic's Now Playing
nova://notes/newA new note
nova://clock/timerClock's Timer tab
nova://messages/conversation/<id> · nova://messages/compose?to=<n> (&body=<text> types a draft in, never sends)Messages
nova://phone/dial?number=<n> · nova://phone/voicemailPhone keypad / voicemail
nova://maps/location?x=&y=&label=Maps focused on a point
nova://mail/message/<id>A mail
nova://services/service/<id>Services, preselected
nova://store · nova://store/app/<appId> · nova://store/updates · nova://store/install/<appId>Nova Store (install opens the app's page, installs it through the normal Store install and opens it; an installed app just opens)
nova://settings/<section>Settings section: general, display, sounds, notifications, communication, security, home, device, accessibility, apps, backup, account, wifi, bluetooth, hotspot, battery, controlCenter, cellular
nova://rides/trip · nova://rides/drive · nova://rides/historyRides: your trip / Drive tab / History
nova://racing/lobby/<raceId> · nova://racing/join/<code> · nova://racing/track/<code> · nova://racing/result/<raceId> · nova://racing/crew/<crewId> · nova://racing/live · nova://racing/races · nova://racing/tracks · nova://racing/ranks · nova://racing/profile · nova://racing/historyRacing: a lobby / the lobby with that invite code (joining is a tap) / a track by its share code / a race result / a crew / your live race / a tab / your race history
nova://browser/site/<domain>/<path> · nova://browser/open?url=<address>The Browser add-on, when installed
nova://<yourApp>/<anything>Your app; the route arrives as the SDK deepLink event

A link to an app that is not installed opens its Nova Store page (nothing happens on a phone without the Nova Store). A link to an app the server currently hides for that player, such as Racing without its Racing USB, does nothing. nova:// links in Messages and Mail render as tappable chips.

Server helpers:

lua
exports.wd_phone:OpenDeepLink(source, 'nova://novanews/story/42')   --> boolean
exports.wd_phone:BuildDeepLink('maps', { 'location' }, { x = 12.5, y = -40, label = 'Pier' })

OpenDeepLink routes the link on the player's phone screen; it does not open the phone.

Widgets

lua
exports.wd_phone:RegisterWidget({
    id = 'novanews_headlines',
    label = 'Headlines',
    icon = 'newspaper',
    app = 'novanews',                    -- only offered/kept where the app is installed
    deepLink = 'nova://novanews/story/42',
    layout = 'list',                     -- 'value' (default) | 'list' | 'progress'
    sizes = { 'small', 'medium' },
    defaultSize = 'medium',
})

exports.wd_phone:UpdateWidget('novanews_headlines', {
    items = { { label = 'Harbour bridge reopens', value = '#41' } },  -- list layout, max 5
    detail = 'Updated just now',
}, target)                               -- a source, or omit/-1 for everyone

exports.wd_phone:UnregisterWidget('novanews_headlines')

Data fields: value, status, detail, unit (strings ≤ 64 or numbers), progress (0–1, progress layout), color (#rrggbb), items (≤ 5 label/value pairs), deepLink. Payloads over 2 KB are rejected. Only the registering resource may update or unregister a widget. Widgets are removed when your resource stops and from the home screen when the owning app is uninstalled.

Server exports reference

ExportPurpose
RegisterApp(def) → ok, reasonRegister/replace your external app
UnregisterApp(appId)Remove it (automatic on resource stop)
GetInstalledApps(source){ { appId, version, source } } for the phone the player has open (nil when none is open)
IsAppInstalled(source, appId)boolean, for the phone the player has open (false when none is open)
InstallApp(deviceId, appId) / UninstallApp(deviceId, appId) → ok, errorCodeTrusted install state changes
SendAppMessage(source, appId, data) → booleanPush data into your app frame (owner resource only, ≤ 8 KB; the phone must be open with the app installed)
OpenDeepLink(source, uri) / BuildDeepLink(app, path, query)Deep links
SendNotification(source, n)Accepts channel and data.deepLink
RegisterWidget / UpdateWidget / UnregisterWidgetWidgets

To plug in banking, economy, garage, housing, business, City, Services, Mail actions or dispatch, see Exports & integrations.

Events

Server events (AddEventHandler), metadata only:

EventArguments
wd_phone:platform:appInstalleddeviceId, appId, version
wd_phone:platform:appUninstalleddeviceId, appId
wd_phone:platform:appUpdateddeviceId, appId, fromVersion, toVersion
wd_phone:platform:permissionChangeddeviceId, appId, permission, state
wd_phone:server:deviceLeftPlayersource, deviceId: the phone stopped being that player's (dropped, stashed, handed over, put on a charging pad, lost mode, switched off, logout or disconnect). Can fire more than once: keep handlers safe to repeat.

Browser add-on: in-game sites

The Browser (nova_browser) is a separate resource that registers with the phone as an SDK app; players download it from the Nova Store. Start it after wd_phone. It shows in-game sites that resources serve under made-up domains, and only the real HTTPS websites you list in its config.lua (allowedOrigins, empty by default, because a real website sees the player's IP address). Its README, inside the zip, covers configuration.

Any resource can add an in-game site from its server script:

lua
exports.nova_browser:RegisterSite('lifeinvader.ls', {
    title = 'Lifeinvader',
    url = 'html/index.html',   -- an .html file inside THIS resource
    icon = 'html/icon.png',    -- optional image inside THIS resource
    color = '#e5484d',         -- optional tile colour
}) --> ok, reason
exports.nova_browser:UnregisterSite('lifeinvader.ls')
exports.nova_browser:GetSites()  --> { ['lifeinvader.ls'] = { title, resource } }
  • Register again whenever nova_browser starts (listen for onResourceStart), as with RegisterApp. A resource's sites are removed when it stops.
  • Domains are lowercase letters, digits and dashes in dot-separated labels, 4-64 characters (www. is dropped). The first resource to register a domain owns it. allowedResources in the Browser's config can limit who may register, and maxSites (default 100) caps the total.
  • List the page and its icon in your fxmanifest.lua files { ... }.
  • The page runs with its own resource's origin, so it can call its own NUI callbacks like an SDK app page. The path after the domain arrives as the URL hash: lifeinvader.ls/u/42 loads html/index.html#/u/42; listen for hashchange.
  • A site page talks to the Browser with window.parent.postMessage(msg, '*'):
MessageEffect
{ novaBrowser: 1, type: 'title', title }Sets the tab and history title
{ novaBrowser: 1, type: 'navigate', path: '/u/43' }Goes to another path of this site (adds a Back entry and counts as a page load)
{ novaBrowser: 1, type: 'open', address: 'other.ls/news' }Opens another address, with the same rules as the address bar

The Browser accepts these only from the current tab's frame, and only when the sender's origin is that site's resource. Each page load uses the phone's mobile data (useData('page')).

Security model

  • The NUI accepts SDK messages only when event.source is your iframe's window and event.origin equals https://cfx-nui-<your_resource>.
  • The iframe is sandboxed and cannot navigate the phone or read its storage.
  • Everything that touches server data (notifications, contacts, mobile data, app storage) is re-checked on the server: device possession, install state, permission, rate limit. App storage is keyed by the server's device ID and the calling app ID, so an app only ever sees its own keys on that phone.
  • Never trust data coming back from your own UI either. Validate it in your client/server scripts exactly as NovaOS does.
  • Your app gets no access to phone numbers, device ids, PINs, wallet data, messages or other apps' data unless you are granted the matching permission and the SDK method exists.

Icons

The NUI bundles a curated icon set. Besides the built-in app icons, these are available to external apps and widgets: app-window, gamepad-2, music, radio, newspaper, globe, shopping-cart, utensils, dumbbell, graduation-cap, stethoscope, fuel, bike, ticket, coffee, book-open, tv, car-front, badge-dollar-sign, chart-line, qr-code, key, siren, store, link, external-link, car, truck, home, heart, star, wrench, shield, briefcase, map-pin, package. Unknown names render as a circle.

Available

Wolfden Phone

$89.99

or $19.99 a month, first 30 days free

Was this page helpful?
Made with care, by Wolfden.
© 2026 Wolfden StudiosBuilt for the stories you create.