Arca/Documentation
Join Waitlist

Go SDK

The github.com/arcaresearch/arca-go-sdk module is an idiomatic Go client for the Arca platform — ideal for building your backend in Go. Every method takes a context.Context, errors are typed (usable with errors.As), mutations return generic operation handles, and real-time data is delivered via channel/callback watch streams. Requires Go 1.23+.

Installation

bash
go get github.com/arcaresearch/arca-go-sdk@latest
go
import arca "github.com/arcaresearch/arca-go-sdk"

It is a public module, so no GOPRIVATE or auth is needed. Pin a specific release with @vX.Y.Z (e.g. @v0.1.1).

Initialization

Create a client with an API key (server-side), a scoped JWT, or an auto-refreshing token provider, then call Ready to resolve the realm slug to its internal id (it also resolves lazily on the first call).

go
ctx := context.Background()
// API key (backend)
client, err := arca.New(arca.Config{APIKey: "arca_...", Realm: "my-realm"})
if err != nil {
log.Fatal(err)
}
if err := client.Ready(ctx); err != nil {
log.Fatal(err)
}
// Scoped JWT (realm read from the token claims)
client, _ = arca.FromToken(jwt, arca.Config{})
// Auto-refreshing token provider
client, _ = arca.FromTokenProvider(func(ctx context.Context) (string, error) {
return fetchArcaTokenFromMyBackend(ctx)
}, arca.Config{Realm: "my-realm"})

End-to-end example

go
alice := "/users/alice/wallet"
bob := "/users/bob/wallet"
// Create two wallets (Wait blocks until the create operation settles).
if _, err := client.EnsureDenominatedArca(ctx, arca.EnsureDenominatedArcaOptions{Ref: alice}).Wait(ctx); err != nil {
log.Fatal(err)
}
if _, err := client.EnsureDenominatedArca(ctx, arca.EnsureDenominatedArcaOptions{Ref: bob}).Wait(ctx); err != nil {
log.Fatal(err)
}
// Fund Alice (dev/test only — use CreatePaymentLink in production).
if _, err := client.FundAccount(ctx, arca.FundAccountOptions{ArcaRef: alice, Amount: "1000"}).Wait(ctx); err != nil {
log.Fatal(err)
}
// Transfer $50 — the nonce path is the idempotency key.
nonce, _ := client.Nonce(ctx, "/op/transfer/alice-to-bob/001")
if _, err := client.Transfer(ctx, arca.TransferOptions{
Path: nonce.Path, From: alice, To: bob, Amount: "50",
}).Wait(ctx); err != nil {
log.Fatal(err)
}
balances, _ := client.GetBalancesByPath(ctx, bob)
fmt.Println("Bob settled:", balances[0].Settled) // 50

Conventions. Money/amount values are decimal strings ("50", "0.01"). Coin ids are canonical {exchange}:{id} ("hl:0:BTC", "hl:1:TSLA"), case-sensitive. Market-data timestamps are Unix epoch milliseconds; all others are RFC3339 UTC strings.

Authentication

Three credential modes, mirroring the TypeScript SDK:

ModeConstructorUse
API keyarca.New(Config{APIKey, Realm})Server-side backends
Scoped JWTarca.FromToken(jwt, Config{})Per-user token; realm read from claims
Token providerarca.FromTokenProvider(fn, Config{Realm})Auto-refresh before expiry & on 401

A token provider is called on first use, ~30s before expiry, and on HTTP 401 (then the failed request is retried once). Concurrent refreshes are deduplicated.

Step-up auth

Destructive actions on production realms return HTTP 412 STEP_UP_REQUIRED. Register a handler to obtain a single-use step-up token; the SDK retries the original request once with it and never persists it.

go
client, _ := arca.New(arca.Config{
APIKey: "arca_...", Realm: "prod",
StepUpHandler: func(ctx context.Context, ch arca.StepUpChallenge) (string, error) {
return confirmInBrowser(ctx, ch.Action, ch.Resources) // returns the step-up JWT
},
})

Auth-error listener

go
unsub := client.OnAuthError(func(err error) {
showSessionExpiredUI()
})
defer unsub()

Arca Objects

Objects are accounts/wallets addressed by path. Creation is idempotent — safe to call on every request.

