Decode CS2 Inspect Links Locally (No API, 2026)
Since March 2026 CS2 inspect links carry the item inside the link itself. Full anatomy of the payload, the protobuf field map, CRC and XOR masking, a worked byte-by-byte example and a runnable stdlib-only Python decoder.
Short answer: you no longer need an API to read the float and paint seed of a modern CS2 inspect link. Since March 2026 the link carries the item's data inside itself - the hex tail after csgo_econ_action_preview is a serialised CEconItemPreviewDataBlock protobuf message. Decoding it is pure local computation: unwrap an envelope, verify a CRC (or undo an XOR mask), parse protobuf, and reinterpret one uint32 field as an IEEE-754 float. No Steam login, no bot account, no rate limit, no network call at all. api.csfloat.com is gone as a resolution path - the domain does not even resolve any more, and the csfloat/inspect repository was archived on 25 March 2026 with a notice pointing users at the serializer library instead.
The catch, and it is a real one: classic S/A/D links still cannot be decoded offline. They are pointers, not payloads, and resolving them still requires a game-coordinator connection. We cover exactly where that line falls below.
What actually changed in March 2026
Before the change, an inspect link looked like a set of database keys:
steam://rungame/730/76561202255233023/+csgo_econ_action_preview S76561198084749846A698323590D7935523998312483177
S is the owner's SteamID (or M, a market listing id), A the asset id, D a deterministic "d-value" proof. None of those bytes tell you the float. To learn it you had to log a Steam bot into the CS2 game coordinator, send the preview request, wait for the response, and hope the GC was healthy. That is why float APIs existed at all: they were pools of bot accounts, each capped at roughly one request per second.
The new format inverts the model. Valve now emits links whose tail is pure hexadecimal, and that hex is the item:
steam://rungame/730/76561202255233023/+csgo_econ_action_preview 0010DAB7CD80A4011807209A02...
Everything the preview needs - paint index, paint seed, wear, stickers, charms - travels in the URL. The game coordinator is cut out of the loop entirely for these links.
Telling the two apart is trivial. Take the tail after csgo_econ_action_preview. If it is entirely [0-9A-Fa-f] with an even length, it is self-encoding and you can decode it locally. If it contains S, M, A or D letters as parameter markers, it is a classic link and needs the GC. That single check is the branch point of any modern resolver.
Anatomy of a self-encoding link
The hex decodes to a byte buffer with a small envelope wrapped around a protobuf message.
| Part | Bytes | Meaning |
|---|---|---|
| Key byte | buf[0] | 0x00 = unmasked payload. Any other value = masked; it is the XOR key |
| Payload | buf[1:-4] | Serialised CEconItemPreviewDataBlock protobuf message |
| Checksum | buf[-4:] | Big-endian uint32, CRC32-derived, validated on unmasked payloads |
Two variants exist in the wild:
- Unmasked - first byte is
0x00. The four trailing bytes are a checksum you should verify. Links produced by tools and generators are usually this shape. - Masked - first byte is non-zero. Every byte of the buffer, including the first, is XOR-ed with that first byte. XOR the whole buffer by
buf[0]and you get the unmasked form back (the key byte XOR-ed with itself becomes0x00, which is the invariant you assert to confirm you did it right). Links the game itself generates typically arrive masked.
The checksum is not a plain CRC32 - it is CRC32 with a length fold on top. The reference implementation in @csfloat/cs-inspect-serializer is:
export const getChecksum = (payload: Uint8Array): number => {
const bufferPayload = Buffer.concat([Uint8Array.from([0]), payload]);
const crc = CRC32.buf(bufferPayload);
const x_crc = (crc & 0xffff) ^ (payload.byteLength * crc);
return (x_crc & 0xffffffff) >>> 0;
};
Note the two easy-to-miss details: the CRC is taken over 0x00 prepended to the payload, and the result is folded with (crc & 0xffff) ^ (length * crc) before being truncated to 32 bits.
The protobuf field map
The payload is CEconItemPreviewDataBlock, defined in cstrike15_gcmessages.proto and tracked publicly by SteamDB's GameTracking-CS2 repository. Field numbers are what matter - you can parse the message with a minimal reader and never touch a .proto compiler.
| # | Field | Type | Notes |
|---|---|---|---|
| 1 | accountid | uint32 | Owner account id. Frequently absent in self-encoded links |
| 2 | itemid | uint64 | Asset id |
| 3 | defindex | uint32 | Weapon type (7 = AK-47) |
| 4 | paintindex | uint32 | Paint kit / skin id (282 = Redline) |
| 5 | rarity | uint32 | Rarity tier |
| 6 | quality | uint32 | Quality tier |
| 7 | paintwear | uint32 | Float, as raw IEEE-754 bits - see below |
| 8 | paintseed | uint32 | Pattern index |
| 9 | killeaterscoretype | uint32 | Present means a StatTrak / Souvenir counter exists |
| 10 | killeatervalue | uint32 | Kill count |
| 11 | customname | string | Name tag |
| 12 | stickers | repeated Sticker | Applied stickers |
| 13 | inventory | uint32 | Inventory slot / bucket |
| 14 | origin | uint32 | How the item was obtained |
| 15 | questid | uint32 | |
| 16 | dropreason | uint32 | |
| 17 | musicindex | uint32 | Music kit |
| 18 | entindex | int32 | |
| 19 | petindex | uint32 | |
| 20 | keychains | repeated Sticker | Charms - same sub-message shape as stickers |
| 21 | style | uint32 | |
| 22 | variations | repeated Sticker | |
| 23 | upgrade_level | uint32 |
The nested Sticker message, reused for stickers, keychains and variations:
| # | Field | Type |
|---|---|---|
| 1 | slot | uint32 |
| 2 | sticker_id | uint32 |
| 3 | wear | float |
| 4 | scale | float |
| 5 | rotation | float |
| 6 | tint_id | uint32 |
| 7-9 | offset_x / offset_y / offset_z | float |
| 10 | pattern | uint32 |
| 11 | highlight_reel | uint32 |
| 12 | wrapped_sticker | uint32 |
paintwear is the single biggest trap. It is declared uint32 and travels the wire as a varint, but the integer is meaningless as a number - its 32 bits are the float32. You must reinterpret the bits, not cast them. In Python: struct.unpack("<f", struct.pack("<I", value))[0]. Casting instead of reinterpreting gives you values in the billions and a bug that looks like corrupt data.
Worked example: taking a link apart
Here is a complete self-encoding link. Every number below was produced by actually running the decode, not copied from documentation.
steam://rungame/730/76561202255233023/+csgo_econ_action_preview%200010DAB7CD80A4011807209A022805300438899F9BF3034095056205080010A82768838080800C7004E71B40F1
- Strip the wrapper. URL-decode (
%20is the space) and take the tail aftercsgo_econ_action_preview. It is 90 hex characters, all[0-9A-F], even length - self-encoding confirmed. - Hex to bytes. 90 characters become 45 bytes.
- Read the key byte.
buf[0]is0x00, so this payload is unmasked. No XOR needed. - Split the envelope. Payload = bytes 1 through 40 (40 bytes). Trailer = the last 4 bytes,
E7 1B 40 F1. - Verify the checksum. CRC32 over
0x00plus the payload, folded as above, gives0xE71B40F1- matching the trailer exactly. The payload is intact. - Parse the protobuf. Walk tag/value pairs to the end of the buffer.
The 40 payload bytes parse into exactly ten fields:
| Raw bytes | Field | Wire | Value |
|---|---|---|---|
10 DA B7 CD 80 A4 01 | 2 itemid | varint | 44024683482 |
18 07 | 3 defindex | varint | 7 (AK-47) |
20 9A 02 | 4 paintindex | varint | 282 (Redline) |
28 05 | 5 rarity | varint | 5 |
30 04 | 6 quality | varint | 4 |
38 89 9F 9B F3 03 | 7 paintwear | varint | 1046925193 -> 0.22540106 |
40 95 05 | 8 paintseed | varint | 661 |
62 05 08 00 10 A8 27 | 12 stickers | length-delimited | slot 0, sticker_id 5032 |
68 83 80 80 80 0C | 13 inventory | varint | 3221225475 |
70 04 | 14 origin | varint | 4 |
Read one tag by hand to see the mechanism. The byte 0x20 is 32 decimal; protobuf tags pack the field number in the high bits and the wire type in the low three: 32 >> 3 is 4 (field 4, paintindex) and 32 & 7 is 0 (varint). The following varint 9A 02 is little-endian base-128: (0x9A & 0x7F) | (0x02 << 7) = 26 | 256 = 282. Redline.
Field 7 is the interesting one. The varint 89 9F 9B F3 03 yields the integer 1046925193. As a number that is nonsense. Reinterpreted as float32 bits it is 0.22540105879306793 - a Field-Tested AK-47 | Redline. That is the float, obtained with zero network traffic.
The same item, masked with key 0x9E, is the identical buffer XOR-ed byte by byte:
csgo_econ_action_preview 9E8E4429531E3A9F8699BE049CB69BAE9AA61701056D9DDE0B9BFC9B969E8E36B9F61D1E1E1E92EE9A7985DE6F
XOR every byte by 0x9E, the first byte becomes 0x00, and the decode proceeds identically - same float, same seed.
A runnable decoder in about 60 lines
Standard library only - no protobuf compiler, no dependencies. This handles both masked and unmasked payloads.
import re, struct, zlib, urllib.parse
LINK = re.compile(r"csgo_econ_action_preview[\s+]*([0-9A-Fa-f]+)\s*$")
def read_varint(b, i):
shift = val = 0
while True:
byte = b[i]; i += 1
val |= (byte & 0x7F) << shift
if not byte & 0x80:
return val, i
shift += 7
if shift > 70:
raise ValueError("varint too long")
def parse_pb(b):
out, i, n = {}, 0, len(b)
while i < n:
key, i = read_varint(b, i)
field, wt = key >> 3, key & 7
if wt == 0:
v, i = read_varint(b, i)
elif wt == 5:
v, i = b[i:i+4], i + 4
elif wt == 2:
ln, i = read_varint(b, i)
v, i = b[i:i+ln], i + ln
if len(v) < ln:
raise ValueError("truncated field")
else:
raise ValueError(f"unsupported wire type {wt}")
out.setdefault(field, []).append(v)
return out
def checksum(payload):
crc = zlib.crc32(b"\x00" + payload) & 0xFFFFFFFF
return ((crc & 0xFFFF) ^ (len(payload) * crc)) & 0xFFFFFFFF
def unwrap(buf):
if len(buf) < 5:
raise ValueError("payload too short")
if buf[0] != 0: # masked: XOR every byte by the key byte
buf = bytes(x ^ buf[0] for x in buf)
else: # unmasked: verify the trailing checksum
if checksum(buf[1:-4]) != struct.unpack(">I", buf[-4:])[0]:
raise ValueError("checksum mismatch")
return buf[1:-4]
def sticker(raw):
pb = parse_pb(raw)
f = lambda k: struct.unpack("<f", pb[k][-1])[0] if k in pb else None
return {"slot": pb.get(1, [None])[-1], "sticker_id": pb.get(2, [None])[-1],
"wear": f(3), "rotation": f(5), "pattern": pb.get(10, [None])[-1]}
def decode(link):
m = LINK.search(urllib.parse.unquote(link.strip()))
if not m:
raise ValueError("not a self-encoding link (classic S/A/D links need the GC)")
pb = parse_pb(unwrap(bytes.fromhex(m.group(1))))
g = lambda k: pb[k][-1] if k in pb else None
wear = g(7)
return {
"itemid": g(2),
"defindex": g(3),
"paint_index": g(4),
"rarity": g(5),
"quality": g(6),
"float": struct.unpack("<f", struct.pack("<I", wear))[0] if wear is not None else None,
"paint_seed": g(8),
"killeater_type": g(9), # present => StatTrak / Souvenir counter
"kill_count": g(10),
"custom_name": g(11).decode("utf-8") if g(11) else None,
"stickers": [sticker(x) for x in pb.get(12, [])],
"keychains": [sticker(x) for x in pb.get(20, [])],
}
if __name__ == "__main__":
link = ("steam://rungame/730/76561202255233023/+csgo_econ_action_preview%20"
"0010DAB7CD80A4011807209A022805300438899F9BF303409505"
"6205080010A82768838080800C7004E71B40F1")
for k, v in decode(link).items():
print(f"{k:15} {v}")
Output:
itemid 44024683482
defindex 7
paint_index 282
rarity 5
quality 4
float 0.22540105879306793
paint_seed 661
killeater_type None
kill_count None
custom_name None
stickers [{'slot': 0, 'sticker_id': 5032, ...}]
keychains []
Validate before you trust. Arbitrary hex can parse as syntactically valid protobuf and hand you garbage. Require at least one identifying field (itemid, defindex, paintindex, paintseed, or a non-empty stickers/keychains array) before returning a result. For masked payloads the reference implementation is stricter still, requiring itemid, defindex, paintindex, inventory and origin to all be present. Anything failing those checks should raise, not return nulls.
What you still cannot get locally
Being honest about the boundary matters more than the decode itself, because building a product on a wrong assumption here is expensive.
Classic S/A/D and M/A/D links. These carry no item data whatsoever. Resolving them requires a Steam account logged into the CS2 game coordinator. If your inputs include historical links, Steam Community Market listings, or anything scraped from older sources, you still need GC infrastructure - or you need to accept that those inputs cannot be resolved. Every offline library says the same; the Helyux/cs2inspect docs state plainly that legacy pointers are outside the scope of an offline library.
Live ownership and tradability. The payload has an accountid field, but in self-encoded links it is often absent, and even when present it is a snapshot from link-generation time, not a live fact. Current owner, trade-lock expiry, listing status and price are not in the link and never will be.
Human-readable names. The link gives you defindex 7 and paintindex 282, not "AK-47 | Redline". Turning ids into names requires the game's item schema (items_game.txt or a mirrored schema dump). Same for sticker id 5032 - that is a lookup, not a decode.
Wear tier names. "Field-Tested" is not in the payload. It is derived from the float against the skin's wear bounds, and the skin-specific min/max wear also live in the schema, not the link.
Anything derived from the seed. Fade percentage, blue-gem tiers, Doppler phases, Case Hardened patterns - the link gives you the raw paintseed integer only. Converting a seed into "97.4% fade" or "Tier 1 blue gem" is separate computation or a lookup table on your side.
A self-encoded link is a snapshot, not a live record. Nothing stops someone from crafting a valid link describing an item that does not exist, or that they do not own. The CRC protects against corruption, not forgery - anyone can compute a valid checksum. If your pipeline makes financial decisions, treat a decoded link as an unverified claim about an item until you confirm the asset independently.
Common mistakes
- Casting
paintwearinstead of reinterpreting it. The number one bug.float(1046925193)is not0.2254. You need the bit pattern. - Assuming every link self-encodes. Branch on the tail format. A resolver that throws on classic links is correct; one that silently returns nulls is not.
- Skipping the odd-length check. Truncated hex raises a confusing error deep inside
bytes.fromhex. Check that the hex length is even up front. - Forgetting the leading
0x00in the checksum. CRC over the bare payload gives a mismatch on every valid link and sends you hunting for a nonexistent bug. - Validating the checksum on masked payloads. Undo the mask first; do not verify the CRC against masked bytes.
- Reading only the first occurrence of a repeated field. Protobuf permits repeated tags; stickers and keychains are genuinely repeated. Collect them into a list.
- Not URL-decoding. Links copied from browsers and chat clients arrive with
%20for the space and sometimes a trailing newline. - Trusting parse success as validation. Random hex frequently parses cleanly. Apply the field-presence checks.
- Hardcoding an item-name mapping. Paint indexes accrete with every case release. Refresh the schema instead of embedding a table.
Where this leaves float APIs
The practical effect is that float resolution has gone from an infrastructure problem to a parsing problem, at least for links generated after March 2026. A decode that used to mean a bot account, a GC session, retries and a queue is now microseconds of local CPU. Rate limits vanish. Outages vanish. The GC's well-documented unreliability stops being your problem - for these links.
What remains is an unglamorous but real split in any production resolver: a fast local path for hex tails, and a slow, fragile, account-dependent path for legacy pointers. We run exactly that split, and the honest summary is that the local path handles the overwhelming majority of current traffic while the GC path refuses to die completely.
csgo_econ_action_preview is pure hexadecimal, the float is encoded inside the link and you can decode it locally with no network access. Classic S/A/D links still require a game-coordinator connection.0x00 and end with a 4-byte checksum you can verify. Masked buffers begin with a non-zero key byte, and every byte has been XOR-ed with that key. XOR the whole buffer by its first byte to recover the unmasked form.paintwear integer instead of reinterpreting its bits. The field is declared uint32, but those 32 bits are the IEEE-754 float32 representation. Use struct.unpack("<f", struct.pack("<I", value))[0].defindex and paintindex values. Mapping those to "AK-47 | Redline" requires the game item schema, which you maintain separately from the decoder.Decoding is the easy half. Turning paintseed 661 into a pattern verdict, or a float into a wear tier and a fair price, is where the actual work lives - and that is what our free tools exist for.