Skip to content

Luau SDK reference

The SDK is one ModuleScript named Rolink with two children, Http and Signal. It calls the Rolink API over HttpService with your project’s API key, and receives live events over MessagingService. This page covers everything the module exports. To install it, download Rolink.rbxmx and add it as shown in the Quickstart.

ServerScriptService/Wallets.server.luau
local HttpService = game:GetService("HttpService")
local Rolink = require(game:GetService("ServerScriptService").Rolink)
local rolink = Rolink.new({
apiKey = HttpService:GetSecret("rolink_api_key"),
})
MemberKindYieldsReturns
Rolink.new(config)constructornoa client
Rolink.DEFAULT_API_URLconstantstring
Rolink.errorCode(err)functionnostring?
Rolink.newIdempotencyKey()functionnostring
:GetProject()methodyesProject
:GetWallet(userId)methodon a cache missstring?
:GetWallets(userIds)methodon a cache miss{ [number]: string }
:GetSolBalance(address)methodyesstring
:GetTokenBalance(address, mint)methodyesstring
:GetTokens(address)methodyes{ TokenBalance }
:GetAssets(address, collection?)methodyes{ Asset }
:OwnsCollection(address, collection)methodyesboolean
:GetSignatureStatus(signature)methodyesSignatureStatus
:TransferSol(params)methodyesTxResult
:TransferToken(params)methodyesTxResult
:MintToken(params)methodyesTxResult
:GetTransaction(idempotencyKey)methodyesTxResult
:Destroy()methodnonothing
.WalletLinkedsignal(userId, address, player?)
.WalletUnlinkedsignal(userId, address, player?)
.TransactionUpdatedsignal(TxUpdate)
.ChainActivitysignal(ChainActivity)
  • Server only. Rolink.new asserts RunService:IsServer(). Your project API key must never reach a client.
  • One project per client. Everything a client reads or sends belongs to the project its API key was created in.
  • Methods yield. Every method that talks to Rolink yields until the request finishes. Call them from a script or a task.spawn thread, never from a context that cannot yield.
  • Errors are strings. A failed call raises a string such as "[Rolink] <code>: <message> (HTTP <status>)". Wrap calls in pcall and branch on Rolink.errorCode(err). The codes are listed in Error codes.
  • Amounts are decimal strings. Balances come back as strings such as "12.5", and transaction amounts are sent as strings, so large token amounts stay exact. Numbers are accepted on input and converted.
  • Recipients. Transaction methods take to as a player’s UserId (a number) or a Solana address (a string).
  • Every request counts. Each call that reaches the API counts toward the project’s request quota. Answers served from the SDK’s wallet cache don’t.
local ok, result = pcall(rolink.GetWallet, rolink, player.UserId)
if not ok then
warn(result) -- "[Rolink] network_error: ..."
return
end
Rolink.new(config: Config) -> Rolink

Creates a client. Unless subscribe is false, it also starts a background thread that subscribes to the events topic. The constructor itself never yields.

FieldTypeDefaultDescription
apiKeySecret or stringrequiredYour project API key (rlk_live_...) from the dashboard. Use HttpService:GetSecret("rolink_api_key"). A plain string also works in tests; never commit a key to a script. It is sent in the x-api-key header.
apiUrlstring?Rolink.DEFAULT_API_URLBase URL of the Rolink API. Trailing slashes are removed. Leave it unset in your game; set it only to point the SDK at a test server.
topicstring?"rolink"MessagingService topic to subscribe to. Must match the project’s event topic in the dashboard.
walletCacheSecondsnumber?60How long GetWallet and GetWallets answers are cached, including “no wallet” answers. Live link and unlink events update the cache anyway.
subscribeboolean?trueSet to false to skip the MessagingService subscription. Signals then never fire.

Rolink.new raises a plain assertion error (with no code) when:

  • it runs on a client: [Rolink] Rolink runs on the server only; never expose your project API key to clients
  • config is not a table: [Rolink] Rolink.new expects a config table
  • config.apiKey is nil: [Rolink] config.apiKey is required (use HttpService:GetSecret)
  • config.apiUrl is set to something other than a string: [Rolink] config.apiUrl must be a string

