Signs a request with credentials the host has already obtained. Internal.
This package does not handle authentication
It signs. The host implements authentication, holds the credentials, and decides which kind to use. This module is handed the result and turns it into headers — it never obtains a credential, never stores one, never refreshes one, and never reads one from the environment.
Webull's scheme
Every authenticated request carries:
x-app-key: <app key>
x-timestamp: <ISO 8601 UTC, second precision, "…Z">
x-signature: base64(HMAC-SHA1(app_secret <> "&", encoded_str))
x-signature-algorithm: HMAC-SHA1
x-signature-version: 1.0
x-signature-nonce: <unique random string>
x-version: v2
host: <api hostname, no scheme>
x-access-token: <token> # only when the account has 2FA enabledThe signature, step by step
- Collect the signing inputs: every query parameter, plus exactly six header pairs —
host,x-app-key,x-signature-algorithm,x-signature-nonce,x-signature-version,x-timestamp.x-signatureandx-versiondeliberately do not participate. - Sort by name ascending and join as
name=value&name=value&…→str1. - If there is a body, uppercase-hex
MD5(body)→str2. With no body, omit it entirely — not an MD5 of the empty string, which is a different value and a different signature. str3 = path & str1, orpath & str1 & str2when a body is present.- Percent-encode
str3with no safe characters, so/becomes%2Fand:becomes%3A. - The HMAC key is
app_secret <> "&"— the trailing ampersand is part of the key, not a separator. Sign with SHA-1 and base64 the result.
The host participates in the signature, which is what stops a captured signature being replayed against a different environment. It is also why moving between production and the UAT host changes the signature rather than just the URL.
Verified against the venue's own worked example
Webull publishes an example whose expected output is kvlS6opdZDhEBo5jq40nHYXaLvM=. The
test suite runs that example end to end and asserts the match, which is the only way to
know the six steps above were read the same way the venue meant them.
Summary
Functions
The full header set for a signed request.
A fresh per-request nonce — 32 random hex characters.
The raw signature value.
The current time in the venue's required form: YYYY-MM-DDThh:mm:ssZ.
Types
@type credentials() :: %{ :app_key => String.t(), :app_secret => String.t(), optional(:access_token) => String.t() | nil }
Credentials the host obtained. Signed with, and not kept.
@type request() :: %{ path: String.t(), query_params: %{optional(String.t()) => String.t()}, body: String.t(), host: String.t(), timestamp: String.t(), nonce: String.t() }
Everything the signature covers.
Functions
@spec headers(request(), credentials()) :: {:ok, [{String.t(), String.t()}]} | {:error, term()}
The full header set for a signed request.
host is the API hostname without a scheme. It participates in signing, so a caller
must pass the host it will actually reach.
Returns {:error, {:missing_credentials, :webull}} rather than signing with a partial
credential — a request signed with an absent secret fails at the venue with an error
about signatures, which sends the reader looking in the wrong place.
@spec nonce() :: String.t()
A fresh per-request nonce — 32 random hex characters.
@spec signature( String.t(), String.t(), String.t(), %{optional(String.t()) => String.t()}, String.t(), String.t(), String.t(), String.t() ) :: String.t()
The raw signature value.
Exposed so the venue's published worked example can be verified directly — a signature scheme with six ordering-sensitive steps is not something to assume was read correctly.
@spec timestamp(DateTime.t()) :: String.t()
The current time in the venue's required form: YYYY-MM-DDThh:mm:ssZ.
Second precision with no fractional part. A fractional second changes the string, and the string is signed.