Skip to content

The server-side data API: bundle.Server. Index it with a Player to get that player’s typed accessor tree (Data[player].Coins.Increment(50)), and call its methods to drive persistence, monetization, leaderboards, and administration.

Data[player] and Data.Get error while a profile is still loading. Use Server.WaitForData first (or read inside a command handler, which only runs once the player is Ready).

Choose a group, then follow a member link for its signature and behavior.

Lifecycle

WaitForData · GetState · Get · Batch · Transaction · Stop

Persistence

Flush · GetSaveInfo · GetOffline · UpdateOffline · ListVersions · GetVersion · RestoreVersion · Erase · Export

Commands

Command

Exchange

Exchange.Attempt · Exchange.Open · Exchange.Discard · Exchange.Settle · Exchange.Redirect

Leaderboards

GetLeaderboard · GetMyRank · GetLeaderboardRefreshIn · GetLeaderboardStatus · GetLeaderboardResetIn

Monetization

PromptPurchase · GetProductState · PromptGift · GetGiftCredits · HandleReceipt · TryHandleReceipt · Owns · OwnsAsync · ObserveOwned · GrantPerk · RevokePerk · Purchase · RecordPurchase · GetPurchases · OnOwnershipChanged

Cooldowns

OnCooldown · PeekCooldown · ClearCooldown

Signals

OnSave · SessionEnded · OnAnomaly · OnGiftReceived · OnCooldownEnded · OnLeaderboard · OnGiftCredit

Messaging

OnMessage · SendMessage

Escape hatch

ProfileStore · Raw

Reads

ServerStore

server yields
Server.WaitForData(player: Player, timeout: number?) → (Value?, LifecycleReason?)

Types: LifecycleReason

Yields until the player’s profile is Ready, then returns their accessor tree, waiting up to timeout seconds (default 60). On failure returns (nil, reason), so always handle the nil branch. This is the safe way to read in PlayerAdded.

Before calling: require your shared data module on the server. Handle a missing result before using any fields:

local data, reason = Data.WaitForData(player)
if not data then
warn("Data unavailable:", reason)
return
end
print(data.Coins.Get())

reason is exported as the Scribe.LifecycleReason type and the Scribe.Reason table so you can branch without guessing at spellings:

ReasonMeaningRetryable
player-leftThe player left. By far the most common.no
still-loadingThe wait elapsed while the load was still in flight. Nothing has failed; call again, or read Server.GetState to watch it.yes
timeoutThe wait elapsed and Scribe has no session for this player at all, usually because the bundle was constructed after they joined.no
load-failedThe profile could not be loaded.no
erasingThis player’s profile is being erased; joining is refused until the erase completes.after the erase completes
migration-failedA migration errored, so the profile was released unmigrated.no
session-endedThe session ended while the player was still in game.no
shutdownThe server is closing.no

still-loading and timeout were one value until 2.0, so handle both if you branch on timeout today. Session Lifecycle says why.

server
Server.GetState(player: Player) → "Loading" | "Ready" | "SessionEnded" LoadPhase?

Types: LoadPhase

The player’s session state, without yielding.

The second return identifies the loading phase and is nil outside Loading. AcquiringSession includes waiting for a previous server’s session to release. Relay it through your own remote if needed before data replication starts.

server
Server.Get(player: Player) → Value

The player’s accessor tree. Errors if the profile is not Ready. Prefer Server.WaitForData on join. Data[player] is shorthand for this.

server
Server.Batch(player: Player, fn: () -> ())

Runs fn as a batch: every write inside coalesces into a single replication flush and one Changed pass. Use it for bulk updates.

“One Changed pass” applies to containers. Writing four fields of one table fires that table’s (and the root’s) Changed once rather than four times, on the server and on the client. Each individual field still fires its own leaf Changed, because each is a distinct transition with its own old and new value. Use Value.OnChildChanged, which is never coalesced, to learn which children moved.

