---
title: "Hyperliquid orders vs trades vs fills"
description: "On Hyperliquid, orders, trades, and fills are three different records. Here's each HyperCore payload, how they join on oid and tid, and how to reconcile them."
---

# Hyperliquid orders vs trades vs fills

<ImageBlock
  src="https://media.alchemy.com/overviews/hyperliquid-orders-trades-fills-hero-16x9-20261020.png"
  alt="Hyperliquid orders vs trades vs fills"
  width={1920}
  height={1080}
  priority
/>

On Hyperliquid, an order is an instruction to buy or sell, a trade is one match between two orders, and a fill is one account's side of that match. Every trade produces exactly two fills, one per account. This guide walks through the payload for each record on HyperCore, the keys that join them, and the failure cases that break reconciliation in production.

The three records arrive on different streams and change at different times, so code that treats them as the same thing drifts out of sync with the exchange. Everything below was checked against live Hyperliquid mainnet data, and the two scripts in this guide run today against the public Hyperliquid API.

## What is the difference between an order, a trade, and a fill?

HyperCore is Hyperliquid's native exchange engine, separate from the HyperEVM smart-contract environment. It runs a fully onchain order book, so every order placement, cancel, and match is recorded in a block.

The three records describe that activity from different angles:

<EmbeddedTable
  table={{
    columns: [
      { key: "record", width: 180, title: "Record", dataType: "object" },
      {
        key: "what_it_describes",
        width: 180,
        title: "What it describes",
        dataType: "object",
      },
      {
        key: "identifier",
        width: 180,
        title: "Identifier",
        dataType: "object",
      },
      { key: "scope", width: 180, title: "Scope", dataType: "object" },
      { key: "stream", width: 180, title: "Stream", dataType: "object" },
    ],
    data: [
      {
        record: { title: "Order update", tooltip: "", icon: "" },
        what_it_describes: {
          title:
            "A change in an order's status (open, filled, canceled, rejected)",
          tooltip: "",
          icon: "",
        },
        identifier: { title: "<code>oid</code>", tooltip: "", icon: "" },
        scope: { title: "One account", tooltip: "", icon: "" },
        stream: {
          title:
            "<code>orderUpdates</code> (native), <code>userOrderUpdates</code> (Alchemy)",
          tooltip: "",
          icon: "",
        },
        id: 0,
      },
      {
        record: { title: "Trade", tooltip: "", icon: "" },
        what_it_describes: {
          title: "One match between a buyer and a seller in a market",
          tooltip: "",
          icon: "",
        },
        identifier: { title: "<code>tid</code>", tooltip: "", icon: "" },
        scope: { title: "One market, both accounts", tooltip: "", icon: "" },
        stream: { title: "<code>trades</code>", tooltip: "", icon: "" },
        id: 1,
      },
      {
        record: { title: "Fill", tooltip: "", icon: "" },
        what_it_describes: {
          title: "One account's side of a trade, with fees and PnL",
          tooltip: "",
          icon: "",
        },
        identifier: {
          title: "<code>tid</code> and <code>oid</code>",
          tooltip: "",
          icon: "",
        },
        scope: { title: "One account", tooltip: "", icon: "" },
        stream: { title: "<code>userFills</code>", tooltip: "", icon: "" },
        id: 2,
      },
    ],
  }}
/>

A trade is the market's view of a match, which is why it lists both accounts and no fees. A fill is the account's view of the same match, so it carries the fee that account paid, the position it held before, and the order it came from. Order updates describe the order itself, and as the next section shows, they are sparser than most developers expect.

If you are building a trading bot or a portfolio tracker, fills are the source of truth for what executed, and order updates are the source of truth for what is still resting on the book.

## What does an order update look like?

An order update is emitted when an order's status changes. The payload wraps the order in a status record:

<CodeSnippet
  language="json"
  code={`{
  "status": "open",
  "statusTimestamp": 1780000000000,
  "hash": "0xabc...def",
  "order": {
    "oid": 12345,
    "coin": "BTC",
    "side": "B",
    "limitPx": "100000.0",
    "sz": "0.10",
    "origSz": "0.10",
    "orderType": "Limit",
    "tif": "Gtc",
    "reduceOnly": false,
    "isTrigger": false,
    "triggerPx": "0.0",
    "cloid": null
  }
}`}
/>

