PearPear
API Integration

Venue Accounts

Read balance, positions, leverage, and basket capacity straight from the exchange with @pear-protocol/exchanges-sdk.

@pear-protocol/exchanges-sdk reads the exchange account itself: its balance, its positions, its per-asset leverage and margin mode, and how large a basket it can still open. It talks to Binance, Bybit, Hyperliquid, Lighter, and OKX directly, behind one interface.

Reach for it when the gateway does not hold what you need. The gateway serves Pear's own baskets, orders, and history. It does not serve the venue's account state, so a balance widget, a margin check before an order, or a "max size" button reads from here.

npm install @pear-protocol/exchanges-sdk @pear-protocol/core-sdk

It reads credentials through core-sdk, so it works on any trade account you already connected. It obtains them with POST /trade-accounts/{id}/credentials, which is a write-scoped call: a read API key and a read OAuth grant are both refused with 403.

Construct it

import PearSDK from '@pear-protocol/core-sdk';
import { ExchangesSDK } from '@pear-protocol/exchanges-sdk';

const sdk = new PearSDK({ /* … */ });
const exchanges = new ExchangesSDK({ sdk });
FieldRequiredMeaning
sdkYesA @pear-protocol/core-sdk instance. It fetches the credentials and mints Lighter's auth tokens.
demoNoDefaults to false. true points every venue read at its test environment.
proxyNo{ enabled, url? }. Sends the exchange REST calls to url with the original venue host in an X-Proxy-Target header. It is a constructor field because the SDK also runs in the browser, where environment variables do not exist. A config with enabled: false or no url turns the proxy off.

There is no options key. The approval targets status() needs are a per-call argument, not constructor state. See Check readiness.

The demo flag

demo: true is the only way to reach a venue's test environment from this SDK. No gateway endpoint takes a testnet flag; this is a direct venue read, so the routing is local to the SDK.

VenueWhere demo: true points
BinanceFutures testnet.
BybitDemo trading.
HyperliquidTestnet REST and WebSocket.
LighterTestnet REST and WebSocket.
OKXSimulated trading, on the same host, through the venue's simulated-trading header.

Two Lighter limits apply in demo mode. Anything that needs a backend-signed payload or an auth token fails closed, because the signing service signs for mainnet only. status() therefore always reports lighter_api_key_invalid on a demo Lighter account without contacting the venue. Public snapshots and live tracking still work.

Choose a read path

CallOpens a socketReturns
snapshot(tradeAccountId)NoBalance and open positions, once.
snapshotAssets(tradeAccountId, coins)NoConfigured leverage and margin mode per asset, once.
maxBasketSize(tradeAccountId, input)NoHow large a basket the account can open.
connect(tradeAccountId)NoA connection the tracker, the status check, and the syncer all take.
createTracker(connection) + tracker.start()YesA live subscription to balance, positions, and per-asset settings.

Every stateless call takes a trade account id and fetches its credentials itself. Nothing is left running, so there is nothing to tear down.

Read the account once

const { balance, positions } = await exchanges.snapshot(tradeAccountId);

if (balance) console.log(balance.accountMode, balance.totalEquity);
for (const p of positions) console.log(p.symbol, p.side, p.size);

balance is null when the account mode cannot be represented safely. That is not a zero balance. Treat it as "unknown", and read the reason from status().

{
  "accountMode": "hyperliquid:unified",
  "totalEquity": "125430.18",
  "withdrawableBalance": "98210.44",
  "availableMargin": "97880.1",
  "poolsShared": false,
  "perCollateral": [
    { "asset": "USDC", "equity": "104300.5", "availableMargin": "97880.1", "withdrawable": "98210.44" },
    { "asset": "HYPE", "equity": "480.25", "availableMargin": null, "withdrawable": "480.25" }
  ],
  "timestamp": 1787678001408,
  "tradingEquity": "121200.55",
  "vaultEquity": "4229.63",
  "stakedEquity": "0",
  "portfolioValue": "125430.18",
  "unrealizedPnl": "+1820.44",
  "perpsMaintenanceMargin": "3310.02",
  "unifiedAccountRatio": "0.0264",
  "unifiedAccountLeverage": "0.71",
  "perDexState": [
    {
      "dex": "",
      "collateralToken": 0,
      "accountValue": "121200.55",
      "crossNotional": "86400.00",
      "crossMaintenanceMargin": "3310.02",
      "withdrawable": "98210.44"
    }
  ]
}

Every venue and every mode fills these six core fields:

FieldTypeMeaning
totalEquitydecimal string or nullThe whole account in USD. null when the venue cannot state one spendable figure.
withdrawableBalancedecimal string or nullWhat can leave the trading account right now.
availableMargindecimal string or nullCollateral usable as margin for every tracked asset. A floor across that set, not a per-asset figure.
poolsSharedbooleantrue: one pool funds every position, so only the scalars are spendable. false: the perCollateral rows are separate budgets that cannot fund each other.
perCollateralarrayOne row per collateral pool, each in that asset's own units. Never converted, never summed.
timestampnumberMilliseconds since epoch.

Each perCollateral row is { asset, equity, availableMargin, withdrawable }, the last two nullable.

null always means "the venue cannot tell us", never zero. A blank field on a venue's account endpoint is "not stated". Rendering it as 0 reports a number the venue never published.

Every figure is a string, never a JS number. Precision is load-bearing at these sizes.

A position looks like this:

[
  {
    "symbol": "BTC",
    "side": "long",
    "size": "1.5",
    "entryPrice": "104200.5",
    "unrealizedPnl": "2130.64",
    "leverage": "10",
    "marginType": "cross",
    "liquidationPrice": "62110.0"
  },
  {
    "symbol": "ETH",
    "side": "short",
    "size": "-25.0",
    "entryPrice": "4210.35",
    "unrealizedPnl": "-310.20",
    "leverage": "10",
    "marginType": "cross",
    "liquidationPrice": null
  }
]
FieldNotes
symbolThe venue's own market name, not a gateway instrument ID.
sidelong, short, or both. both appears only when a venue reports a position without a direction.
sizeSigned: negative on a short.
unrealizedPnlThe venue's own signed figure.
marginTypecross or isolated.
liquidationPricenull when the venue publishes none.

A position whose size reaches '0' is dropped, so a closed position never lingers in the list.

Per-asset settings

snapshot() carries leverage only for assets you hold. To read what an asset is configured to, positioned or not, use snapshotAssets():

const assets = await exchanges.snapshotAssets(tradeAccountId, ['BTC', 'ETH']);
{
  "BTC": { "coin": "BTC", "leverage": "10", "marginType": "cross" },
  "ETH": null
}

The map is keyed by the asset id you asked for. null means the venue publishes no readable setting for it. An empty list returns {}, and duplicates are read once.

Use each venue's own identifier, the same one trackAsset() takes:

VenueFormatExample
Binance<BASE><QUOTE>BTCUSDT
Bybit<BASE><QUOTE>BTCUSDT
HyperliquidBase coinBTC
LighterBase coinBTC
OKXInstrument idBTC-USDT-SWAP

On Lighter, an asset with no open position reports the market default, because the venue's account read does not publish a custom setting until a position exists.

Account modes

AccountBalance is a discriminated union tagged by accountMode. The tag names the venue and the mode in one literal, so narrowing needs no separate venue check. Narrow on it to reach the fields that venue and mode actually publish.

accountModeVenue's own labelBeyond the six core fields
hyperliquid:standardStandardspotEquity, perpsEquity, vaultEquity, stakedEquity, unrealizedPnl, maintenanceMargin, crossMarginRatio, crossAccountLeverage, perDexState
hyperliquid:unifiedUnified AccounttradingEquity, vaultEquity, stakedEquity, portfolioValue, unrealizedPnl, perpsMaintenanceMargin, unifiedAccountRatio, unifiedAccountLeverage, perDexState
hyperliquid:portfolioMarginPortfolio MargintradingEquity, vaultEquity, stakedEquity, portfolioValue, unrealizedPnl, perpsMaintenanceMargin, portfolioMarginRatio, portfolioAccountLeverage, borrowCapUsed, perDexState. Its perCollateral rows are signed, because a borrowed coin holds a negative balance, and add ltv and borrowCapUsed.
lighter:classicClassictradingEquity, perpetualsEquity, spotEquity, publicPoolsEquity, stakingEquity, creditedInLit, unrealizedPnl, crossLeverage, maintenanceMargin, crossMarginUsage, crossMarginRatio
lighter:unifiedUnifiedThe Classic set, plus portfolioMargin.
binance:singleAssetSingle-Asset ModeCore only.
binance:multiAssetMulti-Asset ModeCore only.
bybit:isolatedMarginIsolated MarginCore only.
bybit:regularMarginCross MarginCore only.
bybit:portfolioMarginPortfolio MarginCore only.
okx:spotSpot ModeCore only.
okx:futuresFutures ModeCore only.
okx:multiCurrencyMarginMulti-Currency MarginCore only.
okx:portfolioMarginPortfolio MarginCore only.

hyperliquid:standard covers the venue's default and disabled abstractions, which publish identical figures.

Write the switch with no default: clause. A mode added in a later release then fails your build instead of silently returning undefined:

import type { AccountBalance } from '@pear-protocol/exchanges-sdk';

function unrealizedPnlOf(balance: AccountBalance): string | null {
  switch (balance.accountMode) {
    case 'hyperliquid:standard':
    case 'hyperliquid:unified':
    case 'hyperliquid:portfolioMargin':
    case 'lighter:classic':
    case 'lighter:unified':
      return balance.unrealizedPnl;

    // The three CEXs publish no account-level PnL.
    case 'binance:singleAsset':
    case 'binance:multiAsset':
    case 'bybit:isolatedMargin':
    case 'bybit:regularMargin':
    case 'bybit:portfolioMargin':
    case 'okx:spot':
    case 'okx:futures':
    case 'okx:multiCurrencyMargin':
    case 'okx:portfolioMargin':
      return null;
  }
}

Render the tag with ACCOUNT_MODE_LABELS, which maps every mode to the label the venue's own account page uses:

import { ACCOUNT_MODE_LABELS } from '@pear-protocol/exchanges-sdk';

ACCOUNT_MODE_LABELS['hyperliquid:unified']; // "Unified Account"

Track the account live

const connection = await exchanges.connect(tradeAccountId);
const tracker = exchanges.createTracker(connection);

await tracker.start();

const offBalance = tracker.trackBalance((balance) => {
  if (!balance) return;
  render(balance.accountMode, balance.totalEquity);
});

const offPositions = tracker.trackPosition((positions) => renderPositions(positions));
const offAsset = tracker.trackAsset('ETH', (info) => render(info.leverage, info.marginType));

// Later
offBalance();
offPositions();
offAsset();
await tracker.disconnect();

connect() opens no socket. start() opens the venue WebSocket, and disconnect() closes it.

MemberDoes
start()Opens the venue WebSocket and loads the first state.
disconnect()Closes the socket and drops every listener.
trackBalance(cb)Subscribes to balance updates. Returns an unsubscribe function.
trackPosition(cb)Subscribes to the whole position list on every change. Returns an unsubscribe function.
trackAsset(asset, cb)Subscribes to one asset's leverage and margin mode. Returns an unsubscribe function.
getBalance()The cached AccountBalance, or null before the first update. Synchronous.
getPositions()The cached AccountPosition[]. Synchronous.
getTrackedAsset(coin)The cached TrackedAssetInfo for one asset, or null. Synchronous.
snapshot()The same one-shot read as exchanges.snapshot(), on an existing connection.
snapshotAssets(coins)The same one-shot per-asset read, on an existing connection.
maxBasketSize(input)Basket sizing over the live path.
snapshotMaxBasketSize(input)Basket sizing over REST alone, so it works before start().
isConnectedWhether the socket is up. Read-only.
isInitializedWhether the first balance or position state has arrived. Asset metadata alone does not count. Read-only.
hasListenersWhether anything is still subscribed. Read-only.