The API key isn’t checked until the first request. Call GetProject at startup if you want to fail early.

Rolink.DEFAULT_API_URL: string

The base URL used when config.apiUrl is not set: https://api.rolink.tech, the hosted Rolink API.

Rolink.errorCode(err: any) -> string?

Extracts the code from an error raised by a Rolink method, such as "wallet_not_linked" from "[Rolink] wallet_not_linked: Player 42 has not linked a wallet (HTTP 404)".

It returns nil when err is not a string or does not start with [Rolink] <code>:. Assertion errors raised before a request is sent (a missing config field, a bad to or amount) have no code, so they also return nil.

local ok, err = pcall(rolink.TransferToken, rolink, params)
if not ok then
local code = Rolink.errorCode(err)
if code == "wallet_not_linked" then
-- ask the player to link a wallet
elseif code == "player_rate_limited" then
-- try again later
else
warn(err)
end
end
Rolink.newIdempotencyKey() -> string

Returns a random GUID from HttpService:GenerateGUID(false), without braces.

rolink:GetProject() -> Project

Returns the project the API key belongs to, as a Project. Useful at startup to check that the key works, to find the managed wallet’s address, and to get the linking page URL to show players.

local project = rolink:GetProject()
print(project.name, project.cluster) -- "My Game" "devnet"
print(project.linkUrl) -- the hosted linking page for this game
if not project.events then
warn("Live events are off: add a universe ID and Open Cloud key in the dashboard")
end
rolink:GetWallet(userId: number) -> string?

Returns the Solana address the player linked to this project, or nil if they have not linked one.

  • Calls GET /v1/players/:userId/wallet.
  • Caching. Answers, including nil, are cached for walletCacheSeconds. A cached answer returns without yielding. WalletLinked and WalletUnlinked events update the cache as they arrive. See Wallet cache.
  • Errors. invalid_user_id when userId is not a positive integer, plus the common errors.
rolink:GetWallets(userIds: { number }) -> { [number]: string }

Looks up many players at once. The result maps each UserId to its address and only contains players who have linked a wallet. Players without a wallet are absent from the table.

  • Calls POST /v1/players/wallets once per 200 uncached players. Cached players are answered from the cache, and an empty or fully cached list sends no request.
  • Caching. Same cache as GetWallet. Every looked-up player is cached, with or without a wallet.
  • Errors. invalid_request when an id is not a positive integer, plus the common errors.
local Players = game:GetService("Players")
local ids = {}
for _, player in Players:GetPlayers() do
table.insert(ids, player.UserId)
end
local wallets = rolink:GetWallets(ids)
print(wallets[ids[1]]) -- address, or nil

Reads use the project’s cluster (devnet or mainnet). Balance, token and asset reads are cached by Rolink for about 10 seconds, so repeated calls within that window return the same value. Signature lookups are not cached.

rolink:GetSolBalance(address: string) -> string

Returns the SOL balance as a decimal string without trailing zeros, such as "1.5" or "0". Read at confirmed commitment.

rolink:GetTokenBalance(address: string, mint: string) -> string

Returns the wallet’s balance of one SPL or Token-2022 mint in token units, such as "12.5". Returns "0" when the wallet holds none. Balances across several token accounts of the same mint are added together.

rolink:GetTokens(address: string) -> { TokenBalance }

Returns one TokenBalance per mint the wallet has a token account for, across the Token and Token-2022 programs. Empty token accounts are included with an amount of "0".

rolink:GetAssets(address: string, collection: string?) -> { Asset }

Returns the NFTs and other digital assets the wallet owns, as Asset tables. With collection, only assets grouped under that collection address are returned.

rolink:OwnsCollection(address: string, collection: string) -> boolean

Returns true when GetAssets(address, collection) returns at least one asset. Same request, cache and errors as GetAssets.

if rolink:OwnsCollection(address, FOUNDERS_COLLECTION) then
grantFounderBadge(player)
end
rolink:GetSignatureStatus(signature: string) -> SignatureStatus

