webull

package module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 5, 2026 License: MIT Imports: 17 Imported by: 0

README

webull

CI codecov Go Reference

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

Documentation

Overview

Package webull provides a Go client for the Webull OpenAPI.

This is an independent open-source project and is not affiliated with, maintained by, or endorsed by Webull.

Construct a Client with NewClient and use its service clients:

client, err := webull.NewClient(webull.Config{
	AppKey:      os.Getenv("WEBULL_APP_KEY"),
	AppSecret:   os.Getenv("WEBULL_APP_SECRET"),
	Environment: webull.Sandbox,
})
accounts, err := client.Trade.Accounts(ctx)

There is no default Environment: sandbox and production are different deployments with separately issued credentials, and a sandbox key returns "404 Route Not Found" for every production path.

Every request is signed automatically. Failures from Webull are returned as *APIError, which matches the package's sentinel errors with errors.Is. The client never retries a POST, because in this API a replayed order is a duplicated order.

The package is under active development and its public API is not yet stable.

Example

Example shows the entry point: construct a client, then reach a service through it. Credentials come from the Webull developer portal; sandbox and production keys are separate.

package main

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

	"github.com/sfreiberg/webull"
)

func main() {
	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(context.Background())
	if err != nil {
		log.Fatal(err)
	}
	for _, a := range accounts {
		fmt.Println(a.AccountID, a.AccountClass)
	}
}

Index

Examples

Constants

View Source
const DefaultTimeout = 30 * time.Second

DefaultTimeout applies to requests when Config.HTTPClient is nil. It is restated here rather than referencing transport.DefaultTimeout so godoc shows the value; a test asserts the two stay equal.

View Source
const Version = "0.1.0"

Version is the semantic version of this SDK. It is reported in the User-Agent header of outgoing requests so that Webull can identify SDK traffic and so that bug reports can be tied to a specific release.

Development builds between releases carry a -dev suffix.

Variables

View Source
var (
	// ErrAuthentication indicates the request was not authenticated: a missing,
	// malformed or rejected signature, or absent credentials.
	ErrAuthentication = errors.New("webull: authentication failed")

	// ErrPermission indicates valid credentials that lack access to the
	// requested resource, such as an account the key cannot see or a market
	// data tier the key is not entitled to. A market data entitlement failure
	// also matches marketdata.ErrNotSubscribed, which is more specific.
	ErrPermission = errors.New("webull: permission denied")

	// ErrInvalidRequest indicates the request was rejected as malformed or
	// carrying invalid parameters.
	ErrInvalidRequest = errors.New("webull: invalid request")

	// ErrNotFound indicates the endpoint or resource does not exist.
	ErrNotFound = errors.New("webull: not found")

	// ErrRateLimited indicates the request exceeded a rate limit.
	ErrRateLimited = errors.New("webull: rate limited")

	// ErrServer indicates Webull reported a server-side failure.
	ErrServer = errors.New("webull: server error")
)

Sentinel errors for conditions callers commonly branch on. Match them with errors.Is; the underlying *APIError remains reachable with errors.As.

View Source
var ErrMissingCredentials = errors.New("webull: AppKey and AppSecret are required")

ErrMissingCredentials is returned when AppKey or AppSecret is empty.

View Source
var ErrRedirectNotAllowed = transport.ErrRedirectNotAllowed

ErrRedirectNotAllowed is returned when Webull responds with a redirect. Redirects are refused because Go forwards the signature headers — including the app key — to the redirect target; see transport.NewHTTPClient.

Functions

func UserAgent

func UserAgent() string

UserAgent returns the default User-Agent header value for SDK requests. It identifies the SDK, its version, and the Go runtime, following the conventional product/version format described in RFC 9110.

Types

type APIError