Data.Batch(player, function()
state.Index.Set(i + 1)
state.StartedAt.Set(0)
state.Current.Set(next)
end)
-- state.Changed fires ONCE with the end state
-- state.OnChildChanged fires three times: Index, StartedAt, Current
-- state.Index.Changed fires once, with its own old and new
server
Server.Transaction(player: Player, fn: () -> ()) → (boolean, string?)

Runs fn atomically: if it throws, every write inside is rolled back and (false, error) is returned. On success returns (true, nil).

fn must not yield. A yield is refused with (false, error) and rolled back, because a concurrent write landing during the yield would be pulled into the transaction, so do the async work before or after. Tagged Value.Increment and Value.Decrement calls inside fn defer their economy event to commit and drop it on rollback.

Atomic in memory, not on the DataStore. A true return means the writes landed on this player’s tree together, not that they are saved: they queue for the next save like any other write. Pair it with Server.Flush when the operation must be durable before you acknowledge it.

One player, and only one. While the transaction is open, any write to a different player’s tree is refused, and that refusal fails this transaction. There is no cross-player or cross-key transaction. For one-sided value, use the durable outbox in Cross-Key Transactions instead.

server
Server.Stop()

Releases everything this bundle holds on the process: the Timed sweep, the leaderboard write pacer and refresh cycle, the per-frame replication flush, the Players and MarketplaceService listeners, the ProfileStore signal handlers, and MarketplaceService.ProcessReceipt if this bundle owns it.

A Scribe bundle is normally built once and lives for the server’s lifetime, so a game never needs this. It exists because a PROCESS that builds many bundles (a test suite, or a simulation standing up a fleet of servers) otherwise accumulates immortal loops and listeners, and ends up measuring that accumulation rather than Scribe.

Idempotent, and does NOT save: call Data.Flush first if the data matters. Loaded sessions are left alone; drop the bundle and they go with it.

server yields
Server.Flush(player: Player, opts: { Force: boolean?, Timeout: number? }?) → boolean

Saves this player’s pending changes and waits for confirmation.

Requires: a Ready profile; use Server.WaitForData first. A profile that is not Ready returns false without starting a save.

local saved = Data.Flush(player)
if not saved then
warn("Save not confirmed; keep the operation pending")
end
ResultWhat to do
trueThe save is confirmed, or this profile was already saved.
falseCheck save state or retry the flush. A timed-out save can still finish; do not repeat the grant or undo it.

Timeout limits the confirmation wait in seconds (default 15), not the save itself. Force = true bypasses a blocked wipe guard as well as the already-saved shortcut. Reserve it for a deliberate recovery operation; ordinary grants and checkpoints should use Data.Flush(player).

When the profile is already on disk (nothing written since the last save, no save still in flight, and that save succeeded), this returns true immediately and spends no DataStore request, because the answer is already known. Force = true always goes to the store. The saving is real for a game that flushes on a timer or a checkpoint; it never applies right after a grant, since a grant leaves the profile dirty by definition.

server
Server.GetSaveInfo(player: Player) → SaveInfo

Types: SaveInfo

The player’s save state ({ LastSaveAt, LastResult, Dirty, Size }) for “Saving… / Saved ✓ / Unsaved changes” UI. Also mirrored to the owner. Size is the approximate byte size of the last successful save (nil before the first), useful for spotting a profile growing toward the ~4 MB ceiling.

server yields
Server.GetOffline(userId: number) → { [string]: any }?

Returns a read-only snapshot of a user’s raw profile data. For a Ready player on this server, it clones the live data. Otherwise it reads the last saved data, which may be older than another server’s live session.

Returns: the data table, or nil if the profile is missing or the read failed. Check the OFFLINE_READ_FAIL log before treating nil as proof that no profile exists. Use Server.UpdateOffline to save an offline edit.

server yields
Server.UpdateOffline(userId: number, fn: (data: { [string]: any }) -> (), options: { Validate: boolean? }?) → (boolean, string?, { { Path: { string | number }, Kind: string, Detail: string } }?)

Edits an existing offline profile and saves the result.

Requires: the player must be offline on every server. The callback receives a plain data table, not the live field methods returned by Server.WaitForData. Prefer { Validate = true } for routine edits.