go
// Create a user wallet (denominated object)
wallet, _ := client.EnsureDenominatedArca(ctx, arca.EnsureDenominatedArcaOptions{
Ref: "/users/alice/wallet",
Labels: map[string]string{"tier": "gold"},
}).Wait(ctx)
// Any object type
client.EnsureArca(ctx, arca.EnsureArcaOptions{Ref: "/escrow/x", Type: arca.ObjectEscrow})
// Read
obj, _ := client.GetObject(ctx, "/users/alice/wallet")
detail, _ := client.GetObjectDetail(ctx, obj.ID)
list, _ := client.ListObjects(ctx, &arca.ListObjectsOptions{Path: "/users/"})
browse, _ := client.BrowseObjects(ctx, &arca.BrowseObjectsOptions{Path: "/users/"})
// Balances
bals, _ := client.GetBalances(ctx, obj.ID)
bals, _ = client.GetBalancesByPath(ctx, "/users/alice/wallet")
// Labels + delete
client.UpdateLabels(ctx, arca.UpdateLabelsOptions{ObjectID: obj.ID, Labels: map[string]*string{"tier": nil}})
client.EnsureDeleted(ctx, arca.EnsureDeletedOptions{Ref: "/users/alice/wallet", SweepTo: "/treasury"}).Wait(ctx)

Transfers & Deposits

The operation Path is the idempotency key — generate it once with Nonce and reuse it on retries.

go
nonce, _ := client.Nonce(ctx, "/op/transfer/payroll/001")
resp, err := client.Transfer(ctx, arca.TransferOptions{
Path: nonce.Path,
From: "/users/alice/wallet",
To: "/users/bob/wallet",
Amount: "50",
}).Wait(ctx)
// Production deposits/withdrawals: hosted payment links
link, _ := client.CreatePaymentLink(ctx, arca.CreatePaymentLinkOptions{
Type: "deposit", ArcaRef: "/users/alice/wallet", Amount: "100",
ReturnURL: "https://myapp.com/done",
}).Wait(ctx)
// redirect the user to link.PaymentLink.URL
// Dev/test only
client.FundAccount(ctx, arca.FundAccountOptions{ArcaRef: "/users/alice/wallet", Amount: "1000"}).Wait(ctx)
client.DefundAccount(ctx, arca.DefundAccountOptions{ArcaPath: "/users/alice/wallet", Amount: "10"}).Wait(ctx)
fee, _ := client.EstimateFee(ctx, arca.FeeEstimateParams{Action: "transfer", Amount: "50", SourcePath: "/a", TargetPath: "/b"})

Operations & Events

Every state change is an operation with a lifecycle. Read them, the immutable event log, and balance deltas.

go
op, _ := client.GetOperation(ctx, "op_123", &arca.GetOperationOptions{IncludeEvidence: true})
ops, _ := client.ListOperations(ctx, &arca.ListOperationsOptions{Type: arca.OpTransfer, Path: "/users/"})
events, _ := client.ListEvents(ctx, &arca.ListEventsOptions{ArcaPath: "/users/alice/wallet"})
deltas, _ := client.ListDeltas(ctx, "/users/alice/wallet")
summary, _ := client.Summary(ctx)
// Block until a specific operation reaches a terminal state.
final, err := client.WaitForOperation(ctx, "op_123", 30*time.Second)

Exchange (Perps)

Create an exchange object, then place orders. PlaceOrder and ClosePosition return an *OrderHandle.

go
ex, _ := client.EnsurePerpsExchange(ctx, arca.CreatePerpsExchangeOptions{Ref: "/traders/t1/exchange"}).Wait(ctx)
nonce, _ := client.Nonce(ctx, "/op/order/btc")
order := client.PlaceOrder(ctx, arca.PlaceOrderOptions{
Path: nonce.Path, ObjectID: ex.Object.ID,
Coin: "hl:0:BTC", Side: arca.Buy, OrderType: "MARKET", Size: "0.01",
})
if _, err := order.Wait(ctx); err != nil { /* placement failed */ }
fill, _ := order.Filled(ctx)
state, _ := client.GetExchangeState(ctx, ex.Object.ID)
positions, _ := client.ListPositions(ctx, ex.Object.ID)
client.UpdateLeverage(ctx, arca.UpdateLeverageOptions{ObjectID: ex.Object.ID, Coin: "hl:0:BTC", Leverage: 5})
// Margin mode: switch an asset between cross and isolated (close any open
// position first; rejected on isolated-only HIP-3 markets).
client.SetMarginMode(ctx, arca.SetMarginModeOptions{ObjectID: ex.Object.ID, Coin: "hl:0:BTC", MarginMode: arca.MarginModeIsolated})
// Isolated margin: add (+) or remove (-) collateral from an isolated position.
client.UpdateIsolatedMargin(ctx, arca.UpdateIsolatedMarginOptions{ObjectID: ex.Object.ID, Coin: "hl:0:BTC", Amount: "25"})
client.ClosePosition(ctx, arca.ClosePositionOptions{Path: "/op/close/btc", ObjectID: ex.Object.ID, Coin: "hl:0:BTC"})

Position TP/SL (ergonomic)