type APIError struct {
	// StatusCode is the HTTP status.
	StatusCode int
	// Code is Webull's machine-readable error code, such as
	// "OPENAPI_PARAM_ERR". Empty for gateway-level errors.
	Code string
	// Message is Webull's human-readable description.
	Message string
	// RequestID correlates the request with Webull support, when the response
	// carries one.
	RequestID string
	// RetryAfter is the delay Webull asked the caller to wait, taken from the
	// Retry-After header. Zero when the response did not carry one.
	//
	// The SDK does not retry rate-limited requests automatically: how long to
	// wait, and whether waiting is appropriate at all, is the caller's
	// decision. This value is what they need to make it.
	RetryAfter time.Duration

	// Body is the raw response body, bounded in size. It is retained because
	// Webull's error catalogue is not exhaustively documented, and the raw
	// payload is often the only way to diagnose an unfamiliar failure.
	Body []byte

	// Truncated reports that Body was cut short at the size limit, so an empty
	// Code or Message may reflect unparseable truncated JSON rather than an
	// error shape the SDK does not recognise.
	Truncated bool
}

APIError is a structured error response from Webull.

Webull returns two different error shapes. Application errors carry a machine-readable code and a message; errors produced by the API gateway, such as an unrouted path, carry only a message under a different key. Both decode into this type, and Code is empty for the latter.

Example

ExampleAPIError shows matching a failure against the sentinel errors. Every failure from Webull is an *APIError carrying the HTTP status, Webull's code and message, and a request ID for support.

package main

import (
	"context"
	"errors"
	"fmt"
	"log"
	"os"

	"github.com/sfreiberg/webull"
)

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

	_, err = client.Trade.Accounts(context.Background())
	if errors.Is(err, webull.ErrRateLimited) {
		var apiErr *webull.APIError
		if errors.As(err, &apiErr) {
			fmt.Printf("rate limited; retry after %s\n", apiErr.RetryAfter)
		}
	}
}

func (*APIError) Error

func (e *APIError) Error() string

Error implements error.

func (*APIError) ErrorCode

func (e *APIError) ErrorCode() string

ErrorCode returns Webull's machine-readable code, or "" if none. It lets packages that cannot import this one classify errors by code.

func (*APIError) Is

func (e *APIError) Is(target error) bool

Is reports whether this error matches one of the package sentinels, so that errors.Is(err, ErrPermission) works without callers inspecting status codes.

func (*APIError) Temporary

func (e *APIError) Temporary() bool

Temporary reports whether retrying the same request might succeed.

It is deliberately conservative: it never reports true for a client error, because replaying a rejected request produces the same rejection. Callers must not treat a true result as licence to retry a non-idempotent request such as order placement without first reconciling.

type AccessToken

type AccessToken struct {
	// Token is the credential itself, sent as the x-access-token header by
	// a Client whose Config.AccessToken is set.
	Token string `json:"token"`
	// ExpiresAt is when the token becomes invalid.
	ExpiresAt time.Time `json:"-"`
	// Status is the token's lifecycle state. A newly created token starts
	// PENDING until verified through SMS and the Webull app; the test
	// environment skips verification.
	Status TokenStatus `json:"status"`
}

AccessToken is a token for deployments that require token authentication in addition to the request signature — TokenCheckEnabled reports whether this deployment does. A token lives 15 days by default and is not refreshable; create a new one when it expires.

func (*AccessToken) UnmarshalJSON

func (t *AccessToken) UnmarshalJSON(data []byte) error

UnmarshalJSON decodes the wire shape, whose expiry is epoch milliseconds that Webull may send as a number or a numeric string; the shared wire decoder absorbs both.

type Client

type Client struct {
	// Trade covers accounts, balances, positions, activities, instrument
	// reference data and orders.
	Trade *trade.Client
	// MarketData covers snapshots, quotes, ticks, bars and reference data.
	MarketData *marketdata.Client
	// Events streams real-time trade events over gRPC. Each Subscribe call
	// opens its own connection whose lifetime belongs to the caller.
	Events *events.Client
	// Streaming delivers real-time market data over MQTT. Each Connect call
	// opens its own connection whose lifetime belongs to the caller.
	Streaming *streaming.Client
	// contains filtered or unexported fields
}

Client is the entry point to the Webull OpenAPI.

Construct one with NewClient and share it: it is safe for concurrent use by multiple goroutines, and reusing it reuses the underlying HTTP connection pool. Constructing a Client performs no I/O.