local ok, reason, findings = Data.UpdateOffline(userId, function(data)
data.Coins = 100 -- example: set a field declared by your template
end, { Validate = true })
if not ok then
warn("Offline edit refused:", reason)
end
ReturnMeaning
oktrue when the edit was saved; false on a refusal or error.
reasonExplanation of an unsuccessful edit, otherwise nil.
findingsOptional validation details, each with Path, Kind, and Detail.

Concurrent changes: your callback runs against a copy and may yield for as long as it likes, and the commit is a compare-and-set: the write lands only if, at that instant, the profile is still free of a live session and still byte-for-byte the one your callback was handed. A refusal writes nothing at all, not even a key version.

By default the callback’s result is checked only for what the DataStore cannot store. { Validate = true } also checks the change against the template. It rejects invalid changes rather than clamping or repairing them: a type, bound, member or size the callback got wrong refuses with the offending path in the reason and the findings as a third return ({ Path, Kind, Detail } each). A field that was already wrong before the callback ran passes through as long as the callback left it alone, so one legacy field cannot block an unrelated edit; changing it, or growing a container already over its cap, is the callback’s doing and refuses. A profile carrying 256 or more violations is refused outright rather than compared. Validation also refuses a change under the reserved _Scribe root, a write to a derived or Scribe.Session field, and, before the callback runs, a profile whose schema version is not this build’s. None of those refusals counts against service health.

Common refusals include "profile does not exist", since it cannot create one; "profile has an active session on another server", because it fails closed rather than stealing a live lock; and "profile changed while the update was being prepared", meaning something else wrote the key first. These refusals write nothing. For the changed-profile refusal, call again to prepare the edit from fresh data. Keep external side effects out of the callback so repeating it does not repeat a payment or other unrelated action. A session on this server, validation errors, callback errors, and store failures also return false; the examples above are not an exhaustive list.

A session whose last write is older than the store’s dead-session threshold counts as dead and the write proceeds, so a crashed server cannot lock a profile out forever. Offline Profiles covers the threshold and the custom-store caveat.

server yields
Server.ListVersions(userId: number, limit: number?) → { { VersionId: string, CreatedAt: number, Size: number? } }

Lists a profile’s DataStore version history, for support/rollback tools.

server yields
Server.GetVersion(userId: number, versionId: string) → { [string]: any }?

Reads the raw data of a specific historical version.

server yields
Server.RestoreVersion(userId: number, versionId: string, opts: { RollBackReserved: boolean? }?) → (boolean, string?)

Restores a profile to a historical version. Fails closed if the user has a live session anywhere, and while service health reports an outage. It needs a live key to write over, so restoring an erased profile fails with no live profile exists for this user to restore over.

The reserved _Scribe root is not rolled back. Receipts granted, gifts paid for, perks bought, the purchase log and running cooldowns do not un-happen because a backup was restored, so that root is carried across from the live profile and stamped with RestoredFrom. A preserved root that differed from the snapshot’s logs RESTORE_RESERVED_PRESERVED. The schema Version is the one exception: it describes the shape of the game data, so it travels with it.

RollBackReserved = true rolls that root back too. It is a repair tool for a _Scribe that is itself corrupt, and nothing else: see the danger note in Offline Profiles.

server yields
Server.Erase(userId: number) → (boolean, string?)

Deletes a user’s profile and their leaderboard entries.

Requires: no live session for this user on any server. While the erase is in progress, this user’s joins are held or refused; other players can join normally. A long leaderboard history can take multiple attempts.

local ok, reason = Data.Erase(userId)
if not ok then
warn("Erase incomplete; inspect the reason before retrying:", reason)
end

Returns: (true, nil) on completion, or (false, reason) when it cannot finish. Retrying the same user resumes recorded cleanup progress. The profile’s erase marker keeps joins blocked until cleanup completes. See Offline Profiles for retry and recovery details.

server yields
Server.Export(userId: number) → string?

GDPR data export: the profile as a JSON string (buffers base64-encoded), or nil if it doesn’t exist.

server
Server.Command(name: string, specOrHandler: ({ Args: { any }?, Idempotent: boolean? } | (player: Player, ...any) -> ...any), handler: ((player: Player, ...any) -> ...any)?)