The fields that matter most for state tracking are:

- **`oid`.** The exchange-assigned order ID. Fills reference it, so it is the join key between an order and its executions.
- **`origSz` and `sz`.** The size at placement and the size still resting at the time of this status record.
- **`status`.** The new state. `open`, `filled`, `canceled`, and `triggered` are the common ones, and there is a long tail of specific rejection and cancel reasons such as `badAloPxRejected`, `reduceOnlyCanceled`, `selfTradeCanceled`, and `liquidatedCanceled`.
- **`tif`.** Time in force. `Gtc` rests until canceled, `Alo` (add liquidity only) is rejected if it would cross the spread, and `Ioc` (immediate or cancel) takes what it can and drops the rest.
- **`cloid`.** An optional client-supplied order ID, useful for matching an order to the request your system sent.

The [userOrderUpdates reference](https://www.alchemy.com/docs/chains/websockets/hypercore/streams/user-order-updates) lists every status value observed in node output and states that the list is not exhaustive. Treat an unknown status string as a terminal state you log and investigate, not as a crash.

Three behaviors of order updates break naive state machines.

A partial fill does not produce an order update. When we watched live accounts, a resting order that was partially filled emitted `open`, then one or more fills, then nothing until it reached a terminal state. When such an order is later canceled, the `canceled` record carries the remaining size in `sz`. If you need the live remaining size of an open order, compute it from fills or read open orders on demand.

A `filled` status does not always mean fully filled. An `Ioc` order that matches part of its size ends with status `filled` and a non-zero `sz`, which is the unfilled remainder that was dropped. In one 45-second sample from an active account, 72 of the 127 `Ioc` orders that reached `filled` still had size left over. Check `sz` before you treat an order as complete.

The `hash` field is populated only for `open`. Canceled and rejected records have a null hash, so the transaction hash is not a usable key for order history.

## What does a trade look like?

The `trades` stream publishes every match in one market. Each record is small:

<CodeSnippet
  language="json"
  code={`{
  "coin": "ETH",
  "side": "A",
  "px": "2658.9",
  "sz": "1.0",
  "time": 1790608928951,
  "hash": "0xd8c5bd7a...25ae9e33",
  "tid": 825882878864874,
  "users": ["0x4cf1...e717", "0xe4f5...c0c4"]
}`}
/>

`side` is the taker's side, where `A` (ask) means the taker sold and `B` (bid) means the taker bought. `users` always lists the buyer first and the seller second, so the taker is `users[0]` when `side` is `B` and `users[1]` when `side` is `A`. In the example above the taker sold, which makes `0xe4f5...c0c4` the taker and `0x4cf1...e717` the resting buyer.

One taker order that sweeps several resting orders produces several trades. Each has its own `tid`, and they share the same `hash` and `time` because one transaction caused all of them. That means `hash` identifies the transaction, not the trade.

Some trades carry an all-zero hash. In a sample of 80 recent trades across eight markets, 7 had a zero hash. The one we traced back was a TWAP slice, which the exchange executes on the user's behalf without a new signed transaction, and [Hyperliquid's info endpoint docs](https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/info-endpoint) confirm that TWAP fills carry a zero hash. Code that joins trades to transactions by hash needs to handle that case.

Use `trades` for market-wide data such as tape, volume, and price charts. For anything about a specific account, subscribe to fills instead, because the trade record has no fees, no order ID, and no position context.

## What does a fill look like?

A fill is one account's side of a trade. Here are the two fills for the ETH trade above, pulled from mainnet:

