Skip to content

Send transactions

A game server can’t hold a signing key safely, so each Rolink project has a managed wallet. You create it in the dashboard, fund it, and decide in the dashboard what it may send. Your servers then call TransferSol, TransferToken and MintToken, and Rolink signs each request with the project’s wallet after checking it against your policy.

Two things keep this safe to call from game code:

  • Every request carries an idempotency key, so a retry never pays twice, even after a crash.
  • Every request passes your transaction policy, so a bug in your game has a ceiling.
  1. Create the wallet. In the dashboard, open your project’s Overview and select Create wallet on the Managed wallet card. Each project has one wallet, on the project’s cluster (devnet or mainnet).

    The private key is generated inside Turnkey’s secure enclaves and never leaves them. Rolink stores the wallet’s ID and public address, and asks Turnkey for a signature each time it sends a transaction. Until the wallet exists, TransferSol, TransferToken and MintToken raise wallet_not_configured.

    Managed wallets may not be turned on for the hosted API yet. Until they are, Create wallet answers “Managed wallets aren’t set up on this server yet, so transactions are off.” (wallets_unavailable). Linking, reads and live events work meanwhile.

  2. Fund it with SOL. The wallet pays every network fee. For token transfers and mints, it also creates the recipient’s token account when they don’t have one yet, which costs a rent deposit of about 0.002 SOL. The address and its SOL balance are on the Overview page.

    Terminal window
    # Devnet only: free SOL for fees and rent
    solana airdrop 1 <wallet address> --url devnet
  3. Add the tokens it sends. TransferToken sends from the wallet’s own balance, so transfer the tokens to the wallet’s address first:

    Terminal window
    spl-token transfer <mint> 1000 <wallet address> --fund-recipient
  4. For MintToken, make it the mint authority. The wallet can only mint a token it has authority over:

    Terminal window
    spl-token authorize <mint> mint <wallet address>
  5. Set the transaction policy. Open Transactions in the dashboard. Nothing is allowed until you allow it: SOL transfers are off and no mint is listed. See The transaction policy below.

  6. Check from a server. rolink:GetProject() returns the wallet’s address once it exists:

    print(rolink:GetProject().walletAddress) -- nil until the wallet is created
ServerScriptService/DailyReward.server.luau
local HttpService = game:GetService("HttpService")
local ServerScriptService = game:GetService("ServerScriptService")
local Rolink = require(ServerScriptService.Rolink)
local rolink = Rolink.new({ apiKey = HttpService:GetSecret("rolink_api_key") })
local REWARD_MINT = "<your token mint address>"
local function payDaily(player: Player)
local ok, result = pcall(rolink.TransferToken, rolink, {
to = player.UserId,
mint = REWARD_MINT,
amount = "1",
idempotencyKey = `daily:{player.UserId}:{os.date("!%Y-%m-%d")}`,
})
if not ok then
warn(result) -- "[Rolink] <code>: <message> (HTTP <status>)"
end
end
MethodParameters
TransferSol{ to, amount, idempotencyKey }
TransferToken{ to, mint, amount, idempotencyKey }
MintToken{ to, mint, amount, idempotencyKey }
GetTransactionidempotencyKey
  • to is a player’s UserId (a number), which Rolink resolves to the wallet they linked to this project, or a Solana address (a string). A UserId without a linked wallet raises wallet_not_linked.
  • amount is a decimal string in token units, such as "1.5". It must be greater than zero and have no more decimals than the asset allows (9 for SOL). Numbers are accepted and converted with up to 9 decimals, but strings stay exact.
  • mint works for SPL Token and Token-2022 mints alike. Rolink reads the mint’s program and decimals from the chain.

Each method returns a TxResult:

{
idempotencyKey = "daily:1234567:2026-10-08",
kind = "transfer-token", -- "transfer-sol" | "transfer-token" | "mint-token"
status = "confirmed", -- "pending" | "submitted" | "confirmed" | "failed"
signature = "5h6x…", -- nil until the transaction is signed
recipient = "8vRf…VfXr", -- the wallet address it went to
error = nil, -- why it failed, when status is "failed"
}
StatusMeaningWhat to do
confirmedIt landed (confirmed or finalized on-chain).Done.
submittedIt was sent, but didn’t confirm within the 15 seconds the request waits.Wait for TransactionUpdated, or call GetTransaction later.
pendingAnother request with this key is being prepared right now.Same as submitted.
failedNo funds reached the recipient. error says why.Fix the cause if needed, then send again with the same key.

A failed status is a return value, not an error. It covers a transaction the network rejected in simulation, before it was sent (not enough SOL, not enough tokens, wrong mint authority), one that failed on-chain (the network fee is still paid), and one that provably expired. When Rolink can’t build or sign the transaction, the call raises rpc_error or wallet_unavailable instead, and the attempt is stored as failed: GetTransaction and TransactionUpdated report it that way.

An idempotency key names one intended payment. Rolink stores every key it accepts, permanently, per project. Two projects can use the same key without affecting each other. Here is what happens when a key comes back:

