# Query Hyperliquid Balances for Many Wallets in One Pass

You have a list of Hyperliquid wallets. It might be your own trading sub-accounts, a fund's accounts, a set of whales you watch, or everyone who signed up for your app. You want each wallet's spot and perp balance in USD, now. Hyperliquid's Info API answers that one wallet at a time. It needs one request for spot state, one for perp state and one more for the account mode. For 1,000 wallets that is 3,000 requests.

In this guide we build a Python script that reads all of it in a handful of requests. It sends 1,000 wallets per request to Dwellir's batch Info types and splits longer lists into chunks. It retries the wallets that fail, values every spot token in USD and writes one CSV row per wallet. On the 1,000 most active wallets on the Hyperliquid leaderboard it finished in about a second.

Two details decide whether the totals are right. Unified and portfolio margin accounts report their collateral in the spot state, so adding spot and perp counts the same USDC twice. Standard accounts keep a separate balance on every HIP-3 DEX, which the default query does not return. The script handles both, and every number below comes from running it on 11 November 2026.

## What You Will Learn

- Read spot balances, perp state and account mode for up to 1,000 wallets in one request
- Chunk any wallet list to the batch limits and retry `failed_wallets`
- Value spot balances in USD from `spotMetaAndAssetCtxs`, without trusting thin markets
- Avoid double counting unified and portfolio margin accounts
- Add HIP-3 DEX balances for the standard accounts that have them
- Compare the batch run with the same work on the public endpoint, and its rate limit

## Prerequisites

- Python 3.10 or newer. Run `python3 --version` to check.
- A Dwellir API key from the [dashboard](https://dashboard.dwellir.com/register). The batch types are part of Dwellir's [HyperCore Info endpoint](https://www.dwellir.com/docs/hyperliquid/info-endpoint). You do not need a gRPC or Order Book add-on.
- One package:

```bash
pip install httpx
```

- A text file with one wallet address per line. The script ignores lines that start with `#`.

Set the key in your shell:

```bash
export DWELLIR_API_KEY=your-api-key
```

## The Requests We Need

Hyperliquid's Info API has a per-wallet query for each part of a balance. Dwellir's Info endpoint adds a batch version of each that takes a `users` list. We measured each one with the same 1,000 wallets on 11 November 2026, from a client in Sweden:

| Per wallet               | Dwellir batch type                                                                              | Returns                                    | 1,000 wallets   | Credits per wallet |
| ------------------------ | ----------------------------------------------------------------------------------------------- | ------------------------------------------ | --------------- | ------------------ |
| `spotClearinghouseState` | [`batchSpotClearinghouseState`](https://www.dwellir.com/docs/hyperliquid/batch-spot-clearinghouse-state) | Spot token balances and holds              | 0.19 s, 0.59 MB | 1                  |
| `clearinghouseState`     | [`batchClearinghouseStates`](https://www.dwellir.com/docs/hyperliquid/batch-clearinghouse-states)        | Perp account value, margin, positions      | 0.16 s, 1.41 MB | 1                  |
| `userAbstraction`        | none                                                                                            | Account mode                               |                 |                    |
| all three                | [`batchPortfolioState`](https://www.dwellir.com/docs/hyperliquid/batch-portfolio-state)                  | Perp state, spot balances and account mode | 0.20 s, 2.03 MB | 3                  |

We probed the limits of the batch types with the same request format:

| Request                                  | Response                                       |
| ---------------------------------------- | ---------------------------------------------- |
| 1,000 distinct addresses                 | 200                                            |
| 1,001 entries                            | 413 `{"error":"too many users"}`               |
| 1,000 addresses plus one duplicate       | 200, the duplicate is dropped                  |
| Body larger than 65,536 bytes            | 413 `{"error":"request body too large"}`       |
| `"dex": "ALL_DEXES"` with 101 addresses  | 413 `{"error":"too many users for ALL_DEXES"}` |
| `users: []`, or only malformed addresses | 400 `{"error":"users must not be empty"}`      |
| One address in upper case and lower case | 200, both returned and both billed             |

The last row affects cost. The server drops exact duplicates but treats `0xA822...` and `0xa822...` as two wallets, so we lowercase every address before sending it.

The rest of the guide uses `batchPortfolioState`, because the account mode in its response decides how spot and perp add up. If you only need token balances, the spot type alone costs a third as much. Step 3 shows it.

## Step 1: A Client With Retries

Create `hl_client.py`. It contains the endpoint, one function that sends a query and retries timeouts, 429 and 5xx responses, and one that cleans the wallet list:

```python
"""Small client for Hyperliquid's Info API through Dwellir.

Two jobs: send one Info query with retries, and split a long wallet list
into batches the batch endpoints accept.
"""

import os
import re
import time

import httpx

API_KEY = os.environ.get("DWELLIR_API_KEY", "")
INFO_URL = f"https://api-hyperliquid-mainnet-info.n.dwellir.com/{API_KEY}/info"

MAX_USERS = 1000  # above this the batch endpoints answer 413 "too many users"
MAX_USERS_ALL_DEXES = 100  # with "dex": "ALL_DEXES" the cap is 100
ADDRESS = re.compile(r"^0x[0-9a-fA-F]{40}$")

_client = httpx.Client(timeout=60.0)

def info(query: dict, attempts: int = 5):
    """POST one Info query and return the parsed JSON.

    Retries timeouts, dropped connections, 429 and 5xx with exponential
    backoff. Other errors raise at once.
    The API key is part of the URL, so errors never print the URL.
    """
    if not API_KEY:
        raise RuntimeError("Set DWELLIR_API_KEY first")
    for attempt in range(attempts):
        try:
            resp = _client.post(INFO_URL, json=query)
        except httpx.TransportError:  # timeout or dropped connection
            time.sleep(0.5 * 2**attempt)
            continue
        if resp.status_code == 200:
            return resp.json()
        if resp.status_code == 429 or resp.status_code >= 500:
            time.sleep(0.5 * 2**attempt)
            continue
        raise RuntimeError(f"{query['type']}: HTTP {resp.status_code} {resp.text[:200]}")
    raise RuntimeError(f"{query['type']}: still failing after {attempts} attempts")

def clean_wallets(lines):
    """Lowercase, validate and de-duplicate addresses, keeping input order."""
    seen, wallets, rejected = set(), [], []
    for line in lines:
        addr = line.strip()
        if not addr or addr.startswith("#"):
            continue
        if not ADDRESS.match(addr):
            rejected.append(addr)
            continue
        addr = addr.lower()
        if addr not in seen:
            seen.add(addr)
            wallets.append(addr)
    return wallets, rejected
```

`info()` retries only what a retry can fix: timeouts, dropped connections, 429 and server errors. A 413 or 400 is a problem with the request and raises at once. The key is part of the URL path, so the error message includes the query type and status but never the URL.

`clean_wallets()` does on our side what the server would otherwise charge us for. The function reports a malformed line instead of dropping it silently, and it merges mixed-case duplicates into one.

## Step 2: Batch Any Number of Wallets

Add `batch()` to the bottom of `hl_client.py`:

```python
def batch(query_type: str, wallets: list[str], **extra) -> tuple[dict, list[str]]:
    """Run a Dwellir batch query over any number of wallets.

    Sends chunks of up to 1,000 addresses (100 with ALL_DEXES). Wallets that
    come back in failed_wallets get one more try. Returns ({address: state},
    [wallets that failed twice]).
    """
    size = MAX_USERS_ALL_DEXES if extra.get("dex") == "ALL_DEXES" else MAX_USERS
    states: dict = {}
    pending = wallets
    for attempt in range(2):
        failed = []
        for i in range(0, len(pending), size):
            result = info({"type": query_type, "users": pending[i : i + size], **extra})
            for address, state in result["successful_states"]:
                states[address.lower()] = state
            failed += result["failed_wallets"]
        if not failed:
            break
        pending = failed
    return states, failed
```

Every batch type returns the same envelope: `successful_states`, a list of `[address, state]` pairs, and `failed_wallets`, the addresses whose upstream lookup failed. A failed wallet does not fail the request, so the function collects them and sends them once more. It returns a dict keyed by lowercase address, which makes the later lookups order-independent.

`**extra` passes optional fields such as `dex` through to the request and picks the smaller chunk size for `ALL_DEXES`.

## Step 3: Spot Balances Only

Before you write the full script, check that the batching works. Create `spot_only.py`:

```python
"""Spot token balances only, at 1 credit per wallet."""

import sys

from hl_client import batch, clean_wallets

with open(sys.argv[1]) as f:
    wallets, _ = clean_wallets(f)

states, failed = batch("batchSpotClearinghouseState", wallets)
holders = {}
for address, state in states.items():
    for bal in state["balances"]:
        if float(bal["total"]) > 0:
            holders[bal["coin"]] = holders.get(bal["coin"], 0) + 1

print(f"{len(states)} wallets, {len(failed)} failed")
for coin, count in sorted(holders.items(), key=lambda kv: -kv[1])[:5]:
    print(f"{coin:<8} held by {count} wallets")
```

For a test list we took the 1,000 wallets with the most 30-day volume on the Hyperliquid leaderboard. Run it:

```bash
python spot_only.py wallets.txt
```

```
1000 wallets, 0 failed
USDC     held by 926 wallets
HYPE     held by 455 wallets
MAX      held by 296 wallets
LICKO    held by 116 wallets
MUNCH    held by 102 wallets
```

The run sent one request for 1,000 wallets and took under half a second. Each balance has `coin`, `token` (the spot token index), `total`, `hold` (the part locked in open orders) and `entryNtl`. `total` minus `hold` is what the wallet can spend.

## Step 4: Price Spot Tokens in USD

A spot balance is a token amount. To add it to a perp account value, we need a USD price per token. `spotMetaAndAssetCtxs` returns every spot pair with its mark price and daily volume in one call. Create `balances.py` and start it with the pricing:

```python
"""Spot and perp balances for a list of Hyperliquid wallets, in USD.

    python balances.py wallets.txt --out balances.csv
"""

import argparse
import csv
import time

from hl_client import batch, clean_wallets, info

USDC = 0  # spot token index of USDC
MIN_DAY_VOLUME = 10_000  # only trust prices from pairs that trade this much a day
SHARED = {"unifiedAccount", "portfolioMargin"}  # spot state holds all collateral

def spot_prices() -> dict[int, float]:
    """USD price per spot token index, from each pair's mark price.

    Pairs below MIN_DAY_VOLUME are skipped: a thin pair's mark price turns a
    deployer's unsold supply into billions. Tokens that other pairs quote in
    (USDT0, USDH) are always priced, and those pairs convert through them.
    """
    meta, ctxs = info({"type": "spotMetaAndAssetCtxs"})
    ctx_by_pair = {c["coin"]: c for c in ctxs}  # the two lists are not aligned by index
    quote_tokens = {pair["tokens"][1] for pair in meta["universe"]}
    pairs = []
    for pair in meta["universe"]:
        ctx = ctx_by_pair.get(pair["name"])
        base, quote = pair["tokens"]
        if ctx and ctx["markPx"] and (
            float(ctx["dayNtlVlm"]) >= MIN_DAY_VOLUME or (base in quote_tokens and quote == USDC)
        ):
            pairs.append((base, quote, float(ctx["markPx"])))
    prices = {USDC: 1.0}
    for _ in range(2):  # the second pass prices tokens quoted in non-USDC tokens
        for base, quote, px in pairs:
            if base not in prices and quote in prices:
                prices[base] = px * prices[quote]
    return prices
```

The code handles two traps.

**The two lists are not aligned.** `spotMetaAndAssetCtxs` returns `[meta, ctxs]`. On 11 November `meta.universe` had 351 pairs and `ctxs` had 1,190 entries, so zipping them by position paired tokens with the wrong prices. Each context contains a `coin` field (`"PURR/USDC"`, `"@107"`) that matches the pair's `name`, so we join on that.

**Thin markets inflate balances.** We also ran the script on the 1,000 largest accounts on the leaderboard. Priced at mark, one of them held 998,546,335 UUUSPX worth $368 million, on a pair that had traded $6,750 that day. The `MIN_DAY_VOLUME` filter drops prices like that. On that list it removed $1.67 billion of value. On the run below it priced 47 tokens, including HYPE, UBTC, UETH, USOL and USDT0, and left the rest out of the USD total. Tokens that other pairs use as their quote token, such as USDH, skip the floor. The USDH/USDC pair traded only $8,671 that day. Without the exemption, every USDH balance would drop out.

The same list also showed a problem that no price filter fixes. Its largest wallet held 499 million USOL, worth $41.9 billion at a liquid price. That looks like a token issuer's inventory. If your list can contain issuer or bridge addresses, exclude them by address.

The volume floor is a judgment call. Hyperliquid's own portfolio view values every token at its mark price. A wallet that holds many illiquid tokens will show more there than in our CSV. The script lists the coins it could not price in an `unpriced` column, so the CSV shows what the USD total leaves out.

## Step 5: Add Spot and Perp by Account Mode

Hyperliquid accounts run in one of several [account abstraction modes](https://hyperliquid.gitbook.io/hyperliquid-docs/trading/account-abstraction-modes), and `batchPortfolioState` returns the mode as `userAbstraction`. In our 1,000 wallets:

| `userAbstraction`     | Wallets | Where the collateral is reported              |
| --------------------- | ------- | --------------------------------------------- |
| `unifiedAccount`      | 729     | Spot state, one balance per asset             |
| `portfolioMargin`     | 124     | Spot state, including borrows                 |
| `disabled` (standard) | 131     | Spot and perp separately, plus each HIP-3 DEX |
| `default`             | 16      | Treated as standard: spot and perp separately |

Hyperliquid's documentation lists standard, unified, portfolio margin and a discontinued DEX abstraction mode, and none called `default`. The `default` wallets keep separate spot and perp balances like `disabled` ones, so the script treats both as standard.

Hyperliquid's documentation states that unified and portfolio margin accounts "show all balances and holds in the spot clearinghouse state." The largest wallet in our list, a unified account, had a perp `accountValue` of $77.5 million. Its spot USDC `total` was $94.8 million, and $90.5 million of that was on `hold` as margin. Adding the two would report $236 million for a wallet whose spot balances are worth $158 million.

![Which Hyperliquid balance to add, by account mode: unified and portfolio margin accounts use spot balances only, because perp margin is drawn from spot USDC. Standard accounts add spot balances, native perp account value and every HIP-3 DEX's account value.](https://www.dwellir.com/posts/hyperliquid-batch-wallet-balances/account_mode_balance_rule.webp)

Which balance to add, by account mode (1,000 wallets, 11 November 2026):

| Mode                            | Wallets | Equity                                          |
| ------------------------------- | ------- | ----------------------------------------------- |
| unifiedAccount, portfolioMargin | 853     | spot balances in USD only                       |
| disabled, default (standard)    | 147     | spot balances + perp account value on every DEX |

- A unified account with $77.5M perp account value held $94.8M spot USDC, $90.5M of it on hold as margin. Adding both double counts.
- A standard account held $32.85M on the native perp DEX and $13.19M on the xyz HIP-3 DEX.

Add the row builder to `balances.py`:

```python
def perp_totals(perp: dict) -> tuple[float, float, int]:
    """Account value, notional and position count, for one DEX or a DEX map."""
    dexes = [perp] if "marginSummary" in perp else list(perp.values())
    value = sum(float(d["marginSummary"]["accountValue"]) for d in dexes)
    notional = sum(float(d["marginSummary"]["totalNtlPos"]) for d in dexes)
    positions = sum(len(d["assetPositions"]) for d in dexes)
    return value, notional, positions

def wallet_row(address: str, state: dict, prices: dict[int, float]) -> dict:
    mode = state["userAbstraction"]
    spot_usd, unpriced = 0.0, []
    for bal in state["spotClearinghouseState"]["balances"]:
        token, total = bal.get("token"), float(bal["total"])  # HIP-4 outcomes have no token
        if token in prices:
            spot_usd += total * prices[token]
        elif total:
            unpriced.append(bal["coin"])
    perp_value, notional, positions = perp_totals(state["clearinghouseState"])
    # Unified and portfolio margin accounts keep all collateral in the spot
    # state. Their perp account value is part of it, so adding both double counts.
    equity = spot_usd if mode in SHARED else spot_usd + perp_value
    return {
        "address": address,
        "mode": mode,
        "spot_usd": round(spot_usd, 2),
        "perp_account_value": round(perp_value, 2),
        "perp_notional": round(notional, 2),
        "positions": positions,
        "equity_usd": round(equity, 2),
        "unpriced": " ".join(unpriced),
    }
```

`perp_totals()` accepts either one `clearinghouseState` or a map of them keyed by DEX, which is what Step 6 will add for standard accounts. Some spot balances have no `token` field. These coins are HIP-4 outcome positions such as `+85721`, and `bal.get()` keeps them from crashing the loop. Portfolio margin accounts can have a negative USDC `total` when they borrow. Multiplying by the price subtracts the debt, which is the intended result.

We checked the rule against Hyperliquid's own `portfolio` query, which returns the account value its app shows, for three wallets with open positions in each mode. With every token at its mark price and staked HYPE added (from `delegatorSummary`), all 12 matched the app to within 0.07%. Adding perp to spot would have overstated the six unified and portfolio margin wallets by between 4% and 100%. Without staked HYPE, one standard wallet came out at $0.9 million against $8.4 million in the app. That wallet had 103,234 HYPE delegated to validators.

## Step 6: HIP-3 DEXes and the Main Function

A standard account keeps a separate margin balance on each HIP-3 DEX, and `batchPortfolioState` without a `dex` field returns only the native one. One standard wallet in our list showed $32.85 million on the native DEX. In a separate check, Hyperliquid's app showed $46.05 million of perp value for it. The other $13.19 million was on the `xyz` DEX.

Passing `"dex": "ALL_DEXES"` returns a map with every DEX for each wallet. The limit is 100 wallets per request, and the price is higher: [`1 + users × (N + 2)` credits](https://www.dwellir.com/docs/hyperliquid/batch-portfolio-state), where N is the number of DEX keys in the response. Unified and portfolio margin accounts do not need it, because their collateral is already in the spot state. So we read all wallets once, and only the standard ones again across all DEXes.

Add `main()` to the bottom of `balances.py`:

```python
def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("wallets", help="file with one address per line")
    parser.add_argument("--out", default="balances.csv")
    args = parser.parse_args()

    with open(args.wallets) as f:
        wallets, rejected = clean_wallets(f)
    print(f"{len(wallets)} wallets, {len(rejected)} rejected")

    started = time.perf_counter()
    prices = spot_prices()
    states, failed = batch("batchPortfolioState", wallets)

    # Standard accounts keep a separate balance on every HIP-3 DEX.
    # Read those wallets again across all DEXes.
    standard = [a for a in wallets if a in states and states[a]["userAbstraction"] not in SHARED]
    if standard:
        all_dexes, failed_dexes = batch("batchPortfolioState", standard, dex="ALL_DEXES")
        states.update(all_dexes)
        failed += failed_dexes

    rows = [wallet_row(a, states[a], prices) for a in wallets if a in states]
    elapsed = time.perf_counter() - started

    rows.sort(key=lambda r: r["equity_usd"], reverse=True)
    with open(args.out, "w", newline="") as f:
        writer = csv.DictWriter(f, fieldnames=list(rows[0]))
        writer.writeheader()
        writer.writerows(rows)

    total = sum(r["equity_usd"] for r in rows)
    print(f"{len(rows)} wallets read in {elapsed:.2f} s ({len(standard)} also across HIP-3 DEXes), "
          f"{len(failed)} failed")
    print(f"total equity ${total:,.0f}, written to {args.out}\n")
    for r in rows[:5]:
        print(f"{r['address']}  {r['mode']:<15} ${r['equity_usd']:>15,.0f}  {r['positions']:>3} positions")

if __name__ == "__main__":
    main()
```

For the 1,000-wallet list that is four requests: one for prices, one `batchPortfolioState` for all wallets, and two `ALL_DEXES` requests for the 147 standard wallets. It costs 3,000 credits for the first pass and 1,766 for the second, with 10 DEX keys per wallet.

On 11 November `perpDexs` listed 11 DEXes, including the native one, and the `ALL_DEXES` map had 10 keys. The `io` DEX was missing, with no error. The [`batchClearinghouseStates` docs](https://www.dwellir.com/docs/hyperliquid/batch-clearinghouse-states) note that the DEX list can be truncated silently. If a wallet you track trades on a DEX that the map leaves out, query that DEX by name with `"dex": "io"`. Compare the map's keys with [`perpDexs`](https://www.dwellir.com/docs/hyperliquid/perp-dexs) now and then.

## Run the Script

Run the script on the 1,000 wallets:

```bash
python balances.py wallets.txt
```

```
1000 wallets, 0 rejected
1000 wallets read in 1.00 s (147 also across HIP-3 DEXes), 0 failed
total equity $2,088,597,632, written to balances.csv

0xb83de012dba672c76a7dbbbf3e459cb59d7d6e36  unifiedAccount  $    158,888,231   30 positions
0x5b5d51203a0f9079f8aeb098a6523a13f298c060  unifiedAccount  $    140,403,473   23 positions
0xecb63caa47c7c4e77f60f1ce858cf28dc2b82b00  disabled        $     70,832,548  132 positions
0xfc667adba8d4837586078f4fdcdc29804337ca06  disabled        $     64,011,784   64 positions
0xdf954bbe619273a56e0cfa90dad8382195dc7d72  unifiedAccount  $     49,286,632  161 positions
```

The CSV has one row per wallet:

```
address,mode,spot_usd,perp_account_value,perp_notional,positions,equity_usd,unpriced
0xb83de012dba672c76a7dbbbf3e459cb59d7d6e36,unifiedAccount,158888231.42,77440679.4,323180012.24,30,158888231.42,LATINA LICKO
0xecb63caa47c7c4e77f60f1ce858cf28dc2b82b00,disabled,24861702.49,45970845.21,97166808.14,132,70832547.7,MEOW PEAR ...
```

A longer list only adds chunks. We also ran a list of 2,500 wallets plus one malformed line and three upper-case duplicates. The script rejected the malformed line and merged the duplicates. It read all 2,500 wallets in 1.33 seconds over three `batchPortfolioState` requests and three `ALL_DEXES` requests for 274 standard wallets.

## The Same Work on the Public Endpoint

To see what the batch saves, `public_baseline.py` reads the same three things one wallet at a time from `api.hyperliquid.xyz`, waiting and retrying on 429:

```python
"""The same balances from Hyperliquid's public endpoint, one wallet at a time.

    python public_baseline.py wallets.txt --limit 200
"""

import argparse
import time

import httpx

from hl_client import clean_wallets

PUBLIC_INFO = "https://api.hyperliquid.xyz/info"
QUERIES = ["clearinghouseState", "spotClearinghouseState", "userAbstraction"]

client = httpx.Client(timeout=30.0)
stats = {"requests": 0, "429": 0}

def public_info(query: dict):
    delay = 1.0
    while True:
        stats["requests"] += 1
        resp = client.post(PUBLIC_INFO, json=query)
        if resp.status_code != 429:
            resp.raise_for_status()
            return resp.json()
        stats["429"] += 1
        time.sleep(delay)
        delay = min(delay * 2, 30.0)

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("wallets")
    parser.add_argument("--limit", type=int, default=200)
    args = parser.parse_args()
    with open(args.wallets) as f:
        wallets, _ = clean_wallets(f)
    wallets = wallets[: args.limit]

    started = time.perf_counter()
    for address in wallets:
        for query_type in QUERIES:
            public_info({"type": query_type, "user": address})
    elapsed = time.perf_counter() - started
    print(f"{len(wallets)} wallets, {stats['requests']} requests, "
          f"{stats['429']} answered 429, {elapsed:.1f} s")

if __name__ == "__main__":
    main()
```

```bash
python public_baseline.py wallets.txt --limit 1000
```

```
1000 wallets, 3000 requests, 0 answered 429, 856.9 s
```

The same 1,000 wallets took 14 minutes 17 seconds. The batch script took between 0.67 and 1.00 seconds across our runs. The loop never saw a 429. One request at a time from Sweden averaged 286 ms, so the round trip set the pace.

Hyperliquid [documents](https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/rate-limits-and-user-limits) the public limit as 1,200 weight per minute per IP address. `clearinghouseState` and `spotClearinghouseState` weigh 2, and most other info requests, `userAbstraction` among them, weigh 20. By that table the three calls cost 24 weight per wallet, or 50 wallets a minute.

What we measured was looser, and less predictable. After three minutes idle we sent `spotClearinghouseState` requests on 16 parallel connections. The endpoint accepted 2,405 of them in 42.5 seconds before the first 429. After that it alternated between stretches of 20 to 30 seconds that were mostly refused and stretches that were mostly accepted. Over 155 seconds it accepted 5,473 of 9,000. A 429 comes back with the body `null` and no `Retry-After` header, so the client has to pick its own backoff. Every service behind the same IP address shares the allowance.

![Requests to read spot balance, perp state and account mode for 1,000 Hyperliquid wallets. Dwellir's batch Info types needed 4 requests. The public endpoint needed 3,000 requests, one wallet at a time.](https://www.dwellir.com/posts/hyperliquid-batch-wallet-balances/requests_per_1000_wallets.webp)

Requests to read spot, perp and account mode for 1,000 wallets (11 November 2026):

| Approach                              | Requests | Wall time   |
| ------------------------------------- | -------- | ----------- |
| Dwellir batch types                   | 4        | 1.0 s       |
| Public endpoint, one wallet at a time | 3,000    | 14 min 17 s |

## Going to Production

**Budget by wallet, not by request.** Dwellir bills the batch types per distinct valid wallet, including the ones in `failed_wallets`. A snapshot of 1,000 wallets with the HIP-3 pass for 147 of them costs about 4,800 credits. At one snapshot every five minutes, that is 1.37 million credits a day. At one snapshot an hour, it is 114,000. If you only need token balances, `batchSpotClearinghouseState` at one credit per wallet is the cheapest read. See [Hyperliquid pricing](https://www.dwellir.com/docs/hyperliquid/pricing) for the plans.

**Lowercase before you send.** The server treats an address in different cases as different wallets and bills both. `clean_wallets()` already does this. Keep it if you replace the input step.

**Treat `failed_wallets` as data.** Dwellir bills a wallet in `failed_wallets`, and the response has no state for it. The script retries once and then reports the count. In production, add the failed wallets to the next run instead of writing a zero balance for them. Two cases are harder to spot. With `ALL_DEXES`, a wallet whose call fails on some DEXes but not others comes back with only the DEXes that answered. The response does not list it as failed, so its perp total is low. Also, a standard wallet that fails both `ALL_DEXES` tries keeps its native-only row in the CSV and also counts as failed.

**Check the snapshot time.** Every `clearinghouseState` contains a `time` in milliseconds. In our runs, all 1,000 states in one response had the same `time`. That time was about 0.7 seconds old when the response arrived, so one request is one consistent snapshot. Separate chunks are separate snapshots. If you reconcile wallets against each other across chunks, compare their `time` values first.

**Re-check modes.** A wallet can switch between standard, unified and portfolio margin at any time. The switch changes whether its collateral is in the spot state or the perp state. The script reads `userAbstraction` on every run for that reason. Do not cache it.

**Keep the key out of logs.** The key is part of the URL path. If your HTTP logging records URLs, send it in an `X-Api-Key` header to `https://api-hyperliquid-mainnet-info.n.dwellir.com/info` instead.

**Add staked HYPE if you need it.** Delegated and undelegated HYPE are not part of the spot and perp states. `delegatorSummary` returns them per wallet and works through Dwellir's Info endpoint, but has no batch type, so it costs one request per wallet.

**Decide what "balance" means for your use.** `equity_usd` is the account's value. For "what can this wallet withdraw or spend", use `withdrawable` from the perp state on standard accounts, and `total` minus `hold` from the spot balances.

## Next Steps

- Watch the same wallets live instead of polling. Dwellir's gRPC [`StreamFills`](https://www.dwellir.com/docs/hyperliquid/stream_fills) accepts a `users` filter, so one stream delivers every fill for your list between snapshots.
- Read every vault's positions the same way. [Hyperliquid vault positions in one request](https://www.dwellir.com/blog/hyperliquid-vault-data-api) runs `batchClearinghouseStates` over all 493 open vault accounts and adds up their exposure by coin.
- Build the wallet list from the leaderboard. The [vaults and leaderboard guide](https://www.dwellir.com/guides/hyperliquid-vaults-leaderboard) downloads all 48,000 ranked traders, which is the list we sampled for this guide.

This guide used Dwellir's Hyperliquid Info endpoint for every balance read. To run it yourself, [create a free account](https://dashboard.dwellir.com/register) and set `DWELLIR_API_KEY`.

### References

- [batchPortfolioState](https://www.dwellir.com/docs/hyperliquid/batch-portfolio-state), [batchClearinghouseStates](https://www.dwellir.com/docs/hyperliquid/batch-clearinghouse-states), [batchSpotClearinghouseState](https://www.dwellir.com/docs/hyperliquid/batch-spot-clearinghouse-state)
- [userAbstraction](https://www.dwellir.com/docs/hyperliquid/user-abstraction), [spotMeta](https://www.dwellir.com/docs/hyperliquid/spot-meta), [perpDexs](https://www.dwellir.com/docs/hyperliquid/perp-dexs)
- [Hyperliquid account abstraction modes](https://hyperliquid.gitbook.io/hyperliquid-docs/trading/account-abstraction-modes)
- [Hyperliquid rate limits and user limits](https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/rate-limits-and-user-limits)