Returns the status of any transaction signature as a SignatureStatus. Rolink searches the RPC node’s transaction history and does not cache this read.

These methods ask Rolink to sign and send a transaction with the project’s managed wallet. The wallet must exist, and the transaction must fit the policy set in the dashboard. The guide is Send transactions.

Each call waits up to 15 seconds for confirmation and returns a TxResult:

statusMeaning
confirmedThe transaction landed.
failedNothing reached the recipient. error says why. Calling again with the same key and the same parameters retries it.
submittedSent but not yet confirmed. The final result arrives through TransactionUpdated and GetTransaction.
pendingAnother request with this key is being prepared right now. Rare. Check again with GetTransaction.

Idempotency. Rolink remembers every idempotencyKey a project uses, permanently:

  • the same key and the same parameters after a pending, submitted or confirmed attempt returns that attempt and sends nothing new
  • the same key and the same parameters after a failed attempt sends again
  • the same key with different parameters raises idempotency_conflict. to = 42 and to = "<the same wallet's address>" count as different parameters, and so do "1" and "1.0".

A request refused by validation, the policy or a quota never reserves its key, so you can fix it and reuse the key. The exceptions are rpc_error and wallet_unavailable: Rolink couldn’t build or sign the transaction, so it records the attempt as failed. Send the same parameters again with the same key to retry it.

Errors common to the three methods, in the order Rolink checks them: invalid_request (and, for the token methods, invalid_address for mint), wallet_not_configured, idempotency_conflict, wallet_not_linked or invalid_address for to, then the asset and amount checks listed under each method, then daily_tx_quota_exceeded, recipient_rate_limited, player_rate_limited, the hourly budget (invalid_limit_config if the stored budget doesn’t fit the asset’s decimals, otherwise hourly_budget_exceeded), and finally wallet_unavailable and rpc_error. Plus the common errors. The full sequence is in the HTTP API’s order of checks.

rolink:TransferSol(params: TransferSolParams) -> TxResult

Sends SOL from the managed wallet.

FieldTypeDescription
toRecipientA UserId with a linked wallet, or a Solana address.
amountAmountSOL to send, such as "0.01". At most 9 decimals, greater than zero, and no more than the policy’s Max SOL per transfer.
idempotencyKeystring1 to 128 characters, unique per intended payment.
  • Calls POST /v1/tx/transfer-sol.
  • Asset and amount errors. sol_transfers_disabled, invalid_amount, amount_over_limit, invalid_limit_config.
local ok, result = pcall(rolink.TransferSol, rolink, {
to = player.UserId,
amount = "0.01",
idempotencyKey = `tournament:{tournamentId}:{player.UserId}`,
})
if ok and result.status == "confirmed" then
print("paid", result.signature)
end
rolink:TransferToken(params: TokenParams) -> TxResult

Sends an SPL or Token-2022 token from the managed wallet’s associated token account. The recipient’s associated token account is created if it does not exist, paid for by the managed wallet.

FieldTypeDescription
toRecipientA UserId with a linked wallet, or a Solana address.
mintstringThe token mint. It must be under Allowed mints in the policy.
amountAmountToken units, such as "25". At most the mint’s decimals, greater than zero, and no more than the mint’s limit.
idempotencyKeystring1 to 128 characters.
  • Calls POST /v1/tx/transfer-token.
  • Asset and amount errors. invalid_address (for mint, checked first), mint_not_allowed, invalid_mint, invalid_amount, amount_over_limit, invalid_limit_config.
rolink:MintToken(params: TokenParams) -> TxResult

Mints new tokens to the recipient. The managed wallet must be the mint authority of mint, or the result is failed. Same fields, policy and errors as TransferToken.

rolink:GetTransaction(idempotencyKey: string) -> TxResult

Returns the current state of a transaction request. It answers immediately. If the request is still submitted, Rolink resumes checking its confirmation in the background. It works whether or not the managed wallet exists.

rolink:Destroy() -> ()

Disconnects the MessagingService subscription and every handler connected to the four signals. If the subscription is still being set up, it is disconnected as soon as it succeeds. The wallet cache is left as is.

