Sending via WebSocket
Persistent wss:// connection — open once, stream as many transactions or bundles as you need. The TLS handshake is paid once at connect time and amortized across every subsequent message. Browser-native, language-agnostic, identical validation and tip rules to HTTPS.
Endpoints
| Stream | URL pattern |
|---|---|
| Single transactions | wss://{region}.fastrelay.sh/v1/stream/tx |
| Atomic bundles (≤ 5 txs) | wss://{region}.fastrelay.sh/v1/stream/bundle |
{region} is fra, ams, ny, or tyo. The bare fastrelay.sh is the website, not a relay.
Authentication
Same as HTTPS — auth happens once at the upgrade request, not per message. Choose either form:
// Header (preferred for non-browser clients)
new WebSocket("wss://fra.fastrelay.sh/v1/stream/tx", undefined, {
headers: { "x-api-key": "YOUR_API_KEY" }
});
// Query parameter (works from browsers, where headers can't be set on WS)
new WebSocket("wss://fra.fastrelay.sh/v1/stream/tx?api-key=YOUR_API_KEY");API keys are optional. Without one, public submissions still work; with one, you get rate tracking and the stake-scaled rate limit.
Single-Transaction Stream
Each frame you send is one JSON object:
{ "id": "client-correlation-id", "tx": "BASE64_ENCODED_TRANSACTION" }The server replies with a matching frame:
{ "id": "client-correlation-id", "status": "accepted", "request_id": "uuid", "signature": "5xy…" }On rejection:
{ "id": "client-correlation-id", "status": "rejected", "error": "Tip 100000 below threshold 1000000" }The id field is yours — it's echoed back verbatim so you can match each reply to the request that produced it. Replies may arrive out of order; correlate by id.
Bundle Stream
Atomic groups of up to 5 transactions that land together or not at all:
{ "id": "b1", "txs": ["BASE64_TX_1", "BASE64_TX_2", "BASE64_TX_3"] }Server reply:
{ "id": "b1", "status": "accepted", "bundle_id": "jito-uuid", "signatures": ["sig1", "sig2", "sig3"] }bundle_id is the Jito Block Engine's bundle id — you can track the bundle with it directly. Tip convention: any single transaction paying the standard 0.001 SOL FastRelay tip covers the whole group, and the bundle must additionally include a Jito tip. Bundles are submitted only through the Jito Block Engine's atomic path — never split across TPU/RPC channels — so atomicity is preserved end-to-end.
Pipelined Sends
You don't have to wait for a reply before sending the next message. Fire as many frames as your rate limit allows; replies stream back asynchronously, identified by id.
for (const tx of myTxs) {
ws.send(JSON.stringify({ id: tx.localId, tx: tx.b64 }));
}
// Replies arrive out of order — match by id.A rate-limit rejection on one frame doesn't close the connection — the next frame goes through normally.
Browser Example
const ws = new WebSocket(
"wss://fra.fastrelay.sh/v1/stream/tx?api-key=YOUR_API_KEY"
);
ws.onopen = () => {
ws.send(JSON.stringify({ id: "1", tx: BASE64_TX }));
};
ws.onmessage = (e) => {
const r = JSON.parse(e.data);
if (r.status === "accepted") {
console.log("landed", r.signature);
} else {
console.error("rejected", r.error);
}
};Node.js Example (ws)
import WebSocket from "ws";
const ws = new WebSocket("wss://fra.fastrelay.sh/v1/stream/tx", {
headers: { "x-api-key": "YOUR_API_KEY" },
});
ws.on("open", () => {
ws.send(JSON.stringify({ id: "1", tx: BASE64_TX }));
});
ws.on("message", (data) => {
const r = JSON.parse(data.toString());
console.log(r);
});Python Example (websockets)
import asyncio, json, websockets
async def submit(tx_b64_list):
async with websockets.connect(
"wss://fra.fastrelay.sh/v1/stream/tx",
additional_headers={"x-api-key": "YOUR_API_KEY"},
) as ws:
# Pipeline: fire all txs, collect replies as they arrive
for i, tx in enumerate(tx_b64_list):
await ws.send(json.dumps({"id": str(i), "tx": tx}))
for _ in tx_b64_list:
print(json.loads(await ws.recv()))Rust Example (tokio-tungstenite)
use futures_util::{SinkExt, StreamExt};
use tokio_tungstenite::{connect_async, tungstenite::Message};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let url = "wss://fra.fastrelay.sh/v1/stream/tx?api-key=YOUR_API_KEY";
let (mut ws, _) = connect_async(url).await?;
let frame = serde_json::json!({ "id": "1", "tx": tx_base64 });
ws.send(Message::Text(frame.to_string())).await?;
while let Some(msg) = ws.next().await {
if let Message::Text(text) = msg? {
println!("{}", text);
break;
}
}
Ok(())
}Connection Lifecycle
- Idle behavior: streams stay alive aggressively — no idle timeout you need to design around. A connection opened at process boot will stay hot through quiet periods.
- Reconnect: on transient drops (network blip, server restart), reconnect immediately and resume sending. There's no session state to recover — each frame is independent.
- Close: send a close frame when you're done; the server will flush any in-flight replies and finish the close handshake.
Important: Tip and validation rules are identical to HTTPS and QUIC — minimum 0.001 SOL to a registered tip wallet, no transactions using Address Lookup Tables (ALT), max ~1.6KB per transaction. Bundles inherit the same threshold (one tx paying the FastRelay tip covers the whole bundle) and additionally require a Jito tip.