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 Muntaha9 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 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
Acceptheader. Both 3x-ui and Marzban return an HTML info page when the request saysAccept: 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, includingcurl, gets the base64 list. On 3x-ui's raw path, when Clash output and auto-detection are on, a user agent that matchesclashormihomogets 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:
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/. GNUbase64 -d(Debian 12, Ubuntu 22.04 and 24.04) stops withbase64: invalid input. The macOSbase64(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 thenbase64: invalid input, with exit code 1. A script running underset -estops 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.
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:
#!/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 |
vmess:// |
base64 JSON after the scheme | add, port, id, net, tls, sni, host, path, ps |
v2rayN wiki |
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 |
hysteria2:// or hy2:// |
user part (auth) | sni, obfs, obfs-password, insecure, pinSHA256 |
Hysteria 2 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:
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:
link='vmess://eyJ2IjoiMiIsInBzIjoiZGUtZnJhLTEiLCJhZGQiOiJ2cG4ueW91cmRvbWFpbi5jb20iLCJwb3J0IjoiNDQzIiwiaWQiOiI1ZjBjMmE3ZS0zYjFkLTRlOGEtOWM2Zi0yZDRiOGExZTdjOTMiLCJhaWQiOiIwIiwibmV0Ijoid3MiLCJwYXRoIjoiL3dzIiwidGxzIjoidGxzIiwic25pIjoidnBuLnlvdXJkb21haW4uY29tIn0='
printf '%s' "${link#vmess://}" | b64fix | base64 -d | jq .
A few details catch people when they read the fields:
- In VLESS,
snifalls back to the host when it is missing, andfpfalls back tochrome. A REALITY link must carry bothpbk(the server's public key) andfp. For what each field does, see 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 wholemethod: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:
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):
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:
jq -r '.outbounds[] | select(.server) | "\(.tag) \(.type) \(.server):\(.server_port)"' body.txt
For SIP008, the servers are in a servers array. The SIP008 spec also defines optional bytes_used and bytes_remaining fields, the JSON version of the userinfo header:
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:
- You got HTML. The server saw a browser
Acceptheader, or a CDN in front answered with a challenge page.head -c 200 body.txtshows it at once. - 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.
- 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.
- Windows line endings.
\rat the end of each line ends up inside the last parameter, often the name.tr -d '\r'fixes the decoded text. - 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. You can also paste a single link into the free TunnelHQ tester, 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.