Every track* method returns an unsubscribe function. Each callback also fires immediately with the current value if one has already arrived, so you do not need a separate first read.

Dropping the last listener on an asset releases that asset's venue subscription. disconnect() clears all three listener sets, so calling the unsubscribe functions afterwards is harmless but pointless.

A callback that throws does not break the tracker. The error is reported and the remaining listeners still run.

Check readiness

An account can be stored, and still be unable to trade, because a key was revoked at the venue or an approval is missing. status() names the problem.

const health = await exchanges.status(connection, {
  builderAddress,
  builderFeeTenthsBps: 60,
});
{ "status": "warning", "reasons": ["hyperliquid_builder_fee_not_approved"] }

status is active or warning. reasons is empty on active, and lists every problem found on warning:

ReasonVenueMeaning
api_key_invalidBinance, Bybit, OKXThe venue rejected the stored credentials.
hyperliquid_api_wallet_invalidHyperliquidThe agent wallet is missing, expired, or not the one the account approved.
hyperliquid_builder_fee_not_approvedHyperliquidThe builder approval is missing or below the threshold you passed.
hyperliquid_account_mode_check_failedHyperliquidThe account mode could not be read. Retry.
hyperliquid_account_mode_unsupportedHyperliquidThe account is in a mode Pear does not model.
lighter_api_key_invalidLighterThe API key cannot mint an auth token.
lighter_integrator_not_approvedLighterThe integrator approval is missing or below the thresholds you passed.

ConnectOptions has five fields, all optional:

FieldVenueMeaning
builderAddressHyperliquidThe builder address whose approval to check.
builderFeeTenthsBpsHyperliquidThe minimum approved builder fee, in tenths of a basis point.
integratorAccountIndexLighterThe integrator account whose approval to check.
integratorMinPerpsMakerFeeLighterThe minimum approved perps maker fee, in exchange fee ticks (parts per million).
integratorMinPerpsTakerFeeLighterThe minimum approved perps taker fee, in the same units.

Three rules, and the first two are the common mistakes:

  • The options are a per-call argument, not constructor state. The approval targets can change, so you fetch the current values and pass them on every check. Omit them and the approval checks are skipped silently: status() still reports a broken key, but it can never report hyperliquid_builder_fee_not_approved or lighter_integrator_not_approved.
  • A target without its threshold throws. builderAddress with no builderFeeTenthsBps throws, and so does integratorAccountIndex without both Lighter fees. A configured target with no threshold to compare against would pass an under-approved account, so the check fails closed instead.
  • The address or index comes from GET /fee/recipient, the thresholds do not. That endpoint's whole response is { recipient? }. You supply builderFeeTenthsBps, integratorMinPerpsMakerFee, and integratorMinPerpsTakerFee yourself, and they must match what the trading backend is configured with. Set them too high and status() reports an approval as missing when it is not.

recipient is absent when no recipient is configured for your role. Orders then name no builder or integrator, so there is nothing to approve: leave the target out of ConnectOptions rather than passing undefined.

Connect an Exchange shows this inside the full connection walkthrough, with a helper that builds ConnectOptions per connector, and the fix for each reason.

status() takes a connection from connect(), not a trade account id.

Size a basket

maxBasketSize() answers one question: how large a basket can this account open. It opens no socket.

