Skip to content
sfreibergPublic

About

An independent Go SDK for the Webull OpenAPI

Topics

Resources

Contributing

Security policy

Stars

2 stars

Watchers

0 watching

Forks

Latest commit

 

History

31 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

webull

CI codecov Go Reference License: MIT

An independent, open-source Go SDK for the Webull OpenAPI.

Status: pre-1.0. Every endpoint the core product documents is implemented: trading (accounts, instrument data, orders across every asset class), the Market Data HTTP API (quotes, fundamentals, funds, screeners, watchlists), MQTT market-data streaming, real-time trade events over gRPC, access tokens, and the Connect API — verified against Webull's sandbox except where the compatibility matrix says otherwise. The exceptions, with reasons, live in that matrix: news is blocked, and corporate actions turn out to belong to a separate Webull product that is excluded as a whole. The public API may change without notice until v1.0.0.

Disclaimer

This is an independent open-source project. It is not affiliated with, maintained by, authorized by, or endorsed by Webull. "Webull" and related marks belong to their respective owners.

Trading involves risk. This software is provided without warranty of any kind. You are responsible for any orders it places on your behalf.

Documentation

  • API reference on pkg.go.dev — every exported type and method, with the behaviour verified against Webull's sandbox recorded on the items it affects
  • API compatibility matrix — what is implemented, what is planned, and what is out of scope
  • Discovery findings — inventory of the Webull OpenAPI surface, authentication, streaming and wire format

Contributing

Contributions are welcome — see CONTRIBUTING.md for build, test and code standards, and SECURITY.md for reporting vulnerabilities.

Installation

go get github.com/sfreiberg/webull

Go 1.26 or later.

Authentication

Every request is signed with an app key and secret issued by the Webull developer portal. The SDK handles the signature (HMAC-SHA256 over a canonical form of the request); you supply the credentials and choose an environment:

client, err := webull.NewClient(webull.Config{
    AppKey:      os.Getenv("WEBULL_APP_KEY"),
    AppSecret:   os.Getenv("WEBULL_APP_SECRET"),
    Environment: webull.Sandbox, // or webull.Production
})
if err != nil {
    log.Fatal(err)
}

Keep credentials out of source: read them from the environment or a secret store. The SDK never logs them or includes them in error messages.

Sandbox and production keys are separate. A sandbox key returns 404 Route Not Found for every production host and vice versa — the most common cause of a request that "should work" failing. There is no default environment: choosing between simulated and real orders is not a decision the SDK makes for you. Orders placed against Production are real.

Some deployments additionally require a per-user access token (token_check_enabled); client.TokenCheckEnabled(ctx) reports whether yours does. The sandbox does not, so most integrations need only the app key and secret. When yours does, create a token with client.CreateAccessToken(ctx) (it starts PENDING until verified through SMS and the Webull app; tokens live 15 days and are not refreshable), check it with CheckAccessToken, and supply it through Config.AccessToken — the SDK then sends it as x-access-token on every request.

Quick start

Credentials come from the Webull developer portal. Sandbox and production keys are separate; a sandbox key returns 404 Route Not Found for every production path, which is worth knowing when a request fails.

client, err := webull.NewClient(webull.Config{
    AppKey:      os.Getenv("WEBULL_APP_KEY"),
    AppSecret:   os.Getenv("WEBULL_APP_SECRET"),
    Environment: webull.Sandbox, // or webull.Production; there is no default
})
if err != nil {
    log.Fatal(err)
}

accounts, err := client.Trade.Accounts(ctx)
if err != nil {
    log.Fatal(err)
}
acct := accounts[0]

balance, err := client.Trade.Balance(ctx, acct.AccountID)
if err != nil {
    log.Fatal(err)
}
fmt.Println(acct.AccountID, balance.TotalCashBalance)

Buying shares

order := &trade.Order{
    Symbol:     "AAPL",
    Side:       trade.Buy,
    Type:       trade.Limit,
    Quantity:   trade.Price("10"),
    LimitPrice: trade.Price("180.00"),
}