Registers a named command clients can call via Client.Request. Sender identity is always the real player; args are shape-validated and rate-limited, and the handler only runs once the caller is Ready.

An Args entry is a type string ("string", "number?"), a Scribe declarator (Scribe.Int, Number, String, Enum, Flags, Optional, ArrayOf, SetOf, DictOf, MapOf), or a shape table describing a table argument. A declarator brings its own constraints, so the range is checked before the handler runs rather than inside it.

Data.Command("EquipItem", { Args = { "string" } }, function(player, itemId)
if not Data[player].Inventory[itemId].Get() then return false, "not owned" end
Data[player].Equipped.Set(itemId)
return true
end)
Data.Command("CreateSaveSlot", {
Args = { Scribe.Int(1, { Min = 1, Max = 4 }) },
}, function(player, slotNumber)
-- slotNumber is an integer in [1, 4]; anything else was refused already.
end)

Idempotent = true makes the command require a caller-supplied key, sent with Client.RequestOnce. The handler then runs at most once per key, and every repeat, including one arriving while the first is still yielding, is answered with the original reply. The requirement is symmetric: a key sent to a command without Idempotent, or a plain Client.Request to one with it, is refused with "bad-idempotency-key".

Commands & Requests covers Args and every refusal reason.

server yields
Server.Exchange.Attempt(playerA: Player, basketA: { ExchangeLeg }, playerB: Player, basketB: { ExchangeLeg }) → ("Committed" | "Aborted")?, string?

Types: ExchangeLeg

Exchanges two baskets between two players who are both loaded on THIS server.

Each basket lists what that player GIVES. A leg is one of three kinds:

KindShapeMoves
Key{ Path = { "Inventory" }, Kind = "Key", Key = itemId }One whole container entry
Qty{ Path = { "Coins" }, Kind = "Qty", Amount = n }An amount of a bounded Scribe.Int
Stack{ Path = { "Inventory" }, Kind = "Stack", Key = itemId, Amount = n }Part of one stacked entry

Declare every path in Exchangeable. Scribe checks those declarations at startup and checks each requested path and kind before claiming a slot or moving value. Your server must still verify both players’ consent and your game’s trading rules before calling Attempt. A structurally valid basket is not permission to trade.

local outcome, why = Data.Exchange.Attempt(alice, {
{ Path = { "Inventory" }, Kind = "Key", Key = "Emberblade" },
{ Path = { "Coins" }, Kind = "Qty", Amount = 250 },
}, bob, {
{ Path = { "Inventory" }, Kind = "Key", Key = "Frostbrand" },
})
if outcome ~= "Committed" then
return false, why
end

"Committed" means each basket is owed to the other player. "Aborted" means it is owed back to its original owner. These report the verdict; a delivery that cannot fit may still be pending. Check Exchange.Open for open records. A nil outcome means either an early refusal or an unknown verdict; inspect the reason and open records. Do not manually refund an unresolved exchange.

Both profiles follow one stored verdict, but their changes are not isolated: players can see value held in escrow while work is pending. Pending value stays recorded rather than being dropped or granted twice. Recovery has no fixed time limit. The attempt checks a deadline between stages, but storage calls can take longer, so the deadline is not a guaranteed return time.

Exchange covers the whole model, including what a player sees while an exchange is in flight.

server
Server.Exchange.Open(player: Player) → { OpenExchange }

Types: OpenExchange

What this player still has in flight, and what it is holding while it does.

One entry per open exchange: Id, State ("Claimed", "Staked" or "Delivering"), Partner, Staked (what they handed over) and Owed (what they are owed). Escrow takes value out of the balance on purpose and the resolution time is not bounded, so this is how a game tells a player where their coins went rather than leaving them looking at a hole. Empty when nothing is open.

server yields
Server.Exchange.Discard(exchangeId: string) → boolean, string?

Drops an abandoned claim that never took value.

An operator verb, and verdict-gated like the other two: it refuses anything with a verdict and anything holding staked value. Use it for a claim left behind by a server that died between claiming a slot and staking into it.

