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
-- 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) --> booleanClient
-- 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() --> booleanWhen 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. 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
-- 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) --> booleanClient
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() --> booleanSIMs 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:
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 returnfalse, 'RESERVED'. The provider is removed when your resource stops. - With
Config.Dispatch.provider = 'auto', a registered provider is used before the built-in ones. Setprovider(orset 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
racingalert has deliberately fuzzed coordinates and no number. Itssourceis a racer whose game named the street: do not treat that player as a suspect.
Payphones
-- 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):
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
}) --> booleanbalance, 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:
exports.wd_phone:OpenBank(requestId) -- client; requestId is optional
exports.wd_phone:OpenBankPayment('ACCT-...') -- client; a number or account idBusiness 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:
-- 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:
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,
}) --> booleanBatteries and accessories
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 truePhone 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:
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
-- 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 nilLost 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.
| 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.
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 runsSubscriber 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.
-- 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.
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:
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:
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).
Media, maps, services and mail
Server
-- 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
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, Clips and Shade.
-- 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.
-- 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.luacontains editable mechanic and taxi examples. Other server resources can register businesses during startup:
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:
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.moderateACE. Trusted resources can callModerateMarketListing(listingId, action)withremove,restoreordismiss. - 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 and 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). 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). 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:
exports.wd_phone:InstallApp(deviceId, 'novanews')
exports.wd_phone:OpenDeepLink(source, 'nova://maps/location?x=215&y=-810&label=Pillbox')