Server
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).
Find a member
Section titled “Find a member”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
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
Escape hatch
Reads
Lifecycle
Section titled “Lifecycle”.WaitForData
Section titled “.WaitForData”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) returnendprint(data.Coins.Get())reason is exported as the Scribe.LifecycleReason type
and the Scribe.Reason table so you can branch without guessing at spellings:
| Reason | Meaning | Retryable |
|---|---|---|
player-left | The player left. By far the most common. | no |
still-loading | The wait elapsed while the load was still in flight. Nothing has failed; call again, or read Server.GetState to watch it. | yes |
timeout | The wait elapsed and Scribe has no session for this player at all, usually because the bundle was constructed after they joined. | no |
load-failed | The profile could not be loaded. | no |
erasing | This player’s profile is being erased; joining is refused until the erase completes. | after the erase completes |
migration-failed | A migration errored, so the profile was released unmigrated. | no |
session-ended | The session ended while the player was still in game. | no |
shutdown | The 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.
.GetState
Section titled “.GetState”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.Get(player: Player) → ValueThe player’s accessor tree. Errors if the profile is not Ready. Prefer
Server.WaitForData on join. Data[player] is shorthand for this.
.Batch
Section titled “.Batch”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.Transaction
Section titled “.Transaction”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.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.
Persistence
Section titled “Persistence”.Flush
Section titled “.Flush”Server.Flush(player: Player, opts: { Force: boolean?, Timeout: number? }?) → booleanSaves 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| Result | What to do |
|---|---|
true | The save is confirmed, or this profile was already saved. |
false | Check 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.
.GetSaveInfo
Section titled “.GetSaveInfo”Server.GetSaveInfo(player: Player) → SaveInfoTypes: 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.
.GetOffline
Section titled “.GetOffline”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.
.UpdateOffline
Section titled “.UpdateOffline”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 templateend, { Validate = true })if not ok then warn("Offline edit refused:", reason)end| Return | Meaning |
|---|---|
ok | true when the edit was saved; false on a refusal or error. |
reason | Explanation of an unsuccessful edit, otherwise nil. |
findings | Optional 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.
.ListVersions
Section titled “.ListVersions”Server.ListVersions(userId: number, limit: number?) → { { VersionId: string, CreatedAt: number, Size: number? } }Lists a profile’s DataStore version history, for support/rollback tools.
.GetVersion
Section titled “.GetVersion”Server.GetVersion(userId: number, versionId: string) → { [string]: any }?Reads the raw data of a specific historical version.
.RestoreVersion
Section titled “.RestoreVersion”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.
.Erase
Section titled “.Erase”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)endReturns: (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.
.Export
Section titled “.Export”Server.Export(userId: number) → string?GDPR data export: the profile as a JSON string (buffers base64-encoded),
or nil if it doesn’t exist.
Commands
Section titled “Commands”.Command
Section titled “.Command”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 trueend)
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.
Exchange
Section titled “Exchange”.Exchange.Attempt
Section titled “.Exchange.Attempt”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:
| Kind | Shape | Moves |
|---|---|---|
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, whyend"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.
.Exchange.Open
Section titled “.Exchange.Open”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.
.Exchange.Discard
Section titled “.Exchange.Discard”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.
.Exchange.Settle
Section titled “.Exchange.Settle”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.
.Exchange.Redirect
Section titled “.Exchange.Redirect”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.
Leaderboards
Section titled “Leaderboards”.GetLeaderboard
Section titled “.GetLeaderboard”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.
.GetMyRank
Section titled “.GetMyRank”Server.GetMyRank(player: Player, name: string) → number?The player’s current rank on a board, or nil if unranked.
.GetLeaderboardRefreshIn
Section titled “.GetLeaderboardRefreshIn”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.
.GetLeaderboardStatus
Section titled “.GetLeaderboardStatus”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.
.GetLeaderboardResetIn
Section titled “.GetLeaderboardResetIn”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.
Monetization
Section titled “Monetization”.PromptPurchase
Section titled “.PromptPurchase”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 productlocal ok, why = Data.PromptPurchase(player, "VIP") -- a passif not ok then warn(why)endThe 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.
.GetProductState
Section titled “.GetProductState”Server.GetProductState(player: Player, name: string) → ProductStateTypes: 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.PromptGift
Section titled “.PromptGift”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.
.GetGiftCredits
Section titled “.GetGiftCredits”Server.GetGiftCredits(player: Player) → { [string]: number }The player’s unassigned gift credits by product name.
.HandleReceipt
Section titled “.HandleReceipt”Server.HandleReceipt(receiptInfo: table) → Enum.ProductPurchaseDecisionProcesses 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.
.TryHandleReceipt
Section titled “.TryHandleReceipt”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)endHandleReceipt 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.Owns(player: Player, key: string) → booleanThe 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.
.OwnsAsync
Section titled “.OwnsAsync”Server.OwnsAsync(player: Player, key: string, timeout: number?) → booleanLike 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.)
.ObserveOwned
Section titled “.ObserveOwned”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.
.GrantPerk
Section titled “.GrantPerk”Server.GrantPerk(player: Player, key: string)Grants a perk directly (admin/reward flows).
.RevokePerk
Section titled “.RevokePerk”Server.RevokePerk(player: Player, key: string)Revokes a granted perk.
.Purchase
Section titled “.Purchase”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.
.RecordPurchase
Section titled “.RecordPurchase”Server.RecordPurchase(player: Player, entry: table)Appends an entry to the player’s in-game purchase log (your own records).
.GetPurchases
Section titled “.GetPurchases”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.
.OnOwnershipChanged
Section titled “.OnOwnershipChanged”Server.OnOwnershipChanged: SignalFires (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.
Cooldowns
Section titled “Cooldowns”.OnCooldown
Section titled “.OnCooldown”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 offlineData.OnCooldown(player, "Boost", 3600, { IncludeOfflineTime = false }) -- only while playingA 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.
.PeekCooldown
Section titled “.PeekCooldown”Server.PeekCooldown(player: Player, key: string) → (onCooldown: boolean, remaining: number)Read-only cooldown check. Never arms it. Safe for UI.
.ClearCooldown
Section titled “.ClearCooldown”Server.ClearCooldown(player: Player, key: string)Clears a cooldown (support/testing resets).
Signals
Section titled “Signals”.OnSave
Section titled “.OnSave”Server.OnSave: SignalFires after every save attempt with { Player, Ok, Duration, At }.
The value is a Signal, where Connect, Once and Wait are documented.
.SessionEnded
Section titled “.SessionEnded”Server.SessionEnded: SignalFires 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.
.OnAnomaly
Section titled “.OnAnomaly”Server.OnAnomaly: SignalFires 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.
.OnGiftReceived
Section titled “.OnGiftReceived”Server.OnGiftReceived: SignalFires when a player receives a gift with (player, { FromUserId, Product, GiftId }).
The value is a Signal, where Connect, Once and Wait are documented.
.OnCooldownEnded
Section titled “.OnCooldownEnded”Server.OnCooldownEnded: SignalFires 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.
.OnLeaderboard
Section titled “.OnLeaderboard”Server.OnLeaderboard: SignalFires 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.
.OnGiftCredit
Section titled “.OnGiftCredit”Server.OnGiftCredit: SignalFires when a player is issued a gift credit with (player, productName).
The value is a Signal, where Connect, Once and Wait are documented.
Messaging
Section titled “Messaging”.OnMessage
Section titled “.OnMessage”Server.OnMessage: SignalFires 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.
.SendMessage
Section titled “.SendMessage”Server.SendMessage(userId: number, message: any) → booleanSends 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.
Escape hatch
Section titled “Escape hatch”.ProfileStore
Section titled “.ProfileStore”Server.ProfileStore: ProfileStoreThe 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.
Server.Raw: ServerThe same Data object without the typed accessor tree: the untyped escape
hatch if the type solver ever trips on your template.
.ServerStore
Section titled “.ServerStore”Server.ServerStore: tableThe 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.