server yields
Server.Exchange.Settle(exchangeId: string, verdict: string) → boolean, string?

Forces "Commit" or "Abort" on an exchange that cannot resolve itself.

There is no default and a missing verdict is refused: guessing Abort would, in exactly the situation this verb exists for, land a fresh Abort against a Commit the other side already acted on. Commit is additionally refused when it cannot be proved safe, which is when either loaded record holds escrow with no take recorded, or when only one side is loaded. Abort needs neither check.

Loses to a verdict that already exists rather than overwriting it.

server yields
Server.Exchange.Redirect(exchangeId: string, userId: number, newKey: string) → boolean, string?

Lands a parked delivery on a different key of the same container.

For a delivery that cannot apply because its destination key is taken. Refuses a different container, a set (where the key IS the value, so re-keying substitutes a different item), and a parked entry holding more than one key leg, since one new key cannot name several destinations without collapsing them onto each other.

server
Server.GetLeaderboard(name: string, limit: number?, offset: number?) → { LeaderboardEntry }

Types: LeaderboardEntry

The cached top-N of a board ({ Rank, UserId, Name, Score }). Never triggers a live OrderedDataStore read.

offset is 0 for the current period and -1 for the previous one, which a periodic board reads once just after it rolls over. A lifetime or server board has no previous period and answers an empty list for it.

server
Server.GetMyRank(player: Player, name: string) → number?

The player’s current rank on a board, or nil if unranked.

server
Server.GetLeaderboardRefreshIn(name: string) → number?

Seconds until a board next reads its store, for a “refreshes in” label. 0 while a due refresh is waiting on the request budget. nil for a name that is not a board, and until the refresh loop has scheduled its first cycle, a few seconds after boot.

server
Server.GetLeaderboardStatus(name: string) → LeaderboardStatusInfo?

Types: LeaderboardStatusInfo

Read and write health for one board, without making a DataStore request. Includes pending writes, rejected scores and the age of the cached results. This is independent of Scribe.GetStatus, which protects profile persistence.

server
Server.GetLeaderboardResetIn(name: string) → number?

Seconds until a periodic board’s current period ends on the PeriodReset clock, for a “resets in” label. Always above zero: at the instant of rollover the next period has begun. nil for a board with no Period, and for a name that is not a board.

server
Server.PromptPurchase(player: Player, name: string) → (boolean, string?)

Prompts player to buy a declared product or pass for themselves, by the name you gave it rather than its numeric Id.

local ok, why = Data.PromptPurchase(player, "CoinPack500") -- a product
local ok, why = Data.PromptPurchase(player, "VIP") -- a pass
if not ok then
warn(why)
end

The name is resolved against Products and Passes, and the matching engine call is made: PromptProductPurchase for one, PromptGamePassPurchase for the other. A name declared in both tables is a startup error, because resolution is by name and table order would otherwise decide what the player is charged for.

Refuses something the player already owns, so a caller does not have to pair every prompt with its own Server.Owns check. A pass is its own ownership key. A product is checked against its Grants perk, and a product with no Grants is a consumable that can be bought repeatedly.

Ownership is read from Scribe’s cache rather than a live Marketplace call, because this is a UI gate: for a product the receipt path is what actually prevents a double grant, and Roblox itself refuses a pass the player holds.

A PaidRandom product is refused for a player whose policy restricts paid random items, and while that policy is not yet known.

Returns (false, reason), where reason is the Scribe.ProductState the prompt refused on: "not-loaded", "owned", "paid-random-restricted" or "policy-pending". Server.GetProductState answers the same state without prompting. An unknown name, "purchase prompt already open", and a prompt the engine refused return diagnostic text outside that set. None of those raise.

One PromptPurchase dialog can be active per player in this Scribe bundle. The guard covers both this call and Client.PromptPurchase, and clears when that dialog finishes or is cancelled, prompting fails, or the player’s data unloads. Client.PromptPurchase forwards to this server path; successful prompt calls increment the server’s PurchasePrompts metric.

