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
- Registering an app
- The app page and the SDK
- Mobile data and app storage
- Permissions
- Notification channels
- Deep links
- Widgets
- Server exports reference
- Events
- Browser add-on: in-game sites
- Security model
- Icons
How external apps work
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
-- 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:
files { 'html/index.html', 'html/*.js', 'html/*.css' }Rules enforced by RegisterApp (it returns false, reason otherwise):
| Field | Rule |
|---|---|
id | Must not collide with a built-in app. While one resource has it registered, no other resource can register it. |
ui | A relative path (128 characters max) without .., / prefix, or scheme. Only .html/.htm. Served from https://cfx-nui-<resource>/<ui>. |
permissions | Only notifications, location, contacts, media. |
channels | Up to 8, ids match ^[a-z0-9_]{2,32}$, labels 40 characters. default always exists implicitly. defaults fields left out are on. |
| Other fields | description 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. |
| Limits | Config.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:
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:
<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>| Method | Permission | Result |
|---|---|---|
ready() | none | Handshake. 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) | none | Prompts 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 }) | media | Opens the gallery picker (max 1-4); { media: [{ mediaId, url, thumbnailUrl, width, height }] } |
openDeepLink('nova://…') | none | Routes a validated link; { opened } |
close() | none | Closes the app |
useData(kind) | none | Uses 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) | none | Stores any JSON value; { key, stored } |
removeItem(key) | none | { key, removed } |
on(event, fn) / off(event, fn) | none | Listen for context, deepLink and message. Returns NovaSDK. |
activity() | none | Tells 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:
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| Kind | Size (KB, Config.MobileData.usage) |
|---|---|
request | appRequest (10) |
page | appPage (400) |
media | appMedia (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).
| Limit | Default (Config.AppPlatform.sdk.storage) |
|---|---|
| Keys | [A-Za-z0-9_.-], 1-64 characters |
| Keys per app per phone | maxKeys = 32 |
| Value size | maxValueBytes = 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. requestPermissionshows 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:
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.
Deep links
nova://<appId>/<segment>/<segment>?key=value
| Limit | Value |
|---|---|
| Length | 512 characters |
| Segments | 8 including the app id; each [A-Za-z0-9_.-], 64 max |
| Query | 12 parameters; keys [A-Za-z0-9_], values 128 max, no <, > or control characters |
Built-in routes:
| Link | Opens |
|---|---|
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/activity | Snapgram post / profile / DM thread / Activity |
nova://ember/chat/<matchId> · nova://ember/matches · nova://ember/likes | Ember 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/search | Contacts with search open |
nova://music/now | Music's Now Playing |
nova://notes/new | A new note |
nova://clock/timer | Clock'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/voicemail | Phone 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/history | Rides: 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/history | Racing: 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:
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
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
| Export | Purpose |
|---|---|
RegisterApp(def) → ok, reason | Register/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, errorCode | Trusted install state changes |
SendAppMessage(source, appId, data) → boolean | Push 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 / UnregisterWidget | Widgets |
To plug in banking, economy, garage, housing, business, City, Services, Mail actions or dispatch, see Exports & integrations.
Events
Server events (AddEventHandler), metadata only:
| Event | Arguments |
|---|---|
wd_phone:platform:appInstalled | deviceId, appId, version |
wd_phone:platform:appUninstalled | deviceId, appId |
wd_phone:platform:appUpdated | deviceId, appId, fromVersion, toVersion |
wd_phone:platform:permissionChanged | deviceId, appId, permission, state |
wd_phone:server:deviceLeftPlayer | source, 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:
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_browserstarts (listen foronResourceStart), as withRegisterApp. 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.allowedResourcesin the Browser's config can limit who may register, andmaxSites(default 100) caps the total. - List the page and its icon in your
fxmanifest.luafiles { ... }. - 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/42loadshtml/index.html#/u/42; listen forhashchange. - A site page talks to the Browser with
window.parent.postMessage(msg, '*'):
| Message | Effect |
|---|---|
{ 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.sourceis your iframe's window andevent.originequalshttps://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.