Guides, Nov 12, 2026

# How to decode a V2Ray subscription link

A V2Ray subscription link is an HTTPS URL that usually returns one long base64 string. Decode it and you get one share link per line: vless, vmess, trojan, ss, hysteria2. If plain base64 decoding fails, the body is URL-safe base64, it lost its padding, or the server sent Clash YAML or JSON instead. Traffic and expiry are not in the body at all, but in the subscription-userinfo response header.

ST Muntaha, 9 min read

Most people decode a subscription for one of three reasons: a client refuses to import it, they want to see which servers a provider really gives them, or they run the servers and want to check what users receive. This post covers the decoding itself: every common format, the shell and Python commands, how to read each share link, and what the response headers mean. If you are here because you run a fleet and want to monitor the servers behind the link, read [Monitoring V2Ray subscription URLs in production](https://tunnelhq.com/blog/monitoring-v2ray-subscription-urls/) after this one.

## What does a subscription link return?

A subscription link returns one of five body formats, and you can tell them apart from the first few bytes:

| Format | First bytes look like | Who serves it | Who reads it |
| --- | --- | --- | --- |
| Base64 link list | `dmxlc3M6Ly9...` | 3x-ui raw path, Marzban default, most providers | v2rayN, v2rayNG, Hiddify, Streisand, Mihomo providers |
| Plain link list | `vless://...` | hand-made lists | the same clients, most of them |
| Clash / Mihomo YAML | YAML keys such as `port:`, `mode:`, `proxies:` | 3x-ui Clash path, Marzban for Clash user agents | Mihomo and Clash Meta clients |
| sing-box JSON | `{` with an `"outbounds"` array | Marzban for sing-box apps | sing-box apps (a "remote profile") |
| SIP008 JSON | `{"version":1,"servers":[...]}` | Shadowsocks providers | Shadowsocks clients |

There is a sixth kind you may see from panels: complete Xray client configs as JSON (3x-ui's JSON path, Marzban's `v2ray-json`). They start with `{` or `[` and contain `"outbounds"` with `"protocol"` keys, where sing-box uses `"type"`.

The base64 list is the common case. Decoded, it is plain text with one share link per line. That is the whole format. There is no header line, no version, no signature.

## Why does the same link show different content?

The server picks the format from your request, so the same URL can return a web page in a browser, YAML to one app and base64 to another. Three things decide it:

-   **The `Accept` header.** Both 3x-ui and Marzban return an HTML info page when the request says `Accept: text/html`, which every browser does. That is why pasting the link into a browser shows a page, not configs. 3x-ui also returns the page for `?html=1`.
-   **The `User-Agent`.** Marzban (checked against v0.8.4) matches the user agent with regular expressions: Mihomo and Clash Meta clients get Clash Meta YAML, sing-box apps and Hiddify get sing-box JSON, and anything it does not recognise, including `curl`, gets the base64 list. On 3x-ui's raw path, when Clash output and auto-detection are on, a user agent that matches `clash` or `mihomo` gets YAML. The matcher can be changed in the panel settings.
-   **The path.** 3x-ui serves each format on its own path (`subPath`, `subJsonPath`, `subClashPath`, set in the panel). Marzban accepts a suffix to force a format: `/sub/<token>/v2ray`, `/clash-meta`, `/sing-box`, `/v2ray-json`.

So when you debug, request it the way the failing client does. To see what v2rayNG gets, send its user agent. To get the plain base64 list from Marzban whatever happens, add `/v2ray` to the path.

## How do I decode a base64 subscription with curl?

Save the headers and the body separately, then decode the body:

```bash
SUB='https://sub.yourdomain.com/sub/your-token'
curl -sS -D headers.txt -o body.txt "$SUB"
base64 -d < body.txt
```

When the body is standard base64 with correct padding, you get the list:

```
vless://5f0c2a7e-3b1d-4e8a-9c6f-2d4b8a1e7c93@176.61.144.21:443?type=tcp&security=reality&sni=www.yourdomain.com&fp=chrome&pbk=Zx3qO8fJr2mK5vT1yN7cB4hW9pL6sD0aE2gU8iR5oQk&sid=6ba85179&flow=xtls-rprx-vision#nl-ams-1
vmess://eyJ2IjoiMiIsInBzIjoiZGUtZnJhLTEiLCJhZGQiOiJ2cG4ueW91cmRvbWFpbi5jb20iLC...
hysteria2://Hq8vN3sL@vpn.yourdomain.com:443/?sni=vpn.yourdomain.com#de-fra-1
```

Two variations break this command, and they fail in different ways depending on your system. I tested both:

-   **URL-safe base64** uses `-` and `_` where standard base64 uses `+` and `/`. GNU `base64 -d` (Debian 12, Ubuntu 22.04 and 24.04) stops with `base64: invalid input`. The macOS `base64` (checked on macOS 26) exits with no error, and the output is silently wrong.
-   **Missing `=` padding.** Older GNU coreutils (8.32 on Ubuntu 22.04, 9.1 on Debian 12, 9.4 on Ubuntu 24.04) print the decoded text and then `base64: invalid input`, with exit code 1. A script running under `set -e` stops there. Coreutils 9.10 accepts it. macOS drops the last incomplete group without a warning, so the last link loses its final characters.

The silent cases are the dangerous ones. A link that lost its last character or two still looks like a link.

## How do I fix URL-safe base64 and missing padding?

Normalise the text before you decode it: remove whitespace, map `-` to `+` and `_` to `/`, and add `=` until the length is a multiple of 4. This is exactly what v2rayN does in its `Base64Decode` helper, and v2rayNG tries standard base64 first and URL-safe second. Clients are tolerant, so a subscription that imports fine in an app can still break a strict script.

```bash
b64fix() {
  tr -d '\r\n ' | tr '_-' '/+' |
    awk '{ n = length($0) % 4; if (n) $0 = $0 substr("===", 1, 4 - n); print }'
}

curl -sS "$SUB" | b64fix | base64 -d
```

I checked this pipeline with GNU base64 and mawk on Ubuntu 22.04 and with the macOS tools, on a 350 KB URL-safe body without padding. Both produced the same 2,000 lines.

## Can I decode a subscription with Python?

Yes, and Python is the better choice when you want to detect the format too. This script uses only the standard library. It prints the useful headers, works out the format, and lists each server with its protocol and name:

```python
#!/usr/bin/env python3
"""Fetch a subscription URL, detect its format and list what is inside."""
import base64, binascii, json, sys, time, urllib.parse, urllib.request
from collections import Counter

SCHEMES = ("vless", "vmess", "trojan", "ss", "hysteria2", "hy2", "tuic")

def b64decode_loose(text):
    s = "".join(text.split()).replace("-", "+").replace("_", "/")
    return base64.b64decode(s + "=" * (-len(s) % 4), validate=True).decode("utf-8")

def detect(body):
    text = body.lstrip("﻿").strip()
    if text.startswith(("{", "[")):
        data = json.loads(text)
        if isinstance(data, dict) and "servers" in data and "version" in data:
            return "sip008", data
        if isinstance(data, dict) and "outbounds" in data:
            return "sing-box or xray json", data
        return "json", data
    if "proxies:" in text or "proxy-providers:" in text:
        return "clash yaml", text
    if text.split("://", 1)[0] in SCHEMES:
        return "plain uri list", text.splitlines()
    try:
        return "base64 uri list", b64decode_loose(text).splitlines()
    except (binascii.Error, UnicodeDecodeError, ValueError):
        return "unknown", text[:200]

def main(url):
    req = urllib.request.Request(url, headers={"User-Agent": "subdecode/1.0", "Accept": "*/*"})
    with urllib.request.urlopen(req, timeout=20) as resp:
        headers, body = resp.headers, resp.read().decode("utf-8", "replace")
    for name in ("content-type", "subscription-userinfo", "profile-update-interval", "profile-title"):
        if headers.get(name):
            print(f"{name}: {headers[name]}")
    info = dict(p.strip().split("=", 1) for p in headers.get("subscription-userinfo", "").split(";") if "=" in p)
    if int(info.get("expire", 0) or 0) > 0:
        print("expires:", time.strftime("%Y-%m-%d %H:%M UTC", time.gmtime(int(info["expire"]))))
    kind, data = detect(body)
    print("format:", kind)
    if kind.endswith("uri list"):
        links = [l.strip() for l in data if "://" in l]
        print(Counter(l.split("://", 1)[0] for l in links))
        for l in links:
            if l.startswith("vmess://"):
                name = json.loads(b64decode_loose(l[8:])).get("ps", "")
            else:
                name = urllib.parse.unquote(l.rsplit("#", 1)[-1]) if "#" in l else ""
            print(" ", l.split("://", 1)[0], name or "(no name)")
    elif kind == "sip008":
        for s in data["servers"]:
            print(" ", s.get("remarks"), s["method"], f'{s["server"]}:{s["server_port"]}')

if __name__ == "__main__":
    main(sys.argv[1])
```

Run it as `python3 subdecode.py "$SUB"`. On a test subscription it prints:

```
content-type: text/plain
subscription-userinfo: upload=0; download=1073741824; total=53687091200; expire=1798761600
profile-update-interval: 12
profile-title: base64:VHVubmVsIEV4YW1wbGU=
expires: 2027-01-01 00:00 UTC
format: base64 uri list
Counter({'vless': 1, 'vmess': 1, 'trojan': 1, 'ss': 1, 'hysteria2': 1})
  vless nl-ams-1
  vmess de-fra-1
  trojan de-fra-1 Trojan
  ss ss-legacy-b64
  hysteria2 hy2-1
```

It strips a UTF-8 byte order mark before detection, because some generators add one and it breaks the format check on the first characters.

## How do I read each share link?

Every line is a URL, and five of the six common schemes follow normal URL syntax, so any URL parser can split them. VMess is the exception.

| Scheme | Credential sits in | Main parameters | Specification |
| --- | --- | --- | --- |
| `vless://` | user part (UUID) | `type`, `security`, `sni`, `fp`, `pbk`, `sid`, `flow`, `path`, `host`, `serviceName` | [Xray-core discussion #716](https://github.com/XTLS/Xray-core/discussions/716) |
| `vmess://` | base64 JSON after the scheme | `add`, `port`, `id`, `net`, `tls`, `sni`, `host`, `path`, `ps` | [v2rayN wiki](https://github.com/2dust/v2rayN/wiki/Description-of-VMess-share-link) |
| `trojan://` | user part (password) | `security`, `sni`, `type`, `path`, `serviceName` | Xray clients use the VLESS parameter names |
| `ss://` | user part: `method:password`, often base64url | `plugin` | [SIP002](https://shadowsocks.org/doc/sip002.html) |
| `hysteria2://` or `hy2://` | user part (auth) | `sni`, `obfs`, `obfs-password`, `insecure`, `pinSHA256` | [Hysteria 2 URI scheme](https://v2.hysteria.network/docs/developers/URI-Scheme/) |
| `tuic://` | user part: `uuid:password` | `congestion_control`, `sni`, `alpn` | no formal spec; this is what v2rayN reads |

The text after `#` is the display name, percent-encoded. Names in Persian, Russian or with flag emoji look like `%F0%9F%87%A9%F0%9F%87%AA` until you decode them.

For URL-style links, the Python standard library does the work:

```python
from urllib.parse import urlsplit, parse_qs, unquote

link = "vless://5f0c2a7e-3b1d-4e8a-9c6f-2d4b8a1e7c93@176.61.144.21:443?type=tcp&security=reality&sni=www.yourdomain.com&fp=chrome&pbk=Zx3qO8fJr2mK5vT1yN7cB4hW9pL6sD0aE2gU8iR5oQk&sid=6ba85179&flow=xtls-rprx-vision#nl-ams-1"
u = urlsplit(link)
print(u.scheme, u.username, u.hostname, u.port, unquote(u.fragment))
print({k: v[0] for k, v in parse_qs(u.query).items()})
```

For VMess, strip the scheme and decode the rest as base64 JSON, using the same fix as above:

```bash
link='vmess://eyJ2IjoiMiIsInBzIjoiZGUtZnJhLTEiLCJhZGQiOiJ2cG4ueW91cmRvbWFpbi5jb20iLCJwb3J0IjoiNDQzIiwiaWQiOiI1ZjBjMmE3ZS0zYjFkLTRlOGEtOWM2Zi0yZDRiOGExZTdjOTMiLCJhaWQiOiIwIiwibmV0Ijoid3MiLCJwYXRoIjoiL3dzIiwidGxzIjoidGxzIiwic25pIjoidnBuLnlvdXJkb21haW4uY29tIn0='
printf '%s' "${link#vmess://}" | b64fix | base64 -d | jq .
```

A few details catch people when they read the fields:

-   In VLESS, `sni` falls back to the host when it is missing, and `fp` falls back to `chrome`. A REALITY link must carry both `pbk` (the server's public key) and `fp`. For what each field does, see [VLESS protocol explained](https://tunnelhq.com/blog/vless-protocol-explained/).
-   In Shadowsocks, base64url for the user part is optional for the classic AEAD ciphers, and not allowed for the `2022-blake3-*` ciphers, which use plain, percent-encoded text. Old links base64-encode the whole `method:password@host:port`.
-   A Hysteria2 link without a port means port 443. The port can also be a range for port hopping, like `443,5000-6000`.

## What do the subscription headers mean?

The headers carry the account information, and clients show it as a traffic bar and an expiry date. Print them without downloading the body:

```bash
curl -sS -D - -o /dev/null "$SUB"
```

These are the ones that matter. 3x-ui and Marzban both send all of them:

| Header | Example | Meaning |
| --- | --- | --- |
| `subscription-userinfo` | `upload=0; download=1073741824; total=53687091200; expire=1798761600` | Bytes used up and down, the quota in bytes, and the expiry as Unix seconds. `total=0` and `expire=0` mean no limit. |
| `profile-update-interval` | `12` | How often the client should refresh, in hours. |
| `profile-title` | `base64:VHVubmVsIEV4YW1wbGU=` | The profile name, as plain text or base64 after the `base64:` prefix. |
| `content-disposition` | `attachment; filename="user-0412"` | A fallback profile name, from the file name. |
| `support-url`, `profile-web-page-url` | `https://...` | Links the app shows next to the profile. |

To read the expiry as a date, use `date -u -d @1798761600` on Linux or `date -u -r 1798761600` on macOS. To read a base64 title, decode the part after `base64:`.

Two details from client source code explain most "my traffic shows wrong" reports. v2rayNG treats an `expire` value of 100,000,000,000 or more as milliseconds and divides it by 1000, so a panel that sends milliseconds still works there but may not in other apps. Hiddify only uses the header when it finds all four keys, so always send all four, even as zeros. Marzban always sends `upload=0` and puts all used traffic in `download`, so the split between the two numbers means nothing there.

## How do I read Clash, sing-box and SIP008 subscriptions?

These formats are already text, so you only need a parser to list the servers. For Clash and Mihomo YAML, use yq (v4):

```bash
yq '.proxies[] | .name + " " + .type + " " + .server + ":" + (.port | tostring)' body.txt
```

A Mihomo profile can also point at other subscriptions under `proxy-providers` instead of listing `proxies`. Mihomo's providers accept a YAML list, a plain list of share links, or a base64 list, so a provider URL may itself be an ordinary V2Ray subscription.

For a sing-box remote profile, list the outbounds that have a server. Selector and urltest groups have none:

```bash
jq -r '.outbounds[] | select(.server) | "\(.tag) \(.type) \(.server):\(.server_port)"' body.txt
```

For SIP008, the servers are in a `servers` array. The [SIP008 spec](https://shadowsocks.org/doc/sip008.html) also defines optional `bytes_used` and `bytes_remaining` fields, the JSON version of the userinfo header:

```bash
jq -r '.servers[] | "\(.remarks) \(.method) \(.server):\(.server_port)"' body.txt
```

## Why does a subscription fail to decode?

When decoding fails, look at the raw body before you blame base64. These are the causes I check, in order:

1.  **You got HTML.** The server saw a browser `Accept` header, or a CDN in front answered with a challenge page. `head -c 200 body.txt` shows it at once.
2.  **The status was 404.** Marzban returns 404 for an unknown token, a token from before the user was re-created, and a token issued before the subscription was revoked. A revoked link looks exactly like a wrong one.
3.  **The body decodes to an empty or short list.** 3x-ui only includes enabled clients on enabled inbounds, so an expired client or a disabled inbound shrinks the list without any error.
4.  **Windows line endings.** `\r` at the end of each line ends up inside the last parameter, often the name. `tr -d '\r'` fixes the decoded text.
5.  **It is not base64 at all.** Plain lists, YAML and JSON all fail `base64 -d`. The Python script above detects them.

## From a decoded link to a working test

A decoded link only tells you what the server claims. It does not tell you the server answers. To check one link with Xray-core from the command line, see [How to check a VLESS key](https://tunnelhq.com/blog/how-to-check-vless-key/). You can also paste a single link into the [free TunnelHQ tester](https://tunnelhq.com/test/), no account needed.

Full disclosure: I work on TunnelHQ. If you want every server behind a subscription checked all the time, paste the subscription URL into TunnelHQ. It adds the servers it finds, keeps them in sync when the list changes, and completes the real protocol handshake with each one from outside. Every plan starts with a 14-day free trial.

Source: https://tunnelhq.com/blog/decode-v2ray-subscription-link/