Prompting is all it does. The grant happens on the receipt, through the same path every purchase takes, so a player who buys and then leaves is still granted on their next load.

See Monetization.

server
Server.GetProductState(player: Player, name: string) → ProductState

Types: ProductState

The state of a declared product or pass for player, as one Scribe.ProductState string: what Server.PromptPurchase would refuse with, or "purchasable". A shop that greys a button on this and the prompt behind it cannot disagree. Raises for a name that is not declared.

if Data.GetProductState(player, "LootBox") == Scribe.ProductState.Purchasable then
showBuyButton(player)
end
server
Server.PromptGift(buyer: Player, productName: string, recipientUserId: number) → (boolean, string?)

Prompts buyer to gift a product’s perk to recipientUserId. Records a durable intent before money moves; delivery survives cross-server hops and offline recipients, and a held gift credit is consumed with no charge. Returns (false, reason) if the gift can’t proceed. reason is a Scribe.GiftReason sentence; every member but InvalidRecipient can be shown to the buyer as it is.

server
Server.GetGiftCredits(player: Player) → { [string]: number }

The player’s unassigned gift credits by product name.

server
Server.HandleReceipt(receiptInfo: table) → Enum.ProductPurchaseDecision

Processes a developer-product receipt. Scribe binds ProcessReceipt automatically unless OwnReceipts = false, in which case call this from your own handler. Idempotent by PurchaseId and fail-closed. Robux are never eaten.

If the buyer is joining, waits up to LoadTimeout (default 120 seconds, minimum 60) for their data before granting. A failed wait returns NotProcessedYet without cancelling the profile load.

server
Server.TryHandleReceipt(receiptInfo: table) → Enum.ProductPurchaseDecision?

Like Server.HandleReceipt, but returns nil for a product Scribe does not know, so your own ProcessReceipt can fall through to its own handling. Use this one when you route every receipt through Scribe:

MarketplaceService.ProcessReceipt = function(receiptInfo)
local decision = Data.TryHandleReceipt(receiptInfo)
if decision then
return decision -- a Scribe product; already granted and saved
end
return myOwnHandler(receiptInfo)
end

HandleReceipt answers NotProcessedYet for an unknown product instead, which is correct when Scribe owns ProcessReceipt but would stall your product in a permanent retry loop when routed through an external handler. This variant also logs nothing for an unknown product, since there it is normal.

server
Server.Owns(player: Player, key: string) → boolean

The unified ownership check: a granted perk or an owned game pass (cached) or RobloxPlus. Hide “buy” buttons whenever this is true.

Non-yielding, so it reads the gamepass cache that fills asynchronously right after a player joins. For a gate that must be correct at that instant, use Server.OwnsAsync.

server yields
Server.OwnsAsync(player: Player, key: string, timeout: number?) → boolean

Like Server.Owns but authoritative and yielding: if the pass is not already owned in the cache, it verifies live with the server-side UserOwnsGamePassAsync on every call, so a pass bought moments ago (in experience or on the Roblox website) is reflected immediately. Once owned, the cached value is returned without a re-check. This is the ownership check to gate grants on. The client’s Owns/OwnsAsync are only mirror reads and must never gate a grant. (timeout applies only to the client version, which waits on a replicated flag.)

server
Server.ObserveOwned(player: Player, key: string, callback: (owned: boolean) -> ()) → () -> ()

Calls callback with the player’s current ownership of key, then again whenever it changes. Returns a disconnect function. The server twin of Client.ObserveOwned. To react to any key without subscribing per key, use Server.OnOwnershipChanged.

server
Server.GrantPerk(player: Player, key: string)

Grants a perk directly (admin/reward flows).

server
Server.RevokePerk(player: Player, key: string)

Revokes a granted perk.

server
Server.Purchase(player: Player, spec: PurchaseSpec) → (boolean, string?)

Types: PurchaseSpec

Atomic soft-currency purchase: debits Cost, runs Grant, and logs it. All or nothing: insufficient funds or a throwing Grant rolls everything back, no debit.