Earlier request with this keySame requestDifferent request
NoneValidated, checked against the policy, then sent.n/a
Refused (validation error, policy or quota)Treated as new. Refused requests never reserve the key.Treated as new.
pendingReturns the earlier result. Nothing is sent.409 idempotency_conflict
submittedWaits up to 15 seconds for the earlier attempt, then returns it. Nothing new is sent.409 idempotency_conflict
confirmedReturns the earlier result. Nothing is sent.409 idempotency_conflict
failedSent again as a new attempt with a new signature, after the same checks.409 idempotency_conflict

“Same request” means the same method, to, mint and amount, compared exactly as sent:

  • to = 1234567 and to = "8vRf…VfXr" are different requests, even if that address is the player’s linked wallet.
  • amount = "1" and amount = "1.0" are different requests. The number 1 is sent as "1", so 1 and "1" match.
  • TransferToken and MintToken with the same key are different requests.

This is what makes retries safe:

  • After an error with no answer (network_error, a timeout), call again with the same key. If the first request went through, you get its result back instead of a second payment.
  • After failed, call again with the same key. A failed attempt moved no funds, so sending again keeps the “at most once” guarantee.
  • After a refusal such as wallet_not_linked or player_rate_limited, the key is still unused. Call again later with the same key.
  • Keys don’t expire. A key confirmed last month still returns its original result.

The SDK already retries network errors and 5xx answers (including rpc_error and wallet_unavailable) with the same body, up to 3 attempts.

Build keys from what you’re paying for, so the same payment always produces the same key, even from a different server after a crash:

PaymentKey
A quest reward`quest:{userId}:{questId}`
A daily reward`daily:{userId}:{os.date("!%Y-%m-%d")}` (UTC date)
A season prize`season:{seasonId}:rank:{userId}`
  • One key per payment, forever. Put whatever makes the payment unique (date, quest, season) in the key.
  • 1 to 128 characters. Longer or empty keys are refused with invalid_request.
  • Prefix keys if one project serves several places. For example `lobby:quest:{userId}:{questId}`, so two places can’t collide on the same key.
  • Avoid random keys. A GUID generated at send time is lost if the server crashes, and the retry would get a new one and pay again. Rolink.newIdempotencyKey() exists for one-off payments where you store the key (in a DataStore, say) before calling.

If the same purpose produces a different amount or recipient, Rolink answers 409 idempotency_conflict instead of sending. That’s the key doing its job: it caught a second, different payment for something already paid.

The policy lives in the dashboard, under Transactions, and applies to every request your servers make. Changes take effect within about 30 seconds, with nothing to redeploy.

Dashboard fieldWhat it limitsDefaultRefused with
Max SOL per transferThe largest SOL amount one TransferSol may send. Empty turns SOL transfers off.Empty: SOL transfers offsol_transfers_disabled, amount_over_limit
Allowed mintsThe mints the wallet may transfer or mint, each with a maximum per transaction, in token units. Up to 20.None: no token transfers or mintsmint_not_allowed, amount_over_limit
Per recipient walletTransactions to one wallet per rolling hour20recipient_rate_limited
Per playerTransactions to one player per rolling hour, whatever wallet they use20player_rate_limited
Hourly budgetsThe total sent or minted per asset (SOL or an allowed mint) over any rolling hour, across all recipientsNonehourly_budget_exceeded

On top of the policy, the beta allows 1,000 transactions per project per rolling 24 hours. A request over it is refused with daily_tx_quota_exceeded. See Limits and quotas.

  • Rolling windows. The hourly limits, the budgets and the daily quota count requests created in the window that are pending, submitted or confirmed. Failed attempts don’t count.
  • Players, not just wallets. A transaction counts toward a player’s limit when it’s sent to their UserId, or to an address linked to them in this project. Switching wallets doesn’t reset it.
  • Budgets bound the damage. Per-transaction caps and hourly limits stop one runaway loop. Hourly budgets are the only setting that caps total spend, and minting and transferring the same mint share one budget. Set a budget for every asset you allow.
  • Checked when you save. The dashboard refuses a mint that isn’t a token mint on the project’s cluster, an amount with more decimals than its asset allows (such as 0.5 on a 0-decimal mint), and a budget for a mint that isn’t allowed.
  • Atomic. Rolink checks the limits and reserves the key in one step, under a per-project lock, so concurrent requests from many servers can’t overshoot a limit together.
  • The SDK doesn’t retry these refusals. recipient_rate_limited, player_rate_limited, hourly_budget_exceeded and daily_tx_quota_exceeded are raised immediately, because waiting a second won’t lift an hourly or daily cap. Queue the payment and try again later with the same key.

A request waits up to 15 seconds for its transaction to confirm. If confirmation takes longer, it answers submitted, and Rolink keeps tracking the signature in the background: it checks every 1.5 seconds, and re-checks everything still submitted every 30 seconds. When the transaction settles, Rolink publishes a tx event, which the SDK fires as TransactionUpdated on every server. This needs live events.

TransactionUpdated fires for every final result, including ones that were already confirmed in the response, so match on the key:

ServerScriptService/Rewards.server.luau
local HttpService = game:GetService("HttpService")
local ServerScriptService = game:GetService("ServerScriptService")
local Rolink = require(ServerScriptService.Rolink)
local rolink = Rolink.new({ apiKey = HttpService:GetSecret("rolink_api_key") })
local REWARD_MINT = "<your token mint address>"
-- Codes that mean "not now". The same key will work later.
local LATER = {
wallet_not_linked = true,
recipient_rate_limited = true,
player_rate_limited = true,
hourly_budget_exceeded = true,
daily_tx_quota_exceeded = true,
rate_limited = true,
daily_quota_exceeded = true,
rpc_error = true,
wallet_unavailable = true,
network_error = true,
}
local function onSettled(key: string, status: string, err: string?)
if status == "confirmed" then
print(`{key}: paid`)
else
warn(`{key}: failed, nothing was sent ({err})`) -- retry later with the same key
end
end
local function grantQuestReward(userId: number, questId: string)
local key = `quest:{userId}:{questId}`
local ok, result = pcall(rolink.TransferToken, rolink, {
to = userId,
mint = REWARD_MINT,
amount = "5",
idempotencyKey = key,
})
if not ok then
local code = Rolink.errorCode(result)
if code and LATER[code] then
-- Queue it and call grantQuestReward again later. The key is still safe to reuse.
else
warn(result) -- a bug or a policy setting to fix, not something to retry
end
return
end
if result.status == "confirmed" or result.status == "failed" then
onSettled(key, result.status, result.error)
return
end
-- "submitted" or "pending": the event usually arrives. If it doesn't, ask Rolink.
task.delay(120, function()
local okTx, tx = pcall(rolink.GetTransaction, rolink, key)
if okTx and (tx.status == "confirmed" or tx.status == "failed") then
onSettled(key, tx.status, tx.error)
end
end)
end
rolink.TransactionUpdated:Connect(function(update)
if string.match(update.idempotencyKey, "^quest:") then
onSettled(update.idempotencyKey, update.status, update.error)
end
end)

onSettled can run twice for the same key (once from the event and once from the fallback, or on several servers), so make what it does idempotent too: record the payment by key, not by counting calls.

GetTransaction returns the current TxResult and raises not_found for a key the project has never accepted. Calling it for a submitted transaction also nudges Rolink to check on it.

A Solana transaction is only valid until its blockhash expires. If one never lands, Rolink must eventually call it failed so you can retry, but calling it failed too early could lead to paying twice. So Rolink waits for proof:

  1. the finalized chain is more than 32 blocks past the transaction’s last valid block height, so it can no longer land, and
  2. an RPC node that has caught up to that finalized slot still has no record of the signature.

Only then does the attempt become failed with Transaction expired before it was confirmed. An unknown answer from a lagging node is not taken as proof. In practice, a transaction that didn’t land is reported as expired a minute or two after it was sent.

MintToken mints new tokens straight into the recipient’s token account, creating it if needed. The managed wallet must be the mint’s mint authority, or the network rejects the transaction and the result is failed. The mint must also be in Allowed mints, and its per-transaction cap and hourly budget apply to mints exactly as they do to transfers.

Transaction methods raise "[Rolink] <code>: <message> (HTTP <status>)". Branch with Rolink.errorCode(err):

CodeHTTPWhat to do
wallet_not_configured409Create the managed wallet on the project’s Overview page.
wallet_not_linked404Ask the player to link a wallet, then retry with the same key.
invalid_address / invalid_mint / invalid_amount / invalid_request400Fix the request. It’s a bug in the calling code.
mint_not_allowed403Add the mint under Allowed mints, or fix the mint.
sol_transfers_disabled403Set Max SOL per transfer if you want SOL transfers.
amount_over_limit403Lower the amount, or raise the cap.
recipient_rate_limited / player_rate_limited429Try again later with the same key.
hourly_budget_exceeded429Try again later. Check why the budget ran out: it can be the first sign of a bug or an exploit.
daily_tx_quota_exceeded429The project reached the beta’s 1,000 transactions in 24 hours. Try again later with the same key.
rate_limited / daily_quota_exceeded429The project’s request quota. See Limits and quotas.
idempotency_conflict409The key was used for a different request. Make keys more specific, or find the bug that changed the amount.
wallet_unavailable502The managed wallet couldn’t sign. Nothing was sent. The SDK retries; retry later with the same key.
rpc_error502The Solana RPC couldn’t be reached to build the transaction. Nothing was sent. The SDK retries; retry later with the same key.
invalid_limit_config422A policy amount doesn’t fit the asset’s decimals. Open the policy in the dashboard and save it again: the dashboard points out the value to fix.
not_found404GetTransaction for a key the project never accepted.
network_errorn/aNo answer reached the server. Retry with the same key: you’ll get the original result if it went through.

Every code is described in Error codes.

  • Live events: make sure TransactionUpdated reaches your servers.
  • Watch addresses: add the managed wallet as a watch to see every transaction it signs.
  • Security model: what the policy does and doesn’t protect.
  • Troubleshooting: refusals, failed results and transactions stuck on submitted.