<CodeSnippet
  language="json"
  code={`[
  {
    "coin": "ETH", "px": "2658.9", "sz": "1.0", "side": "B",
    "time": 1790608928951, "startPosition": "19.3599", "dir": "Open Long",
    "closedPnl": "0.0", "hash": "0xd8c5bd7a...25ae9e33",
    "oid": 559115773954, "crossed": false, "fee": "-0.079767",
    "tid": 825882878864874, "feeToken": "USDC", "twapId": null,
    "cloid": "0xdd0cd482496f45f79c5ffbe7c27e1511"
  },
  {
    "coin": "ETH", "px": "2658.9", "sz": "1.0", "side": "A",
    "time": 1790608928951, "startPosition": "-776.0", "dir": "Open Short",
    "closedPnl": "0.0", "hash": "0xd8c5bd7a...25ae9e33",
    "oid": 559115788953, "crossed": true, "fee": "1.196505",
    "builderFee": "0.26589",
    "tid": 825882878864874, "feeToken": "USDC", "twapId": null
  }
]`}
/>

Both fills share the trade's `tid`, price, size, and time. Everything else is specific to the account:

- **`oid`.** The order that produced this fill. The buyer and seller each have their own.
- **`crossed`.** `true` for the taker, `false` for the maker (the order that was resting on the book).
- **`fee`.** The fee this account paid. A negative value is a maker rebate, as on the buyer's fill above. `builderFee`, when present, is the portion paid to a builder (a frontend or app that routed the order) and is already included in `fee`.
- **`startPosition`, `dir`, and `closedPnl`.** The position size before the fill, a display label such as `Open Long` or `Close Short`, and the realized PnL this fill closed. These let you rebuild position history from fills alone.
- **`liquidation`.** Null for normal fills. For a liquidation it names the liquidated account, the mark price, and whether the liquidation went through the market or the backstop.
- **`twapId`.** The TWAP order that produced the fill, when there is one.

Fees, rebates, realized PnL, and liquidation details exist only on fills, so any ledger or tax report has to be built from them.

## How do orders, trades, and fills join together?

Two keys carry almost all of the joins:

<EmbeddedTable
  table={{
    columns: [
      { key: "join", width: 260, title: "Join", dataType: "object" },
      { key: "key", width: 260, title: "Key", dataType: "object" },
      {
        key: "cardinality",
        width: 260,
        title: "Cardinality",
        dataType: "object",
      },
    ],
    data: [
      {
        join: { title: "Trade to fills", tooltip: "", icon: "" },
        key: {
          title:
            "<code>tid</code>, with <code>coin</code> and <code>time</code>",
          tooltip: "",
          icon: "",
        },
        cardinality: {
          title: "One trade, exactly two fills",
          tooltip: "",
          icon: "",
        },
        id: 0,
      },
      {
        join: { title: "Fill to order", tooltip: "", icon: "" },
        key: { title: "<code>oid</code>", tooltip: "", icon: "" },
        cardinality: {
          title: "One order, zero or more fills",
          tooltip: "",
          icon: "",
        },
        id: 1,
      },
      {
        join: { title: "Order to client request", tooltip: "", icon: "" },
        key: { title: "<code>cloid</code>", tooltip: "", icon: "" },
        cardinality: {
          title: "One to one, when you set it",
          tooltip: "",
          icon: "",
        },
        id: 2,
      },
      {
        join: { title: "Fill or trade to transaction", tooltip: "", icon: "" },
        key: { title: "<code>hash</code>", tooltip: "", icon: "" },
        cardinality: {
          title: "Many to one, with an all-zero hash for TWAP slices",
          tooltip: "",
          icon: "",
        },
        id: 3,
      },
    ],
  }}
/>

The first two rows are the ones to build on. We checked the trade-to-fill join on live data by pulling a trade from `trades`, then querying fills for both addresses in the `users` array. Each address had exactly one fill with the trade's `tid`, one with `crossed: true` and one with `crossed: false`.

The remaining size of an order follows from the fill-to-order join:

<CodeSnippet
  language="text"
  code={`remaining = origSz - sum(fill.sz for every fill where fill.oid == order.oid)`}
/>

That formula holds for every order, and it is the one to trust over the `sz` in the last order update you saw.

## How do you reconcile order state from fills?

The script below subscribes to order updates and fills for one account, tracks each order's filled size from its fills, and checks that math against the `sz` the exchange reports when the order reaches a terminal state. It runs on a recent Node release with built-in WebSocket support and needs no dependencies.

