@aetherwealth/sdk
Typed TypeScript client for the Aether Wealth public API. Programmatic
access to your trading journal, accounts, analytics, alerts, market data, and
diary over the public REST surface (/api/public/v1/…).
Keep your API key secret. Authentication uses an API key for the public API
(aw_live_…) sent as a bearer token. It is a server-side secret: treat it like
a password — never commit it or ship it to an untrusted client.
Install
npm install @aetherwealth/sdk
Quickstart
import { AetherClient } from '@aetherwealth/sdk'
const client = new AetherClient({
auth: { type: 'apiKey', apiKey: process.env.AETHER_API_KEY! },
})
const { data: openTrades } = await client.trades.list({ status: 'OPEN' })
const stats = await client.stats.summary({ pair: 'EURUSD' })
Authentication
Each request sends your secret API key as Authorization: Bearer <apiKey>. The
key scopes every operation to its owner — you never pass userId in a request
body. auth is a discriminated union (one member today, apiKey) so future
auth modes can be added without breaking existing callers.
new AetherClient({
baseUrl,
auth: { type: 'apiKey', apiKey },
fetchImpl,
userAgent,
timeoutMs: 30_000,
validateResponses: false,
dangerouslyAllowBrowser: false,
onRequest, onResponse,
})
The diagnostics hooks receive only { method, url, status, ms } — your API key
travels solely on the Authorization header and never appears in a hook
payload, a thrown error, or the request URL.
Browser use is blocked by default
The API key is a server-side secret. Constructing the client in a
browser-like environment (window.document present) throws — a key shipped to a
browser is visible in the bundle and DevTools and must be treated as public.
Call the API from your server. If you are certain you are in a trusted
non-browser runtime that happens to define window, set
dangerouslyAllowBrowser: true (mirrors the OpenAI SDK).
Resources
client.trades | list · get · create · update · close · delete · pages · listAll |
client.accounts | list · create · update · delete · trades · tradesAll |
client.stats | summary |
client.alerts | list · createPrice · createTrendline · update · delete · listIndicator · createIndicator · updateIndicator · deleteIndicator |
client.market | config · calendar · macroSeries · macro · subscribe |
client.diary | list · get · upsert · delete · pages · listAll |
List methods return { data, pagination }; single-item methods return the
object directly. All response fields are camelCase.
pages() and listAll() async-iterate a paginated resource, auto-advancing
page until the last page (or an empty page), with an infinite-loop backstop:
for await (const page of client.trades.pages({ status: 'CLOSED', limit: 100 })) {
console.log(page.pagination.page, page.data.length)
}
for await (const trade of client.trades.listAll({ pair: 'EURUSD' })) {
handle(trade)
}
Iteration starts at query.page if given, else page 1.
Live market stream
client.market.subscribe mints a short-lived ticket from aether-backend and
opens one reconnecting WebSocket for up to 25 symbols. The user API key needs
market:read; it is sent only on the HTTPS ticket request, never in the
WebSocket URL.
const stop = client.market.subscribe(
{ symbols: ['EURUSD', 'GBPUSD'] },
{
onUpdate(update) {
console.log(update.symbol, update.candle.close)
},
onError(error) {
console.error(error.message)
},
onStatus(status) {
if (status.state === 'terminal') {
console.error('stream stopped permanently', status.error)
}
},
},
)
stop()
The V1 feed is fixed to 1m. candle.close is the latest observed price, not
an executable bid/ask quote. Network failures, 5xx ticket responses, transient
408/425/429 responses, missed heartbeat pongs, and transient socket closes
reconnect automatically with a fresh ticket; a 429 waits at least as long as
Retry-After. The backend's planned 4001 reauthentication close is also
transient and remints the ticket.
Other ticket 4xx responses, the backend's explicit
market_stream_unavailable response, unscoped non-retryable gateway errors,
and policy/auth socket closes stop that subscription. They call onError and
emit an onStatus event whose state is terminal; no later reconnect occurs.
Retryable gateway errors reconnect. A non-retryable error scoped to symbol
is reported as AetherMarketStreamGatewayError while the socket stays alive
for unaffected symbols. Local and whole-stream gateway policy failures use
AetherMarketStreamTerminalError; ticket HTTP failures keep their typed
AetherApiError subclass.
One AetherClient may have at most five active market.subscribe() calls,
with up to 25 symbols per call. A sixth call throws
AetherMarketStreamTerminalError with code MARKET_STREAM_LIMIT. Calling the
returned unsubscribe function—or reaching a terminal failure—immediately frees
that slot.
The package's conditional Node export supplies the ws transport. Runtimes
resolving the universal entry, including edge bundlers, use their native
WebSocket, keeping that entry free of Node-only built-ins.
Timeouts & cancellation
Every request is bounded by timeoutMs (default 30s), overridable per call. A
timeout aborts the request and rejects with AetherTimeoutError (a subclass of
AetherNetworkError, so it's retryable and caught by existing network-error
handlers). You can also pass your own AbortSignal — the request aborts when
either the timeout or your signal fires:
const slow = new AetherClient({ baseUrl, auth, timeoutMs: 60_000 })
await client.request('/api/public/v1/trades/list', { method: 'POST', body: {}, timeoutMs: 5_000 })
const controller = new AbortController()
setTimeout(() => controller.abort(new Error('cancelled')), 1_000)
await client.request('/api/public/v1/stats/stats', { method: 'POST', body: {}, signal: controller.signal })
A timeout throws AetherTimeoutError; a caller-initiated abort surfaces your
signal's own reason (it is not treated as a transient error to retry).
Idempotency & safe retries
Each create — trades.create, accounts.create, alerts.createPrice,
alerts.createTrendline, alerts.createIndicator — sends an Idempotency-Key
header so a create that the server committed but whose response you never saw
won't duplicate on a re-send. If you don't pass one, the SDK generates a fresh
key per call:
await client.trades.create(input)
An auto per-call key protects a single invocation. It does not make a create
retry-safe: a new key each attempt means the backend can't dedup. To retry a
create safely, pass a stable key so every attempt targets the same record:
import { withRetry } from '@aetherwealth/sdk'
const idempotencyKey = crypto.randomUUID()
const trade = await withRetry(
() => client.trades.create(input, { idempotencyKey }),
{ maxAttempts: 3 },
)
Do not generate the key inside the retried closure — each attempt would
get a different key and dedup would be lost. withRetry only retries
transient failures (AetherRateLimitError, AetherNetworkError,
AetherTimeoutError); retrying a mutation is only safe with a stable key.
Errors
Non-2xx responses (and { success: false } envelopes) throw a typed
AetherApiError subclass so you can branch on the failure mode:
import {
AetherRateLimitError,
AetherNotFoundError,
AetherAuthError,
AetherTimeoutError,
} from '@aetherwealth/sdk'
try {
await client.trades.get(id)
} catch (err) {
if (err instanceof AetherNotFoundError) { }
else if (err instanceof AetherRateLimitError) { }
else if (err instanceof AetherAuthError) { }
else if (err instanceof AetherTimeoutError) { }
else throw err
}
AetherTimeoutError and AetherNetworkError (its parent) never reached the
server; they carry no HTTP status. All HTTP failures are AetherApiError
subclasses.
Optional runtime validation
Compile-time types come from the SDK. Runtime validation is off by default
for performance and additive-field tolerance (a backend that adds a field won't
break older SDK consumers). Turn it on two ways:
Whole-client — every resource validates its response with a Zod parser and
throws a ZodError on shape drift (unknown fields are still tolerated):
const client = new AetherClient({ baseUrl, auth, validateResponses: true })
Per-call — import a parser and validate a single response:
import { parseTrade } from '@aetherwealth/sdk'
const trade = parseTrade(await client.trades.get(id))
Not covered
AI chat conversations remain tRPC-only and are not part of this SDK. API-key
management is likewise out of scope: an API key can't provision other keys.
License
MIT. See the LICENSE file included with this package. Service use is governed
by the Aether Wealth Terms of Service.