# Exports & integrations

Optional examples for connecting your other server resources.

> Advanced guide: these examples are for developers connecting another resource. You do not need to edit exports to install or use Wolfden Phone.

Server exports are trusted interfaces for your own server resources. They still validate their input, but skip the per-player checks a player's own actions go through. Never call them from untrusted client events.

Events listed on this page are server events (`AddEventHandler`) unless marked as client events. They carry metadata only: ids, numbers and statuses, never message text or media.

## Notifications and phone state

### Server
```lua
-- Sends to the player's open phone, else the one they last looked at,
-- else the first phone they carry. nil when they carry none.
exports.wd_phone:SendNotification(source, {
    app = 'system',
    channel = 'default', -- the app's notification channel; unknown ids fall back to 'default'
    title = 'NovaOS',    -- required
    content = 'Hello!',
    icon = 'bell',
    duration = 5000,     -- banner time in ms, 1000-20000
    persistent = true,   -- stored in history + shown; false = transient banner
    data = {},           -- up to 4 KB; data.deepLink = 'nova://...' is the tap target
})
-- Returns { suppressed = true } when the app is not installed or the
-- channel is turned off. That is not an error.

-- Is the player's phone currently open?
exports.wd_phone:IsPhoneOpen(source) --> boolean
```

### Client
```lua
-- Local, transient banner (no server or database involvement; shown only
-- while the phone is open).
exports.wd_phone:SendLocalNotification({ title = 'Hi', content = '...', icon = 'bell' })

-- Is the phone open locally?
exports.wd_phone:IsOpen() --> boolean
```

When a phone stops being a player's (dropped, stashed, handed over, put on a charging pad, put in lost mode, switched off, or the player logs out or disconnects), the server fires `wd_phone:server:deviceLeftPlayer` with `source, deviceId`. Any call on that phone has already ended. It can fire more than once for the same phone, so keep your handler safe to repeat.

## Phone items

