OmniSkin Wiki
RU EN
Sign in Trade-Up Market Top Inventory Folio Arbitrage table →
Decode CS2 Inspect Links Locally (No API, 2026)

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.

Published 27.08.2026 · Updated 08.09.2026 · Русский

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.


The hex decodes to a byte buffer with a small envelope wrapped around a protobuf message.

PartBytesMeaning
Key bytebuf[0]0x00 = unmasked payload. Any other value = masked; it is the XOR key
Payloadbuf[1:-4]Serialised CEconItemPreviewDataBlock protobuf message
Checksumbuf[-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 becomes 0x00, 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.

#FieldTypeNotes
1accountiduint32Owner account id. Frequently absent in self-encoded links
2itemiduint64Asset id
3defindexuint32Weapon type (7 = AK-47)
4paintindexuint32Paint kit / skin id (282 = Redline)
5rarityuint32Rarity tier
6qualityuint32Quality tier
7paintwearuint32Float, as raw IEEE-754 bits - see below
8paintseeduint32Pattern index
9killeaterscoretypeuint32Present means a StatTrak / Souvenir counter exists
10killeatervalueuint32Kill count
11customnamestringName tag
12stickersrepeated StickerApplied stickers
13inventoryuint32Inventory slot / bucket
14originuint32How the item was obtained
15questiduint32
16dropreasonuint32
17musicindexuint32Music kit
18entindexint32
19petindexuint32
20keychainsrepeated StickerCharms - same sub-message shape as stickers
21styleuint32
22variationsrepeated Sticker
23upgrade_leveluint32

The nested Sticker message, reused for stickers, keychains and variations:

#FieldType
1slotuint32
2sticker_iduint32
3wearfloat
4scalefloat
5rotationfloat
6tint_iduint32
7-9offset_x / offset_y / offset_zfloat
10patternuint32
11highlight_reeluint32
12wrapped_stickeruint32

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.


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
  1. Strip the wrapper. URL-decode (%20 is the space) and take the tail after csgo_econ_action_preview. It is 90 hex characters, all [0-9A-F], even length - self-encoding confirmed.
  2. Hex to bytes. 90 characters become 45 bytes.
  3. Read the key byte. buf[0] is 0x00, so this payload is unmasked. No XOR needed.
  4. Split the envelope. Payload = bytes 1 through 40 (40 bytes). Trailer = the last 4 bytes, E7 1B 40 F1.
  5. Verify the checksum. CRC32 over 0x00 plus the payload, folded as above, gives 0xE71B40F1 - matching the trailer exactly. The payload is intact.
  6. Parse the protobuf. Walk tag/value pairs to the end of the buffer.

The 40 payload bytes parse into exactly ten fields:

Raw bytesFieldWireValue
10 DA B7 CD 80 A4 012 itemidvarint44024683482
18 073 defindexvarint7 (AK-47)
20 9A 024 paintindexvarint282 (Redline)
28 055 rarityvarint5
30 046 qualityvarint4
38 89 9F 9B F3 037 paintwearvarint1046925193 -> 0.22540106
40 95 058 paintseedvarint661
62 05 08 00 10 A8 2712 stickerslength-delimitedslot 0, sticker_id 5032
68 83 80 80 80 0C13 inventoryvarint3221225475
70 0414 originvarint4

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.

🔧Check float & patternfree, no sign-up

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.

🔧Patterns: blue gem, fade%free, no sign-up

Common mistakes

  • Casting paintwear instead of reinterpreting it. The number one bug. float(1046925193) is not 0.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 0x00 in 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 %20 for 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.

Can I get a CS2 float without an API in 2026?
Yes, for modern links. If the tail after 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.
Is the CSFloat inspect API dead?
As a resolution path, yes. The csfloat/inspect repository was archived on 25 March 2026 with a notice directing users to the serializer library, and api.csfloat.com no longer resolves in DNS. The recommended approach is local decoding.
What is the difference between a masked and an unmasked inspect link?
Both wrap the same protobuf payload. Unmasked buffers begin with 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.
Why is my decoded float a huge number like 1046925193?
You cast the 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].
Can I get the item name from an inspect link?
Not directly. The link carries numeric defindex and paintindex values. Mapping those to "AK-47 | Redline" requires the game item schema, which you maintain separately from the decoder.
Does the inspect link contain stickers and charms?
Yes. Stickers are field 12 and keychains (charms) are field 20, both repeated and sharing the same sub-message shape with slot, id, wear, rotation, offset and pattern. Field 22, variations, uses the same structure.
Can someone fake an inspect link?
Yes. The checksum detects corruption, not forgery - anyone can compute a valid one for a payload they invented. A decoded link is an unverified claim about an item. Verify the asset independently before acting on it financially.

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.

3 days of full access freeSign in with Steam, no card. Level 3+ required.Open
← All articles
OmniSkin Wiki — CS2, Dota 2, Rust and TF2 price database across all marketplaces. RU EN CS2Dota 2RustTeam Fortress 2 About Arbitrage table → Portfolio →