PearPear
API IntegrationAccess Management

Connect an Exchange

What each venue needs before a trade account can trade, from deposit to approvals.

POST /trade-accounts stores credentials. It does not set up the venue side. Hyperliquid and Lighter both need on-chain approvals and a funded account first, and those steps happen against the exchange, not against Pear.

ExchangeBefore you can create the trade account
HyperliquidDeposit, approve Pear as builder, generate an API wallet, approve it.
LighterDeposit (this registers the account), create an API key, approve Pear as integrator.
Binance, Bybit, OKXCreate an API key on the venue. Nothing else.

Hyperliquid

Two libraries split the work, along a clean line:

  • Checking state, use the Hyperliquid helper in @pear-protocol/exchanges-sdk. Every check below is one call, already pointed at the right network and already applying Pear's own thresholds.
  • Approving, use @nktkas/hyperliquid. Approvals are transactions the user's wallet signs, so they cannot come from Pear.
import { createHyperliquidHelper } from '@pear-protocol/exchanges-sdk';

const hyperliquid = createHyperliquidHelper(); // pass true for testnet

It needs no SDK instance and no credentials. If you already hold an ExchangesSDK, exchanges.createHelper('hyperliquid') returns the same thing on the network that SDK is configured for.

1. Deposit

Hyperliquid has no account until it holds funds. Deposit USDC to the wallet you intend to trade with.

const hasAccount = await hyperliquid.checkAccountActivation(userAddress);

A failed request throws rather than returning false, so an outage is never shown to a user as "you need to deposit". Let it throw and retry.

2. Approve Pear as builder

Pear attributes its fee through Hyperliquid's builder-code mechanism, and the venue will not accept the order until the user has approved that builder.

Read the address from the gateway rather than hardcoding it, so it stays right if it changes:

const { recipient } = await sdk.core.fee.recipient({ connector: 'hyperliquid' });

The production builder address is currently:

0xA47D4d99191db54A4829cdf3de2417E527c3b042

Then approve it:

import { HttpTransport } from '@nktkas/hyperliquid';
import { approveBuilderFee } from '@nktkas/hyperliquid/api/exchange';

await approveBuilderFee(
  { transport: new HttpTransport(), wallet: walletClient },
  { maxFeeRate: '0.10%', builder: recipient },
);

Approve at least 0.06%, which is 6 basis points. maxFeeRate is a ceiling, not the fee: the fee actually charged is usually lower, and Pear never charges more than the ceiling. Pear's own UI approves 0.10% to leave headroom, so a later fee change does not force every user to re-approve.

Approve less than 6 bps and the account is stored but fails its health check with hyperliquid_builder_fee_not_approved.

To check an existing approval:

const approved = await hyperliquid.checkBuilderFeeApproval(userAddress, recipient, 60);

The third argument is the required ceiling in tenths of a basis point, so 6 bps is 60. This is the same comparison the health check runs, which is why it is the check to use: an approval can name the right builder and still be too low, and only comparing the number catches that. Omit the argument and it degrades to "any positive approval".

3. Generate an API wallet

Pear trades through a Hyperliquid agent wallet, so the user's main private key is never involved. Generate a fresh keypair in the browser:

import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts';

const signerKey = generatePrivateKey();
const agentWalletAddress = privateKeyToAccount(signerKey).address;

The private key becomes credentials.signer_key on the trade account. The address becomes metadata.agentWalletAddress.

4. Approve the API wallet

Approve the agent on Hyperliquid with the user's main wallet:

import { HttpTransport } from '@nktkas/hyperliquid';
import { approveAgent } from '@nktkas/hyperliquid/api/exchange';

await approveAgent(
  { transport: new HttpTransport(), wallet: walletClient },
  { agentAddress: agentWalletAddress, agentName: `pear-${userAddress.slice(0, 6)}` },
);

Confirm it landed:

const ok = await hyperliquid.checkApiWallet({ userAddress, agentWalletAddress });

This also rejects an approval that has expired, which a plain presence check would miss.

5. Create the trade account

{
  "connector": "hyperliquid",
  "alias": "main",
  "exchangeIdentifier": "0x1f9840a85d5af5bf1d1762f925bdaddc4201f984",
  "credentials": { "signer_key": "0x…" },
  "metadata": {
    "agentWalletAddress": "0x…",
    "isSubaccount": false,
    "mainAccountAddress": "0x1f9840a85d5af5bf1d1762f925bdaddc4201f984"
  }
}

exchangeIdentifier is the wallet that holds the funds. To trade a subaccount, set isSubaccount: true, put the subaccount address in exchangeIdentifier, and the parent in mainAccountAddress.

Listing a wallet's subaccounts is the one read the helper does not cover, so it goes to Hyperliquid directly:

import { HttpTransport, InfoClient } from '@nktkas/hyperliquid';