// Preview returns estimated cost and fees without placing anything.
preview, err := client.Trade.PreviewOrder(ctx, acct.AccountID, order)
if err != nil {
    log.Fatal(err)
}
fmt.Println("estimated cost", preview.EstimatedCost)

receipt, err := client.Trade.PlaceOrder(ctx, acct.AccountID, order)
if err != nil {
    var apiErr *webull.APIError
    if errors.As(err, &apiErr) {
        log.Fatalf("rejected: %s (%s)", apiErr.Message, apiErr.Code)
    }
    // A transport failure means the outcome is unknown. The SDK generated
    // order.ClientOrderID before sending, so the order can be looked up
    // rather than blindly resent.
    log.Fatalf("outcome unknown for %s: %v", order.ClientOrderID, err)
}
fmt.Println("placed", receipt.OrderID)

The SDK fills in what Webull requires but a caller should not have to know: market, combo type, entrust type, the trading session, and the client order ID. It also validates locally — a limit order without a limit price fails before any request is sent.

Buying a call

Option legs are described by underlying, strike, expiry and type. LegFromSymbol builds one from the OCC symbols that OptionContracts returns.

chain, err := client.Trade.OptionContracts(ctx, trade.OptionContractsRequest{
    UnderlyingSymbols: []string{"AAPL"},
    OptionType:        trade.Call,
    StartDate:         "2026-12-18",
    EndDate:           "2026-12-18",
})
if err != nil {
    log.Fatal(err)
}

leg, err := trade.LegFromSymbol(chain.Contracts[0].Symbol) // e.g. AAPL261218C00240000
if err != nil {
    log.Fatal(err)
}

receipt, err := client.Trade.PlaceOrder(ctx, acct.AccountID, &trade.Order{
    Symbol:         "AAPL",
    Side:           trade.Buy,
    Type:           trade.Limit,
    Quantity:       trade.Price("1"),
    LimitPrice:     trade.Price("5.50"),
    PositionIntent: trade.BuyToOpen,
    Legs:           []trade.OrderLeg{leg},
})
if err != nil {
    log.Fatal(err)
}
fmt.Println("placed", receipt.OrderID)

Attaching a take-profit and stop-loss

A bracket is one placement: the entry plus its exits, cancelled together.

combo := trade.Bracket(
    &trade.Order{Symbol: "AAPL", Side: trade.Buy, Type: trade.Limit, TimeInForce: trade.GTC, Quantity: trade.Price("1"), LimitPrice: trade.Price("180.00")},
    &trade.Order{Symbol: "AAPL", Side: trade.Sell, Type: trade.Limit, TimeInForce: trade.GTC, Quantity: trade.Price("1"), LimitPrice: trade.Price("200.00")},   // take profit
    &trade.Order{Symbol: "AAPL", Side: trade.Sell, Type: trade.StopLoss, TimeInForce: trade.GTC, Quantity: trade.Price("1"), StopPrice: trade.Price("170.00")}, // stop loss
)
receipt, err := client.Trade.PlaceCombo(ctx, acct.AccountID, combo)
if err != nil {
    log.Fatal(err)
}
fmt.Println("placed group", receipt.ComboOrderID)

// Later: CancelCombo cancels every order in the group still working; an
// unfilled master takes its exits with it, while a filled master leaves
// independent exit orders that this call still cancels.
if err := client.Trade.CancelCombo(ctx, acct.AccountID, combo); err != nil {
    log.Fatal(err)
}

A vertical spread

Multi-leg strategies are one Order with a strategy and its legs.

long, err := trade.LegFromSymbol("AAPL261218C00240000")
if err != nil {
    log.Fatal(err)
}
short, err := trade.LegFromSymbol("AAPL261218C00250000")
if err != nil {
    log.Fatal(err)
}
long.Side, short.Side = trade.Buy, trade.Sell

receipt, err := client.Trade.PlaceOrder(ctx, acct.AccountID, &trade.Order{
    Symbol:         "AAPL",
    Side:           trade.Buy,
    Type:           trade.Limit,
    Quantity:       trade.Price("1"),    // spreads
    LimitPrice:     trade.Price("3.50"), // net debit
    OptionStrategy: trade.StrategyVertical,
    Legs:           []trade.OrderLeg{long, short},
})
if err != nil {
    log.Fatal(err)
}
fmt.Println("placed", receipt.OrderID)

