> For the complete documentation index, see [llms.txt](https://oph3zdev.gitbook.io/docs/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://oph3zdev.gitbook.io/docs/free/phone/third-party-apps.md).

# Third-party apps

You can build your own app for the phone without touching the phone's code. Your app is a completely separate FiveM resource that communicates with the phone. The phone puts your icon on the home screen and loads your page inside itself.

Your page can be anything the browser understands — plain HTML/CSS/JS, React, Vue, Tailwind, or whatever you want to use.

[**Download the app template**](https://github.com/Oph3Z1/oph3z-phone-example-app) — a working app you can copy and build on.

***

### How it works

1. Your resource starts and calls one export to register the app (icon + name).
2. The phone shows your icon. When the player opens it, your `ui/index.html` loads **inside an iframe** on the phone screen.
3. Your page and the phone talk with `postMessage`. The phone tells your page who the player is; your page can ask the phone to close, show a popup, send a notification, and so on.

Anything that needs real phone data — contacts, sending a message, a saved notification — goes through the **server**, because that data lives there.

***

### Install

1. Copy the template folder into `resources` and rename it to your app, for example `phone-notes`. If you rename it, update the `notify` event names in `client.lua` and `server.lua` so they still match each other.
2. Add it to `server.cfg`, **after** the phone:<br>

   ```cfg
   ensure oph3z-phone
   ensure phone-notes
   ```
3. Start the server and open the phone. **My App** is on the home screen, with demo buttons for every feature described below.

***

### What's in the folder

```
phone-notes/
├── fxmanifest.lua      resource manifest
├── config.lua          the settings you edit
├── client.lua          registers the app, passes UI requests to the server
├── server.lua          calls the phone's server exports
└── ui/
    ├── index.html      your page
    ├── style.css       your styles
    ├── app.js          your logic + the phone bridge helpers
    └── icon.svg        the home-screen icon
```

{% hint style="warning" %}
**Do not add `ui_page` to `fxmanifest.lua`**. `ui_page` draws your page as its own full-screen layer that is always on, even with the phone closed. The phone loads your page in an iframe, so it only needs to be listed under `files`.
{% endhint %}

***

### config.lua

Everything you normally change is in one table:

```lua
local resource = GetCurrentResourceName()

Config.App = {
    id          = 'template',   -- unique id (change this!)
    label       = 'My App',
    developer   = 'your-name',
    description = 'A starter app for oph3z-phone.',
    place       = 'grid',
    share       = true,

    icon = ('nui://%s/ui/icon.svg'):format(resource),
    ui   = ('nui://%s/ui/index.html'):format(resource),

    headerImage = ('nui://%s/ui/header.webp'):format(resource),
    swiperItems = {},
}
```

| Field         | Required | What it does                                                |
| ------------- | -------- | ----------------------------------------------------------- |
| `id`          | yes      | Unique app id. Everything keys off this, so change it first |
| `ui`          | yes      | The page the phone opens                                    |
| `label`       |          | Name under the icon. Defaults to the id                     |
| `icon`        |          | Icon image. SVG, PNG, JPG and WEBP all work                 |
| `place`       |          | Accepted, but see the note below                            |
| `deletable`   |          | `false` stops players uninstalling it. Defaults to `true`   |
| `share`       |          | `true` lists your app in the Messages share sheet           |
| `developer`   |          | Shown on the App Store page                                 |
| `description` |          | Shown on the App Store page                                 |
| `headerImage` |          | Banner on the App Store page                                |
| `swiperItems` |          | Screenshot URLs for the App Store carousel                  |

{% hint style="info" %}
Third-party apps are never placed on the home screen automatically. They are always listed in the App Store, and when the player installs one it lands on the first home screen page with free space. `place` is accepted but does not change that, so a third-party app cannot start in the dock — the player can drag it there themselves.\
\
**Planned:** in the next update you will be able to put a third-party app straight on the home screen as a default app, the same way the built-in apps work, instead of it only being available through the App Store.
{% endhint %}

`nui://<resource>/...` means "a file inside my own resource". Whatever you point at must be listed under `files` in `fxmanifest.lua`, or the phone cannot load it.

`headerImage` and `swiperItems` are optional — the template lists them so you can see where they go.

***

### Building your UI

The files in `ui/` are a normal web page. Edit them and restart the resource to see changes.

Using React or Vue? Build to a folder, point `ui` at the built `index.html`, and add the built files to `files` in `fxmanifest.lua`. Nothing else changes.

***

### Sizing and safe areas

Your page is its own document inside an iframe, so it does not inherit anything from the phone. Three things you have to set yourself, or your app will look wrong.

**Font size.** The phone scales every app by setting a root font size, so `em` units grow and shrink with the phone. Your iframe starts at the browser default of 16px instead, which makes everything too big. You can copy the phone's formula:

```js
const DESIGN_WIDTH = 390;

function applyScale() {
    const root = document.documentElement;
    const w = root.clientWidth || window.innerWidth || DESIGN_WIDTH;
    root.style.fontSize = `${(w / DESIGN_WIDTH) * 16}px`;
}

applyScale();
window.addEventListener('resize', applyScale);
```

**Safe areas.** The phone draws its status bar over the top of your page and the home bar over the bottom. Define these in your CSS and use them for your top and bottom padding:

```css
:root {
    --safe-top: 3.2em;
    --safe-bottom: 1.6em;
}
```

**A CSS reset.** Your page has none, so buttons keep the browser's default padding and icons sit off-centre. At minimum:

```css
* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

button {
    font-family: inherit;
    color: inherit;
    border: none;
    background: none;
    cursor: pointer;
}
```

The template already does all three.

***

### Talking to the phone

Your page and the phone send each other `postMessage` messages. `ui/app.js` already wraps the common ones in helper functions, but this is the full list:

| Direction   | Message                                                                                                               | What it does                                                                                                                |
| ----------- | --------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| phone → app | `{ type: 'oph3z:init', identity, app: { id, label }, language }`                                                      | Sent when your page loads. `identity` is `{ number, numberRaw, citizenid, name, email, avatar }`                            |
| app → phone | `{ type: 'oph3z:ready' }`                                                                                             | Ask the phone to (re)send `oph3z:init`                                                                                      |
| phone → app | `{ type: 'oph3z:language', language }`                                                                                | The player changed the phone's language                                                                                     |
| app → phone | `{ type: 'oph3z:close' }`                                                                                             | Go back to the home screen                                                                                                  |
| app → phone | `{ type: 'oph3z:confirm', id, title, message, confirmText?, cancelText?, destructive? }`                              | Native yes/no dialog. Replies `oph3z:confirm:result` `{ id, confirmed }`                                                    |
| app → phone | `{ type: 'oph3z:alert', id, title, message, buttons }`                                                                | Dialog with your own buttons. `style` is `default`, `cancel` or `destructive`. Replies `oph3z:alert:result` `{ id, value }` |
| app → phone | `{ type: 'oph3z:prompt', id, title, message?, placeholder?, value?, confirmText?, cancelText?, maxLength?, fields? }` | Text-input popup. Replies `oph3z:prompt:result` `{ id, value }`                                                             |
| app → phone | `{ type: 'oph3z:toast', toastType?, title?, body? }`                                                                  | A throwaway status toast. No reply                                                                                          |
| app → phone | `{ type: 'oph3z:airdrop', title?, payload }`                                                                          | ShareDrop a payload to a nearby player                                                                                      |
| app → phone | `{ type: 'oph3z:share', item: { title, subtitle?, image?, payload } }`                                                | Open the native share sheet                                                                                                 |
| phone → app | `{ type: 'oph3z:airdrop:received', payload }`                                                                         | Someone shared something with you                                                                                           |
| phone → app | `{ type: 'oph3z:shareRequest' }`                                                                                      | Your app was opened from the Messages share sheet to provide an item                                                        |
| app → phone | `{ type: 'oph3z:shareResult', item }` / `{ type: 'oph3z:shareCancel' }`                                               | Return the item to post in the chat, or cancel                                                                              |
| phone → app | `{ type: 'oph3z:nowplaying', data }`                                                                                  | What your app is playing, so your screen stays in sync. Only sent to the app that published it                              |
| app → phone | `{ type: 'oph3z:setVolume', volume }`                                                                                 | Set the phone's media volume, 0-100                                                                                         |

The first thing your page should do is ask for the player's info:

```js
window.addEventListener('message', (e) => {
    const msg = e.data || {};
    if (msg.type === 'oph3z:init') {
        const { number, citizenid, name } = msg.identity;
        // build your screen
    }
});

window.parent.postMessage({ type: 'oph3z:ready' }, '*');
```

***

### The helpers in app.js

**Confirm dialog:**

```js
const ok = await phoneConfirm({
    title: 'Delete post?',
    message: 'This cannot be undone.',
    confirmText: 'Delete',
    destructive: true,
});
```

**Text popup** (one field returns a string, `null` if cancelled):

```js
const name = await phonePrompt({
    title: 'Set nickname',
    placeholder: 'Type a name',
    confirmText: 'Save',
});
```

**Text popup with several fields** (returns an object):

```js
const item = await phonePrompt({
    title: 'Add New Item',
    confirmText: 'Add',
    fields: [
        { key: 'name', placeholder: 'Name' },
        { key: 'url', placeholder: 'Paste URL' },
    ],
});
// item.name, item.url
```

**Status toast** (uses your app's icon, one at a time):

```js
phoneToast('success', 'Saved', 'Your changes were saved.');
phoneToast('error', 'Oops', 'Something went wrong.');
```

**Close the app:**

```js
closeApp();
```

Also available: `phoneAirdrop(title, payload)`, `phoneShare(item)`, `phoneShareResult(item)`, `phoneShareCancel()`, `requestIdentity()`.

***

### Reading phone data and doing actions

Phone data lives on the **server**, and your web page cannot reach it directly. Anything that reads contacts, sends a message or pushes a saved notification goes through this path:

```
your page (app.js) → your client.lua → your server.lua → the phone's export
```

#### Server-side reads

Call these from your `server.lua` with the player's `source`:

```lua
exports['oph3z-phone']:GetCitizenId(src)         -- the player's citizenid
exports['oph3z-phone']:GetPhoneNumber(src)       -- "555-0142"
exports['oph3z-phone']:GetPhoneNumberRaw(src)    -- "5550142" (digits only)
exports['oph3z-phone']:GetLanguage(src)          -- "en"
exports['oph3z-phone']:GetContacts(src)          -- { { id, name, number, notes, img, favorite }, ... }
exports['oph3z-phone']:ResolveContact(src, num)  -- one contact by number, or nil
exports['oph3z-phone']:GetPhotos(src)            -- gallery items
exports['oph3z-phone']:GetRecents(src)           -- call history
exports['oph3z-phone']:IsAirplaneMode(src)       -- true / false
exports['oph3z-phone']:GetBlockedNumbers(src)    -- { [digits] = { number, name, ts }, ... }
exports['oph3z-phone']:IsBlocked(src, number)    -- true / false
```

#### Server-side actions

```lua
exports['oph3z-phone']:PushNotification(src, {
    app = 'myapp', title = 'My App', body = 'Hello!',
    route = { app = 'myapp' },  -- tapping the notification opens your app
})

exports['oph3z-phone']:SendMessage(src, '5550142', { body = 'Hi from my app' })
exports['oph3z-phone']:PlaceCall(src, '5550142')

exports['oph3z-phone']:SendMail(src, {
    from = 'LS Bank', subject = 'Your statement', body = 'Balance updated.',
    attachments = { { url = 'https://.../file.png' } },  -- optional
})
```

Reads are open, but writes are kept small on purpose: notifications, messages, calls and mail. Contacts, settings and the block list are read-only to outside apps.

See the Exports page for the full return shapes.

#### A full example

The **Send phone notification** button in the template is the whole path end to end.

Your page asks its own resource to do something:

```js
// ui/app.js
function phoneFetch(name, data) {
    const resource = window.location.hostname.replace(/^cfx-nui-/, '');
    return fetch('https://' + resource + '/' + name, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json; charset=UTF-8' },
        body: JSON.stringify(data || {}),
    }).then((r) => r.json().catch(() => ({})));
}

phoneFetch('notify', { title: 'My App', body: 'You have a new notification!' });
```

Your client passes it to the server:

```lua
-- client.lua
RegisterNUICallback('notify', function(data, cb)
    TriggerServerEvent('myserver-notes:notify', data)
    cb({ ok = true })
end)
```

Your server pushes the real notification, because only the server has `source`:

```lua
-- server.lua
RegisterNetEvent('myserver-notes:notify', function(data)
    exports['oph3z-phone']:PushNotification(source, {
        app = Config.App.id, title = data.title, body = data.body,
        route = { app = Config.App.id },
    })
end)
```

{% hint style="warning" %}
The phone does **not** store anything for you. If your app needs to save things, use your own database; oxmysql, JSON, whatever you want to use.
{% endhint %}

***

### Client-side exports

```lua
exports['oph3z-phone']:RegisterApp(def)                  -- add or replace your app (client.lua does this for you)
exports['oph3z-phone']:UnregisterApp('template')         -- remove an app you registered
exports['oph3z-phone']:OpenApp('template')               -- open the phone straight to your app
exports['oph3z-phone']:IsOpen()                          -- is the phone open right now?
exports['oph3z-phone']:Toast('success', 'Hi', 'Body')    -- status toast from Lua (only while the phone is open)
exports['oph3z-phone']:GetNumber()                       -- the player's number
exports['oph3z-phone']:GetIdentity()                     -- { number, numberRaw, citizenid, name, email, avatar }
exports['oph3z-phone']:GetLanguage()                     -- current phone language, e.g. 'en'
exports['oph3z-phone']:SetNowPlaying(data)               -- show your track on the island, control center and lock screen
exports['oph3z-phone']:ClearNowPlaying()                 -- playback stopped
exports['oph3z-phone']:GetMediaVolume()                  -- the phone's media volume, 0-100
```

`GetNumber` and `GetIdentity` return `nil` until the player has opened the phone once that session.

You do not have to call `UnregisterApp` when your resource stops — the phone notices and removes your app on its own. Use it only to pull an app while your resource keeps running.

***

### Notifications vs toasts

**Saved notification** (`PushNotification` on the server):

* shows on the lock screen, as a banner, and on the closed-phone peek
* is saved to the Notification Center and survives relogs
* puts a red count badge on your app's icon
* uses your app's icon automatically

Notifications clear when the player opens your app, badge and all. Add `route = { app = 'your-id' }` so tapping one opens your app.

**Status toast** (`phoneToast` in the browser) shows for a few seconds and is gone. Not saved, no badge.

Rule of thumb: if the player might want to see it later, use a notification. If it's instant feedback, use a toast.

***

### Now Playing (music and radio apps)

If your app plays audio, the phone can show it on the **dynamic island**, in the **control center** and on the **lock screen**. You publish what is playing and the phone draws it, so every app gets the same look and the player gets the same controls.

This part is Lua, not postMessage. The island has to show while your app is closed, and when it is closed your page is not running.

```lua
-- from your client.lua, whenever the track or play state changes
exports['oph3z-phone']:SetNowPlaying({
    appId    = 'myapp',   -- tapping the island opens this app
    title    = 'Song name',
    artist   = 'Artist',
    artwork  = 'https://.../cover.jpg',
    position = 42,        -- seconds
    duration = 210,       -- seconds
    playing  = true,
    hasNext  = true,      -- greys out the next button when false
    hasPrev  = false,
})

-- when playback stops
exports['oph3z-phone']:ClearNowPlaying()
```

`title` is required. Call `SetNowPlaying` again for every change, including the position — once a second is enough.

The phone sends the player's button presses back to you:

```lua
AddEventHandler('oph3z-phone:media:command', function(action, value)
    -- 'toggle' | 'next' | 'prev' | 'seek' (value = seconds)
    -- 'pauseFor'   the player got a call, pause
    -- 'resumeAuto' the call ended, resume if you paused for it
end)
```

Volume is the phone's media volume, the one in the control center:

```lua
local volume = exports['oph3z-phone']:GetMediaVolume()  -- 0-100

AddEventHandler('oph3z-phone:media:volume', function(volume)
    -- the player moved the slider
end)
```

Your app is also told what is playing, so your own screen stays in sync while it is open:

```js
window.addEventListener('message', (e) => {
    if (e.data?.type === 'oph3z:nowplaying') {
        const { title, artist, artwork, playing, position, duration } = e.data.data;
    }
});
```

And your app can move the phone's volume slider:

```js
window.parent.postMessage({ type: 'oph3z:setVolume', volume: 60 }, '*');
```

Your now-playing is cleared automatically when your resource stops.

***

### Language

The phone tells your app which language is active so you can match it — in `oph3z:init` when your page loads, and again in `oph3z:language` whenever the player changes it.

```js
const STRINGS = {
    en: { hi: 'Hello' },
    tr: { hi: 'Merhaba' },
};

function applyLanguage(code) {
    const s = STRINGS[code] || STRINGS.en;
    document.querySelector('h1').textContent = s.hi;
}

window.addEventListener('message', (e) => {
    const m = e.data || {};
    if (m.type === 'oph3z:init' || m.type === 'oph3z:language') {
        applyLanguage(m.language);
    }
});
```

On the Lua side, `GetLanguage()` on the client and `GetLanguage(src)` on the server give you the same code — useful for sending a notification in the player's own language.

***

### Sharing into Messages

With `share = true`, your app appears in the Messages share sheet (the `+` menu in a chat). When the player picks it, the phone opens your app with `oph3z:shareRequest`. Show your own "pick something to share" screen, then send the chosen item back. It is posted into the chat as a card, and tapping that card later reopens your app with the item's `data`.

```js
window.addEventListener('message', (e) => {
    if (e.data?.type === 'oph3z:shareRequest') {
        window.parent.postMessage({
            type: 'oph3z:shareResult',
            item: { title: 'Profile: @oph3z', subtitle: 'Tap to open', data: { userId: 42 } },
        }, '*');
        // or { type: 'oph3z:shareCancel' } to back out
    }
});
```

The template ships a working version of this you can copy.