<CodeSnippet
  language="javascript"
  code={`// Tracks one account's orders by joining order-status events with fills.
// Usage: node reconcile.mjs 0xYourAddress
const WS_URL = process.env.HL_WS_URL ?? "wss://api.hyperliquid.xyz/ws";
const user = process.argv[2];
if (!/^0x[0-9a-fA-F]{40}$/.test(user ?? "")) throw new Error("pass a 0x address");
// Sizes arrive as decimal strings. Sum them as scaled integers so partial fills add up exactly.
const SCALE = 10;
const units = (s) => {
  const [i, f = ""] = s.split(".");
  return BigInt(i + f.padEnd(SCALE, "0").slice(0, SCALE));
};
const fmt = (u) => (Number(u) / 10 ** SCALE).toString();
const orders = new Map(); // oid -> { coin, origSz, filled, status, reported }
const seenFills = new Set(); // coin|time|tid, so a fill delivered twice is counted once
const fillKey = (f) => \`\${f.coin}|\${f.time}|\${f.tid}\`;
const order = (oid) => {
  if (!orders.has(oid)) orders.set(oid, { filled: 0n, status: "unknown" });
  return orders.get(oid);
};
// Runs whenever a terminal order learns something new, so either stream can arrive first.
function check(oid, o) {
  if (o.reported === undefined) return; // not terminal yet
  const remaining = o.origSz - o.filled;
  if (remaining === o.reported && o.filled === 0n) return; // plain cancel or reject
  const result =
    remaining === o.reported
      ? "ok"
      : remaining > o.reported
        ? \`waiting for fills (\${fmt(remaining - o.reported)} outstanding)\`
        : \`MISMATCH (fills say \${fmt(remaining)})\`;
  console.log(\`\${oid} \${o.coin} \${o.status}: filled \${fmt(o.filled)} of \${fmt(o.origSz)}, sz=\${fmt(o.reported)} \${result}\`);
}
function onOrderUpdate(u) {
  const o = order(u.order.oid);
  Object.assign(o, { coin: u.order.coin, origSz: units(u.order.origSz), status: u.status });
  if (u.status === "open") return;
  o.reported = units(u.order.sz);
  check(u.order.oid, o);
}
function onFill(f, quiet = false) {
  const key = fillKey(f);
  if (seenFills.has(key)) return;
  seenFills.add(key);
  const o = order(f.oid);
  o.filled += units(f.sz);
  if (!quiet) {
    const role = f.crossed ? "taker" : "maker";
    console.log(\`\${f.oid} \${f.coin} fill tid=\${f.tid} \${f.side} \${f.sz}@\${f.px} \${role} fee=\${f.fee}\${f.liquidation ? " LIQUIDATION" : ""}\`);
  }
  check(f.oid, o);
}
const ws = new WebSocket(WS_URL);
ws.onopen = () => {
  for (const type of ["orderUpdates", "userFills"]) {
    ws.send(JSON.stringify({ method: "subscribe", subscription: { type, user } }));
  }
};
ws.onmessage = ({ data }) => {
  const msg = JSON.parse(data);
  if (msg.channel === "orderUpdates") msg.data.forEach(onOrderUpdate);
  if (msg.channel !== "userFills") return;
  // The first message is a snapshot of recent fills. Count it, so orders already partly
  // filled at startup add up, but don't print it. The dedupe set absorbs it on reconnect.
  const { fills, isSnapshot } = msg.data;
  fills.forEach((f) => onFill(f, isSnapshot));
  if (isSnapshot) console.log(\`loaded \${fills.length} fills from the snapshot\`);
};
ws.onclose = () => console.log("closed; reconnect and backfill from your last stored fill time");`}
/>

Run it against any active address, for example one pulled from the `users` array of a recent trade. Output from a 45-second run against a busy account looked like this:

<CodeSnippet
  language="text"
  code={`loaded 30 fills from the snapshot
580847595557 HYPE fill tid=524471650157202 B 153.74@81.33 taker fee=1.800529
580847595557 HYPE fill tid=1042881790178215 B 60.73@81.33 taker fee=0.71124
580847595557 HYPE filled: filled 214.47 of 221.99, sz=7.52 ok
580847576684 ZEC fill tid=38729269326008 B 0.02@1250.0 maker fee=-0.0005
580847576684 ZEC canceled: filled 0.02 of 2.47, sz=2.45 ok`}
/>