Other asset classes

Set InstrumentType; the rules for each asset class are checked locally.

// Crypto, sized in coins (up to eight decimal places), $2 minimum.
if _, err := client.Trade.PlaceOrder(ctx, cryptoAcct.AccountID, &trade.Order{
    InstrumentType: trade.InstrumentCrypto, Symbol: "BTCUSD",
    Side: trade.Buy, Type: trade.Limit, TimeInForce: trade.GTC,
    Quantity: trade.Price("0.001"), LimitPrice: trade.Price("60000"),
}); err != nil {
    log.Fatal(err)
}

// Event contracts are limit-only and need the outcome being bought.
if _, err := client.Trade.PlaceOrder(ctx, eventsAcct.AccountID, &trade.Order{
    InstrumentType: trade.InstrumentEvent, Symbol: "KXRATECUTCOUNT-26DEC31-T3",
    Side: trade.Buy, Type: trade.Limit,
    Quantity: trade.Price("5"), LimitPrice: trade.Price("0.10"),
    EventOutcome: trade.OutcomeYes,
}); err != nil {
    log.Fatal(err)
}

Futures, crypto and event contracts each have their own account; use the one whose AccountClass matches.

Changing a working order

Only the fields you set are changed; the rest are left as they are.

if _, err := client.Trade.ReplaceOrder(ctx, acct.AccountID, trade.OrderModification{
    ClientOrderID: order.ClientOrderID,
    LimitPrice:    trade.Price("182.00"),
}); err != nil {
    log.Fatal(err)
}

Cancelling

if _, err := client.Trade.CancelOrder(ctx, acct.AccountID, order.ClientOrderID); err != nil {
    log.Fatal(err)
}

Cancel, replace and lookup are keyed by the client order ID, not Webull's own OrderID. An Order value describes one placement: once accepted, its ID is consumed, and placing it again returns trade.ErrDuplicateOrder.

Market data

snaps, err := client.MarketData.Snapshots(ctx, marketdata.SnapshotsRequest{
    Symbols: []string{"AAPL", "SPY"}, ExtendedHours: true,
})
if err != nil {
    log.Fatal(err)
}
fmt.Println(snaps[0].Price.Decimal, snaps[0].LastTradeTime)

bars, err := client.MarketData.Bars(ctx, marketdata.BarsRequest{
    Symbols: []string{"AAPL"}, Timespan: marketdata.Daily, Count: 30,
})
if err != nil {
    log.Fatal(err)
}
fmt.Println(len(bars[0].Bars), "daily bars")

Data the key is not subscribed to fails with marketdata.ErrNotSubscribed, and the wrapped *webull.APIError names the product required.

Trade events

stream, err := client.Events.Subscribe(ctx, events.SubscribeRequest{
    AccountIDs: []string{acct.AccountID},
})
if err != nil {
    log.Fatal(err)
}
defer func() { _ = stream.Close() }()
for {
    ev, err := stream.Recv()
    if err != nil {
        break
    }
    if ev.Kind == events.KindOrder && ev.Order != nil {
        fmt.Println(ev.Order.Scene, ev.Order.Symbol, ev.Order.FilledQuantity)
    }
}

Subscribe blocks until the server acknowledges, so a returned stream is live. Recv swallows heartbeats and reconnects on transient failures; authentication rejection and the server's connection limit surface as events.ErrAuthFailed and events.ErrConnectionLimit instead of being retried.

Market data streaming

stream, err := client.Streaming.Connect(ctx)
if err != nil {
    log.Fatal(err)
}
defer func() { _ = stream.Close() }()
if err := stream.Subscribe(ctx, streaming.SubscribeRequest{
    Symbols: []string{"AAPL"},
    Types:   []streaming.SubType{streaming.SubSnapshot, streaming.SubTick},
}); err != nil {
    log.Fatal(err)
}
for {
    msg, err := stream.Recv(ctx)
    if err != nil {
        break
    }
    if msg.Type == streaming.TypeTick && msg.Tick != nil {
        fmt.Println(msg.Tick.Symbol, msg.Tick.Price, msg.Tick.Volume)
    }
}