const sized = await exchanges.maxBasketSize(tradeAccountId, {
  legs: [
    { asset: 'BTC', side: 'BUY',  weight: 60, leverage: '10', marginMode: 'cross' },
    { asset: 'ETH', side: 'SELL', weight: 40, leverage: '10', marginMode: 'cross' },
  ],
});
{
  "maxNotional": "978801",
  "legs": [
    { "asset": "BTC", "notional": "587280.6", "marginRequired": "58728.06" },
    { "asset": "ETH", "notional": "391520.4", "marginRequired": "39152.04" }
  ],
  "bindingConstraint": { "kind": "cross-pool", "pool": "USDC" }
}

The input is { legs }, one or more legs:

FieldTypeRule
assetstringThe venue's own market name, as in the table above.
sideBUY or SELLCarried through, but it does not enter the maths: a margin requirement does not depend on direction.
weightnumberUnsigned and greater than 0. The set sums to 100.
leveragedecimal stringThe leverage you want. It is capped before it is used.
marginModecross or isolatedCarried through, so your basket passes through unchanged.

The output:

FieldMeaning
maxNotionalThe whole basket's maximum gross notional in USD, or null. Not per side and not per asset.
legsEach leg's notional and marginRequired at that basket size. These are derived from maxNotional, not independently reachable per-leg maxima.
bindingConstraintWhat ran out first: { "kind": "cross-pool", "pool": … } when a collateral pool's budget bound the basket, { "kind": "isolated-leg", "asset": … } when one leg reached its own venue ceiling. null when the basket could not be sized.

A basket is atomic: every leg executes or none do. So the answer is one ceiling for the whole structure. Two constraints combine. Each collateral pool is a budget summed across the legs it funds, and the basket stops at the tightest pool. Each leg also has its own venue ceiling, and the tightest of those applies too. Pools cannot fund each other, so a USDT-settled leg and a USDC-settled leg draw on separate budgets.

A leg's margin fraction is 1 / min(requested leverage, venue maximum, the account's current leverage). The account's current setting is in that minimum on purpose: opening a basket does not change leverage, so a leverage the venue has not been told about would overstate the size. On Hyperliquid each leg is capped again at its asset's next margin-tier boundary.

maxNotional is null, never zero, when the basket cannot be sized. Two cases produce it:

  • The venue publishes no per-asset maximum leverage this SDK reads today. Binance, Bybit, and OKX all return null with an empty legs array. Only Hyperliquid and Lighter are sized today.
  • A leg draws on a collateral pool that could not be valued. Sizing against a pool nobody can price is exactly the guess this call exists to remove.

A null is not "no collateral". Do not render it as $0.

Sync venue activity

createSyncer(connection) returns an AccountSyncer with synchronizeFills() and synchronizeFunding(). Each runs a whole cycle and returns an outcome rather than throwing on the normal failure paths: 'synced', 'deferred' (fills only), or 'aborted'.

const syncer = exchanges.createSyncer(connection);

const fills = await syncer.synchronizeFills();
const funding = await syncer.synchronizeFunding();

This is what makes a position you opened directly on the venue appear in Pear. Syncing Venue Activity documents the whole story, including what each outcome means and what still throws.

Venue helpers

createHelper('hyperliquid' | 'lighter') returns the venue helper already pointed at the network this SDK is configured for:

const hyperliquid = exchanges.createHelper('hyperliquid');
const lighter = exchanges.createHelper('lighter');

These are the same objects as the standalone createHyperliquidHelper() and createLighterHelper(sdk) factories. There are two entry points, not two helpers. Use the standalone factories before you have an ExchangesSDK, such as during onboarding, and createHelper afterwards so the network and the SDK instance are already set.

The helpers wrap the on-chain and gateway round trips each venue needs before its credentials work: the Hyperliquid agent wallet and builder-fee approvals, and the Lighter deposit, API key, and integrator approval. Connect an Exchange documents every method.

On this page