The first line is the snapshot the subscription opens with. The next three are one `Ioc` order that swept two resting orders, produced two fills, and ended as `filled` with 7.52 HYPE unfilled. The last two are an `Alo` order that rested on the book, filled 0.02 ZEC as the maker, and was canceled with the rest still open. Across the 115 orders that reached a terminal state with fills in that run, the fill-derived remaining size matched the exchange's reported `sz` every time.

Three details in the script matter. Sizes are strings, and summing them as JavaScript floats produces rounding drift on long-lived orders with many partial fills, so the script sums them as scaled integers. The check also runs from both handlers. A terminal status stores the reported `sz`, and any fill that arrives afterward reruns the comparison, so an order whose last fill lands after its `filled` status shows `waiting for fills` and then `ok` instead of a false mismatch. Finally, fills are deduplicated before they touch state, so a fill that arrives twice, for example in the snapshot after a reconnect or from a backfill that overlaps the live stream, is counted once.

[Hyperliquid's WebSocket docs](https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/websocket/subscriptions) describe `tid` as a 50-bit hash and name block time, `coin`, and `tid` together as the globally unique trade ID, so the script keys fills on `coin`, `time`, and `tid`. A trade and its two fills share all three. That key is unique for one account. If you track many accounts in one process, add `user` to it, because both sides of a trade share the rest.

## What breaks in production?

Most reconciliation bugs come from the following cases.

- **Overlapping deliveries.** When you resubscribe, the native `userFills` stream opens with a snapshot of recent fills (`isSnapshot: true`), and a backfill overlaps whatever the live stream already delivered. Deduplicate fills on `user`, `coin`, `time`, and `tid`, and order updates on `oid` plus `status` plus `statusTimestamp`.
- **Cross-stream ordering.** Order updates and fills arrive on separate channels. Do not assume a fill arrives before the `filled` status for its order, or the reverse. Keying state by `oid`, letting either event create the record, and rerunning the check when a late fill lands, as the script does, removes the dependency.
- **Disconnects.** A WebSocket subscription delivers only what happens while it is connected, so any downtime needs a backfill. Store the time of the last fill you processed and query fills from that point, as shown below.
- **TWAP slice fills.** TWAP slices execute with a zero `hash`. On the public Hyperliquid API, the slice fill we traced appeared in `userTwapSliceFills` rather than in `userFillsByTime`, so a backfill that only reads `userFillsByTime` can miss them.
- **Unknown statuses.** New rejection and cancel reasons get added. Handle unknown values without crashing, and treat `triggerCondition` as a display string. The machine-readable trigger price is `triggerPx`.
- **Market identifiers.** Coin strings encode the market type. `BTC` is the perpetual, spot markets use `@index` strings such as `@107`, outcome markets use `#N`, and HIP-3 markets deployed by third parties use `dex:name`, as described in the [markets, assets, and DEXes docs](https://www.alchemy.com/docs/data/hypercore/concepts/markets-assets-and-dexes). Coin strings are case-sensitive, so compare them exactly and never lowercase them.
- **Advancing a checkpoint too early.** Persist your last-fill time only after the fill has been written to storage. Advancing first means a crash between the two steps silently drops a fill.

Backfilling fills for a time range takes a short loop. `userFillsByTime` returns fills oldest first, at most 2,000 per response according to [Hyperliquid's info endpoint docs](https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/info-endpoint), so the loop restarts at the newest time it has seen and lets the same dedupe key absorb the overlap:

<CodeSnippet
  language="javascript"
  code={`// Pulls every fill for an account between two times, for use after a stream gap.
// Usage: node backfill.mjs 0xYourAddress <startMs> [endMs]
const INFO_URL = process.env.HL_INFO_URL ?? "https://api.hyperliquid.xyz/info";
const [user, start, end = Date.now()] = process.argv.slice(2);
async function fillsBetween(user, startTime, endTime) {
  const byKey = new Map();
  let from = Number(startTime);
  while (true) {
    const res = await fetch(INFO_URL, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ type: "userFillsByTime", user, startTime: from, endTime: Number(endTime) }),
    });
    const page = await res.json();
    const before = byKey.size;
    for (const f of page) byKey.set(\`\${f.coin}|\${f.time}|\${f.tid}\`, f);
    // Pages are capped. Restart at the newest time seen; the dedupe key absorbs the overlap.
    if (page.length === 0 || byKey.size === before) break;
    from = Math.max(...page.map((f) => f.time));
  }
  return [...byKey.values()].sort((a, b) => a.time - b.time);
}
const fills = await fillsBetween(user, start, end);
console.log(\`\${fills.length} fills\`);`}
/>

Against the same busy account, a 30-minute window returned 6,049 fills across four pages. Feed the result through the same `onFill` handler the stream uses, and the dedupe set keeps the overlap between the backfill and the live stream from double counting.

The same docs note that only the 10,000 most recent fills are available through `userFillsByTime`. At the rate above, that is under an hour of history, so a longer outage on an account this active falls outside what the public endpoint can return. Size your alerting so a disconnected consumer is noticed well inside that window.

## How does Alchemy's HyperCore streaming change this?

[HyperCore streaming on Alchemy](https://www.alchemy.com/docs/chains/websockets/hypercore) uses the native subscription envelope, so the payloads above and the join logic stay the same. The differences are in what each frame tells you about its source block and how much one subscription can cover.

Every data frame carries top-level metadata alongside the native payload, including `subscriptionId`, `blockHeight`, and `blockTime`. Order updates and fills still arrive on separate channels, but each frame names the block it came from, which gives you a shared reference point when you line the two streams up.

Subscriptions also accept arrays. Native subscriptions take a single `user` or `coin`, while ours accept `users`, `coins`, and `builders`, so one subscription can cover a whole set of accounts. Every fill and order record carries its `user`, which makes the multi-account dedupe key above the default case rather than the edge case.

A few streams exist so you don't have to derive them from a broad feed:

- **`liquidationFills`.** Only fills that carry a liquidation, filtered by market.
- **`builderFills` and `builderOrderUpdates`.** Fills and order updates attributed to a builder address, for apps that route orders and need to track their own flow and fees.
- **`allFills`.** Fills across every account in selected markets, with the full fill schema rather than the slimmer trade record.

To run the reconcile script against our endpoint, point `HL_WS_URL` at the WebSocket URL you receive with early access, authenticate as described in the [HyperCore connection lifecycle docs](https://www.alchemy.com/docs/chains/websockets/hypercore/connection-lifecycle), and subscribe to `userOrderUpdates` instead of `orderUpdates`. Our order-update frames wrap the status records in an `orders` array, so read them from `msg.data.orders` rather than `msg.data`. For history, our HyperCore REST API will add `userFillsByTime` and `historicalOrders` with cursor-based pagination. Both are documented in the [query wallet trading history guide](https://www.alchemy.com/docs/data/hypercore/guides/query-wallet-trading-history) but are not callable yet, so the backfill script above uses the public Hyperliquid endpoint in the meantime.

## How do you get access to HyperCore streaming?

HyperCore streaming is in early access, part of the [HyperCore work we announced in June](https://www.alchemy.com/blog/alchemy-going-all-in-on-hyperliquid). You stream from Hyperliquid nodes we operate, so there is no `hl-node` to run, and if you already build on Alchemy we enable it on the API key you use today. Start with the [HyperCore Data API overview](https://www.alchemy.com/docs/data/hypercore/overview) to see the streams and REST methods, then [request HyperCore early access](https://www.alchemy.com/hypercore-early-access) to get connection details and work directly with our team.

If you are choosing between streaming transports for the rest of your stack, our comparison of [webhooks, WebSockets, and gRPC](https://www.alchemy.com/overviews/webhooks-vs-websockets-vs-grpc) covers the tradeoffs. For the HyperEVM side of Hyperliquid, see our [Hyperliquid RPC guide](https://www.alchemy.com/overviews/complete-guide-to-hyperliquid-rpc-providers-top-8-solutions-for-2026).