Data.Purchase(player, {
Cost = { Path = "Coins", Amount = 45000 },
Category = "Vehicle", ItemId = "Police01",
Grant = function(data) data.Vehicles.Insert("Police01") end,
})

Cost.Path may point into a typed container, where the element’s Min floor and int rule apply to the debit. An unresolvable path returns (false, "invalid cost path"), but a Scribe.DictOf accepts any key, so a typo there spends the element default as a balance nobody granted.

An optional IdempotencyKey (up to 64 bytes, valid UTF-8) makes the purchase a once-guard: a repeat under that key answers what the first call answered and spends nothing. Monetization covers claims and reasons.

reason is a Scribe.PurchaseReason sentence. InsufficientFunds, DataNotLoaded and the two paid-random members can be shown to the player; the four Cost refusals mean the call is wrong, and belong in a log.

server
Server.RecordPurchase(player: Player, entry: table)

Appends an entry to the player’s in-game purchase log (your own records).

server
Server.GetPurchases(player: Player, filter: PurchaseFilter?) → { table }

Types: PurchaseFilter

The player’s purchase history (Robux receipts and in-game purchases), newest first, optionally filtered.

PurchaseFilter is { Kind: ("Robux" | "InGame")?, Category: string?, ItemId: string?, Since: number?, Limit: number? }, exported as Scribe.PurchaseFilter.

signal server
Server.OnOwnershipChanged: Signal

Fires (player, key, owned) whenever a player’s effective ownership of a pass, perk, or RobloxPlus actually changes, covering purchases, grants, gift deliveries, and revokes. Pre-existing ownership at join is the baseline and does not fire. Use this to react to any grant without subscribing per key; use Server.ObserveOwned for one specific key.

Ownership only ever gains within a session, so a game pass refund is not detected until the player rejoins (a cached true is never re-verified).

The value is a Signal, where Connect, Once and Wait are documented.

server
Server.OnCooldown(player: Player, key: string, seconds: number, opts: { IncludeOfflineTime: boolean? }?) → (onCooldown: boolean, remaining: number)

Checks and arms a keyed cooldown: returns false and starts a new seconds cooldown if it was off. Call it only at the grant or claim moment, and use Server.PeekCooldown for display checks.

Pass { IncludeOfflineTime = false } for a cooldown that only ticks down while the player is online. The default counts wall-clock time, so a one-hour cooldown armed before logging off is over on a rejoin the next day; with the option off, that same rejoin still has the full hour left.

Data.OnCooldown(player, "DailyChest", 86400) -- ticks while offline
Data.OnCooldown(player, "Boost", 3600, { IncludeOfflineTime = false }) -- only while playing

A key holds one cooldown either way: re-arming it in the other mode replaces the first rather than leaving two running, and Server.ClearCooldown and Server.PeekCooldown cover both without needing to know which was used.

server
Server.PeekCooldown(player: Player, key: string) → (onCooldown: boolean, remaining: number)

Read-only cooldown check. Never arms it. Safe for UI.

server
Server.ClearCooldown(player: Player, key: string)

Clears a cooldown (support/testing resets).

signal server
Server.OnSave: Signal

Fires after every save attempt with { Player, Ok, Duration, At }.

The value is a Signal, where Connect, Once and Wait are documented.

signal server
Server.SessionEnded: Signal

Fires when a session ends with (player, reason). With KickOnSessionEnd = true (the default) the player is also kicked.

The value is a Signal, where Connect, Once and Wait are documented.

signal server
Server.OnAnomaly: Signal

Fires for integrity anomalies (out-of-bounds writes, a tripped wipe guard, an unserializable snapshot) with { Player, Path, Value, Reason }.

The value is a Signal, where Connect, Once and Wait are documented.

signal server
Server.OnGiftReceived: Signal

Fires when a player receives a gift with (player, { FromUserId, Product, GiftId }).

The value is a Signal, where Connect, Once and Wait are documented.

signal server
Server.OnCooldownEnded: Signal

Fires with (player, key) when a cooldown lapses, within about a second of its expiry. A cooldown holds no value, so unlike a Scribe.Timed field it fires no Changed; this is its only expiry notification.

