Hyperliquid orders vs trades vs fills
Written by Uttam Singh

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:
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:
{
"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.origSzandsz. The size at placement and the size still resting at the time of this status record.status. The new state.open,filled,canceled, andtriggeredare the common ones, and there is a long tail of specific rejection and cancel reasons such asbadAloPxRejected,reduceOnlyCanceled,selfTradeCanceled, andliquidatedCanceled.tif. Time in force.Gtcrests until canceled,Alo(add liquidity only) is rejected if it would cross the spread, andIoc(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 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:
{
"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 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:
[
{
"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.truefor the taker,falsefor 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 infee.startPosition,dir, andclosedPnl. The position size before the fill, a display label such asOpen LongorClose 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:
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:
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.
// 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:
loaded 30 fills from the snapshot
580847595557 HYPE fill tid=524471650157202 B [email protected] taker fee=1.800529
580847595557 HYPE fill tid=1042881790178215 B [email protected] taker fee=0.71124
580847595557 HYPE filled: filled 214.47 of 221.99, sz=7.52 ok
580847576684 ZEC fill tid=38729269326008 B [email protected] maker fee=-0.0005
580847576684 ZEC canceled: filled 0.02 of 2.47, sz=2.45 okThe 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 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
userFillsstream opens with a snapshot of recent fills (isSnapshot: true), and a backfill overlaps whatever the live stream already delivered. Deduplicate fills onuser,coin,time, andtid, and order updates onoidplusstatusplusstatusTimestamp. - Cross-stream ordering. Order updates and fills arrive on separate channels. Do not assume a fill arrives before the
filledstatus for its order, or the reverse. Keying state byoid, 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 inuserTwapSliceFillsrather than inuserFillsByTime, so a backfill that only readsuserFillsByTimecan miss them. - Unknown statuses. New rejection and cancel reasons get added. Handle unknown values without crashing, and treat
triggerConditionas a display string. The machine-readable trigger price istriggerPx. - Market identifiers. Coin strings encode the market type.
BTCis the perpetual, spot markets use@indexstrings such as@107, outcome markets use#N, and HIP-3 markets deployed by third parties usedex:name, as described in the markets, assets, and DEXes docs. 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, so the loop restarts at the newest time it has seen and lets the same dedupe key absorb the overlap:
// 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 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.builderFillsandbuilderOrderUpdates. 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, 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 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. 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 to see the streams and REST methods, then request 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 covers the tradeoffs. For the HyperEVM side of Hyperliquid, see our Hyperliquid RPC guide.
Related Overviews
TechnicalMay 19, 2026
Webhooks vs WebSockets vs gRPC
Three protocols dominate real-time data delivery. Here's how webhooks, WebSockets, and gRPC differ, when each one breaks down, and how to choose between them.
InfraJanuary 15, 2026
Complete guide to Hyperliquid RPC providers: top 8 solutions for 2026
Looking for the best Hyperliquid RPC in 2026? This guide showcases the best providers.
WalletsOctober 19, 2026
What is account abstraction? ERC-4337 explained
Account abstraction lets a smart contract act as your account. ERC-4337 delivers it without changing Ethereum, and EIP-7702 brings it to existing EOAs.

Build blockchain magic
Alchemy combines the most powerful web3 developer products and tools with resources, community and legendary support.