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.
Set up the managed wallet
Section titled “Set up the managed wallet”-
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,TransferTokenandMintTokenraisewallet_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. -
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 rentsolana airdrop 1 <wallet address> --url devnet -
Add the tokens it sends.
TransferTokensends 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 -
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> -
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.
-
Check from a server.
rolink:GetProject()returns the wallet’s address once it exists:print(rolink:GetProject().walletAddress) -- nil until the wallet is created
Send from your game
Section titled “Send from your game”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>)" endend| Method | Parameters |
|---|---|
TransferSol | { to, amount, idempotencyKey } |
TransferToken | { to, mint, amount, idempotencyKey } |
MintToken | { to, mint, amount, idempotencyKey } |
GetTransaction | idempotencyKey |
tois a player’sUserId(a number), which Rolink resolves to the wallet they linked to this project, or a Solana address (a string). AUserIdwithout a linked wallet raiseswallet_not_linked.amountis 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.mintworks 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"}| Status | Meaning | What to do |
|---|---|---|
confirmed | It landed (confirmed or finalized on-chain). | Done. |
submitted | It was sent, but didn’t confirm within the 15 seconds the request waits. | Wait for TransactionUpdated, or call GetTransaction later. |
pending | Another request with this key is being prepared right now. | Same as submitted. |
failed | No 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.
Idempotency keys
Section titled “Idempotency keys”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 key | Same request | Different request |
|---|---|---|
| None | Validated, 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. |
pending | Returns the earlier result. Nothing is sent. | 409 idempotency_conflict |
submitted | Waits up to 15 seconds for the earlier attempt, then returns it. Nothing new is sent. | 409 idempotency_conflict |
confirmed | Returns the earlier result. Nothing is sent. | 409 idempotency_conflict |
failed | Sent 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 = 1234567andto = "8vRf…VfXr"are different requests, even if that address is the player’s linked wallet.amount = "1"andamount = "1.0"are different requests. The number1is sent as"1", so1and"1"match.TransferTokenandMintTokenwith 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_linkedorplayer_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.
Design your keys
Section titled “Design your keys”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:
| Payment | Key |
|---|---|
| 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 transaction policy
Section titled “The transaction policy”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 field | What it limits | Default | Refused with |
|---|---|---|---|
| Max SOL per transfer | The largest SOL amount one TransferSol may send. Empty turns SOL transfers off. | Empty: SOL transfers off | sol_transfers_disabled, amount_over_limit |
| Allowed mints | The mints the wallet may transfer or mint, each with a maximum per transaction, in token units. Up to 20. | None: no token transfers or mints | mint_not_allowed, amount_over_limit |
| Per recipient wallet | Transactions to one wallet per rolling hour | 20 | recipient_rate_limited |
| Per player | Transactions to one player per rolling hour, whatever wallet they use | 20 | player_rate_limited |
| Hourly budgets | The total sent or minted per asset (SOL or an allowed mint) over any rolling hour, across all recipients | None | hourly_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,submittedorconfirmed. 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.5on 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_exceededanddaily_tx_quota_exceededare 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.
“submitted” and TransactionUpdated
Section titled ““submitted” and TransactionUpdated”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:
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 endend
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) endend)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.
Expiry is proven, not guessed
Section titled “Expiry is proven, not guessed”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:
- the finalized chain is more than 32 blocks past the transaction’s last valid block height, so it can no longer land, and
- 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.
Minting
Section titled “Minting”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.
SOL transfers to new addresses
Section titled “SOL transfers to new addresses”Errors
Section titled “Errors”Transaction methods raise "[Rolink] <code>: <message> (HTTP <status>)". Branch with Rolink.errorCode(err):
| Code | HTTP | What to do |
|---|---|---|
wallet_not_configured | 409 | Create the managed wallet on the project’s Overview page. |
wallet_not_linked | 404 | Ask the player to link a wallet, then retry with the same key. |
invalid_address / invalid_mint / invalid_amount / invalid_request | 400 | Fix the request. It’s a bug in the calling code. |
mint_not_allowed | 403 | Add the mint under Allowed mints, or fix the mint. |
sol_transfers_disabled | 403 | Set Max SOL per transfer if you want SOL transfers. |
amount_over_limit | 403 | Lower the amount, or raise the cap. |
recipient_rate_limited / player_rate_limited | 429 | Try again later with the same key. |
hourly_budget_exceeded | 429 | Try again later. Check why the budget ran out: it can be the first sign of a bug or an exploit. |
daily_tx_quota_exceeded | 429 | The project reached the beta’s 1,000 transactions in 24 hours. Try again later with the same key. |
rate_limited / daily_quota_exceeded | 429 | The project’s request quota. See Limits and quotas. |
idempotency_conflict | 409 | The key was used for a different request. Make keys more specific, or find the bug that changed the amount. |
wallet_unavailable | 502 | The managed wallet couldn’t sign. Nothing was sent. The SDK retries; retry later with the same key. |
rpc_error | 502 | The Solana RPC couldn’t be reached to build the transaction. Nothing was sent. The SDK retries; retry later with the same key. |
invalid_limit_config | 422 | A 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_found | 404 | GetTransaction for a key the project never accepted. |
network_error | n/a | No 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.
Next steps
Section titled “Next steps”- Live events: make sure
TransactionUpdatedreaches 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.