Signals fire on every server that has a client, and only when the project has live events configured (a universe ID and an Open Cloud API key in the dashboard). Delivery is best effort, so treat events as hints and call Rolink when you need the authoritative state. Payloads are described in Events.

rolink.WalletLinked: Signal<number, string, Player?>
-- handler(userId: number, address: string, player: Player?)

Fires when a player links a wallet to this project, including when they replace their wallet or link the same one again. player is set when that player is on this server. Before the signal fires, the wallet cache is updated with the new address.

rolink.WalletUnlinked: Signal<number, string, Player?>
-- handler(userId: number, address: string, player: Player?)

Fires when a player unlinks their wallet, or when another Roblox account proves ownership of it in this project and takes it over. In the second case userId is the account that lost the wallet. A player who swaps to a new wallet only fires WalletLinked. If the cached address for userId matches address, it is dropped from the cache.

rolink.TransactionUpdated: Signal<TxUpdate>
-- handler(update: TxUpdate)

Fires when a transaction sent by the managed wallet reaches confirmed or failed, on every server. It also fires for transactions whose call already returned that status. See TxUpdate.

rolink.TransactionUpdated:Connect(function(update)
if update.status == "confirmed" then
markPaid(update.idempotencyKey)
end
end)
rolink.ChainActivity: Signal<ChainActivity>
-- handler(activity: ChainActivity)

Fires once for each new transaction that touches one of the project’s watched addresses, oldest first. See ChainActivity and Watch addresses.

Each signal is a small signal object from Signal.luau. Every handler runs in its own thread through task.spawn, so a handler that errors or yields never blocks the others.

MemberDescription
signal:Connect(handler) -> ConnectionCalls handler every time the signal fires.
signal:Once(handler) -> ConnectionCalls handler the next time only, then disconnects.
signal:DisconnectAll()Disconnects every handler.
connection.Connectedtrue until the connection is disconnected.
connection:Disconnect()Stops this handler.

signal:Fire(...) also exists. The SDK uses it internally, and calling it yourself fires your own handlers on this server only.

All types are exported from the module, so you can write Rolink.TxResult in type annotations once you require it.

export type Config = {
apiKey: any, -- Secret or string
apiUrl: string?, -- default Rolink.DEFAULT_API_URL
topic: string?, -- default "rolink"
walletCacheSeconds: number?, -- default 60
subscribe: boolean?, -- default true
}
export type Project = {
id: string,
name: string,
slug: string, -- used in the linking page URL
cluster: "devnet" | "mainnet",
walletAddress: string?, -- the managed wallet, nil until created in the dashboard
linkUrl: string, -- the hosted linking page for this project
events: boolean, -- true when a universe ID and an Open Cloud key are set
}

events says that both settings are present, not that Roblox accepts them. Use the test event in the dashboard’s Settings to check that.

export type TokenBalance = {
mint: string,
amount: string, -- raw base units, e.g. "2500000"
decimals: number,
uiAmount: string, -- token units, e.g. "2.5"
}
export type Asset = {
id: string, -- asset address
name: string?,
collection: string?, -- collection address, if grouped
image: string?, -- image URL from the asset metadata
interface: string?, -- DAS interface, e.g. "V1_NFT"
}
export type SignatureStatus = {
signature: string,
status: "processed" | "confirmed" | "finalized" | "failed" | "unknown",
slot: number?, -- nil when unknown
error: string?, -- the on-chain error as JSON, when failed
}

unknown means the RPC node has no record of the signature.

export type TxStatus = "pending" | "submitted" | "confirmed" | "failed"
export type TxResult = {
idempotencyKey: string,
kind: string, -- "transfer-sol" | "transfer-token" | "mint-token"
status: TxStatus,
signature: string?, -- set once the transaction is signed
recipient: string, -- the resolved wallet address
error: string?, -- why it failed
}
export type TxUpdate = {
idempotencyKey: string,
kind: string,
status: "confirmed" | "failed",
signature: string?,
userId: number?, -- the recipient player, if known
error: string?,
}

userId is set when the request used to = userId, or when the recipient address was linked to a player in this project at the time of the request.