const info = new InfoClient({ transport: new HttpTransport() });
const subaccounts = await info.subAccounts({ user: userAddress });

Creation validates all of this against Hyperliquid before it stores anything, and a failed invariant is a 400 carrying the message below:

RuleMessage when it fails
signer_key must control agentWalletAddressPrivate key does not match the agent wallet address
agentWalletAddress must differ from exchangeIdentifierAgent wallet address must differ from the exchange identifier
agentWalletAddress must differ from mainAccountAddressAgent wallet address must differ from the main account address
With isSubaccount: true, exchangeIdentifier must be a child of mainAccountAddressSubaccount does not belong to the provided main account
The agent must be authorized on HyperliquidAgent wallet address not found for the provided user address. Ensure the agent wallet is authorized.

The builder-fee approval is the one thing creation does not check. That surfaces later, through the health check.

Re-approving an expired agent

Agent approvals expire. When one does, generate a new keypair, approve it on Hyperliquid, then PATCH the account:

{
  "connector": "hyperliquid",
  "credentials": { "signer_key": "0x…" },
  "metadata": {
    "agentWalletAddress": "0x…",
    "isSubaccount": false,
    "mainAccountAddress": "0x1f9840a85d5af5bf1d1762f925bdaddc4201f984"
  }
}

Three fields are easy to leave out and all three are required:

  • connector selects which venue's body shape the gateway reads. Every PATCH needs it.
  • metadata is not partial. Send agentWalletAddress, isSubaccount, and mainAccountAddress together, even when only one changed.
  • credentials must accompany any metadata change, or the call returns 400 Private key is required to update Hyperliquid wallet metadata.

The new key must resolve to the same exchange account. One that does not returns 400 Cannot change exchange account. Delete this trade account and create a new one. See Trade Accounts.

Lighter

Lighter's steps run through Pear's gateway rather than a third-party library, because each one needs a transaction the backend signs and the user's L1 wallet authorizes. The Lighter helper in @pear-protocol/exchanges-sdk wraps each of those round trips into one call:

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

const lighter = createLighterHelper(sdk); // the core SDK, not an ExchangesSDK

The Lighter endpoints are listed under each step, for clients that are not on TypeScript.

1. Deposit first

Lighter creates the account when the first deposit lands. Until then the address has no account index, and there is nothing to make an API key for.

deposit() runs a whole deposit, from validating the amount through to Lighter crediting it. Pick a route and it handles the rest:

const result = await lighter.deposit({
  route: 'funxyz',
  ownerAddress,
  connectedAddress,
  chainId,
  amount: '250',
  market: 'perps',
  wallet,          // your wallet adapter, see below
  onProgress: (p) => setDepositState(p.state),
  settlement: { follow: 'outcome' },
});
routeWhat it does
funxyzSends USDC to the wallet's fun.xyz universal deposit address. Mints one per supported chain family, all crediting the same Lighter account. USDC only.
cctpSends native USDC to a Lighter CCTP intent address bound to that exact amount. listCctpDepositNetworks() lists the source chains, which Lighter adds to without a release.
ethereumCalls Lighter's Ethereum gateway contract directly, signed by the owner's wallet.

createDepositAddress() is the other half: it hands back an address that any wallet or exchange can fund, for callers who are not moving the money themselves. Use it for an ETH deposit too, which the funxyz route does not cover.

The SDK stays free of any wallet library, so you pass a small wallet adapter with three functions: readUsdcBalance, retrieveWallet, and confirmTransaction. Wire those to Wagmi, viem, or whatever you use.

Following a deposit to its outcome

onProgress reports the deposit's life after the transfer is submitted:

stateMeaning
confirmingWaiting on the source chain.
bridgingIn flight to Lighter.
creditedLighter booked it. credit carries the record the route could actually read.
registeredTerminal state of a first deposit. Reports the mainAccountIndex and subAccountIndexes the deposit created.
unsettledThe watch window closed first, so nobody waits for an outcome that never arrives.
untrackedYou asked nobody to follow this transfer.
failed, action-requiredCCTP only. Lighter's deposit history is the one source that names them; the fun.xyz bridge documents no failure state.

settlement decides what gets watched, and the first deposit is the special case: it cannot authenticate against an account it is about to create. On CCTP, say so explicitly with { follow: 'account-registration' }, and use { follow: 'deposit-history', selectedAccountIndex, retrieveCredentials } once the account exists. On fun.xyz, pass { follow: 'outcome' } and the bridge decides which record can answer. Omit settlement to submit a deposit nobody waits on.

Watch a first deposit with onProgress rather than blocking on it. The SDK polls for one hour before it gives up and reports unsettled; the CCTP route can shorten that with settlementTimeoutMs. Pear's own UI re-checks every 15 seconds and shows the wait. CCTP normally settles in minutes.

listUdaDeposits(walletAddress) reads what the fun.xyz bridge has seen for a wallet, with each deposit's bridge status, which is what you show someone who is still waiting. It is GET /misc/lighter/uda/{walletAddress} underneath, and POST /misc/lighter/uda mints the addresses. The wallet must be the caller's own linked wallet identity; any other address returns 403 This wallet does not belong to the authenticated user.

Fun.xyz deposit minimums

A deposit below the minimum for its source is not credited. Send more than the minimum, because bridging and swapping lose some of the amount to slippage.

SourceNew accountExisting account
EVM chain (not Ethereum mainnet)$5$3
Ethereum mainnet$5$5
Solana$5$3
Tron$5$5
Bitcoin$10$10

The routable EVM chains are Ethereum, Optimism, Polygon, Monad, HyperEVM, Base, Arbitrum One, and Ink. Native USDC only, on every source.

The account is ready when Lighter returns an account index for the L1 address. checkOnboarding reports that as an object with a step, and account-ready already carries the indexes:

const onboarding = await lighter.checkOnboarding(l1Address);

if (onboarding.step === 'deposit-required') {
  // No account yet. Deposit, then poll again.
} else {
  const { mainAccountIndex, subAccountIndexes } = onboarding;
}

Because the indexes ride on the result, the follow-up lookupAccount(l1Address) is only for callers that want them on their own.

2. Create an API key

One call. The helper picks a free key index, mints the key through the gateway, has the user's L1 wallet sign, and registers it on Lighter:

const { apiKeyIndex, apiPrivateKey, txHash } = await lighter.connectWallet(walletClient, {
  accountIndex: mainAccountIndex,
});

Underneath it is POST /misc/lighter/create-api-key → L1 signature → POST /misc/lighter/change-pub-key. Drive those yourself only if you are not on TypeScript.

api_key_index must be between 4 and 254; lower indexes are reserved. The helper chooses a free one for you, so hand-picking an index is only for a client that manages several keys itself.

3. Approve Pear as integrator

Read the integrator the same way you read the Hyperliquid builder, from the gateway rather than a constant:

const { recipient } = await sdk.core.fee.recipient({ connector: 'lighter' });

For Lighter this returns the integrator account index rather than an address. Then approve:

await lighter.approveIntegrator(walletClient, {
  accountIndex: mainAccountIndex,
  apiKeyIndex,
  apiPrivateKey,
});

That is POST /misc/lighter/prepare-approve-integrator → L1 signature → POST /misc/lighter/submit-approve-integrator. The gateway signs the approval with the fee parameters it is configured with, so you never set the ceiling yourself. When no integrator is configured for the caller's role, the prepare call returns 404 and there is nothing to approve.

Lighter records the approval with a maker and taker fee ceiling in parts per million, plus an expiry. Pear requires at least 600 on each, which is 6 basis points — the same ceiling Hyperliquid's builder approval needs. The gateway sets the expiry five years out. Check an existing one:

const approved = await lighter.checkIntegratorApproval(mainAccountIndex, Number(recipient), {
  minPerpsMakerFee: 600,
  minPerpsTakerFee: 600,
});

Like the Hyperliquid check, this compares the numbers rather than just looking for the integrator, and it rejects an approval that has expired. Either failure surfaces as lighter_integrator_not_approved.

4. Create the trade account

{
  "connector": "lighter",
  "alias": "main",
  "exchangeIdentifier": "123456",
  "credentials": { "api_private_key": "0x…", "api_key_index": 4 },
  "metadata": { "l1_address": "0x1f9840a85d5af5bf1d1762f925bdaddc4201f984" }
}

exchangeIdentifier is the Lighter account index, not an address. The L1 wallet goes in metadata.l1_address.

Private Lighter reads need a short-lived auth token, which POST /misc/lighter/create-auth-token mints. retrieveLighterAuthToken in the same package is the cache the account trackers use, and passing it to @pear-protocol/market-sdk as an authToken provider also raises Lighter's rate limit on public market data. See Market Data.

The rest of the Lighter helper

Onboarding is only part of what it covers. The same helper runs the account for its whole life:

MethodDoesHTTP
changeAccountTier({ accountIndex, apiKeyIndex, apiPrivateKey, tier })Moves the account to standard, plus, or premium. Lighter allows this once every 24 hours, and only while the account holds no open orders or position. It refuses the change itself and says which rule stopped it, so that wording arrives as the thrown error.POST /misc/lighter/change-account-tier
fastWithdraw(walletClient, params)A wallet-authorized USDC withdrawal through Lighter's transfer pool. Returns { txHash, fee }, so you can reconcile what actually left. It enforces Lighter's minimum before spending a signature.POST /misc/lighter/prepare-fast-withdraw → L1 signature → POST /misc/lighter/submit-fast-withdraw
withdraw(walletClient, params)An on-chain withdrawal through the gateway contract.— (signed on chain)
getDepositHistory(params), getWithdrawHistory(params)One page each, newest first.— (read from Lighter)
applyReferralCode(params)Applies a referrer's code. Lighter accepts this at most once per owner.— (read from Lighter)
getUserReferrals(params)The owner's referral record, including the code that referred them.— (read from Lighter)