The item exports `usePhone`, `useSim`, `useNovaPods`, `useSpeaker`, `useRouter`, `useCarCharger`, `usePowerBank`, `useChargingPad` and `useShadeCard` are client exports. On ox_inventory they are called through the item entries in [Installation](#/docs/wd_phone/installation). On qb-inventory, Wolfden Phone registers its usable items itself.

A phone is known by the `deviceId` in its item metadata (a SIM by `simId`, accessories by their own ids). Wolfden Phone signs these ids when it writes them and ignores items whose signature does not match. When your resource gives or sells a phone, SIM or accessory, give the plain item without identity metadata: the phone sets it up and signs it on first use. Never copy one item's metadata onto another.

## Numbers, calls and messages

### Server
```lua
-- The player's main line: the one on their open or last-opened phone.
exports.wd_phone:GetPhoneNumber(source)            --> string | nil
exports.wd_phone:GetActiveLine(source)             --> { lineId, phoneNumber, lineType, label, isSuspended } | nil
-- A number is online while a carried phone holds its SIM (open or not).
exports.wd_phone:GetSourceFromNumber(phoneNumber)  --> src | nil
exports.wd_phone:IsNumberOnline(phoneNumber)       --> boolean
-- Checks the block list of whoever owns the phone holding ownerNumber.
exports.wd_phone:IsNumberBlocked(ownerNumber, targetNumber) --> boolean
-- Calls as the player's active line. options: { anonymous?, video?, collect? }
exports.wd_phone:StartCall(source, targetNumber, options) --> call data | false
-- A system notification in Messages on the phone holding that number.
-- message is text or { title?, content }. false when the number is not online.
exports.wd_phone:SendMessage(targetNumber, message) --> boolean
```

### Client
```lua
exports.wd_phone:OpenConversation(conversationId)
exports.wd_phone:OpenDialer(number)
exports.wd_phone:IsInCall()      --> boolean
exports.wd_phone:GetCallState()  --> string
exports.wd_phone:OpenSimManager()
exports.wd_phone:GetSignalLevel()       --> 0-5
exports.wd_phone:HasActiveLine()        --> boolean
exports.wd_phone:IsAirplaneModeEnabled() --> boolean
```

### SIMs and carriers

| Export (server) | Returns |
| --- | --- |
| `GetInstalledSims(source)` | The SIM slots of the player's open phone (`{}` when none is open) |
| `GetEnabledLines(source)` | The enabled lines of the player's open phone |
| `GetDefaultCallLine(source)` / `GetDefaultMessageLine(source)` | A line ID, or `nil` |
| `GetSimByNumber(phoneNumber)` | The SIM ID holding that number, or `nil` |
| `GetCarrierByNumber(phoneNumber)` | The carrier ID, or `nil` |
| `IsLineReachable(phoneNumber)` | `true` when the number is online and not suspended |
| `SuspendSim(simId, reason)` / `ReactivateSim(simId)` | Suspend or reactivate a SIM. Only `ReactivateSim` lifts a suspension made this way; paying a bill does not. |
| `ExpireTemporarySim(simId)` | Expire a temporary SIM |
| `TransferNumber(source, sourceSimId, targetSimId)` | `result, nil` or `nil, errorCode`. Needs the player's open phone; both SIMs must be registered to that player and the target must be a SIM without a number. |

### Events

| Event | Arguments |
| --- | --- |
| `wd_phone:server:messageSent` | `senderNumber, conversationId` |
| `wd_phone:server:messageDelivered` | `messageId` |
| `wd_phone:server:messageRead` | `messageId` |
| `wd_phone:server:callStarted` | `callerNumber, receiverNumber` (once per person invited, so once per member in a group call) |
| `wd_phone:server:callAnswered` | `callId` (the first answer) |
| `wd_phone:server:callEnded` | `callId, reason` |
| `wd_phone:server:numberBlocked` | `ownerNumber, blockedNumber` (`ownerNumber` is `nil` on a phone without a SIM) |
| `wd_phone:server:simInstalled` / `simRemoved` | `simId, deviceId` |
| `wd_phone:server:simActivated` | `simId, kind` (`physical` or `esim`) |
| `wd_phone:server:simSuspended` | `simId, reason` |
| `wd_phone:server:simExpired` | `simId` |
| `wd_phone:server:numberTransferred` | `lineId, sourceSimId, targetSimId` |
| `wd_phone:server:carrierPlanChanged` | `simId, planId` |
| `wd_phone:server:towerSabotaged` / `towerRepaired` | `towerId, source` |

Client events for the player's own phone: `wd_phone:client:callConnected` (`callId`), `wd_phone:client:callEnded` (`callId`) and `wd_phone:client:signalChanged` (`level`).

## Emergency calls and dispatch

Every emergency call fires `wd_phone:server:emergencyCall` with `source, number, callerNumber`. `callerNumber` is `nil` when the caller hides it, and for Wolfden Phone Secure and Ghost Mobile lines.

Seven dispatch resources are built in (`ps-dispatch`, `cd_dispatch3d`, `cd_dispatch`, `qs-dispatch`, `rcore_dispatch`, `core_dispatch`, `lb-tablet`). Any other CAD or MDT can receive the same emergency, Services and Racing alerts by registering a provider from its own server script. Start it after `wd_phone`:

```lua
local ok, reason = exports.wd_phone:RegisterDispatchProvider('my_cad', function(alert)
    -- alert.kind      'emergency' | 'service' | 'racing' (underground race heat)
    -- alert.id        emergency number ('911'), service id ('police') or race id
    -- alert.source    caller's server id; alert.requestId for a service request
    -- alert.code / alert.title / alert.message (cleaned, from the templates)
    -- alert.priority  'high' | 'medium' | 'low'; alert.jobs = { 'police', ... }
    -- alert.coords    { x, y, z } of the caller's ped, read on the server
    -- alert.street / alert.zone / alert.location (display text from the caller's game)
    -- alert.number    caller number, nil when withheld; alert.caller = number or 'Withheld'
    -- alert.blip      { sprite, colour, scale, minutes, flash, radius }; alert.notifySeconds
    exports.my_cad:CreateCall(alert)
end)

exports.wd_phone:GetDispatchProvider() -- the provider name in use, or nil
```

- The name is 2-32 letters, digits, `-` or `_`. Built-in names return `false, 'RESERVED'`. The provider is removed when your resource stops.
- With `Config.Dispatch.provider = 'auto'`, a registered provider is used before the built-in ones. Set `provider` (or `set wd_phone:dispatch "my_cad"`) to choose it explicitly.
- Your function runs in its own thread with a copy of the alert. An error is logged and never affects the caller's call or request.
- The number is already withheld where it must be. Do not look it up again. Show the street as text; never use it to decide anything.
- A `racing` alert has deliberately fuzzed coordinates and no number. Its `source` is a racer whose game named the street: do not treat that player as a suspect.

## Payphones

```lua
-- Where a call came from, for dispatch: { payphoneId, number, coords } or nil.
local box = exports.wd_phone:GetPayphoneByNumber('555-1234')

-- Put every payphone within 50 m out of order for 30 minutes
-- (no minutes = until a restart)...
exports.wd_phone:SetPayphonesOutOfOrder(coords, 50.0, 30)
-- ...and back in order.
exports.wd_phone:SetPayphonesOutOfOrder(coords, 50.0, nil, true)
```

## Nova Wallet and banking

Nova Wallet is the player's bank account in your banking system. To connect a banking system the phone does not support, register a provider during server startup and set `wd_phone:bank` to `custom` (`auto` also uses a registered provider first):

```lua
exports.wd_phone:RegisterBankProvider({
  balance = function(person, kind) end,             -- person = { src?, citizenid }
  add = function(person, amount, reason, kind) return true end,
  remove = function(person, amount, reason, kind) return true end,
  sharedBalance = function(name) end,                -- optional: job / society accounts
  sharedAdd = function(name, amount, reason) return true end,
  sharedRemove = function(name, amount, reason) return true end,
  log = function(info) end,                          -- optional: record a phone payment in your history
  history = function(target, limit) end,             -- optional: { { id, amount, incoming, text, at } }
  offline = true,                                    -- can pay offline players
}) --> boolean
```

`balance`, `add` and `remove` are required. `history` fills the Wallet's Activity with your bank's own history; `target` is `{ citizenid }` for a person or `{ shared = name }` for a job account.

`nova://pay?account=<accountId>` links in Messages and Mail open a payment. Trusted client resources can open the Wallet too, which grants no payment authority:

```lua
exports.wd_phone:OpenBank(requestId)               -- client; requestId is optional
exports.wd_phone:OpenBankPayment('ACCT-...')       -- client; a number or account id
```

Business and job integrations use trusted server exports. The payer is resolved from the phone number's owner and must still approve an invoice in Nova Wallet:

```lua
-- The third argument links the business to its job / society account in the
-- banking system; without it (or with the `framework` bank) Nova keeps the
-- balance itself. One business account per owner: calling again returns it.
local accountId = exports.wd_phone:CreateBusinessAccount(citizenId, 'Downtown Autos', 'mechanic')
exports.wd_phone:GrantWalletAccess(accountId, employeeCitizenId, {
  role = 'manager', canSpend = true, canManage = true
})
local wallet = exports.wd_phone:GetWalletAccount(citizenId)
local requestId, err = exports.wd_phone:CreateInvoice('555-0142', {
  accountId = accountId,
  requesterLineId = businessLineId,
  amount = 1250,
  note = 'Vehicle repair'
})
-- exports.wd_phone:RevokeWalletAccess(accountId, employeeCitizenId)
```

`CreateInvoice` bills as the business account itself, so check who may bill before you call it.

Carrier, shop, repair and Wi-Fi charges go through the economy setting. To route them through another system, set `wd_phone:economy` to `custom` and register debit and credit functions. They are called for online players only:

```lua
exports.wd_phone:RegisterEconomyAdapter({
  -- account: 'bank' | 'cash' (payphones charge cash by default)
  debit = function(source, amount, reason, account) return MyEconomyDebit(source, amount, reason) end,
  credit = function(source, amount, reason, account) return MyEconomyCredit(source, amount, reason) end,
}) --> boolean
```

## Batteries and accessories

```lua
exports.wd_phone:SetDeviceCharging(deviceId, true, 1.5)    -- % per minute; false stops
exports.wd_phone:GetDeviceBattery(deviceId)                -- { level, health, charging, poweredOff }
exports.wd_phone:DamageDevice(deviceId, amount)
exports.wd_phone:RepairDevice(deviceId, condition, batteryHealth)

-- NovaBuds, for police or pawn scripts: model, owned, lost (with the owner's
-- lost message and number), battery and health. Never who owns them.
exports.wd_phone:GetNovaPodsStatus(novapodsId)
exports.wd_phone:GetNovaPodsStatusFromSlot(source, slot)

-- { wifiEnabled, mobileDataEnabled, airplaneMode } for the player's open
-- phone, or nil. (Its rotationLock field is deprecated and always true.)
local state = exports.wd_phone:GetDeviceConnectivity(source) -- server
local online = exports.wd_phone:HasDataConnection()          -- client
local wifi = exports.wd_phone:IsWifiEnabled()                -- client
local mobile = exports.wd_phone:IsMobileDataEnabled()        -- client
local rotationLocked = exports.wd_phone:IsRotationLocked()   -- client, deprecated: always true
```

## Phone stores

Phone stores sell phones and accessories over the counter. Your resource can add stores while the server runs; they are kept until a restart:

```lua
exports.wd_phone:AddMobileShop({ id = 'popup_vinewood', label = 'Pop-up Store',
    coords = vector3(300.0, 180.0, 104.0), products = { 'nova', 'novapods' } }) --> true | false, reason
exports.wd_phone:RemoveMobileShop('popup_vinewood') --> boolean
exports.wd_phone:GetMobileShops() --> { { id, label, x, y, z, heading, blip?, hours?, ped?, hidden? } }
```

Every sale fires `wd_phone:server:mobileShopSale` with `{ storeId, productId, item, quantity, total, account, src }`.

## Lost and stolen phones

```lua
-- For a police MDT or pawn shop: { lostMode, stolen, wipePending, wipedAt }. No location.
local status = exports.wd_phone:GetDeviceSecurityStatus(deviceId)
exports.wd_phone:SetDeviceLostMode(deviceId, { enabled = true, stolen = true, message = '...', contact = '555-0100' })
exports.wd_phone:RequestRemoteWipe(deviceId)        --> boolean
exports.wd_phone:GetDeviceAccount(deviceId, 'nova') -- { accountId, login, displayName } or nil
```

Lost mode set this way on a phone without a Nova Account has no Activation Lock; only an admin or a resource can lift it.

When a Wolfden Phone Secure burns, from Settings or with its Burn PIN, the server fires `wd_phone:server:secureBurned` with the device ID and who burned it. See [Wolfden Phone Secure](#/docs/wd_phone/nova-secure?section=burn-phone).

| Event | Arguments |
| --- | --- |
| `wd_phone:ops:lostModeChanged` | `deviceId, { lost, stolen, actor }` |
| `wd_phone:ops:deviceWiped` | `deviceId, actor` |
| `wd_phone:ops:backupRestored` | `deviceId, backupId, novaAccountId` |
| `wd_phone:ops:deviceTransferred` | `fromDeviceId, toDeviceId, actor` (the transferring player's citizen ID, or `src:<id>` when they have none) |
| `wd_phone:account:created` | `service, accountId, citizenId` |
| `wd_phone:account:signedIn` / `signedOut` | `deviceId, service, accountId` |

## Admin and support tools

Most of these mirror the `phoneadmin` tools; see [Admin tools](#/docs/wd_phone/admin).

```lua
exports.wd_phone:GetDeviceInfo(deviceId)               -- model, battery, condition, PIN, holder, lost / stolen flags
exports.wd_phone:GetDeviceAudit(deviceId, 25)          -- audit rows
exports.wd_phone:ResetDevicePin(deviceId)              --> boolean
-- Into the Nova Account signed in on the phone; ACCOUNT_REQUIRED without one.
exports.wd_phone:CreateDeviceBackup(deviceId, citizenId, 'Support backup') --> backupId | nil, code
exports.wd_phone:GetSchemaStatus()                     -- database schema report
exports.wd_phone:GetPerfReport('p95', 20)              -- profiler rows
exports.wd_phone:RunSelfTest('report')                 -- { passed, failed, warnings, skipped, lines }; nil while one runs
```

## Subscriber records

A police MDT can read the same carrier records officers see at the data center terminal. Both exports return `nil` when subscriber records are turned off.

```lua
-- server
-- { number, carrier, lineType, status, subscriber, sims = { ... }, history = { ... } } or nil.
-- A Ghost Mobile number returns only { number, ghost = true, carrier }.
local record = exports.wd_phone:GetSubscriberRecord('555-0101', 'mdt')

-- { person = { citizenid, name }, numbers = { { number, carrier, status, lineType } } }
-- Ghost Mobile numbers are never listed.
local owned = exports.wd_phone:GetCitizenNumbers(citizenId, 'mdt')
```

The second argument names who asked, for the log; without it, the calling resource's name is used. Every call is logged in `phone_records_lookups`. Every search, at a terminal or through an export, also fires the `wd_phone:server:recordsLookup` server event with `{ officer, type, query, found }`. See [Subscriber records](#/docs/wd_phone/subscriber-records).

## Voicemail and widgets

Visual voicemail has an inbox, read and delete state, carrier mailbox dialing and notifications. Because `pma-voice` does not record audio, a recording resource supplies the clip or transcript:

```lua
exports.wd_phone:LeaveVoicemail(targetNumber, {
  callerNumber = '555-0123', audioUrl = 'https://media.example/clip.ogg',
  durationSeconds = 18, transcript = 'Optional transcript'
})
exports.wd_phone:GetVoicemailNumber('nova_mobile')
```

Recording providers can listen for `wd_phone:server:voicemailRequested`, which receives `{ callId, callerSource, callerLineId, callerNumber, recipientLineId, anonymous }`. It fires while the caller is still on the line, when a call went unanswered, was declined, or reached a phone that is off or out of reach. `wd_phone:server:voicemailCreated` (`voicemailId, recipientLineId`) fires when a voicemail is saved.

Trusted server resources can add status widgets players place on their home screen:

```lua
exports.wd_phone:RegisterWidget({
  id = 'fuel', label = 'Fuel', icon = 'car', sizes = { 'small', 'medium' },
  defaultSize = 'small'
})
exports.wd_phone:UpdateWidget('fuel', { value = '74%', detail = 'Vehicle' }, source)
exports.wd_phone:UnregisterWidget('fuel')
```

Widget layouts, data fields and limits are in [Custom apps (SDK)](#/docs/wd_phone/app-sdk?section=widgets).

## Media, maps, services and mail

### Server
```lua
-- media
exports.wd_phone:CreateMediaRecord(source, mediaData)       -- -> mediaId; storage limits apply
exports.wd_phone:GrantMediaAccess(mediaId, lineId, accessType)
exports.wd_phone:RevokeMediaAccess(mediaId, lineId, accessType)
exports.wd_phone:GetMedia(mediaId, requestingLineId)        -- access-checked, or nil
exports.wd_phone:SendImageMessage(targetNumber, mediaIds, options)
-- maps
exports.wd_phone:CreateSavedLocation(source, locationData)  -- -> locationId
exports.wd_phone:SendLocationMessage(targetNumber, locationData, options)
-- services
exports.wd_phone:RegisterService(serviceDefinition)         -- { id, label, jobNames, ... }
exports.wd_phone:CreateServiceRequest(source, serviceId, requestData)
exports.wd_phone:GetServiceAvailability(serviceId)          -- { status, activeEmployees }
exports.wd_phone:GetPendingServiceRequests(source, serviceId)
exports.wd_phone:UpdateServiceRequest(source, requestId, status)
-- mail
exports.wd_phone:SendMail(target, mailData)                 -- target: mailbox | number | line ID | src
exports.wd_phone:SendSystemMail(target, mailData)
exports.wd_phone:RegisterMailAction(actionType, handler)    -- handler(ctx, payload, action)
```

`CreateMediaRecord` saves to the gallery of `mediaData.deviceId`, else the phone holding `mediaData.lineId`'s SIM, else the phone the player is using. Access granted to a line goes to the owner of the phone holding that SIM.

### Client
```lua
exports.wd_phone:OpenCamera(options)        -- { facing?, context? }
exports.wd_phone:OpenGallery(options)       -- { mediaId? }
exports.wd_phone:OpenMaps(location)         -- { x, y, label? }
exports.wd_phone:SetPhoneWaypoint(location) -- { x, y }
exports.wd_phone:OpenServices(serviceId)
exports.wd_phone:OpenMail(mailId)
```

Mail action buttons are limited to predefined types and the types your resources register; mail bodies never run code or open arbitrary links.

| Event | Arguments |
| --- | --- |
| `wd_phone:server:mediaCreated` / `mediaDeleted` / `mediaShared` | `mediaId, lineId` |
| `wd_phone:server:imageMessageSent` | `messageId, lineId` |
| `wd_phone:server:locationShared` | `lineId, conversationId` |
| `wd_phone:server:serviceRequestCreated` | `requestId, serviceId` |
| `wd_phone:server:serviceRequestAccepted` | `requestId, citizenId` |
| `wd_phone:server:serviceRequestUpdated` / `serviceRequestCompleted` / `serviceRequestCancelled` | `requestId, status` (a cancellation by the requester sends only `requestId`) |
| `wd_phone:server:mailSent` | `mailId, senderLineId` |
| `wd_phone:server:mailRead` | `mailId, lineId` |
| `wd_phone:server:mailActionExecuted` | `mailId, actionId, succeeded` |

Client events: `wd_phone:client:cameraOpened`, `cameraClosed` and `serviceRequestUpdated`.

## Social apps and moderation

What players and moderators see is covered in [Everyday & social apps](#/docs/wd_phone/productivity?section=social-apps), [Clips](#/docs/wd_phone/clips) and [Shade](#/docs/wd_phone/shade).

```lua
-- Echo
exports.wd_phone:OpenSocialProfile('@alexfixes')        -- client; Echo @handle
exports.wd_phone:OpenSocialPost('POST-67A1B2C3D4E5F6')  -- client
exports.wd_phone:ModerateSocialContent({                -- server; the actor is the invoking resource
  action = 'remove', targetType = 'post', targetId = 'POST-67A1B2C3D4E5F6',
  reason = 'Server moderation policy'
})
local profile = exports.wd_phone:GetSocialProfile('@alexfixes')

-- Snapgram
exports.wd_phone:GetSnapgramProfile('lena.ls')          -- public profile card or nil
exports.wd_phone:RemoveSnapgramPost('SNAP-...')

-- Ember
exports.wd_phone:GetEmberReports(50)                    -- newest first
exports.wd_phone:HideEmberProfile(accountId)            -- removes it from the deck

-- Clips
exports.wd_phone:GetClipsProfile('rio.rides')           -- public profile card or nil
exports.wd_phone:RemoveClip('CLIP-...')                 -- true when it was live
exports.wd_phone:GetClipsReports(50)                    -- open reports, oldest first

-- News
exports.wd_phone:RemoveNewsArticle(articleId)

-- Shade
exports.wd_phone:ShadeBanHandle('ash-wolf-41', 24, 'spam', false) -- hours (0 = permanent), reason, purge messages
exports.wd_phone:ShadeUnbanHandle('ash-wolf-41')
exports.wd_phone:ShadeDeleteMessage('SM-...')

-- Verified badges: 'clips' | 'snapgram' | 'echo'. false = profile not found.
exports.wd_phone:SetProfileVerified('snapgram', 'lena.ls', true)
```

Echo moderation supports `remove` and `restore` for posts, `verify` and `unverify` for profiles, and `dismiss` for reports.

| Event | Arguments |
| --- | --- |
| `wd_phone:server:socialPostCreated` | `postId, accountId` |
| `wd_phone:server:socialContentReported` | `reportId, targetType, targetId` |
| `wd_phone:server:socialContentModerated` | `action, targetType, targetId, actor` |
| `wd_phone:server:snapPostCreated` / `snapStoryCreated` | `postId` or `storyId`, `accountId` |
| `wd_phone:server:clipCreated` | `clipId, accountId` |
| `wd_phone:server:clipReported` | `reportId, targetType, targetId, reason` |
| `wd_phone:server:clipModerated` | `action, targetType, targetId, actor` |
| `wd_phone:server:clipsLiveStarted` | `streamId, accountId` |
| `wd_phone:server:clipsLiveEnded` | `streamId, accountId, reason` |
| `wd_phone:server:clipsLiveGift` | `streamId, hostAccountId, senderAccountId, giftId, amount` |
| `wd_phone:server:brandDealUpdated` | `dealId, status` |
| `wd_phone:server:clipsFundPaid` | `citizenId, week, amount` |
| `wd_phone:server:profileVerified` | `app, profileId, handle, verified, actor` |
| `wd_phone:server:newsPublished` | `articleId, outletId, breaking` |
| `wd_phone:server:newsRemoved` | `articleId, actor` |

## Garage and Homes

The Garage and Homes apps never read vehicle or property tables. A built-in `qbx` Garage adapter works with qbx_vehicles and qbx_garages. Any other garage or housing resource connects with a provider, registered from the resource that owns the data. A registered provider is used first and is dropped when its resource stops. What players see is in [Garage and Homes](#/docs/wd_phone/city-apps?section=garage).

```lua
-- vehicles() returns { id, plate, model?, label?, state ('garaged' | 'out' |
-- 'impounded'), garageId?, garageLabel?, fuel? (0-100), engine?, body? (0-1000),
-- depotPrice? }. Everything but vehicles is optional; the app hides what
-- your provider does not implement.
exports.wd_phone:RegisterGarageProvider({
    vehicles = function(citizenid, source) return exports.my_garage:GetOwnedVehicles(citizenid) end,
    garage = function(garageId) return exports.my_garage:GetGarageCoords(garageId) end, -- { label, x, y, z }
    -- reserve marks the vehicle out before valet() spawns it; release undoes it if delivery fails.
    reserve = function(source, citizenid, vehicle) return exports.my_garage:MarkOut(vehicle.id) end,
    release = function(source, citizenid, vehicle) exports.my_garage:MarkGaraged(vehicle.id) end,
    valet = function(source, citizenid, vehicle, spot) -- spot = { x, y, z, w }
        return exports.my_garage:TakeOutAt(source, vehicle.id, spot)    -- true | false, code
    end,
    -- Give the vehicle to another character; refuse when it is no longer
    -- fromCid's. Racing pink slips are offered only with it.
    transfer = function(fromCid, toCid, vehicle)
        return exports.my_garage:ChangeOwner(vehicle.id, fromCid, toCid) -- true | false, code
    end,
})

-- homes() returns { id, label, address?, role ('owner' | 'keyholder'),
-- coords? { x, y, z }, keyholders? }. Without both giveKey and revokeKey,
-- key sharing is hidden. They must check ownership themselves.
exports.wd_phone:RegisterHomesProvider({
    homes = function(citizenid, source) return exports.my_housing:GetPlayerHomes(citizenid) end,
    giveKey = function(source, ownerCid, homeId, targetCid) return exports.my_housing:AddKey(homeId, ownerCid, targetCid) end,
    revokeKey = function(source, ownerCid, homeId, targetCid) return exports.my_housing:RemoveKey(homeId, ownerCid, targetCid) end,
})
```

The garage provider also accepts `canValet` (a function returning whether the valet is available) and `entityIdKey`. For pink slips, each vehicle record needs `model` (a hash or model name) or `props.model`. If your garage tags spawned vehicles with their record ID in an entity state bag, pass that key as `entityIdKey` so only that record can be staked.

Each valet delivery fires `wd_phone:server:garageValet` with `source, { citizenid, vehicleId, plate, fee }`.

## Business and City providers

- Marketplace listings support photos, categories, search, prices, expiry, map locations, reports and ACE-gated moderation. Buyers never see a seller's phone number; contacting a seller opens a private Messages conversation.
- Business definitions and prices are server-controlled. Customers create orders, and employee queues check live job and duty state. A business Wallet account can issue an invoice when an order is accepted; the customer still confirms payment in Wallet.
- `config/commerce.lua` contains editable mechanic and taxi examples. Other server resources can register businesses during startup:

```lua
exports.wd_phone:RegisterPhoneBusiness({
  id = 'beanmachine', label = 'Bean Machine', category = 'food',
  icon = 'briefcase', color = '#8b5e3c', jobNames = { 'beanmachine' },
  acceptsOrders = true,
  products = {
    { id = 'coffee', label = 'Coffee', description = 'Fresh coffee.', price = 25 }
  },
  -- Optional: accepting a paid order invoices the customer from this
  -- Nova Wallet business account. If the invoice fails, the order stays
  -- in the queue.
  invoiceAccountId = 'BIZ-...'
})
```

  A `food` business that accepts orders is also a Bites restaurant.

- The City app shows data from your other resources, without reading their database. Vehicle, property, government and dispatch resources register server-side providers. Their fetch and action functions receive the player's source, citizen ID, device, model and active line, all resolved by the server:

```lua
exports.wd_phone:RegisterPhoneEcosystemProvider({
  id = 'garage', label = 'My Vehicles', category = 'vehicles',
  icon = 'car', color = '#0a84ff',
  actions = { { id = 'track', label = 'Track Vehicle', icon = 'map-pin' } },
  fetch = function(ctx)
    return exports.my_garage:GetPhoneVehicles(ctx.citizenid)
  end,
  action = function(ctx, actionId, payload)
    if actionId == 'track' then
      return exports.my_garage:TrackOwnedVehicle(ctx.source, payload.plate)
    end
  end,
})
```

  Providers remain responsible for ownership, job, duty, permission and action validation. Use `category = 'secure'` or `secureOnly = true` for tools shown only on Wolfden Phone Secure, which ships without City: add `'city'` to its `apps` in `config/devices.lua` to offer them.

- Marketplace moderation uses the `wd_phone.commerce.moderate` ACE. Trusted resources can call `ModerateMarketListing(listingId, action)` with `remove`, `restore` or `dismiss`.
- Businesses and City providers registered by a resource are dropped when that resource stops.

| Event | Arguments |
| --- | --- |
| `wd_phone:server:marketListingUpdated` | `listingId, 'created' \| 'updated'` |
| `wd_phone:server:marketListingReported` | `reportId, listingId` |
| `wd_phone:server:marketListingModerated` | `listingId, action, actor` |
| `wd_phone:server:businessOrderCreated` | `orderId, businessId` |
| `wd_phone:server:businessOrderUpdated` | `orderId, status` |
| `wd_phone:server:bitesOrderPlaced` / `bitesOrderDelivered` | `orderId, businessId` |

## Rides, Racing and Arcade

The apps themselves are covered in [Rides](#/docs/wd_phone/city-apps?section=rides) and [Racing](#/docs/wd_phone/racing).

Rides events:

| Event | Arguments |
| --- | --- |
| `wd_phone:server:rideRequested` | `rideId, riderCitizenId` |
| `wd_phone:server:rideAccepted` | `rideId, riderCitizenId, driverCitizenId` |
| `wd_phone:server:rideCompleted` | `rideId, riderCitizenId, driverCitizenId, fare` |
| `wd_phone:server:rideCancelled` | `rideId, by, reason` |

Racing server exports, called like the others on `exports.wd_phone`:

| Export | Returns |
| --- | --- |
| `IsPlayerRacing(source)` | The race ID while the player is in a countdown or a live race, else `nil` |
| `GetRacerStats(citizenid)` | `{ alias, rating, races, wins, podiums }` or `nil` |
| `CancelRace(raceId, reason?)` | `true` when cancelled; every buy-in and bet is refunded |

Racing events. Citizen IDs appear here for integrations only and never reach a phone:

| Event | Arguments |
| --- | --- |
| `wd_phone:server:raceCreated` | `raceId, hostCitizenId, underground` |
| `wd_phone:server:raceStarted` | `raceId, underground, startCoords, starters` |
| `wd_phone:server:raceFinished` | `raceId, rows` (`{ citizenid, position, totalMs, dnf }` each) |
| `wd_phone:server:raceCancelled` | `raceId, reason` |
| `wd_phone:server:racingHeat` | `raceId, coords, radius` (the fuzzed alert point of an underground race) |
| `wd_phone:server:racingCheat` | `source, raceId, kind, detail` (`raceId` is `nil` outside a race) |

For an Arcade season reset, `exports.wd_phone:ResetArcadeLeaderboard(game)` clears every phone's best for one game (`tiles`, `snake`, `mines`, `skyhop` or `tictactoe`), or for all games without an argument. It returns the number of scores removed.

## Framework hooks

Wolfden Phone picks Qbox, QBCore or ESX and ox_inventory or qb-inventory when it starts (`setr wd_phone:framework` and `setr wd_phone:inventory`, see [Configuration](#/docs/wd_phone/configuration)). Where your server keeps duty, death, licences or gangs somewhere the framework does not, set these functions in `config/server.lua`. Return `true` or `false` to decide, or `nil` to use the built-in answer:

| Hook | Used for |
| --- | --- |
| `Config.Framework.isDowned(src)` | Dead or last stand |
| `Config.Framework.isOnDuty(src, jobName)` | Duty on ESX (Qbox and QBCore use `job.onduty`) |
| `Config.Framework.hasDriverLicence(src)` | Rides driver's licence |
| `Config.Framework.getGang(src)` | The player's gang on ESX, for Shade crew rooms (returns a gang name or `nil`) |

## Custom apps

For registration, permissions, notifications, deep links, widgets, app storage and the Browser add-on, use [Custom apps (SDK)](#/docs/wd_phone/app-sdk). Apps can be installed on a phone from your resource with `InstallApp(deviceId, appId)`, for example when a job is assigned, and you can open any screen on a player's phone:

```lua
exports.wd_phone:InstallApp(deviceId, 'novanews')
exports.wd_phone:OpenDeepLink(source, 'nova://maps/location?x=215&y=-810&label=Pillbox')
```