export type ChainActivity = {
name: string, -- the watch name from the dashboard
address: string,
signature: string,
slot: number,
failed: boolean, -- true when the transaction failed on-chain
}
export type Recipient = number | string

A number is a UserId and is sent as { "userId": n }. The player must have linked a wallet to this project, or Rolink raises wallet_not_linked. A string is a Solana address and is sent as { "address": s }. A numeric string such as "42" is treated as an address, so pass player.UserId itself. Any other type raises an assertion error before the request is sent.

export type Amount = string | number

A decimal string such as "1.5", or a number. See Amount conversion.

export type TransferSolParams = {
to: Recipient,
amount: Amount,
idempotencyKey: string,
}
export type TokenParams = {
to: Recipient,
mint: string,
amount: Amount,
idempotencyKey: string,
}

Every request goes through HttpService:RequestAsync to apiUrl with the headers x-api-key and Content-Type: application/json. A request is tried up to 3 times, waiting 0.5 seconds before the second attempt and 1.5 seconds before the third. It is retried when:

  • RequestAsync itself fails (HTTP requests disabled, DNS or connection failure, timeout)
  • Rolink answers with a 5xx status
  • Rolink answers 429, except for recipient_rate_limited, player_rate_limited, hourly_budget_exceeded, daily_quota_exceeded and daily_tx_quota_exceeded, because waiting a second does not lift an hourly or daily cap. The per-minute rate_limited is retried.

Other 4xx answers are raised at once. Retrying a transaction is safe because it always carries its idempotency key. A retried request that already went through returns the original result.

After the last attempt, the SDK raises one of these:

Error stringWhen
[Rolink] <code>: <message> (HTTP <status>)Rolink answered with a JSON error body.
[Rolink] http_<status>: <status message> (HTTP <status>)The answer had no Rolink error body, for example from a proxy.
[Rolink] network_error: <HttpService error>No HTTP answer at all.

Any method that sends a request can raise:

  • unauthorized when the API key is missing, revoked, or belongs to a deleted project
  • rate_limited and daily_quota_exceeded when the project is over its request quota
  • internal when Rolink hit an unexpected error, such as an RPC failure during a read
  • network_error and http_<status> from the SDK itself

Every value placed in a URL path or query string goes through HttpService:UrlEncode(tostring(value)): user ids, addresses, mints, collections, signatures and idempotency keys. Values that come from players cannot change the route.

GetWallet and GetWallets share one cache, keyed by UserId. Each entry, whether it holds an address or “no wallet”, expires after walletCacheSeconds.

Live events keep it fresh:

  • wallet_linked stores the new address, then fires WalletLinked.
  • wallet_unlinked drops the entry if it still holds that address, then fires WalletUnlinked.

Each event also bumps a per-player version. A lookup that was already in flight when an event arrived does not overwrite the newer value with its older answer.

Without live events, a player who links while a “no wallet” answer is cached is seen as unlinked until the entry expires. Lower walletCacheSeconds if your project runs without Open Cloud.

Rolink.new subscribes to topic with MessagingService:SubscribeAsync in a background thread. If that fails, it warns [Rolink] could not subscribe to topic "<topic>" (attempt n/5): <error> and tries again after 2, 4, 8 and 16 seconds, for 5 attempts in all. After the fifth failure it gives up, and the signals stay silent until the server restarts.

Incoming messages that are not JSON, that have no "v": 1, or that have a type the SDK doesn’t handle (such as the dashboard’s ping test message) are ignored.

A string amount is sent unchanged, and Rolink checks it against ^\d+(\.\d+)?$. So "1.5" is valid, while "1,5", " 1.5", ".5" and "-1" are refused with invalid_request.

A number is converted with string.format("%.9f", amount), then trailing zeros and a trailing dot are removed:

NumberSent as
1.5"1.5"
10"10"
0.1"0.1"
0.0000000001"0", which Rolink refuses with invalid_amount

Negative numbers and NaN raise an assertion error before the request is sent. Numbers above 2^53 cannot be represented exactly in Luau, so pass large amounts as strings.