The tier endpoint separates the two kinds of refusal, so you know whether to retry: a change already running or one inside the 24-hour window is a 409, and a rule the caller can clear — an open order, an open position, or the tier the account already has — is a 400.

Every method that signs is mainnet only. Construct the helper with createLighterHelper(sdk, true) for testnet reads, and the signing paths throw rather than sending credentials somewhere they will not work.

Binance, Bybit, and OKX

These need only an API key from the venue. There is no deposit check and no on-chain approval.

Create the key against the market Pear trades:

ExchangeMarketCredentials
BinanceUSDⓈ-M Futuresread and write key pairs, each { api_key, api_secret }
BybitLinear Perpetualsapi_key, api_secret
OKXLinear SWAPapi_key, api_secret, api_pass

Binance takes two key pairs so a deployment can separate the reading credential from the trading one. Supplying the same pair for both is valid. Two rules apply to them:

  • Both pairs must belong to the same Binance account. Different accounts fail creation with Read and write API keys must belong to the same Binance account.
  • Both pairs must reach the Spot REST API. Pear trades USDⓈ-M Futures, but creation identifies the account through the Spot account endpoint. A key restricted to futures alone cannot be validated, so creation returns a 400. Enable Spot reads on both keys as well as futures trading on the write key.
{
  "connector": "okx",
  "alias": "main",
  "credentials": { "api_key": "…", "api_secret": "…", "api_pass": "…" }
}

Check the account is ready

An account can be stored and still not be able to trade, because an approval is missing or a key was revoked at the venue. status() in @pear-protocol/exchanges-sdk names the problem.

Pass the approval targets on every call. status() takes them per call rather than at construction, because they come from the gateway and can change. Omit them and it skips the approval checks: it still reports a broken key, but it can never report hyperliquid_builder_fee_not_approved or lighter_integrator_not_approved.

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

async function connectOptions(connector: HttpTypes.Connector): Promise<ConnectOptions> {
  if (connector === 'hyperliquid') {
    const { recipient } = await sdk.core.fee.recipient({ connector });
    return {
      ...(recipient ? { builderAddress: recipient as `0x${string}` } : {}),
      builderFeeTenthsBps: 60,
    };
  }

  if (connector === 'lighter') {
    const { recipient } = await sdk.core.fee.recipient({ connector });
    return {
      ...(recipient ? { integratorAccountIndex: Number(recipient) } : {}),
      integratorMinPerpsMakerFee: 600,
      integratorMinPerpsTakerFee: 600,
    };
  }

  // Binance, Bybit, and OKX have no approval to check.
  return {};
}

const connection = await exchanges.connect(tradeAccountId);
const status = await exchanges.status(connection, await connectOptions(connection.connector));

if (status.status === 'warning') {
  for (const reason of status.reasons) console.warn(reason);
}

ConnectOptions has five fields, all optional:

FieldForMeaning
builderAddressHyperliquidThe builder whose approval to check.
builderFeeTenthsBpsHyperliquidThe required ceiling, in tenths of a basis point. Pear uses 60.
integratorAccountIndexLighterThe integrator whose approval to check.
integratorMinPerpsMakerFeeLighterThe required maker ceiling, in parts per million. Pear uses 600.
integratorMinPerpsTakerFeeLighterThe required taker ceiling, in parts per million. Pear uses 600.

Send each venue's fields together. builderAddress without 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. Binance, Bybit, and OKX take no options.

sdk.core.fee.recipient can return {}. recipient is absent when no recipient is configured for the caller's role, which means orders name no builder or integrator and there is nothing to approve. Leave the target out of ConnectOptions in that case rather than passing undefined through Number().

ReasonFix
api_key_invalidThe venue rejected the credentials. Re-create the key.
hyperliquid_api_wallet_invalidThe agent wallet is missing or expired. Generate and approve a new one.
hyperliquid_builder_fee_not_approvedApprove the builder, at 0.06% or more.
hyperliquid_account_mode_unsupportedThe account is in a mode Pear does not model.
hyperliquid_account_mode_check_failedThe account mode could not be read. Retry.
lighter_api_key_invalidThe API key cannot mint an auth token. Re-create it.
lighter_integrator_not_approvedApprove the integrator, at 600 ppm or more on both maker and taker, and check the approval has not expired.

Once the account is ready, scope trading calls to it with x-trade-account-id. See Trade Accounts.

On this page