Long-lived streaming connections are deliberately not owned by Client. They are created explicitly so that their lifetime, and the goroutines they run, belong to the caller.

func NewClient

func NewClient(cfg Config) (*Client, error)

NewClient returns a Client for the given configuration.

It returns an error if credentials are missing or the environment is not one of Sandbox or Production. Errors never contain credential material.

Example

ExampleNewClient supplies a custom User-Agent identifying the calling application, which is appended to the SDK's own.

package main

import (
	"log"
	"os"

	"github.com/sfreiberg/webull"
)

func main() {
	client, err := webull.NewClient(webull.Config{
		AppKey:      os.Getenv("WEBULL_APP_KEY"),
		AppSecret:   os.Getenv("WEBULL_APP_SECRET"),
		Environment: webull.Sandbox,
		UserAgent:   "acme-trader/1.4",
	})
	if err != nil {
		log.Fatal(err)
	}
	_ = client
}

func (*Client) CheckAccessToken

func (c *Client) CheckAccessToken(ctx context.Context, token string) (*AccessToken, error)

CheckAccessToken reports a token's current status and expiry.

func (*Client) CreateAccessToken

func (c *Client) CreateAccessToken(ctx context.Context) (*AccessToken, error)

CreateAccessToken creates an access token for this app key. The token starts PENDING and must be verified through SMS and the Webull app before it reads NORMAL; the test environment skips verification. Pass the token to a new Client through Config.AccessToken.

Example

ExampleClient_CreateAccessToken shows the token flow for deployments where TokenCheckEnabled reports that token authentication is required.

package main

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

	"github.com/sfreiberg/webull"
)

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

	needed, err := client.TokenCheckEnabled(context.Background())
	if err != nil {
		log.Fatal(err)
	}
	if !needed {
		return // signing alone is enough for this deployment
	}

	// The token starts PENDING until verified through SMS and the Webull
	// app; it lives 15 days and is then recreated, not refreshed.
	tok, err := client.CreateAccessToken(context.Background())
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(tok.Status, tok.ExpiresAt)

	// Requests carry the token once a client is built with it.
	authed, err := webull.NewClient(webull.Config{
		AppKey:      os.Getenv("WEBULL_APP_KEY"),
		AppSecret:   os.Getenv("WEBULL_APP_SECRET"),
		Environment: webull.Sandbox,
		AccessToken: tok.Token,
	})
	if err != nil {
		log.Fatal(err)
	}
	_ = authed
}

func (*Client) Environment

func (c *Client) Environment() Environment

Environment reports which deployment this client talks to.

func (*Client) TokenCheckEnabled

func (c *Client) TokenCheckEnabled(ctx context.Context) (bool, error)

TokenCheckEnabled reports whether this deployment requires an access token in addition to the request signature.

Webull gates token authentication per deployment rather than documenting it statically, so the answer must be asked for. It is not fetched during NewClient, because constructing a client should not perform I/O.

type Config

type Config struct {
	// AppKey and AppSecret are the credentials issued by Webull. They are used
	// to sign every request and are never logged or included in errors.
	AppKey    string
	AppSecret string

	// Environment selects sandbox or production. There is no default: choosing
	// between simulated and real orders is not a decision this SDK will make on
	// a caller's behalf.
	Environment Environment

	// HTTPClient is used for all HTTP requests. If nil, a client with
	// DefaultTimeout is used. Supply your own to control transport, proxies or
	// connection pooling.
	HTTPClient *http.Client

	// EndpointOverrides replaces the host for a named service, for testing
	// against a local server or routing through a proxy. Keys are service
	// names: "trading", "marketdata", "streaming", "events". Each is
	// resolved independently: trading and market data share a host by
	// default, but overriding one does not override the other. The Connect
	// API is configured through connect.Config, which has its own Endpoint
	// override.
	EndpointOverrides map[string]string

	// UserAgent is appended to the SDK's own User-Agent, identifying the
	// calling application. Optional.
	UserAgent string

	// AccessToken is sent as the x-access-token header on every request, for
	// deployments where TokenCheckEnabled reports that token authentication
	// is required in addition to the signature. Create one with
	// Client.CreateAccessToken. Optional; most deployments do not need it.
	AccessToken string
}