SetStopLoss / SetTakeProfit attach a trigger to an open position: they infer the closing side, by default place a reduce-only unsized (SizeToMax: true) order (when it fires it closes the entire live position regardless of size, and it is cancelled when the position closes), and auto-fill leverage + isolated. Set Size (base units) on SetPositionTriggerOptions to scale out a fixed quantity instead — the leg becomes a sized reduce-only partial (capped at the live position; min-notional waived). SetPositionTpsl brackets both legs at once and links them as a true one-cancels-the-other pair: it stamps both legs with one opaque OcoGroupID, so when either leg fills (even partially) the venue cancels the sibling with CancelReason = "sibling_filled" (readable on the cancelled order via GetOrder/ListOrders). Pass an explicit OcoGroupID to reuse a known group. Use StopLossSz / TakeProfitSz for sized partial legs; because a partial fill cancels its OCO sibling, SetPositionTpsl does not auto-link the legs when either is sized (so a half-size take-profit won't cancel the stop on the remainder). ClearPositionTpsl cancels the resting position triggers. SetStopLoss/SetTakeProfit return an *OrderHandle and error with *arca.NotFoundError when there is no open position.

go
// Stop-loss on a long BTC position (reduce-only SELL trigger at 54k)
sl := client.SetStopLoss(ctx, arca.SetPositionTriggerOptions{
Path: "/op/tpsl/btc-sl", ObjectID: ex.Object.ID, Coin: "hl:0:BTC", TriggerPx: "54000",
})
if _, err := sl.Submitted(ctx); err != nil { /* no position, etc. */ }
// Bracket both legs at once (derives /op/tpsl/btc/sl and /op/tpsl/btc/tp)
res, _ := client.SetPositionTpsl(ctx, arca.SetPositionTpslOptions{
Path: "/op/tpsl/btc", ObjectID: ex.Object.ID, Coin: "hl:0:BTC",
StopLossPx: "54000", TakeProfitPx: "72000",
})
_ = res.StopLoss // *OrderHandle; res.TakeProfit likewise
// Clear resting position triggers (pass Tpsl: "sl"/"tp" to filter)
cleared, _ := client.ClearPositionTpsl(ctx, arca.ClearPositionTpslOptions{
Path: "/op/tpsl/btc-clear", ObjectID: ex.Object.ID, Coin: "hl:0:BTC",
})
_ = cleared // []SimOrder that were cancelled

Open with bracket (atomic entry + TP/SL)

OpenWithBracket opens a position and attaches its reduce-only TP/SL triggers in one atomic batch (Hyperliquid normalTpsl parity). Unlike chaining PlaceOrder then SetPositionTpsl, the entry and its triggers are submitted as a single signed batch to one operation: the whole bracket validates and commits at the venue, or none of it does. The trigger legs arm only when the entry fills (they can't fire on mark price before the position exists), and the venue links them with a shared one-cancels-the-other group. At least one of TakeProfitPx / StopLossPx is required. Each trigger leg closes the whole position by default; set TakeProfitSz / StopLossSz (base units) to make it a sized reduce-only partial (Size is the entry size, not the trigger size). The result holds one *OrderHandle per leg (Entry, TakeProfit, StopLoss), all backed by the single bracket operation. Because the legs share one server-stamped OCO group, a partial take-profit fill cancels the stop on the remainder — to “scale out half and keep a stop on the rest”, open the bracket with only the sized TakeProfitSz and attach the (unsized) stop separately via SetStopLoss. Use SetPositionTpsl instead to attach TP/SL to an already-open position.

go
res, err := client.OpenWithBracket(ctx, arca.OpenBracketOptions{
Path: "/op/bracket/btc-1", ObjectID: ex.Object.ID, Market: "hl:0:BTC",
Side: arca.Buy, Size: "0.01",
TakeProfitPx: "72000", StopLossPx: "58000",
})
if err != nil { /* validation / placement error */ }
if _, err := res.Entry.Wait(ctx); err != nil { /* ... */ }
filled, _ := res.Entry.Filled(ctx) // entry fully fills
_ = res.TakeProfit // *OrderHandle (nil if TakeProfitPx empty); res.StopLoss likewise
_ = filled
// Scale out half at the target, then protect the remainder with an unsized
// stop attached separately (the bracket OCO would otherwise cancel a shared-
// group stop on the partial TP fill):
so, _ := client.OpenWithBracket(ctx, arca.OpenBracketOptions{
Path: "/op/bracket/btc-scaleout", ObjectID: ex.Object.ID, Market: "hl:0:BTC",
Side: arca.Buy, Size: "0.02",
TakeProfitPx: "72000", TakeProfitSz: "0.01", // sized: close half at the target
})
if _, err := so.Entry.Filled(ctx); err == nil {
client.SetStopLoss(ctx, arca.SetPositionTriggerOptions{
Path: "/op/bracket/btc-scaleout/sl", ObjectID: ex.Object.ID,
Coin: "hl:0:BTC", TriggerPx: "58000", // unsized: protects the remainder
})
}

Order breakdown (pure, no network)

go
bd := arca.ComputeOrderBreakdown(arca.OrderBreakdownOptions{
Amount: "200", AmountType: "spend", Leverage: 10,
FeeRate: "0.00045", Price: "65000", Side: arca.Buy, SzDecimals: 5,
})
fmt.Println(bd.Tokens, bd.MarginRequired, bd.EstimatedFee, bd.EstimatedLiquidationPrice)

Market data & TWAP

go
meta, _ := client.GetMarketMeta(ctx)
mids, _ := client.GetMarketMids(ctx)
book, _ := client.GetOrderBook(ctx, "hl:0:BTC")
candles, _ := client.GetCandles(ctx, "hl:0:BTC", arca.Interval1h, nil)
client.PlaceTwap(ctx, arca.PlaceTwapOptions{
Path: nonce.Path, ExchangeID: ex.Object.ID, Coin: "hl:0:BTC",
Side: arca.Buy, TotalSize: "1.0", DurationMinutes: 120,
}).Wait(ctx)

Real-time Streaming

Watch streams expose OnUpdate(cb) func(), an Updates() <-chan T channel, Ready(ctx), State(), and Close(). Open once and read on demand or react to every change.

go
// Prices — snapshot is pre-loaded; read any time.
prices, _ := client.WatchPrices(ctx, &arca.WatchPricesOptions{Coins: []string{"hl:0:BTC"}})
defer prices.Close()
px, ok := prices.Get("hl:0:BTC")
prices.OnUpdate(func(m map[string]string) { /* tick */ })
// Balances under a prefix
balStream, _ := client.WatchBalances(ctx, "/users")
defer balStream.Close()
for u := range balStream.Updates() {
fmt.Println("balance changed:", u.EntityPath)
}

Also available: WatchOperations, WatchObject, WatchObjects, WatchAggregation, WatchExchangeState, WatchFills, WatchFunding, WatchCandles, WatchTrades, and WatchTwap. Lower-level access is on client.WS().

Operation & Order Handles

Mutations return a handle immediately; the HTTP request runs in the background.

  • Submitted(ctx) — the HTTP response, before settlement.
  • Wait(ctx) — blocks until the operation reaches a terminal state.
  • WaitTimeout(ctx, d) — Wait with an explicit settlement timeout.
  • Predicted — synchronous optimistic effect (no network).
go
h := client.Transfer(ctx, opts)
submitted, _ := h.Submitted(ctx) // acknowledged, not yet settled
final, err := h.Wait(ctx) // settled (or *OperationFailedError / *OperationStalledError)

*OrderHandle embeds the operation handle and adds the fill lifecycle:

go
order := client.PlaceOrder(ctx, opts)
order.Wait(ctx) // order placed
result, _ := order.Filled(ctx) // wait for terminal fill state
unsub := order.OnFill(ctx, func(f arca.SimFill) { /* each fill */ })
summary, _ := order.FillSummary(ctx) // platform-side P&L / fee breakdown
cancel, _ := order.Cancel(ctx, "") // auto-derives the cancel path
// Resize a sized order (limit / sized TP/SL). Unsized triggers are rejected.
resize, _ := order.Resize(ctx, "0.75", "") // auto-derives a per-resize path

Error Handling

Errors are typed and work with errors.As. The base type *arca.ArcaError carries Code, Message, and an optional ErrorID for support correlation.

go
_, err := client.GetObject(ctx, "/nope")
var notFound *arca.NotFoundError
if errors.As(err, &notFound) { /* 404 */ }
var ae *arca.ArcaError
if errors.As(err, &ae) && ae.Code == "IDEMPOTENCY_VIOLATION" { /* same path, different inputs */ }
// Awaited operations
var failed *arca.OperationFailedError
if errors.As(err, &failed) {
fmt.Println("failed:", failed.Operation.FailureMessage)
}
TypeWhen
*ValidationErrorInvalid request (400)
*UnauthorizedErrorMissing/invalid auth (401)
*NotFoundErrorResource not found (404)
*ConflictErrorIdempotency conflict (409)
*ExchangeErrorExchange rejected the operation
*OperationFailedErrorAwaited operation reached a failed state
*StepUpRequiredError412 with no step-up handler wired

License

The Go SDK is licensed under the PolyForm Shield License 1.0.0 — source-available and free to use to build on Arca, but it may not be used to build a product that competes with Arca. The same applies to the TypeScript, React, and Swift SDKs.