Nothing in this manual matched.

9LABS Scoot Rental · v4.1.4

A scooter kiosk under the pavement.

Place stations in game, set the plans, and let the bunker rise, open its hatch and push a scooter out on a fork. Everything that costs money is decided on the server.

Introduction

A server-authoritative scooter rental. The player picks a duration and a payment account at a station, the underground bunker rises, opens and physically dispenses the configured scooter. It can be returned at any SCOOT station.

Scoot Rental is currently not on sale. Rockstar removed the scooters it was built to rent, so the product has been withdrawn until it is reworked, and it will go back on sale after that. This manual stays online for existing owners.

What is in the resource

  • Animated underground rental bunkers, and optional independently placed world-space DUI tablets.
  • An in-game station creator, with placed stations and active rentals persisted to disk.
  • Qbox, QBCore, ESX, standalone and custom framework support, plus optional vehicle keys.
  • Built-in prompt, ox_lib, ox_target, qb-target and custom interaction modes.
  • Configurable plans, accounts, colours, logos, blips, sounds, UI placement and language (English, Bosnian, German).
  • Server and client exports for phone applications, and lifecycle events for start, return and expiry.

The server decides everything that costs money. A client or phone application cannot choose the price, bypass the station distance, create a second rental or grant itself ownership by calling an export.

Requirements

One framework choice, one streamed scooter model, OneSync. Everything else is optional.

ServerFiveM/FXServer with OneSync enabled.
Scooter modelThe resource does not include or stream a scooter. ScootConfig.scooterModel must name a model your server streams or a vanilla vehicle. The default, labscoot, comes from the separate 9labs_scooter resource.
FrameworkQbox (qbx_core), QBCore (qb-core), ESX (es_extended), standalone rental credits, or a custom server bridge.
Vehicle keysOptional: qbx_vehiclekeys, qb-vehiclekeys, or a custom bridge.

Optional resources

ox_target, qb-target and ox_lib are only needed when the configuration selects them.

ox_lib is not a dependency. The built-in 9labs_ui prompt and notifications work without it, and the shipped notify = 'ox_lib' falls back to the built-in notification when ox_lib is not running. Do not remove ox_lib from your server merely because SCOOT does not need it — other resources or your framework may still depend on it.

Installation

Start it after the framework, target and key resources, then place a station.

  1. Extract the delivered 9labs_scootbunker folder into your server's resources directory.
  2. Make sure the configured scooter model is streamed, and that rental.vehicleType matches the type declared in its vehicles.meta. For the default labscoot, install and start the separate 9labs_scooter resource.
  3. Start the framework, target, key and scooter resources before SCOOT.
  4. Give administrators access to the station creator. Anyone who already has the command ACE can use it without extra setup; to grant it to others, add the scootbunker.place ACE below.
  5. Configure config.lua, start the resource, and reconnect once after a model or YTYP update so the streamed assets refresh.
  6. Run /scootplace in game to place stations.

Example for Qbox with ox_target, Qbox vehicle keys and the default labscoot scooter:

ensure qbx_core
ensure qbx_vehiclekeys
ensure ox_target
ensure 9labs_scooter
ensure 9labs_scootbunker
add_ace group.admin scootbunker.place allow

Keep the folder name. It must stay 9labs_scootbunker for the export examples in this manual to work.

Updating

Replace the resource files together, then put your own files back.

Back these up before you start:

config.lua
locations.json
rentals.json
server/bridge.lua
client/bridge.lua
logo/logo.png
locales/

Merge new configuration keys into your config and preserve locations.json and rentals.json. Stations saved by an older version keep working — they do not need converting to the newer tablet station format.

Asset Escrow and editable files

The release is built for Cfx Asset Escrow. These files are explicitly excluded from encryption by fxmanifest.lua:

config.lua
client/bridge.lua
server/bridge.lua
locales/*.lua
logo/*
locations.json
rentals.json

Frameworks, money, keys, targets, prompts, notifications, translations, logos and storage are all configurable without touching a protected implementation file.

Bunker and tablet stations

Every station has a bunker plus a separate static rounded frame. The frame does not animate with the machine underneath it.

Bunker interaction

The player interacts at the bunker service point. Depending on the configuration, the resource opens its built-in rental menu, or fires the external UI event for your own interface.

DUI tablet

A tablet station adds a physical world-space tablet that is positioned and rotated independently of the bunker. It has to stay outside the bunker and the scooter exit path, and within tablet.maxDistance of its bunker.

tablet = {
    maxDistance    = 12.0,
    cameraDistance = 1.6,
    cameraFov      = 40.0,
    autoFit        = true,
}

The prompt or target moves to the tablet, but the scooter is still released by the bunker.

Tablet stations always use their physical DUI. rental.externalUi replaces the menu for bunker-only stations, never for a tablet.

In-game creator

Aim at the ground, lock the position, adjust it and save. The creator shows its current keyboard controls on screen.

/scootplace             -- open the creator to place a new station
/scootremove            -- open the nearest placed station within 20 m to edit or delete it
/scootglow <texture>    -- paint one kiosk texture to find the glow strip

-- only registered when debug = true
/scootdiag              -- animation and bone diagnostics for the nearest bunker
/scootsound ...         -- sound diagnostics

The creator places bunker-only and DUI-tablet stations. Placed stations are stored in locations.json and can be edited or deleted in game. To change an existing one, stand near it and run /scootremove: it opens the nearest placed station within creator.maxDistance, or reports that no station is nearby. Stations defined in config.lua are fixed and cannot be edited through the creator.

Access and limits

placeAce = 'scootbunker.place'

creator = {
    command             = 'scootplace',
    removeCommand       = 'scootremove',
    adminAce            = 'command',
    maxDistance         = 20.0,
    minimumSpacing      = 5.0,
    maxLocations        = 200,
    moveStep            = 0.05,
    rotateStep          = 1.0,
    precisionMultiplier = 0.2,
}
KeyBehaviour
placeAceACE that grants the creator and scootcredits, for example add_ace group.admin scootbunker.place allow.
creator.adminAceA second accepted ACE. The default command means players who already have the command ACE can use the creator immediately.
creator.command, creator.removeCommandThe names of the two creator commands.
creator.maxDistanceHow close, in metres, the admin must be to a station to save, edit or delete it, and the search radius of /scootremove.
creator.minimumSpacingMinimum distance in metres between two stations.
creator.maxLocationsMaximum number of placed stations.
creator.moveStep, creator.rotateStepMovement and rotation per adjustment step.
creator.precisionMultiplierStep multiplier while Shift is held for fine adjustment.

With framework = 'custom', Custom.isAdmin in server/bridge.lua is also consulted. For any other framework only the two ACEs count.

Static bunker station

locations = {
    {
        coords  = vec3(-1037.5, -2737.2, 19.10),
        heading = 150.0,
        name    = 'Airport SCOOT',
        color   = '#00e5b4',
    },
}

Static tablet station

locations = {
    {
        coords  = vec3(-1037.5, -2737.2, 19.10),
        heading = 150.0,
        name    = 'Airport SCOOT',
        terminal = {
            coords  = { x = -1034.8, y = -2736.2, z = 19.10 },
            heading = 240.0,
        },
    },
}

heading is the direction the bunker door faces, which is also the direction the scooter is dispensed.

Ground and streaming

Bunkers follow mild slopes and are created around each player rather than all at once.

groundAlign     = true
maxSlope        = 12.0
spawnDistance   = 50.0
despawnDistance = 55.0
propCollision   = true

With groundAlign enabled, the bunker and its frame follow mild ground slopes; anything steeper than maxSlope is clamped. The gap between spawnDistance and despawnDistance is deliberate — it stops a kiosk flickering while a player stands on the edge of the radius. With propCollision enabled the kiosk is solid and players cannot walk through it.

Logo, glow and blips

One PNG brands every bunker, and each station can carry its own strip colour and map icon.

Your logo

Place a transparent 1024×1024 PNG at logo/logo.png and restart the resource. It is composited at runtime over the supplied door and roof base textures, so give it a real alpha channel for clean edges. Without an image there, the artwork baked into the model stays.

logo = {
    enabled    = true,
    file       = 'logo/logo.png',
    dictionary = 'scoot_bunker',
    surfaces   = {
        door = { texture = 'scoot_door', base = 'logo/base_door.png',
            x = 0.50, y = 0.50, size = 0.72, turn = 0 },
        roof = { texture = 'scoot_roofpanel', base = 'logo/base_roof.png',
            x = 0.50, y = 0.40, size = 0.78, turn = 0 },
    },
}

Each surface names the texture it replaces and its base image. x, y and size are fractions of the plate, so 0.5 is its middle; turn rotates the logo. logo.enabled = false keeps the model's own artwork.

Glow

Colours accept #rrggbb, rrggbb, 0xRRGGBB, #rgb or aarrggbb. With glow.perStation = true a placed station stores its own colour. The emissive strip uses one shared runtime texture, so the nearest station's colour is the one displayed as the player moves between stations.

glow = {
    enabled    = true,
    color      = '#00e5b4',
    dictionary = 'scoot_bunker',
    textures   = { 'scoot_trim' },
    perStation = true,
    presets    = { '#00e5b4', '#e50000', '#ffb400', '#7a5cff', '#ff2fb0', '#ffffff' },
}

color is the default strip colour and presets are the colours offered in the creator; any other hex can still be typed in. textures should stay scoot_trim, the only texture the emissive strip uses; adding one of the other kiosk textures repaints that whole surface. /scootglow <texture> paints a single texture so you can see which is which.

Blips

Every station gets a map blip unless it is disabled globally or individually. Placed stations may override sprite, colour, scale, short-range mode, flashing and label from the creator.

blips = {
    enabled       = true,
    sprite        = 226,
    color         = 3,
    scale         = 0.75,
    shortRange    = true,
    flash         = false,
    flashInterval = 500,
    label         = 'SCOOT',
    sprites       = { 226, 1, 225, 267, 356, 427, 523, 544 },
    colors        = { 3, 1, 2, 5, 7, 8, 11, 15, 17, 18, 38, 46 },
}

label is used when a station has no name. flashInterval is in milliseconds and only applies while flash is on. sprites and colors are the buttons offered in the creator; any other id can still be typed in, and the blip updates on the map while you edit.

Framework and keys

Normal setup happens in config.lua through the global ScootConfig table.

Framework

ValueBehaviour
autoFirst running resource, in this order: Qbox, QBCore, ESX; otherwise standalone.
qbxUses qbx_core.
qbUses qb-core.
esxUses es_extended.
standalonePersistent SCOOT rental credits stored per FiveM license.
customThe functions at the top of server/bridge.lua.

Pin it on a production server. With auto, a change in resource start order can change which adapter is chosen.

Vehicle keys

ValueBehaviour
autoqbx_vehiclekeys, then qb-vehiclekeys, when one is running.
qbxQbox vehicle keys.
qbQBCore vehicle keys.
noneNo keys are issued or removed.
customCustom.giveKeys and Custom.removeKeys in server/bridge.lua.

Interaction and notifications

Exactly one interaction mode is active at a time.

interaction      = '9labs_ui'
interactKey      = 38
interactKeyLabel = 'E'
interactDistance = 4.5
targetIcon       = 'fa-solid fa-bolt'
prompt           = { title = 'SCOOT RENTAL', scale = 1.0 }

notify = 'ox_lib'
ValueBehaviour
9labs_uiBuilt-in E prompt. No external target or UI library required.
ox_libox_lib TextUI prompt, falling back to the built-in prompt when ox_lib is unavailable.
ox_targetox_target eye interaction only, no E prompt.
qb-targetqb-target eye interaction only, no E prompt.
customThe prompt and target functions in client/bridge.lua.

The Tebex package ships with interaction = '9labs_ui' and notify = 'ox_lib'; change them to whatever your server uses. notify accepts 9labs_ui, ox_lib, native and custom. With ox_lib, notifications fall back to the built-in 9labs_ui style when ox_lib is not running.

targetIcon is the icon shown on ox_target and qb-target options. prompt sets the title and size of the built-in 9labs_ui prompt; scale is clamped between 0.75 and 1.5. interactDistance is measured in metres from the bunker service point, or from the tablet on a tablet station.

Plans and payment

A plan is an id, a duration and a price. The server picks the plan and its authoritative price by id.

rental = {
    plans = {
        { id = 'short',  minutes = 15, price = 75 },
        { id = 'medium', minutes = 30, price = 125 },
        { id = 'long',   minutes = 60, price = 200 },
    },
    accounts          = { 'cash', 'bank' },
    vehicleType       = 'bike',
    warningSeconds    = 60,
    returnDistance    = 8.0,
    requestCooldownMs = 1000,
    externalUi        = false,
}
  • Plan ids must be unique strings.
  • Minutes and prices must be whole numbers. A duration is between 1 and 10080 minutes (seven days); a price is between 0 and 10,000,000.
  • Payment accounts may contain cash, bank or both.
  • One active rental per character. Time continues while the player is disconnected, and returning early refunds nothing.

Never accept a price from a client application. Send the plan id and let the server resolve the price.

The scooter model

The resource rents whichever vehicle you name, as long as the type matches.

scooterModel    = 'labscoot'
warpIntoScooter = false

rental = {
    vehicleType = 'bike',
}

This resource does not include or stream a scooter; it streams only the bunker, frame, tablet and their animation. scooterModel must be a model your server streams. The default, labscoot, expects the separate 9labs_scooter model resource to be installed and started. You can set any other streamed model name, or a vanilla vehicle, instead. Either way, vehicleType must match that model's vehicles.meta type, such as bike or automobile.

With warpIntoScooter = true the renter is seated on the scooter as soon as it is dispensed; the default leaves the player to walk up and get on.

A type mismatch is the usual cause of a scooter that spawns and then behaves strangely, or does not appear at all.

Standalone credits

Standalone mode never touches framework money. It keeps a separate cash and bank rental wallet per FiveM license in resource KVP storage.

standalone = {
    startingCash = 1000,
    startingBank = 0,
}

Add credits from the server console, or in game as an authorized creator admin:

scootcredits <playerId> <positiveAmount> [cash|bank]

Free rentals: set every plan price to 0. Every other rule stays in place.

Interface, sounds and language

The built-in menu, the rental strip, the cycle sounds and the translation are all set in config.lua.

Language and menu

locale = 'en' -- 'en', 'bs' or 'de'

ui = {
    accent          = '#00e5b4',
    accentSecondary = '#36d9ee',
    panel           = '#111918',
    text            = '#ffffff',
    muted           = '#aebeb9',
    scale           = 1.0,
    currency        = '$',
    numberLocale    = 'bs-BA',
    menuAnchor      = 'right',
    menuOffsetX     = 2.35,
    menuOffsetY     = 6.5,
    rentalAnchor    = 'bottom-left',
    rentalOffsetX   = 2.35,
    rentalOffsetY   = 4.0,
}
KeyBehaviour
localeen, bs or de. Missing keys fall back to English. Add a language as a new file in locales/ and list it in fxmanifest.lua.
accent, accentSecondary, panel, text, mutedColours of the built-in menu, prompt and rental strip.
scaleSize of the built-in menu, clamped between 0.65 and 1.3.
currency, numberLocaleCurrency symbol and number formatting for prices and balances. getPlans also returns currency.
menuAnchor, menuOffsetX, menuOffsetYWhere the rent and creator panel sits: left, right or center. Offsets are percent of the screen.
rentalAnchor, rentalOffsetX, rentalOffsetYWhere the active rental strip sits while riding: bottom-left, bottom-right, bottom-center, top-left, top-right or top-center.

Sounds

playSounds       = true
soundVolume      = 0.40
soundDistance    = 25.0
soundRefDistance = 2.5
soundRolloff     = 1.4

sounds = {
    { frame = 1,   file = 'rise_v2.ogg',       volume = 0.85, length = 1.52 },
    { frame = 41,  file = 'door_open_v2.ogg',  volume = 0.45, length = 1.40 },
    { frame = 93,  file = 'fork_out.ogg',      volume = 0.70, length = 2.05 },
    { frame = 158, file = 'fork_in.ogg',       volume = 0.50, length = 2.20 },
    { frame = 215, file = 'door_close_v2.ogg', volume = 0.45, length = 1.35 },
    { frame = 261, file = 'sink_v2.ogg',       volume = 0.85, length = 1.42 },
}

playSounds = false silences the bunker. soundVolume is the master volume for every cue, soundDistance is the range in metres beyond which nothing is heard, soundRefDistance is the distance of full loudness, and a higher soundRolloff drops the volume faster with distance. Each cue in sounds plays a file from web/sounds/ at an animation frame of the 302-frame, 30 fps cycle.

Rental lifecycle

v4.1.4 has a direct flow: a successful rent call is the handover, not a booking.

select station + plan + account
              |
              v
server validates player, distance, ownership, station, balance and storage
              |
              v
payment succeeds -> bunker begins dispensing -> rental is active
              |
              +--------------------+
              |                    |
              v                    v
      returned at a station     timer expires
  • The server creates and charges the rental only while the player is alive, on foot, in routing bucket 0 and within interactDistance of the selected station service point.
  • A successful rent immediately begins the bunker release cycle.
  • The duration begins at the release point, and the calculated expiry includes the release-animation offset.
  • The server enforces one rental per character, per-player and per-station locks, station busy state, a request cooldown, the account balance, persistent storage and model spawning.
  • A rental may be returned at any valid station once its scooter is within rental.returnDistance of it.
  • Unused time is not refunded, and the timer continues while the player is offline.

What the phone API cannot do

There is no remote reserved state in v4.1.4. The phone API lists stations and plans from anywhere, shows the active rental and routes the player to a station — but it cannot buy a rental remotely and hold it for later. Call rent after the player reaches the station; that distance check is intentional and is repeated by the protected server code.

There are also no public reserve, cancel or proximity auto-release exports and events, so do not build a phone UI that promises those states.

Persistence and recovery

Two plain JSON files, both Asset Escrow-exempt, both loaded when the resource starts.

locations.jsonStations placed in the creator.
rentals.jsonActive rentals, including ones whose owner is offline.

A rental that outlived its scooter

An active timer continues across disconnects and restarts. If the rental is still valid but its scooter entity is gone, getRental returns recoverable = true, and the built-in station menu lets the player collect a replacement scooter for the remaining time. No new payment is taken.

Payment safety

The server writes a provisional rental before charging. If it stops inside that narrow window, startup removes the stale provisional entry and prints the owner and the amount in the console so an administrator can decide whether a manual refund is needed. If storage is invalid or cannot be written, rental requests fail rather than continuing on unsafe in-memory payment state.

Server exports

Call these from server-side code only, and always derive the player from the event's own source.

local SCOOT = exports['9labs_scootbunker']

SCOOT:getStations()   -- every static and placed station, keyed by id
SCOOT:getPlans()      -- plans, accounts and the display currency
SCOOT:getRental(source)
SCOOT:rent(source, stationId, planId, account)
SCOOT:returnRental(source, stationId)

Return shapes

{ ok = true }

{ ok = true, rental = Rental }

{ ok = false, error = 'Translated message for the player' }

There is no machine-readable error code in v4.1.4. error is a localized display message — show it to the player and refresh state, never branch your phone logic on the English text.

getStations

Returns every static and creator-placed station, indexed by station id:

{
    id      = 'placed:...',
    coords  = { x = -1037.5, y = -2737.2, z = 19.10 },
    heading = 150.0,
    name    = 'Airport SCOOT',
    color   = '#00e5b4',
    blip    = {
        enabled = true,
        sprite  = 226,
        color   = 3,
        scale   = 0.75,
    },
    fixed    = true,     -- config.lua stations only; nil for placed stations
    version  = 'bunker', -- or 'tablet'; may be nil for older placed stations
    terminal = nil,      -- tablet transform when present
    busy     = false,
}

busy is a live snapshot of the release animation; let the server revalidate availability at rent time rather than treating a fetched busy = false as a lock. fixed is true for stations from config.lua, and name, color, blip and terminal may be absent when defaults are used.

getPlans and getRental

getPlans returns the configured plans, the allowed accounts and the display currency. getRental returns the public rental form, or nil when there is none:

{
    plate       = 'SC123456',
    remaining   = 899,
    expiresAt   = 2000000899,
    netId       = 123,
    recoverable = false,
}

rent

local result = exports['9labs_scootbunker']:rent(source, 'static:1', 'short', 'bank')

if result.ok then
    print(('Scooter %s expires at %s'):format(
        result.rental.plate,
        result.rental.expiresAt
    ))
else
    print(result.error)
end

The player must be alive, on foot, in routing bucket 0 and within interactDistance of the station. Plan, account, price, balance, duplicate rental, cooldown, storage and station state are all checked again on the server.

returnRental

Ends the rental when the rented scooter is within rental.returnDistance of the selected station. The export validates the station id, ownership of an active rental and, when the scooter still exists, its distance. If the scooter entity is already gone, the rental is ended without a distance check.

Unlike the built-in station callback, the export does not require the player's ped to be next to the station, does not apply requestCooldownMs, does not wait for the original station to finish dispensing and does not check the scooter's routing bucket. A phone wrapper that wants the physical behaviour should apply its own proximity policy and rate limit first. On success the rental is removed from storage, keys are removed where supported, the scooter is deleted and scootbunker:rentalEnded fires with reason returned.

Client exports

These work on the local player's streamed station state only.

local SCOOT = exports['9labs_scootbunker']

SCOOT:getLocations()        -- stations last synchronized to this client
SCOOT:getNearestStation()   -- stationId, distance (nil, nil when nothing is loaded)
SCOOT:openStation(stationId)
SCOOT:previewStation(stationId, delay, options)
SCOOT:clearPreview(stationId)

getLocations returns the station table last synchronized to this client, which may be empty for the first few seconds after startup. getNearestStation measures from the player to the main bunker coordinates and returns nil, nil when no station data is loaded. openStation runs the same open flow as walking up to it — the server still validates distance and station state — and it does not return the server mutation result.

previewStation

local started = exports['9labs_scootbunker']:previewStation(
    stationId,
    1.5,
    {
        scooter = true,
        keep    = false,
        cleanup = 45,
    }
)

A local demonstration cycle: no server call, no money, no rental, and the scooter is a local entity. scooter = false runs the cycle with an empty fork, keep = true leaves the preview scooter where it lands, and cleanup overrides the 45-second default. It returns false when the station is not streamed, is busy, or its bunker entity does not exist. clearPreview removes the local scooter when it is safe to delete.

Events and external UI

Client: stationOpened

Local client event, emitted only for bunker-only stations when rental.externalUi = true:

AddEventHandler('scootbunker:stationOpened', function(stationId, data)
    -- data.ok       == true
    -- data.station  station data
    -- data.plans    configured rental plans
    -- data.accounts allowed payment accounts
    -- data.balance  { cash = number, bank = number }
    -- data.rental   active rental or nil
    -- data.busy     current station busy snapshot

    OpenMyPhoneScootScreen(stationId, data)
end)

This is a local integration event, not a purchase endpoint. The rental itself must still go through your phone's server-side wrapper and the rent export.

Server: rentalStarted

Fires after the rental is stored and charged and the handover cycle has started — use it for phone refreshes, analytics or logs.

AddEventHandler('scootbunker:rentalStarted', function(src, info)
    -- info.station, info.plan, info.price, info.account,
    -- info.plate, info.minutes, info.expiresAt, info.netId
end)

Server: rentalEnded

Fires on a successful return, or on expiry for an online player.

AddEventHandler('scootbunker:rentalEnded', function(src, info)
    -- info.station
    -- info.reason == 'returned' or 'expired'
end)

Offline rentals are expired and cleaned up too, but an offline player has no current server id, so do not expect a rentalEnded callback with src for an expiry processed while the owner is disconnected.

Phone workflow

List from anywhere, rent at the station. Keep every mutation on the server side of your phone resource.

  1. Request getStations, getPlans and getRental from the phone's server code.
  2. Show stations, durations, prices and payment methods.
  3. Let the player pick a station and set a waypoint to its main coords.
  4. When the player reaches the station and is on foot, call the phone's server rent callback.
  5. Display the returned remaining, expiresAt and plate.
  6. Refresh on rentalStarted and rentalEnded.
  7. Return the scooter only once it is inside the configured return radius of a station.

Server side

local SCOOT = exports['9labs_scootbunker']

RegisterNetEvent('myphone:scoot:requestCatalog', function()
    local src = source

    TriggerClientEvent('myphone:scoot:catalog', src, {
        stations = SCOOT:getStations(),
        rental   = SCOOT:getRental(src),
        catalog  = SCOOT:getPlans(),
    })
end)

RegisterNetEvent('myphone:scoot:rent', function(stationId, planId, account)
    local src = source -- never take this value from the client payload
    local result = SCOOT:rent(src, stationId, planId, account)

    TriggerClientEvent('myphone:scoot:rentResult', src, result)
end)

RegisterNetEvent('myphone:scoot:returnRental', function(stationId)
    local src = source
    local result = SCOOT:returnRental(src, stationId)

    TriggerClientEvent('myphone:scoot:returnResult', src, result)
end)

Add an application-level cooldown to catalog requests if the phone can refresh them frequently. rent applies its own validation and request cooldown; returnRental validates ownership and distance but has no cooldown, so rate-limit your return endpoint as well.

Client GPS routing

RegisterNetEvent('myphone:scoot:setRoute', function(coords)
    if type(coords) ~= 'table' then return end

    local x, y = tonumber(coords.x), tonumber(coords.y)
    if not x or not y then return end

    SetNewWaypoint(x + 0.0, y + 0.0)
end)

Security rules

What the protected server code validates, and what your integration must not do.

Validated on the server

  • Player and character identity.
  • Routing bucket, player health and the on-foot requirement for a purchase.
  • Real player coordinates for a purchase.
  • Station existence and current busy state.
  • Plan and payment-account allowlists.
  • The server-configured price and the current balance.
  • One active rental per character, per-owner and per-station locks, and request cooldowns.
  • Entity model spawn success and network ownership.
  • Persistent storage before and after payment.
  • Rental ownership and, while the scooter exists, its distance for a return.

Integration rules

  • Call mutation exports only from server-side code.
  • Derive the player id from the network event or callback source.
  • Never accept a price, balance, expiry, plate, network id or owner identity from the client.
  • Treat catalog data as display data; the server revalidates it at action time.
  • Do not create a second client-side payment path around the rent export.
  • Rate-limit public phone catalog endpoints in your phone framework.
  • Display export errors, then refresh authoritative state.

Custom bridges

Both bridge files are Asset Escrow-exempt, so no protected file needs editing.

Framework, money and keys

Set framework = 'custom' and/or keys = 'custom', then implement the CUSTOM functions:

-- server/bridge.lua, CUSTOM block
Custom.identity(src)
Custom.getMoney(src, account)
Custom.removeMoney(src, account, amount, reason)
Custom.addMoney(src, account, amount, reason)
Custom.isAdmin(src)

-- used when keys = 'custom'
Custom.giveKeys(src, vehicle, plate)
Custom.removeKeys(src, vehicle, plate)

Custom.identity must return a stable, non-empty string for the current character — rentals are indexed by it, so a temporary server id will not do. Money functions must return accurate values and booleans: the resource checks the balance before charging, and calls addMoney only when transaction recovery requires a refund. vehicle in the key functions is a server entity handle.

Prompt, target and notifications

Set interaction = 'custom' and/or notify = 'custom', then implement:

-- client/bridge.lua, CUSTOM block
Custom.showPrompt(text, key)
Custom.hidePrompt()
Custom.addTarget(id, coords, radius, distance, options)
Custom.removeTarget(handle)
Custom.notify(data)

A custom target registration must return its removal handle, or nil when no target is created. The resource still handles the station action itself and the server still validates it.

Troubleshooting

The station appears but no scooter is dispensed

  • Confirm scooterModel is streamed and spelled exactly as its model name. For the default labscoot, the separate 9labs_scooter resource must be installed and started before SCOOT.
  • Confirm rental.vehicleType matches its vehicles.meta type.
  • Confirm OneSync is enabled.
  • Reconnect after a streamed model or YTYP change.
  • Set debug = true and read the client F8 console. This also registers /scootdiag and /scootsound.

The phone rent call says the player is too far away

Expected. v4.1.4 lists stations remotely but starts rentals only at a physical station: the player must be within interactDistance, alive, on foot and in routing bucket 0.

A target does not appear

  • Verify the configured interaction name matches ox_target or qb-target exactly.
  • Start the target resource before 9labs_scootbunker.
  • For custom, verify Custom.addTarget returns a handle.
  • Tablet stations target the tablet position, not the bunker service point.

The menu does not use the external phone UI

  • Set rental.externalUi = true.
  • Listen for the local client event scootbunker:stationOpened.
  • Test with a bunker-only station — a physical tablet station deliberately keeps its DUI.

The player cannot return the scooter

  • Use a valid station id.
  • Bring the scooter within rental.returnDistance of that station.
  • With the built-in station UI, bring the player within interactDistance as well, and wait until the original station has finished its dispensing cycle.

Money or keys do not work with a custom framework

  • Select framework = 'custom' and/or keys = 'custom'.
  • Implement the matching functions in server/bridge.lua.
  • Make sure the identity is stable per character.
  • Make sure the money functions return true only after the transaction succeeded.

A rental survived a restart but has no scooter

Expected recovery behaviour. Open a station while the rental still has time left and use Collect remaining rental; no new payment is taken.

When you ask for support

Run this in the server console first. It prints the version, the detected framework, the station count, active and pending rentals, tracked players and the expiry timer state, and warns about any foreign scripts added to the resource's fxmanifest.lua:

scootstatus

Include its output, the resource version, the selected framework, keys, interaction and notification modes, the console and F8 errors, whether the station is static or placed and bunker-only or tablet, the configured scooter model and vehicleType, whether it also happens through the built-in UI, and the steps to reproduce it. Never post license keys, Tebex credentials, full server configs or framework credentials publicly.

Nothing in this manual matched.