The MQTT connection carries the data; Subscribe and Unsubscribe are signed HTTP calls that gate it, and a subscribe refused for a bad key is streaming.ErrSubscribeFailed. A dropped connection reconnects and replays its subscriptions.

Connect API (OAuth)

To act on another user's Webull account — the model for a platform whose users link their Webull accounts — use the connect package's OAuth flow:

authorizer, err := connect.NewAuthorizer(connect.Config{
    ClientID: clientID, ClientSecret: clientSecret,
    AppKey: appKey, AppSecret: appSecret,
    Environment: webull.Production,
    RedirectURI: "https://app.example.com/webull/callback",
})
if err != nil {
    log.Fatal(err)
}

// Send the user here; on the callback, exchange the code.
url := authorizer.AuthorizationURL(state)
token, err := authorizer.ExchangeCode(ctx, code)
if err != nil {
    log.Fatal(err)
}

// Trade on the user's behalf; the access token refreshes itself. Use
// ClientFromStore to keep the rotating token pair in a store of your own.
client, err := authorizer.Client(token)
if err != nil {
    log.Fatal(err)
}
accounts, err := client.Trade.Accounts(ctx)
if err != nil {
    log.Fatal(err)
}

Connect credentials are issued by Webull to partner platforms, not self-service, so this is implemented to the documentation and marked Unverified in the compatibility matrix.

Numbers

Every price, quantity and amount is a fixed-point decimal, never a float. The types embed shopspring/decimal, so all of its methods are available directly — snapshot.Price.String(), amount.Decimal.Equal(...) and so on. Optional fields use a nullable form so that "not reported" is distinguishable from zero. trade.Price("1.50") is a shorthand for setting one.

The wrapper adds one thing over shopspring's own types: it decodes Webull's absent forms — JSON null and an empty string — to an absent or zero value. Webull returns "" for an unreported number (an option greek outside market hours, for instance), which shopspring's own decoder rejects.

Errors

Failures from Webull are *webull.APIError, carrying the HTTP status, Webull's error code and message, and a request ID for support. They match sentinel errors with errors.Is:

if errors.Is(err, webull.ErrRateLimited) {
    var apiErr *webull.APIError
    if errors.As(err, &apiErr) {
        wait := apiErr.RetryAfter
        if wait == 0 {
            wait = time.Second // Webull sent no Retry-After header
        }
        time.Sleep(wait)
    }
}

The SDK never retries a POST: in this API a replayed order is a duplicated order.

Troubleshooting

A few failures are common enough to name, most of them settled while building this SDK against the live sandbox:

Symptom Usual cause
404 Route Not Found A sandbox key against production, or the reverse — the two are not interchangeable. Also returned for an unprovisioned key.
webull: AppKey and AppSecret are required from NewClient Empty credentials — the SDK rejects them before any request, so this never reaches the server as a 401.
417 OPENAPI_PARAM_ERR A parameter Webull rejected. Webull uses 417, not 400, for validation errors; the message names the field.
403 MARKET_DATA_NOT_SUBSCRIBED The key is not entitled to that market data. Matches marketdata.ErrNotSubscribed, and the message names the product (for example STOCK QUOTES LV2).
429 TOO_MANY_REQUESTS Rate limited. Matches webull.ErrRateLimited; APIError.RetryAfter carries Webull's requested delay.
An order rejected off-hours The sandbox enforces market hours for DAY orders. Use GTC, or trade during the regular session.

Some endpoints are implemented but cannot be exercised by a sandbox key — entitlement-gated data, or endpoints the sandbox serves empty. These are marked Unverified in the compatibility matrix with the specific reason, so a surprising empty result can be checked against a known limitation rather than mistaken for a bug.

License

MIT

About

An independent Go SDK for the Webull OpenAPI

Topics

Resources

Contributing

Security policy

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages