Advanced integrations

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. 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

EventArguments
wd_phone:server:messageSentsenderNumber, conversationId
wd_phone:server:messageDeliveredmessageId
wd_phone:server:messageReadmessageId
wd_phone:server:callStartedcallerNumber, receiverNumber (once per person invited, so once per member in a group call)
wd_phone:server:callAnsweredcallId (the first answer)
wd_phone:server:callEndedcallId, reason
wd_phone:server:numberBlockedownerNumber, blockedNumber (ownerNumber is nil on a phone without a SIM)
wd_phone:server:simInstalled / simRemovedsimId, deviceId
wd_phone:server:simActivatedsimId, kind (physical or esim)
wd_phone:server:simSuspendedsimId, reason
wd_phone:server:simExpiredsimId
wd_phone:server:numberTransferredlineId, sourceSimId, targetSimId
wd_phone:server:carrierPlanChangedsimId, planId
wd_phone:server:towerSabotaged / towerRepairedtowerId, 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.

EventArguments
wd_phone:ops:lostModeChangeddeviceId, { lost, stolen, actor }
wd_phone:ops:deviceWipeddeviceId, actor
wd_phone:ops:backupRestoreddeviceId, backupId, novaAccountId
wd_phone:ops:deviceTransferredfromDeviceId, toDeviceId, actor (the transferring player's citizen ID, or src:<id> when they have none)
wd_phone:account:createdservice, accountId, citizenId
wd_phone:account:signedIn / signedOutdeviceId, service, accountId

Admin and support tools

Most of these mirror the phoneadmin tools; see Admin tools.

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.

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).

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.

EventArguments
wd_phone:server:mediaCreated / mediaDeleted / mediaSharedmediaId, lineId
wd_phone:server:imageMessageSentmessageId, lineId
wd_phone:server:locationSharedlineId, conversationId
wd_phone:server:serviceRequestCreatedrequestId, serviceId
wd_phone:server:serviceRequestAcceptedrequestId, citizenId
wd_phone:server:serviceRequestUpdated / serviceRequestCompleted / serviceRequestCancelledrequestId, status (a cancellation by the requester sends only requestId)
wd_phone:server:mailSentmailId, senderLineId
wd_phone:server:mailReadmailId, lineId
wd_phone:server:mailActionExecutedmailId, 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.

lua32 lines
-- 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.

EventArguments
wd_phone:server:socialPostCreatedpostId, accountId
wd_phone:server:socialContentReportedreportId, targetType, targetId
wd_phone:server:socialContentModeratedaction, targetType, targetId, actor
wd_phone:server:snapPostCreated / snapStoryCreatedpostId or storyId, accountId
wd_phone:server:clipCreatedclipId, accountId
wd_phone:server:clipReportedreportId, targetType, targetId, reason
wd_phone:server:clipModeratedaction, targetType, targetId, actor
wd_phone:server:clipsLiveStartedstreamId, accountId
wd_phone:server:clipsLiveEndedstreamId, accountId, reason
wd_phone:server:clipsLiveGiftstreamId, hostAccountId, senderAccountId, giftId, amount
wd_phone:server:brandDealUpdateddealId, status
wd_phone:server:clipsFundPaidcitizenId, week, amount
wd_phone:server:profileVerifiedapp, profileId, handle, verified, actor
wd_phone:server:newsPublishedarticleId, outletId, breaking
wd_phone:server:newsRemovedarticleId, 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.

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.
EventArguments
wd_phone:server:marketListingUpdatedlistingId, 'created' | 'updated'
wd_phone:server:marketListingReportedreportId, listingId
wd_phone:server:marketListingModeratedlistingId, action, actor
wd_phone:server:businessOrderCreatedorderId, businessId
wd_phone:server:businessOrderUpdatedorderId, status
wd_phone:server:bitesOrderPlaced / bitesOrderDeliveredorderId, businessId

Rides, Racing and Arcade

The apps themselves are covered in Rides and Racing.

Rides events:

EventArguments
wd_phone:server:rideRequestedrideId, riderCitizenId
wd_phone:server:rideAcceptedrideId, riderCitizenId, driverCitizenId
wd_phone:server:rideCompletedrideId, riderCitizenId, driverCitizenId, fare
wd_phone:server:rideCancelledrideId, by, reason

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

ExportReturns
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:

EventArguments
wd_phone:server:raceCreatedraceId, hostCitizenId, underground
wd_phone:server:raceStartedraceId, underground, startCoords, starters
wd_phone:server:raceFinishedraceId, rows ({ citizenid, position, totalMs, dnf } each)
wd_phone:server:raceCancelledraceId, reason
wd_phone:server:racingHeatraceId, coords, radius (the fuzzed alert point of an underground race)
wd_phone:server:racingCheatsource, 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:

HookUsed 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:

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

Available

Wolfden Phone

$89.99

or $19.99 a month, first 30 days free

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