Config describes how a Client connects to Webull.

The zero value is not usable: AppKey, AppSecret and Environment are required.

type Environment

type Environment string

Environment selects which Webull deployment the SDK talks to.

const (
	// Sandbox is Webull's test environment. Orders placed here are simulated.
	Sandbox Environment = "sandbox"

	// Production is the live environment. Orders placed here are real.
	Production Environment = "production"
)

func (Environment) IsProduction

func (e Environment) IsProduction() bool

IsProduction reports whether e is the live environment. Guard rails that must never fire against real accounts should test this rather than comparing strings, so that a future environment is not accidentally treated as safe.

func (Environment) String

func (e Environment) String() string

String implements fmt.Stringer.

func (Environment) Valid

func (e Environment) Valid() bool

Valid reports whether e is a known environment.

type TokenStatus

type TokenStatus string

TokenStatus is the lifecycle state of an access token.

const (
	// TokenPending means the token awaits verification through SMS and the
	// Webull app. The test environment skips verification.
	TokenPending TokenStatus = "PENDING"
	// TokenNormal means the token is valid.
	TokenNormal TokenStatus = "NORMAL"
	// TokenInvalid means the token is not usable.
	TokenInvalid TokenStatus = "INVALID"
	// TokenExpired means the token's lifetime has lapsed; create a new one.
	TokenExpired TokenStatus = "EXPIRED"
)

Access token statuses.

Directories

Path Synopsis
Package connect implements Webull's Connect API: OAuth 2.0 access to other people's Webull accounts on their behalf, for platforms that let their users trade a linked Webull account without leaving the platform.
Package connect implements Webull's Connect API: OAuth 2.0 access to other people's Webull accounts on their behalf, for platforms that let their users trade a linked Webull account without leaving the platform.
Package events streams real-time trade events from Webull over gRPC: order status changes, position changes and option events.
Package events streams real-time trade events from Webull over gRPC: order status changes, position changes and option events.
internal
endpoints
Package endpoints holds host constants that more than one package resolves, so that a host rename cannot drift between their tables.
Package endpoints holds host constants that more than one package resolves, so that a host rename cannot drift between their tables.
eventspb
Package eventspb holds the generated protobuf and gRPC bindings for Webull's trade event stream.
Package eventspb holds the generated protobuf and gRPC bindings for Webull's trade event stream.
query
Package query builds URL query strings for service packages.
Package query builds URL query strings for service packages.
signing
Package signing implements Webull's HMAC request signature.
Package signing implements Webull's HMAC request signature.
streampb
Package streampb holds the generated protobuf bindings for Webull's MQTT market-data payloads.
Package streampb holds the generated protobuf bindings for Webull's MQTT market-data payloads.
testutil
Package testutil holds helpers shared by this module's tests.
Package testutil holds helpers shared by this module's tests.
transport
Package transport carries out signed HTTP requests against Webull and turns responses into typed results or errors.
Package transport carries out signed HTTP requests against Webull and turns responses into typed results or errors.
wire
Package wire holds decoding helpers for value forms that recur across Webull's APIs, shared by the service packages.
Package wire holds decoding helpers for value forms that recur across Webull's APIs, shared by the service packages.
Package marketdata provides access to Webull's Market Data API over HTTP: snapshots, quotes, ticks, bars and reference data.
Package marketdata provides access to Webull's Market Data API over HTTP: snapshots, quotes, ticks, bars and reference data.
Package streaming delivers real-time market data from Webull over MQTT: snapshots, top-of-book and depth quotes, and ticks for stocks and event contracts.
Package streaming delivers real-time market data from Webull over MQTT: snapshots, top-of-book and depth quotes, and ticks for stocks and event contracts.
Package trade provides access to Webull's Trading API: accounts, balances, positions, cash activities, reference data for tradable instruments, and orders across every asset class Webull offers.
Package trade provides access to Webull's Trading API: accounts, balances, positions, cash activities, reference data for tradable instruments, and orders across every asset class Webull offers.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL