Sending Transactions
Sending, retrying and confirming Solana transactions, the staked SWQoS path and priority fees.
sendTransaction forwards a signed transaction to the next four leaders in the schedule and rebroadcasts it every 420 ms. The call works as it does on any Solana RPC.
Network and staked paths
| Plan | Network path | Staked path (SWQoS) |
|---|---|---|
| Free | 1/s | — |
| Operator | 15/s | 1/s |
| Blade Runner | 50/s | 5/s |
| Nexus | 200/s | 20/s |
Network is the default path and is available on every plan. Staked sends through stake-weighted quality of service: a leader reserves 80% of its inbound transaction capacity for connections from staked validators and divides that share in proportion to stake, so staked traffic is less likely to be dropped when the leader is busy. Solana's Stake-weighted Quality of Service guide covers the mechanism.
Both figures are a rate per second, not a monthly allowance. There is no monthly cap on either path, and nothing accrues or expires. Limits are counted per region, so a key holds the full rate in New York and the full rate in Frankfurt at the same time. Every sendTransaction call counts, including rebroadcasts.
The Benchmarks page shows live landing times and success rates for both paths.
Staked endpoints
The staked path is selected by hostname. There is no header, no extra parameter and no request to make first.
https://staked-rpc-newyork.constant-k.com/?api-key=YOUR_API_KEY
https://staked-rpc-frankfurt.constant-k.com/?api-key=YOUR_API_KEY
https://staked-rpc-amsterdam.constant-k.com/?api-key=YOUR_API_KEYThe staked hostnames work on Operator, Blade Runner and Nexus, and serve one transaction per call. Every other method works there too, against the same limits as the regular RPC hostname, so getLatestBlockhash and getSignatureStatuses calls do not have to move.
maxRetries and skipPreflight are fixed on staked hostnames
maxRetries is always 0 and skipPreflight is always true. Setting them has no effect, so rebroadcasting is the client's own job, as in the example below. Everything else, including the transaction, encoding and minContextSlot, is passed through unchanged.
Which path a send took
Every response from a staked hostname carries an X-CK-Tx-Route header. It is the only way to confirm that a send went over SWQoS.
curl -sS -D - -o /dev/null "https://staked-rpc-newyork.constant-k.com/?api-key=YOUR_API_KEY" \
-X POST -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"sendTransaction","params":["BASE64_SIGNED_TRANSACTION",{"encoding":"base64"}]}' \
| grep -i "x-ck-tx-route"| Value | Path taken |
|---|---|
| Any value that is not listed below | The staked route. The value names it. |
Any value starting with local_ | The constant-k node. The suffix gives the reason, below. |
passthrough | The request was not a sendTransaction, so the staked path did not apply. |
The header reports the route chosen, not the outcome at the leader
A staked value means the transaction was handed to the staked route. If that route then returns an error, the transaction is sent from the constant-k node instead, and the response still carries the staked value, because the header is written before that happens. No RPC can report whether a leader included a transaction; getSignatureStatuses is what settles that.
When a staked send falls back
A send that cannot take the staked path is not refused. The host sends the transaction from the constant-k node instead and returns the signature as usual, so a fallback is visible only in the header.
| Value | Reason |
|---|---|
local_tier | The plan does not include the staked path. |
local_cap_key | Over the plan's staked sends per second for that region. |
local_cap_site | The region's shared staked capacity is full. |
local_budget | constant-k's own staked budget for the region is spent for the month. This is not a per-customer allowance. |
local_dedup | The same encoded transaction was sent in the last 60 seconds. See Resending the same transaction. |
local_badparams | The transaction parameter is not a string. |
local_oversize | The request body is over 8 KB. |
local_off, local_hold | The staked route is not taking sends at the moment. |
Resending the same transaction
The staked path serves a repeat of the same transaction from the constant-k node, so one transaction is never handed to the staked route twice. The window is 60 seconds, about the life of a blockhash.
The match is on the encoded transaction string as sent. A signed transaction always serializes to the same bytes and the same signature, so a rebroadcast matches and is safe by construction. Re-signing against a fresh blockhash produces a different transaction, which takes the staked path again. The same transaction re-encoded from base58 to base64, or the other way, does not match.
Re-signing while the previous transaction can still land is the hazard this does not cover: two transactions with different blockhashes are two transactions, and both can execute. The old blockhash has to expire first.
A transaction served this way is still sent, and the node rebroadcasts it as described in Rebroadcasting.
Rebroadcasting
The staked route takes one shot: no retry and no rebroadcast. That is why maxRetries is forced to 0 on staked hostnames, and why the client owns retries there.
The constant-k node behaves differently. These settings are the same in every region.
| Setting | Value |
|---|---|
| Leaders a send is forwarded to | The next 4 in the schedule |
| Rebroadcast interval | 420 ms |
Rebroadcasts when maxRetries is unset | 2 |
Highest maxRetries the node honours | 5 |
Which of the two applies depends on how the send was routed.
| Send | Rebroadcast by |
|---|---|
| Any send on a regular hostname | The node, on the schedule above, unless maxRetries: 0 is set. |
A staked hostname, local_ value | The node, on the schedule above. maxRetries is passed through, so the client's own value applies. |
| A staked hostname, staked value | Nobody. maxRetries was rewritten to 0 before the send, and that rewritten value also applies if the staked route errors and the transaction is re-sent from the constant-k node. |
A client that sets maxRetries: 0 and rebroadcasts on its own, as the example below does, replaces this entirely. A client loop makes the interval and the stopping condition explicit, and does not share the node's retry queue with every other caller when the network is busy.
A send-and-confirm loop
This example transfers 0.001 SOL. It sets a priority fee, sends without preflight, rebroadcasts until the transaction confirms, and stops when the blockhash expires.
Plan required
These examples call getLatestBlockhash and getSignatureStatuses, which need the Operator plan or higher.
npm install @solana/kit @solana-program/system @solana-program/compute-budgetimport { readFileSync } from "node:fs";
import {
address,
appendTransactionMessageInstructions,
createKeyPairSignerFromBytes,
createSolanaRpc,
createTransactionMessage,
getBase64EncodedWireTransaction,
getSignatureFromTransaction,
lamports,
pipe,
setTransactionMessageFeePayerSigner,
setTransactionMessageLifetimeUsingBlockhash,
signTransactionMessageWithSigners,
} from "@solana/kit";
import {
getSetComputeUnitLimitInstruction,
getSetComputeUnitPriceInstruction,
} from "@solana-program/compute-budget";
import { getTransferSolInstruction } from "@solana-program/system";
const rpc = createSolanaRpc(
`https://rpc-newyork.constant-k.com/?api-key=${process.env.CK_API_KEY}`,
);
// A Solana CLI keypair file (JSON array of 64 bytes)
const payer = await createKeyPairSignerFromBytes(
new Uint8Array(JSON.parse(readFileSync(process.env.KEYPAIR_PATH!, "utf8"))),
);
// 1. Get a recent blockhash
const { value: latestBlockhash } = await rpc
.getLatestBlockhash({ commitment: "confirmed" })
.send();
// 2. Build the message: compute budget first, then your instructions
const message = pipe(
createTransactionMessage({ version: 0 }),
(m) => setTransactionMessageFeePayerSigner(payer, m),
(m) => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, m),
(m) =>
appendTransactionMessageInstructions(
[
getSetComputeUnitLimitInstruction({ units: 1_000 }),
getSetComputeUnitPriceInstruction({ microLamports: 10_000n }),
getTransferSolInstruction({
source: payer,
destination: address("RECIPIENT_ADDRESS"),
amount: lamports(1_000_000n), // 0.001 SOL
}),
],
m,
),
);
// 3. Sign and serialize
const signedTx = await signTransactionMessageWithSigners(message);
const signature = getSignatureFromTransaction(signedTx);
const wireTx = getBase64EncodedWireTransaction(signedTx);
// 4. Send, then rebroadcast until it confirms or the blockhash expires
const sendOptions = { encoding: "base64", skipPreflight: true, maxRetries: 0n } as const;
while (true) {
await rpc.sendTransaction(wireTx, sendOptions).send();
await new Promise((resolve) => setTimeout(resolve, 400));
const { value: [status] } = await rpc.getSignatureStatuses([signature]).send();
if (status?.err) {
throw new Error(`Transaction ${signature} failed`, { cause: status.err });
}
if (status?.confirmationStatus === "confirmed" || status?.confirmationStatus === "finalized") {
console.log("Confirmed:", signature);
break;
}
const blockHeight = await rpc.getBlockHeight({ commitment: "confirmed" }).send();
if (blockHeight > latestBlockhash.lastValidBlockHeight) {
throw new Error(`Blockhash expired before ${signature} landed. Rebuild and resend.`);
}
}The script runs with KEYPAIR_PATH=~/.config/solana/id.json npx tsx send.ts.
Confirmation over WebSocket
With a WebSocket connection, @solana/kit waits for confirmation instead of polling:
import {
assertIsTransactionWithBlockhashLifetime,
createSolanaRpc,
createSolanaRpcSubscriptions,
getSignatureFromTransaction,
sendAndConfirmTransactionFactory,
} from "@solana/kit";
const key = process.env.CK_API_KEY;
const rpc = createSolanaRpc(`https://rpc-newyork.constant-k.com/?api-key=${key}`);
const rpcSubscriptions = createSolanaRpcSubscriptions(
`wss://ws-newyork.constant-k.com:1337/ws?api-key=${key}`,
);
const sendAndConfirmTransaction = sendAndConfirmTransactionFactory({ rpc, rpcSubscriptions });
// signedTx: built and signed as in the example above
assertIsTransactionWithBlockhashLifetime(signedTx);
await sendAndConfirmTransaction(signedTx, {
commitment: "confirmed",
skipPreflight: true,
});
console.log("Confirmed:", getSignatureFromTransaction(signedTx));Priority fees
Leaders order transactions partly by priority fee. On legacy and version 0 transactions it is a price per compute unit, in micro-lamports, set with a SetComputeUnitPrice instruction. A realistic compute unit limit alongside it avoids paying for units the transaction does not use. Version 1 sets the fee as a total in lamports; see Version 1 transactions.
A price comes from what recent transactions paid to write to the same accounts.
import { address, createSolanaRpc } from "@solana/kit";
const rpc = createSolanaRpc(
`https://rpc-newyork.constant-k.com/?api-key=${process.env.CK_API_KEY}`,
);
// Fees recently paid by transactions that wrote to these accounts
const fees = await rpc
.getRecentPrioritizationFees([address("JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4")])
.send();
const sorted = fees.map((f) => f.prioritizationFee).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
const p75 = sorted[Math.floor(sorted.length * 0.75)] ?? 0n;
console.log(`75th percentile: ${p75} micro-lamports per CU`);Version 1 transactions
sendTransaction takes the signed bytes and does not care which format they are, so sending version 1 needs no parameter or hostname change. constant-k nodes run solana-core 4.2.2 or later and serve it. Four things change in how you build the transaction:
- The signing library decides the format:
@solana/kit8.0.0 or later, or the Rustsolana-*crates at 4.2. - The priority fee is a total in lamports in the message config, not a price per compute unit in a ComputeBudget instruction.
- An unset config field means the minimum, not a default, so the compute unit limit and the loaded accounts data size have to be set explicitly.
- Address lookup tables do not exist in version 1. A transaction that needs one stays on version 0.
Transaction v1 covers the message config, the limits, the client minimums and reading these transactions back.
Client rules
| Rule | Detail |
|---|---|
| A fresh blockhash | Fetched with confirmed commitment immediately before signing. lastValidBlockHeight says when to stop retrying. |
| Preflight off in production | skipPreflight: true drops a simulation before the send. In development, simulateTransaction is a separate call with its own lower rate limit. |
| Client-owned retries | maxRetries: 0, with the client rebroadcasting the same signed transaction until it confirms or the blockhash expires. Left unset, the node rebroadcasts twice instead. See Rebroadcasting. |
| No re-signing while the old transaction can still land | A new blockhash makes a new transaction, and both can execute. The old blockhash has to expire first. |
| One region per flow | Send and confirm on the same region. Nodes can differ by a slot or two. See Choosing a region. |
| Distance to the node | Your latency to the node adds directly to landing time. |
| Rebroadcasts count | Every rebroadcast counts toward your sendTransaction limit. A 400 ms loop sends about 2.5 calls per second per transaction. |
What affects landing
No RPC can guarantee that a transaction lands. These factors decide it:
| Factor | Effect |
|---|---|
| Priority fee | Ranked against other transactions that want the same accounts. |
| Distance | From your sender to the node, and from the node to the current leader. The leader changes every four slots, about 1.1 seconds at current slot times, so this distance keeps changing. |
| Blockhash age | An old blockhash leaves less time to land. |
| Compute unit limit | On legacy and version 0, an oversized limit lowers your priority per unit. On version 1 the fee is a total, so the limit changes your cost, not your price per unit. |
Tips and bundles
sendBundle is not supported. sendTransaction takes single transactions.
Stake-weighted delivery, a priority fee and a tip act at different stages, so one does not substitute for another.
| Mechanism | What it decides |
|---|---|
| Staked path | Whether the leader accepts the connection when its inbound capacity is full. |
| Priority fee | Where the transaction ranks once the leader holds it. |
| Tip to a block builder | Whether several transactions land together and in order, or not at all. |
A staked send carrying no meaningful compute unit price still loses to transactions that pay one, because reaching the leader and being chosen by the leader are separate problems. Atomicity across more than one transaction is the one thing neither the staked path nor a priority fee provides.
The same transaction through several providers
Only one copy can land. The others fail as duplicates, or, on a durable nonce, fail once the nonce advances. That is the expected outcome rather than a fault in any one path. X-CK-Tx-Route reports the path constant-k used, not which provider's copy won.