One signal covers every cooldown, because cooldown keys are arbitrary strings rather than declared fields, so there is no set to subscribe to. Filter inside the handler for a specific one:

Data.OnCooldownEnded:Connect(function(player, key)
if key ~= "DailyReward" then
return
end
-- ...
end)

Cooldowns that lapsed while the player was offline do not fire on join. Read the current state with Server.PeekCooldown instead.

The value is a Signal, where Connect, Once and Wait are documented.

signal server
Server.OnLeaderboard: Signal

Fires with (boardName, entries) when a board’s contents actually change, once per refresh cycle. entries is a fresh copy, safe to keep or mutate, and is rank-ordered: entries[1] is rank 1, the same order and shape Server.GetLeaderboard returns. An unchanged board does not re-fire, so this is quiet on an idle board.

This is the server-side counterpart to the client signal of the same name, and unlike that one it fires for every board, not just Replicate ones. That matters because a server-only board is the default, and polling cannot align with the per-board refresh schedule.

See Leaderboards for the connect example.

The value is a Signal, where Connect, Once and Wait are documented.

signal server
Server.OnGiftCredit: Signal

Fires when a player is issued a gift credit with (player, productName).

The value is a Signal, where Connect, Once and Wait are documented.

signal server
Server.OnMessage: Signal

Fires with (player, message) when a cross-server message sent through Server.SendMessage arrives for an active session.

Delivery is at-least-once, so handlers must be idempotent. A message is retired from the recipient’s key only once a handler has returned without raising, and only in the same write that persists the data that handler produced. So a message survives, and is handed to you again on the player’s next load, whenever nothing was connected here (logged as MESSAGE_NO_LISTENER), a handler raised (MESSAGE_HANDLER_ERROR), or the save carrying its effect never landed. Payloads carry no id of their own, so put one in yours (an exchange id, a payout id) and ignore a repeat. Seeing a message twice is recoverable; losing one is not, which is the trade this makes.

Redelivery is bounded rather than continuous: an unacknowledged message is offered once per session, not once per save.

The value is a Signal, where Connect, Once and Wait are documented.

server yields
Server.SendMessage(userId: number, message: any) → boolean

Sends a durable cross-server message to userId, delivered to Server.OnMessage on whatever server they are (or next become) active on, including offline recipients (queued until their next load). message must be DataStore-serializable. Returns whether it was committed. Rides ProfileStore’s global-update channel, so keep messages small and infrequent.

The queue is bounded at 1,000 undelivered messages per recipient, and a send past the cap returns false and logs MESSAGE_QUEUE_FULL. It used to return true after silently dropping the OLDEST queued message. Always check the return value and keep the message retryable: a recipient’s queue fills when messages arrive faster than they log in to drain them, or when a handler never acknowledges one.

true means committed, not delivered and not handled. Delivery to Server.OnMessage is at-least-once: a message the recipient’s handler never returned from, raised on, or that had no handler connected at all is kept and re-offered on their next load. Give the payload an id of your own and make the handler idempotent.

false does not always mean nothing was written. The store retries internally, so a write can commit and then lose its answer, after which the refusal you get back is reported for a message that is already in the recipient’s inbox. The log line and its Context.ProvablyClean say which happened; the return value cannot. Retrying is still the right response, and that is what the idempotent payload above is for.

property server
Server.ProfileStore: ProfileStore

The underlying ProfileStore instance, an advanced escape hatch for store-level operations Scribe doesn’t wrap (version reads, raw MessageAsync, and so on). It bypasses Scribe’s schema, replication, and session guarantees, so treat it as read-mostly and never mutate an active-session profile through it. Most games never need this; prefer the typed API and Server.SendMessage.

property server
Server.Raw: Server

The same Data object without the typed accessor tree: the untyped escape hatch if the type solver ever trips on your template.

property server
Server.ServerStore: table

The server-owned tree, one per server rather than per player, present only when the bundle declares ServerStore. Each declared root is an accessor reached with no player argument, as in Data.ServerStore.Wave.Set(3).

Nothing in it is saved, and the same shape appears on the client, where it is read-only. See Server-owned state.