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 enabled
Content-Type: application/json # only when there is a body to declareContent-Type was missing entirely until DpCryptoManagement's issue #18: every
signed request with a body — subscribe/3, unsubscribe/3, place_order and every
other JSON-bodied POST this package signs — went out with no declared media type at
all, and the venue's streaming-subscribe endpoint answered every one of them HTTP 415 "Request media type not support". Streaming never delivered a tick as a result. It is
not one of the six signed header pairs below, so adding it cannot desync a request from
what was actually signed.
The 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.