> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbitflare.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Apex Go Client

> orbitflare-apex-go is the Go client for OrbitFlare Apex: a persistent QUIC connection with 0-RTT reconnects, plus HTTP helpers for JSON-RPC, batches, and bundles.

`orbitflare-apex-go` is the Go client for Apex. It keeps one persistent QUIC connection per Apex endpoint, authenticates with a client certificate derived from your API key, sends one serialized transaction per stream, and reconnects with 0-RTT when the connection drops. It has the same features as the [Rust client](/apex/rust-client), and its client certificate is byte-identical to the Rust one.

## Install

The module is `github.com/orbitflare/orbitflare-apex-go`; the source is on [GitHub](https://github.com/orbitflare/orbitflare-apex-go):

```bash theme={null}
go get github.com/orbitflare/orbitflare-apex-go@latest
go get github.com/gagliardetto/solana-go@v1.24.0
```

The module has two packages:

| Package | Import | Contents |
| - | - | - |
| `apex` | `github.com/orbitflare/orbitflare-apex-go` | The QUIC client, regions, tips, and the wire format |
| `rpc` | `github.com/orbitflare/orbitflare-apex-go/rpc` | The Apex endpoint over HTTP, plus blockhash and confirmation helpers for any Solana RPC |

Transactions are built with [`solana-go`](https://github.com/gagliardetto/solana-go), which covers legacy, v0, and [v1](/apex/transaction-v1) messages. The QUIC path does not depend on the `rpc` package.

The module requires Go 1.26 or newer.

## Quick Start

A complete program. It builds a memo transaction with a compute budget and a tip, sends it over QUIC, and waits for confirmation. It reads the same environment variables as the [Quickstart](/apex/quickstart): `APEX_API_KEY`, `KEYPAIR_PATH`, and `SOLANA_RPC_URL`.

```go main.go theme={null}
package main

import (
	"context"
	"fmt"
	"log"
	"os"
	"time"

	"github.com/gagliardetto/solana-go"
	computebudget "github.com/gagliardetto/solana-go/programs/compute-budget"

	apex "github.com/orbitflare/orbitflare-apex-go"
	"github.com/orbitflare/orbitflare-apex-go/rpc"
)

func getenv(name, fallback string) string {
	if v := os.Getenv(name); v != "" {
		return v
	}
	return fallback
}

func main() {
	ctx := context.Background()
	apiKey := os.Getenv("APEX_API_KEY")
	payer, err := solana.PrivateKeyFromSolanaKeygenFile(getenv("KEYPAIR_PATH", "payer.json"))
	if err != nil {
		log.Fatal(err)
	}
	region := apex.Frankfurt

	client, err := apex.Connect(ctx, region, apiKey)
	if err != nil {
		log.Fatal(err)
	}
	defer client.Close()

	tipAccounts, err := rpc.New(region, apiKey).GetTipAccounts(ctx)
	if err != nil {
		log.Fatal(err)
	}
	tipAccount, _ := apex.PickTipAccount(tipAccounts)

	solanaRPC := rpc.NewSolanaRPC(getenv("SOLANA_RPC_URL", "https://api.mainnet-beta.solana.com"))
	blockhash, err := solanaRPC.LatestBlockhash(ctx)
	if err != nil {
		log.Fatal(err)
	}

	memo := solana.NewInstruction(
		solana.MustPublicKeyFromBase58("MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr"),
		solana.AccountMetaSlice{solana.Meta(payer.PublicKey()).WRITE().SIGNER()},
		[]byte(fmt.Sprintf("apex go %d", time.Now().UnixNano())),
	)
	tx, err := solana.NewTransaction([]solana.Instruction{
		computebudget.NewSetComputeUnitLimitInstruction(100_000).Build(),
		computebudget.NewSetComputeUnitPriceInstruction(10_000).Build(),
		memo,
		apex.TipInstruction(payer.PublicKey(), tipAccount, apex.MinTipLamports),
	}, blockhash, solana.TransactionPayer(payer.PublicKey()))
	if err != nil {
		log.Fatal(err)
	}
	if _, err := tx.Sign(func(solana.PublicKey) *solana.PrivateKey { return &payer }); err != nil {
		log.Fatal(err)
	}

	sentAt := time.Now()
	signature, err := client.SendTransaction(ctx, tx)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("sent in %d us\n", time.Since(sentAt).Microseconds())

	slot, landed, err := solanaRPC.Confirm(ctx, signature.String(), 30*time.Second)
	switch {
	case err != nil:
		log.Fatal(err)
	case landed:
		fmt.Printf("landed in slot %d after %d ms: %s\n", slot, time.Since(sentAt).Milliseconds(), signature)
	default:
		fmt.Printf("not confirmed within 30 s: %s\n", signature)
	}
}
```

```bash theme={null}
go mod init apexdemo
go get github.com/orbitflare/orbitflare-apex-go@latest github.com/gagliardetto/solana-go@v1.24.0
APEX_API_KEY=... KEYPAIR_PATH=payer.json go run .
```

`SendTransaction` returns as soon as the transaction is written to the stream, in microseconds, with no acknowledgement. The snippets below drop into this program: they reuse its `ctx`, `client`, `apiKey`, `payer`, `tipAccount`, `blockhash`, `solanaRPC`, and `tx`. Add the standard library imports a snippet uses, such as `errors`, `encoding/base64`, or `sync`.

## Send With an Answer

The bidirectional stream reads Apex's answer before returning: the signature when the transaction is accepted, or a `*apex.RejectedError` with the [admission code](/apex/errors-and-rate-limits#quic-admission-codes) and message. It costs one extra round trip, so use it while integrating, or when you need the reason inline.

```go theme={null}
signature, err := client.SendTransactionWithResponse(ctx, tx)
var rejected *apex.RejectedError
switch {
case errors.As(err, &rejected):
	fmt.Printf("rejected: %s: %s\n", rejected.Code, rejected.Message)
case err != nil:
	log.Fatal(err)
default:
	fmt.Println("accepted:", signature)
}
```

`SendWithResponse(ctx, wire)` does the same with bytes you already hold and returns the raw `apex.Admission` (`Accepted`, `Signature`, `Code`, `Message`).

## v1 Transactions

v1 transactions go up to 4096 bytes. The compute budget lives in the message's `solana.TransactionConfig` instead of ComputeBudget instructions, and every limit left unset is 0, so set the compute unit limit, the loaded accounts data size limit, and, for priority, the fee. See [Transaction v1](/apex/transaction-v1).

```go theme={null}
config := solana.TransactionConfig{}.
	WithComputeUnitLimit(100_000).
	WithLoadedAccountsDataSizeLimit(1024 * 1024).
	WithPriorityFee(4_000)

tx, err := solana.NewTransaction([]solana.Instruction{
	yourInstruction,
	apex.TipInstruction(payer.PublicKey(), tipAccount, apex.MinTipLamports),
}, blockhash,
	solana.TransactionPayer(payer.PublicKey()),
	solana.TransactionV1Config(config),
)
if err != nil {
	log.Fatal(err)
}
if _, err := tx.Sign(func(solana.PublicKey) *solana.PrivateKey { return &payer }); err != nil {
	log.Fatal(err)
}
signature, err := client.SendTransaction(ctx, tx)
if err != nil {
	log.Fatal(err)
}
fmt.Println("sent v1:", signature)
```

## Raw Bytes

If your transaction is already serialized (from another signer, another process, or a file), send the bytes as they are. Nothing re-encodes them.

```go theme={null}
wire, err := base64.StdEncoding.DecodeString(signedBase64)
if err != nil {
	log.Fatal(err)
}
if err := client.SendTransactionBytes(ctx, wire); err != nil {
	log.Fatal(err)
}
```

`apex.EncodePacket(wire, mevProtect, maxRetries)` returns the exact packet the client writes to the stream: an 8-byte length, the transaction, and a 3-byte trailer. It is the reference for other languages.

## HTTP

The `rpc` package covers every HTTP route on the Apex endpoint. It sends the key as the `x-api-key` header.

```go theme={null}
apexRPC := rpc.New(apex.Frankfurt, apiKey)

if err := apexRPC.Ping(ctx); err != nil {
	log.Fatal(err)
}

wire, err := apex.SerializeTransaction(tx)
if err != nil {
	log.Fatal(err)
}

signature, err := apexRPC.SendTransaction(ctx, wire, false, nil)
if err != nil {
	log.Fatal(err)
}
fmt.Println("JSON-RPC:", signature)

signature, err = apexRPC.SendTransactionBinary(ctx, wire, false, apex.RetryBudget(20))
if err != nil {
	var rpcErr *rpc.Error
	if errors.As(err, &rpcErr) {
		fmt.Println("rejected:", rpcErr.Message)
	}
	log.Fatal(err)
}
fmt.Println("send-bin:", signature)
```

`SendTransaction` is JSON-RPC `sendTransaction`, a drop-in for existing code. `SendTransactionBinary` posts the raw bytes to `/send-bin`, the cheapest HTTP path. The last two arguments are `mevProtect` and `maxRetries`; `nil` leaves the endpoint's default retry budget.

### Batches

`SendBatch` sends up to 16 transactions in one request. Each one is admitted on its own, and the result says which were.

```go theme={null}
wires := make([][]byte, 0, len(txs))
for _, tx := range txs {
	wire, err := apex.SerializeTransaction(tx)
	if err != nil {
		log.Fatal(err)
	}
	wires = append(wires, wire)
}

result, err := apexRPC.SendBatch(ctx, wires, false, nil)
if err != nil {
	log.Fatal(err)
}
fmt.Printf("%d accepted, %d rejected\n", result.Accepted, result.Rejected)
for i, item := range result.Results {
	if item.Accepted {
		fmt.Println(i, "accepted:", item.Signature)
	} else {
		fmt.Println(i, "rejected:", item.Error, item.Message)
	}
}
```

### Bundles

`SendBundle` sends one to four transactions that land in order, all or nothing, with exactly one of them tipped. Poll `BundleStatuses` until the bundle lands or fails. See [Bundles](/apex/bundles).

```go theme={null}
firstWire, _ := apex.SerializeTransaction(first)
secondWire, _ := apex.SerializeTransaction(second)

accepted, err := apexRPC.SendBundle(ctx, [][]byte{firstWire, secondWire})
if err != nil {
	log.Fatal(err)
}
fmt.Println("bundle", accepted.BundleID)

for range 60 {
	statuses, err := apexRPC.BundleStatuses(ctx, []string{accepted.BundleID})
	if err != nil {
		log.Fatal(err)
	}
	if len(statuses) > 0 {
		switch statuses[0].State {
		case rpc.BundleLanded:
			fmt.Println("landed in slot", *statuses[0].LandedSlot)
			return
		case rpc.BundleFailed, rpc.BundleInvalid:
			fmt.Println("did not land:", statuses[0].State)
			return
		}
	}
	time.Sleep(500 * time.Millisecond)
}
```

## Concurrent Sends

A `*apex.Client` is safe for concurrent use, and every send gets its own stream on the one connection. Create the client once and send from as many goroutines as you like.

```go theme={null}
var wg sync.WaitGroup
for _, tx := range txs {
	wg.Add(1)
	go func() {
		defer wg.Done()
		signature, err := client.SendTransaction(ctx, tx)
		if err != nil {
			log.Println("send failed:", err)
			return
		}
		log.Println("sent", signature)
	}()
}
wg.Wait()
```

## Regions

Each Apex endpoint is an `apex.Region` constant. `apex.ParseRegion` accepts a code or a city name, and `apex.AllRegions` lists them.

| Constant | Code |
| - | - |
| `apex.Frankfurt` | `fra` |
| `apex.Amsterdam` | `ams` |
| `apex.Dublin` | `dub` |
| `apex.London` | `lon` |
| `apex.NewYork` | `nyc` |
| `apex.SaltLakeCity` | `slc` |
| `apex.Singapore` | `sgp` |
| `apex.Tokyo` | `tyo` |
| `apex.Siauliai` | `sqq` |
| `apex.Global` | `global` |

```go theme={null}
region, ok := apex.ParseRegion("tokyo")
if !ok {
	log.Fatal("unknown region")
}
fmt.Println(region.Code(), region.QUICEndpoint(), region.RPCURL())
```

`QUICEndpoint()` returns `<code>.apex.orbitflare.com:7001` and `RPCURL()` returns `http://<code>.apex.orbitflare.com`. See [Endpoints and regions](/apex/endpoints).

## Transports

| | QUIC unidirectional | QUIC bidirectional | HTTP binary | JSON-RPC |
| - | - | - | - | - |
| **Call** | `SendTransaction`, `SendTransactionBytes` | `SendTransactionWithResponse`, `SendWithResponse` | `rpc.Client.SendTransactionBinary`, `SendBatch` | `rpc.Client.SendTransaction` |
| **Returns** | The signature. Nothing is read back | Accepted, or a rejection code and message | The signature, or an error with a label | The signature, or a JSON-RPC error |
| **Cost per send** | One stream on a warm connection | One stream plus one round trip | An HTTP request with raw bytes | An HTTP request with base64 in JSON |
| **Best for** | Bots on a persistent connection | Integration, debugging, tools that want the reason inline | Batches | Drop-in for `sendTransaction` code |

The transport does not change priority or routing. The tip does.

## API

| Item | Description |
| - | - |
| `apex.Connect(ctx, region, apiKey)` | Connect with an ephemeral local port and the default options |
| `apex.ConnectWithOptions(ctx, opts, apiKey)` | Connect with [`apex.Options`](#options) |
| `SendTransaction(ctx, tx)` | Serialize (legacy, v0, or v1) and send on a unidirectional stream. Returns the first signature |
| `SendTransactionBytes(ctx, wire)` | The same with bytes you already hold. Nothing re-encodes them |
| `SendTransactionWithResponse(ctx, tx)` | Bidirectional stream. A `*apex.RejectedError` on rejection |
| `SendWithResponse(ctx, wire)` | The same with bytes. Returns the raw `apex.Admission` |
| `Health()`, `ReconnectsTotal()`, `ZeroRTTResumptionsTotal()`, `RemoteAddr()` | Connection state, for your metrics |
| `Reconnect(ctx)`, `Close()` | Lifecycle |
| `apex.TipInstruction(payer, tipAccount, lamports)` | Builds the tip transfer |
| `apex.PickTipAccount(accounts)` | Picks one of the tip accounts at random |
| `apex.MinTipLamports` | The standard tier floor: 1,000,000 lamports |
| `rpc.Client` | The Apex endpoint over HTTP: `GetTipAccounts`, `SendTransaction` (JSON-RPC), `SendTransactionBinary`, `SendBatch`, `SendBundle`, `BundleStatuses`, `Ping` |
| `rpc.SolanaRPC` | Any Solana RPC: `LatestBlockhash`, `Confirm(ctx, signature, timeout)` |
| `rpc.FetchVaults(ctx, solanaRPCURL)` | Reads the tip program's vault accounts from chain (`rpc.TipProgramID`). This is not the tip list: it includes unpublished vaults the endpoint rejects. Use `GetTipAccounts` for accounts to tip, and this only to check that a given account is a real vault |
| `apex.ClientPubkey(apiKey)` | The certificate key your API key derives to, as shown on your dashboard |
| `apex.SerializeTransaction(tx)` | The canonical wire bytes, for legacy, v0, and v1 |
| `apex.EncodePacket`, `apex.FrameParts`, `apex.DecodeAdmission` | The QUIC wire format, as a reference for other languages |
| `apex.RetryBudget(n)` | A `MaxRetries` value for the options and the HTTP calls |

Every call that touches the network takes a `context.Context`, so you can cancel a send or give it a deadline. Call `Close` when you are done; it stops the background reconnect and closes the socket.

## Options

The zero value of every field gives you the defaults, so set only what you need:

| Field | Default | Description |
| - | - | - |
| `Endpoint` | Frankfurt's QUIC address | `host:port` of the QUIC address |
| `MEVProtect` | `false` | Skip Shield-blocklisted leaders. See [MEV protection](/apex/mev-protection) |
| `MaxRetries` | `nil` | Retry budget per transaction, as `apex.RetryBudget(n)`. `nil` uses the endpoint's default |
| `BindAddr` | Ephemeral port | Local UDP bind address, for firewall allowlists |
| `ConnectTimeout` | 3 s | Handshake timeout |
| `SendTimeout` | 2 s | Timeout for one send |
| `KeepAlive` | 1 s | QUIC PING interval. The endpoint's idle timeout is 30 s |
| `DisableAutoReconnect` | `false` | When `false`, a send that fails because the connection is gone reconnects and sends once more |
| `DisableProactiveReconnect` | `false` | When `false`, a background goroutine re-handshakes as soon as a drop is noticed, so the next send does not pay for it |

```go theme={null}
client, err := apex.ConnectWithOptions(ctx, apex.Options{
	Endpoint:       apex.NewYork.QUICEndpoint(),
	BindAddr:       "0.0.0.0:47001",
	MEVProtect:     true,
	MaxRetries:     apex.RetryBudget(30),
	ConnectTimeout: 3 * time.Second,
	SendTimeout:    time.Second,
	KeepAlive:      time.Second,
}, apiKey)
if err != nil {
	log.Fatal(err)
}
defer client.Close()
```

## Keep-Alive and Reconnects

The connection stays open with a QUIC PING every second, against an idle timeout of 30 seconds on the Apex endpoint. On a warm connection a send is one stream open and one write.

If the connection drops:

* A background goroutine notices and re-handshakes right away, so the next send usually finds a live connection. If the endpoint itself closed the connection (an unknown key, too many connections), the goroutine doubles its wait after each attempt, up to 30 seconds, instead of retrying hard.
* A reconnect uses a cached session ticket and sends the waiting transaction in the handshake's first flight (**0-RTT**). If the endpoint declines the early data, the client resends it after the handshake.
* A send that fails because the connection is gone reconnects and retries once. Set `DisableAutoReconnect` to handle that yourself.
* Each reconnect looks the host name up again, so a client on `apex.Global` follows the load balancer to the next nearest endpoint when its own one goes down, without a restart. The lookup happens in that background reconnect, not on a send, and if it fails or takes more than two seconds the client keeps the address it had. An IP address endpoint is never looked up.

Export the connection state to your metrics:

```go theme={null}
go func() {
	for range time.Tick(10 * time.Second) {
		log.Printf("apex health=%s reconnects=%d zero_rtt=%d remote=%s",
			client.Health(), client.ReconnectsTotal(), client.ZeroRTTResumptionsTotal(), client.RemoteAddr())
	}
}()
```

## Errors

Errors wrap a sentinel, so `errors.Is` tells you the stage that failed, and `errors.As` gives you the typed ones.

| Error | Meaning |
| - | - |
| `*apex.RejectedError` | The endpoint rejected the transaction on a bidirectional stream. `Code` and `Message` are the [admission code](/apex/errors-and-rate-limits#quic-admission-codes) and its reason |
| `*apex.TooLargeError` | Over 4096 bytes. Caught locally before anything is sent |
| `apex.ErrTimeout` | The connect or send timeout passed |
| `apex.ErrResolve`, `ErrBind`, `ErrTLS`, `ErrConnect`, `ErrConnection`, `ErrWrite`, `ErrRead` | Transport failures |
| `apex.ErrSerialize`, `ErrBadAdmission`, `ErrNoSignature` | Encoding problems |
| `apex.ErrClosed` | A stream could not be finished |
| `apex.ErrClientStopped` | A send after `Close` |

```go theme={null}
_, err := client.SendTransaction(ctx, tx)
var tooLarge *apex.TooLargeError
switch {
case err == nil:
case errors.As(err, &tooLarge):
	fmt.Println("transaction is", tooLarge.Size, "bytes")
case errors.Is(err, apex.ErrTimeout):
	fmt.Println("timed out, the transaction may still have been sent")
case errors.Is(err, apex.ErrConnect), errors.Is(err, apex.ErrConnection):
	fmt.Println("connection problem:", err)
default:
	fmt.Println(err)
}
```

The `rpc` package returns `*rpc.Error` with a code and message for a JSON-RPC or plain HTTP rejection, and `rpc.ErrBadResponse` for a response it cannot read. A plain HTTP rejection has code 0 and the message `label: message`, with the labels listed in [Errors and rate limits](/apex/errors-and-rate-limits).

## Check Your Key

Print the certificate key your API key derives to. It must match the client pubkey shown next to the key on your dashboard; if it does not, the key was copied wrong.

```go theme={null}
fmt.Println(apex.ClientPubkey(apiKey))
```

## Examples in the Repository

All examples read `APEX_API_KEY`, `KEYPAIR_PATH` (default `payer.json`), `SOLANA_RPC_URL`, and optionally `APEX_REGION`, `APEX_QUIC`, `APEX_RPC`, `TIP_LAMPORTS`, `APEX_TX_VERSION` (`legacy` or `v1`), `APEX_MEMO_BYTES`, and `APEX_CU_LIMIT`. Each sends a tipped memo and reports the slot it landed in.

| Example | Shows |
| - | - |
| `quic_send` | Unidirectional stream: the fastest path, then confirmation from a Solana RPC |
| `quic_send_with_response` | Bidirectional stream: the accepted or rejected answer and how to read a rejection |
| `rpc_send` | JSON-RPC `sendTransaction` over HTTP with the same tip rule |
| `http_binary` | `/ping`, `/send-bin` with raw bytes, and a `/send-batch` of three |
| `raw_bytes` | Pre-serialized bytes on the wire, and the exact packet layout |
| `throughput` | One warm connection, N concurrent sends, per-send p50 and p99, landing count |
| `bundle` | An atomic bundle of two transactions, one tipped, and its status until it lands |
| `transfer` | A tipped SOL transfer to `TO` of `LAMPORTS`, legacy or v1, with the admission answer |
| `client_pubkey` | The certificate key an API key derives to, to compare with your dashboard |

```bash theme={null}
git clone https://github.com/orbitflare/orbitflare-apex-go.git
cd orbitflare-apex-go
APEX_API_KEY=... KEYPAIR_PATH=payer.json SOLANA_RPC_URL=https://... \
  go run ./examples/quic_send
```

## What the Client Does Not Do

* Build or sign transactions, or choose your priority fee.
* Simulate or run preflight checks. Nothing between you and the leader does.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.