Overview
SMProxy App is a free cross-platform client for Vless/Reality, VMess, Trojan, Shadowsocks, Socks, WireGuard and AmneziaWG, built on the Xray core. The app does not provide servers and is not a VPN service — the user imports a subscription (config) from a provider and connects. Available on iOS, iPadOS, macOS, Apple TV, Android, Android TV, Windows and Linux.
It's a universal client: it imports a config and connects. Everything else — branding, routing, expiry, statistics — is determined by what the provider returns in the subscription response and by the provider account settings.
This documentation is published as a machine-readable source, so Claude Code, Cursor, Codex and other MCP clients can read the real header formats and deep links instead of guessing them.
claude mcp add --transport http smproxy-docs https://gitmcp.io/SMProxy/docsAdding a subscription
Three import methods:
The subscription body can be a list of vless://…, base64links, a base64 blob or JSON configs. All routing, branding and expiry arrive in the response headers (section 04).
Deep-links (smproxy://)
smproxy://add?url=<url-encoded https URL>smproxy://add/<https URL> · add/<base64 URL>smproxy://crypt1/<base64 AES-GCM of the encrypted URL>smproxy://addhw/<base64 AES-GCM {"u":url,"h":hwid}>smproxy://import/<data>smproxy://onadd/<url>smproxy://connect · opensmproxy://disconnect · closesmproxy://togglesmproxy://statusEncryption uses a shared AES-256-GCM key; addhw binds a link to a single device (anti-sharing). Tapping a link goes through the deep-link handler; pasting into the add form accepts the same forms.
Other clients' links, pasted or scanned. The forms above are accepted with any scheme when the link is pasted into the add form or scanned with the in-app camera: happ://add/<url>, incy://import/<data>, v2raytun://import/<url>, streisand://import/<url>, <anything>://install-sub?url=…, sub://<base64 url> (Shadowrocket). Encrypted links of other clients (happ://crypt4/, incy://crypt1/) cannot be decoded — the app says so. The app does not claim happ:// / incy:// system-wide on Android, iOS or macOS; on Windows and Linux it registers them only when no other handler is installed, so an installed Happ or INCY keeps its links.
Request headers (client → server)
Every subscription fetch carries these headers, so a panel can identify the device and bind a subscription to it (a routing-profile download carries only the User-Agent; geo-database downloads carry none):
X-Hwidaddhw links compare against. Each client app has its own HWID scheme, so a device bound in another client is a different HWID for the panel.X-Device-OsAndroid · iOS · macOS · tvOS · Windows · LinuxX-Ver-Os16, 18.6, 10.0.26200X-Device-ModelSM-S942B, iPhone16,2, PCX-Device-Localeru_RU etc. — device language and regionX-App-Version2.0.5 (422) — marketing version + build in one stringX-App-Build422 — build number alone — the value to gate on (Android 423+, Apple 313+, desktop 199+; older builds send only X-App-Version and the User-Agent)User-AgentSMProxy/<version>/<build>-<OS> — e.g. SMProxy/2.0.5/422-Android. Use it when you want to serve different content to different buildsAll header names are case-insensitive.
User-Agent — one format on every platform
SMProxy/<marketing version>/<build>-<OS>SMProxy/2.0.5/422-AndroidSMProxy/2.0.9/312-iOSSMProxy/2.0.9/312-macOSSMProxy/2.0.9/312-tvOSSMProxy/2.0.2/198-WindowsSMProxy/2.0.2/198-LinuxIt is sent on every subscription fetch and whenever a routing profile is downloaded by URL. The build number is what you gate on — the third segment grows on every release and never repeats, while the marketing version can stay the same across several builds. A panel that serves different content to different builds should parse it like this:
^SMProxy/[^/]+/(\d+)-(Android|iOS|macOS|tvOS|Windows|Linux)$Subscription metadata
The subscription endpoint response is the body (server list) plus metadata (branding, expiry, updates). Metadata can be delivered two ways, and the client reads both.
Profile-Title: My VPN
#profile-title: My VPN
#key: value line in the body is used only if there's no header of the same name. This lets you deliver metadata when you can't set headers (static hosting, Telegram files, etc.).
1 / on / true / yes all mean enabled, and 0 / off / false / no all mean disabled.
s-fragment, s-dns, s-noise, s-resolve — the value you send takes precedence over the user's own toggle, off included. The user's toggle only applies when you send nothing at all. This is deliberate: these parameters have to match what your server expects, and a user switching them on their own would simply break the connection.
Live subscription updates
A provider can change a subscription's bypass settings and routing without waiting for the next subscription re-fetch: changes made in the provider account reach online devices within minutes and are applied without reconnecting — new values take effect on the next connect. The same rules apply as for the HTTP headers above — in particular, they are honored only while the provider is active. The delivery mechanism is internal to the app: nothing needs to be implemented on the subscription endpoint.
Body line syntax
- •Starts with # , then a key [A-Za-z0-9-]+ , then the value.
- •The colon is optional — #profile-title My VPN also works.
- •The key is case-insensitive.
- •The first occurrence of a key wins; duplicates are ignored.
- •The body is scanned both as-is and after full base64 decoding — it works for plain text and for a single base64 blob (the usual vless-link format).
- •Only the keys in the table below are read from the body; lines with other keys are ignored.
Text fields can be encoded by prefixing base64: — works in both header and body. Applies to profile-title, s-title, announce, sub-expire-button-text.
#profile-title: base64:TXkgVlBO → “My VPN”
Keys
Each key works both as a header Key: value and as a body line #key: value. All free.
Branding & links
profile-title / s-titles-sitename🔒s-siteurl🔒s-tgbot / x-tgbot🔒support-urlannounceannounce-url🔒support-email🔒profile-web-page-urlSubscription status & renewal
subscription-userinfosub-expire / s-showexpire🔒sub-expire-button-text🔒sub-expire-button-link-site🔒sub-expire-button-link-tg🔒notification-subs-expire🔒Updates & data
profile-update-intervalfallback-url🔒geoipurl / geositeurlsort-order🔒Migration & access
new-url🔒new-domain🔒fallback-domains🔒hide-settings🔒Identification
providerid / s-provideridsubidBypass
s-noise🔒s-resolve🔒s-fragment🔒s-dns🔒Provider banner — banner-*
A banner on the subscription card, shown only while the provider id is active (INCY-compatible). Colors must be #RRGGBB — anything else is ignored and the app's own theme color is used. The button caption color is picked automatically from the button's brightness, so it stays readable on light and dark backgrounds alike.
banner-text🔒base64: — banner body, up to 5 linesbanner-button-text🔒base64:, ≤ 25 characters — button captionbanner-button-url🔒banner-bg-color🔒#RRGGBB — banner backgroundbanner-button-color🔒#RRGGBB — button backgroundBypass parameters at a glance
A summary of the four bypass headers — the detailed reference for each is in section 08.
s-fragmenton | off | packets=<tlshello|1-3|all>;length=<min-max>;interval=<min-max>[;maxsplit=<n>] · default when only on is sent: packets=tlshello;length=50-100;interval=10-20s-noise (s-noises)on | off | type=<rand|str|hex>;packet=<value>;delay=<min-max> · default when only on is sent: type=rand;packet=50-100;delay=10-20s-resolveon | off | <doh-url>[;ip=<bootstrap>][, …] · default when only on is sent: the built-in resolvers-dnson | off | <doh-url> · default when only on is sent: the built-in resolver (dnsforge.de)Compatibility with INCY / Happ header names
If you already configure another client, the app also understands their atomic keys and folds them into the equivalent setting. Our s-* header always wins — the keys below are read only when the matching s-* header is absent. *-enable: 0 means explicitly off.
fragmentation-enable · fragmentation-packets · fragmentation-length · fragmentation-interval · fragmentation-maxsplits-fragmentnoises-enable · noises-type / noises-packet-type · noises-packet · noises-delay · noises-rands-noiseserver-address-resolve-enable · server-address-resolve-dns-domain · server-address-resolve-dns-ips-resolvenoises-rand: <n> becomes type=rand;packet=<n>. noises-rand-range is not supported.
Where a value can come from, and what wins: 1) the HTTP response header — highest priority; 2) a #key: value line in the body (and, for routing commands, a bare *://routing/… line) — used when the header is absent; 3) the app's own setting — when the provider sends nothing. For the four bypass headers the provider's value wins over the user's toggle, off included.
Text values accept plain UTF-8 or a base64: prefix; any header value may also arrive crypt1/-encrypted.
URL migration: new-url / new-domain
Re-point the saved subscription URL — the provider can move without the user having to re-add the subscription.
- •
new-url— replaces the whole URL (any format), the same for all subscriptions. - •
new-domain— changes only the host (scheme, port, path, query are kept); each subscription keeps its own path/token — safe for personal URLs. If both are sent,new-urlwins.
Applies only if the same response contains providerid AND that provider has provider_active == true. All local subscriptions with that providerId are migrated. The locked flag is preserved: a subscription from crypt1/addhw stays locked on the new URL.
Compatibility aliases (Happ / INCY)
Headers other clients use are read as their SMProxy counterparts when the SMProxy header is absent — a panel written for Happ or INCY works without changes. Aliases are accepted both in HTTP headers and in the #key: value body form.
subscription-nameprofile-title — INCY fallback namecontent-disposition: attachment; filename="x.txt"profile-title — filename without .txt / .yaml / .yml; lowest priorityhomepageprofile-web-page-url — INCYhide-urlhide-settings — 1/true hides, 0/false un-hidessub-info-textbanner-text — Happ “advanced announcements”; 0 = no bannersub-info-button-text / sub-info-button-linkbanner-button-text / banner-button-urlsub-info-color (red · blue · green)banner-bg-color — mapped to a hex coloursub-expire-button-linksub-expire-button-link-tg for t.me / tg://, otherwise sub-expire-button-link-site — Happ single-link formrouting-enable: 0routing: routing/off — Happ “disable routing”subscription-userinfo: 0expire=<milliseconds> in subscription-userinfoThe SMProxy header always wins when both are present.
↑ Back to contentsServer description
A per-server caption shown under the server name instead of the default protocol label (VLESS | TCP | Reality). Use it to say something the label can't — Netflix / streaming, for gaming, 10 Gbps. Unlike the keys above it is not sent per subscription: it rides with each individual server.
s-fragment and s-dns. Without an active provider id the app falls back to the protocol label.
serverDescriptionIn share links
Inside the #fragment, after the title, separated by ?:
vless://uuid@host:443?security=reality&type=tcp#🇳🇱 Netherlands?serverDescription=TmV0ZmxpeCAvIHN0cmVhbWluZw==The same syntax works for vless://, trojan://, ss:// and socks://.
In vmess links
A field inside the base64-encoded JSON body:
{ "ps": "🇳🇱 Netherlands", "add": "host", "port": "443", "serverDescription": "TmV0ZmxpeCAvIHN0cmVhbWluZw==" }In JSON configs
Inside a meta object next to remarks — here the value is plain text, not base64:
{
"remarks": "🇳🇱 Netherlands",
"meta": { "serverDescription": "Netflix / streaming" },
"outbounds": [ … ]
}In links, a value that isn't valid base64, or decodes to nothing, is ignored — the server keeps its protocol label rather than showing broken text.
↑ Back to contentsHiding the subscription URL — hide-settings
hide-settings: 1 stops the subscription URL and its raw content from leaving the device. It puts the subscription into exactly the same state as an encrypted (crypt1) or hardware-bound (addhw) one — internally the app treats “locked” and “hidden by the provider” as one condition.
What it blocks
What keeps working
Connecting, ping, traffic statistics, subscription refresh, the server list, choosing servers, routing profiles, the expiry UI — the user simply cannot see or export the address.
Values and behaviour
10crypt1 lock on that subscription, if the provider set one. This is deliberate: the provider owns the link, so an explicit 0 from an active provider opens the subscription completely.0.Anything other than 1 or 0 is ignored and logged as “value not recognised” — on / off / true / false are not accepted here.
Accepted as an HTTP response header, as #hide-settings: 1 in the body, and from the provider account. Honored only while the provider id is active — an inactive or unknown provider cannot hide (or reveal) anything.
Two channels, one rule: who set a value can clear it
The same keys can arrive two ways — in the subscription response (an HTTP header or a #key: line) and from the provider account (applied to online devices within minutes). They follow one rule:
In short: whoever wrote last, wins, and a value can only be removed by the side that put it there. That is how you take an announcement or a banner down — clear it in the panel and it disappears on the next update; the subscription response keeps whatever it sends.
Fields with no “empty” state — the subscription name, brand links, hide-settings — are never auto-cleared; send a new value to change them.
⚠️ This applies to all keys, including bypass (s-fragment, s-noise, s-resolve, s-dns) and routing: if you set s-fragment from the panel and later remove it there, fragmentation switches off on the next update.
Provider ID
Provider ID links a subscription to a provider account so the provider can see usage statistics for their subscription. It restricts nothing — the app works fully without it. Free.
…?providerid=<ProviderID>#providerid <ProviderID>providerid: or s-providerid:If several are provided, the HTTP header takes priority. You get your Provider ID (and the option to set a custom one) in the provider portal — see section 08.
🔒Requires an active Provider ID
s-fragment s-noise s-noises s-resolve s-dns — and their INCY/Happ equivalents fragmentation-* noises-* server-address-resolve-*new-url new-domain fallback-url fallback-domainss-siteurl s-sitename s-tgbot x-tgbot sort-order serverDescriptionbanner-text banner-button-text banner-button-url banner-bg-color banner-button-color announce-url support-emailsub-expire s-showexpire sub-expire-button-text sub-expire-button-link-site sub-expire-button-link-tg notification-subs-expirehide-settings✓Works with or without a Provider ID
profile-title / s-titleprofile-update-intervalprofile-web-page-urlannounceannounce-url (making it clickable) needs an active providersupport-urlsubscription-userinfosubidrouting autorouting routing-update-url routing-update-interval routing-profilegeoipurl / geositeurlThe same gating applies however the value arrives — an HTTP header, a #key: line in the body, or the provider account.
Domain verification — statistics without a Provider ID
Statistics only work when the app knows your Provider ID. If a build does not send it — an older release, a client you do not control, a config imported by hand — those check-ins land nowhere and you lose sight of your own users.
Verify the domain your subscriptions are served from, and check-ins without a Provider ID are attributed to you automatically, based on the subscription URL the app reports.
Name: smproxy-verification.your-domain.com
Value: 8f3a91c24b774c1e9a025e6d1f0b7c33What it covers
Subdomains are included. Verifying example.com covers sub.example.com, eu.sub.example.com and any other depth — you do not need to add them separately.
Matching is done on the registrable domain, so multi-level suffixes behave correctly: example.co.uk is a domain you can own, co.uk is not, and evil-example.com never matches example.com.
A domain belongs to one provider. If someone else has already verified it, adding it again is refused.
Priority — Provider ID wins
Domain verification never overrides an explicit Provider ID — it only fills the gap when one is missing or unusable.
Where the domain shows up
- •Statistics — a Subscription domains breakdown showing how many devices use each host, and the host under each device's
subid. Full hosts are shown, so you can tellsub.example.comfromsub3.example.com. - •Messages — you can target a message at a domain:
*.example.comfor the domain and all its subdomains, or a single host for just its users.
Only the host is ever displayed — subscription tokens in the URL are never shown.
↑ Back to contentsMoving users to another domain
Your subscription domain gets blocked, or you simply move to a new one. Both cases are handled from the portal — you do not have to change anything in your panel, and users do not have to re-add their subscription.
Both rules are keyed on the domain the request arrived at, so you can run several at once — one per subdomain — and remove them individually.
Migration — new-domain
Open provider.smproxy.io/provider/domains, press Move next to a verified domain and pick:
- •From —
*.example.comto move subscriptions from the whole domain including its subdomains, or a single host such assub.example.comto move only that one. - •To — the new host. It has to be verified in the same account, so nobody can point your users at a domain they do not own.
From then on, every subscription request that arrives at the old host gets a new-domain header, and the app rewrites the address itself. Each user keeps their own path and token:
old.example.com/sub/abc123 → new.example.com/sub/abc123b.example.com already has a migration of its own, you cannot make it the destination of another one — users would be handed an address that immediately moves again.
Backup hosts — fallback-domains
Press Backups next to a domain and list the hosts to fall back to, one per line. Every entry must be a verified domain of yours. The list is sent as fallback-domains for requests to that domain, and the app walks it in order if the main host stops answering.
Use a migration when you have actually moved; use backups when the main address may become unreachable and you want the app to find its own way through.
Two delivery channels
Both rules reach the app in two independent ways:
- •Subscription response headers —
new-domain/fallback-domains, as described above. Requires the request to still reach one of your domains. - •The app's own service channel — the app also picks up the
new-domain/fallback-domainsyou configured for your domain over its own service connection, without fetching the subscription. They are applied on the next subscription refresh:new-domainre-points the stored URL permanently, backup hosts are saved and tried whenever the main host stops answering.
The second channel matters when your subscription domain is already unreachable: it runs over the app's own service endpoints, so existing users still receive the migration or the backup list even though they cannot fetch the subscription itself.
Requires an app build from late August 2026 or newer; older builds keep using headers only.
↑ Back to contentsSubscription ID — subid
What it solves. When you hand a user a new subscription URL — a domain migration, a re-issued token, a move to another panel — the app has no way to tell it is the same subscription. It adds a second entry and the user ends up with two copies of the same thing.
Send a subid and that stops happening: the app treats a matching subid as the same subscription, replaces its URL with the new one and refreshes its servers in place.
subid: my-sub-42#subid: my-sub-42 in the subscription body…?subid=my-sub-42An HTTP header wins over a body comment, which wins over the URL. Format: letters, digits, -, _ and spaces ([A-Za-z0-9 _-]+). Anything else is ignored — the value is simply not stored.
subid is optional. Without it everything works exactly as before — duplicates are detected by URL only. Sending it is simply a way to keep control of your users' entries when your addresses change.
Pick a value that never changes for a given user's subscription. It doesn't have to be secret — it is only compared with what the app already has — but it must be stable and unique per subscription. A UUID or your internal subscription id are both fine.
subid: 8f3a91c2-4b77-4c1e-9a02-5e6d1f0b7c33
Available on all platforms: iOS, Android, macOS, Windows, Linux and Apple TV.
↑ Back to contentsMessages & notifications
SMProxy exclusiveA provider can send users in-app messages. They arrive inside the Provider ID check response and land in the app inbox (bell icon + unread counter), optionally triggering a system notification.
Each message has: id, title, body, optionally date, type (info/update/promo/warning), url, notify and targeting. Read status is stored locally per device.
Targeting
Compose messages in the provider portal. By default a message goes to everyone; each filter below narrows that down, and they combine.
Platform and app build
Tick the platforms that should receive the message. Leave the build field empty to reach every version of that platform, or set a condition (<, ≤, =, ≥, >) to reach only some of them.
Build numbers are per-platform — iOS 200 and Android 200 are unrelated releases — so a condition is always tied to a platform. Ticking nothing means “all platforms”; as soon as you tick one, the others receive nothing. That is how you reach users still on an old iOS build without touching Android:
< 200 · iOS users below build 200< 200, Android < 300 · old builds on both, nothing elsewhereSubscription domain
Pick a verified domain to reach only the users whose subscription is served from it — *.example.com for the domain and all its subdomains, or a single host such as sub.example.com for that host alone. The portal shows how many devices are currently on each host.
Requires a verified domain — see Domain verification (section 05a).
A single device
Paste a HWID to send the message to exactly one device — useful when you are walking one user through a problem.
↑ Back to contentsGeoIP / GeoSite
The Geo rules the tunnel uses live inside the imported Xray config and are resolved against the geoip.dat / geosite.dat databases. The app ships with built-in databases and can download updated ones from the geoipurl / geositeurl headers (supported on iOS, Android and desktop OSes); a freshly downloaded database replaces the built-in one for the engine.
Routing profiles
A routing profile is a full rule set: what goes straight out, what goes through the tunnel and what is blocked, plus optional split-DNS. It answers “my subscription is a single vless:// link, so there is no routing inside it” — the profile carries routing separately from the servers.
The profile
{
"Name": "Bypass RU",
"GlobalProxy": true,
"DirectSites": ["geosite:category-ru", "domain:gosuslugi.ru"],
"DirectIp": ["geoip:ru", "192.168.0.0/16"],
"ProxySites": ["geosite:youtube"],
"BlockSites": ["geosite:category-ads-all"],
"RemoteDNSType": "DoH", "RemoteDNSDomain": "https://dnsforge.de/dns-query",
"DomesticDNSType": "DoU", "DomesticDNSIP": "77.88.8.8",
"DnsHosts": { "example.com": "1.2.3.4" },
"DomainStrategy": "IPIfNonMatch",
"UpdateUrl": "https://example.com/routing.json",
"UpdateInterval": 24,
"LastUpdated": 1756600000
}
NameGlobalProxyDirectSites / ProxySites / BlockSitesDirectIp / ProxyIp / BlockIpRemoteDNS* / DomesticDNS*DnsHostsDomainStrategyFakeDNSUpdateUrlUpdateIntervalLastUpdatedRules are applied in the order block → direct → proxy, and they sit ahead of any rules that came with the server config.
GlobalProxy and FakeDNS are accepted both as JSON booleans and as the strings "true" / "false" that Happ and INCY emit. Fields other clients add for their own UI — RouteOrder, UseChunkFiles — are ignored: SMProxy always applies rules in the order block → direct → proxy.
Split-DNS fields
RemoteDNSTypeDoH, DoT, DoQ or DoU (plain UDP).RemoteDNSDomainhttps://dnsforge.de/dns-query. Empty for plain UDP.RemoteDNSIPRemoteDNSDomain it is the bootstrap address of that domain — the same role ;ip=… plays in the s-resolve header; without a domain it is a plain UDP resolver.DomesticDNSType / DomesticDNSDomain / DomesticDNSIPRemoteDns / DomesticDnsRemoteDNSIP / DomesticDNSIP. Read only when the new field is absent or empty. Do not use them in new profiles.How the resolvers are used:
- •Which resolver answers what. Remote — names that go through the tunnel, domestic — names routed direct (
DirectSites). The remote resolver is queried through the tunnel, the domestic one over the physical network. - •
RemoteDNSDomainis also the client's own resolver for its service requests (subscription refresh, profile download) when the subscription sends nos-resolve. A routing profile therefore sets DNS for everything, not just for traffic inside the tunnel. - •Bootstrap. The
;ip=…suffix ofs-resolvedoes not work insideRemoteDNSDomain— there is no parsing there, the address would go out with junk at the end. Put the bootstrap address intoRemoteDNSIP. - •Fallback chain. The profile's resolvers go first, then the DNS servers from the connection config itself (their own
domains/skipFallbacksplits are kept). If every profile resolver is unreachable, resolution falls through to the config's servers. - •Client support for arrays: SMProxy from Android 411 / iOS, macOS, tvOS 305 / Windows, Linux 193. Happ, INCY and older SMProxy builds read only the string form — send a string unless you know your users are updated.
String or array — an SMProxy extension. RemoteDNSDomain, RemoteDNSIP, DomesticDNSDomain and DomesticDNSIP each accept a single string (the classic form, compatible with Happ/INCY) or an array of strings:
"RemoteDNSDomain": ["https://dns1.example/dns-query", "https://dns2.example/dns-query"],
"RemoteDNSIP": ["1.2.3.4", "5.6.7.8"]Order = priority: the core queries resolvers top-down, so the second entry is the fallback for the first. *DNSIP[i] is the bootstrap address for *DNSDomain[i]; an IP without a paired domain acts as a plain resolver.
Version guard — LastUpdated
When a profile with the same Name arrives automatically (subscription headers, an UpdateUrl refresh, a link), it replaces the stored one only if its LastUpdated is greater. If either side has no timestamp, the profile is replaced unconditionally. Bump the value whenever you change the profile. Manual imports from the app UI always apply.
Handing a profile to the user
By link — the natural way: from a browser, a message or a QR code.
smproxy://routing/add/<base64|url>smproxy://routing/onadd/<base64|url>smproxy://autorouting/add/<url> · …/onadd/<url>smproxy://routing/offThe payload may also be passed as ?data=<base64>. A URL may return the profile JSON, its base64, or another smproxy:// link — all three are accepted.
By subscription header — the profile arrives with the subscription itself.
routingautoroutingrouting-update-urlrouting-update-intervalrouting-profileA profile arriving from a subscription is added to the list but not switched on — the choice stays with the user. These keys work with or without a Provider ID.
Headers take deep-link commands too. The value of routing / autorouting may be a deep-link command — the same forms as the clickable links, with any scheme word (smproxy://, happ://, yourapp://): inside a header the word before :// does not matter.
*://routing/add/<base64|url>*://routing/onadd/<base64|url>*://autorouting/add/<url> · …/onadd/<url>*://routing/offThe payload may be crypt1/<base64>-encrypted, like any other header value.
The same commands are also accepted as a bare line in the subscription body, right among the server links (INCY-compatible):
vless://uuid@server1:443?security=tls#Server1
vmess://eyJhZGQiOiAic2VydmVyMi...
incy://routing/onadd/ewogICJOYW1lIjogIl...A real HTTP header, or an explicit #routing: body header, takes precedence over a bare line.
Global vs per-subscription
The user picks one active profile in Settings — the global choice. A subscription can override it: if it names a profile (in its own settings, or via the routing-profile header), connecting to that subscription's server uses that profile instead. Both are switchable off: globally in Settings (or with routing/off), and per subscription with “Do not apply”.
Which profile wins
When a subscription refreshes, several of the headers above can arrive at once. They are resolved in this order:
routing / autorouting — a profile sent inline wins over one fetched from routing-update-url. Send the profile itself when you want to be sure exactly which version the client gets.
routing-update-url alone — if no profile came inline, the client fetches one from this URL and imports it. The URL works on its own; it does not need routing alongside it.
routing-profile (a name) — decides which profile the subscription uses, and it outranks whatever arrived in this refresh. Naming Corporate while also sending a profile called Default means the subscription uses Corporate; Default is still imported into the list.
A name that matches nothing changes nothing. If routing-profile: Corporate arrives and no profile by that name exists on the device, the subscription keeps the routing settings it already had. The client will not fall back to some other profile, and it will not clear the setting — a typo in the header can't silently reroute your users' traffic.
The global choice is never touched. Everything above sets the profile for that subscription. The profile the user picked in Settings stays as it is, and keeps applying to every other subscription. To change what a subscription uses, use routing-profile; there is no header that repoints the user's global selection.
Changes apply on the next connect. Profiles are read when the tunnel config is built, so a profile that arrives while connected takes effect after a reconnect — refreshing the subscription mid-session does not reroute live traffic.
s-resolve header → the RemoteDNSDomain field of the active routing profile → the user's own setting → the built-in list (if the user enabled it) → the system resolver.s-resolve: off disables pre-resolving altogether — the profile is not picked up in that case.Censorship bypass
DPI systems often block by SNI (the destination domain) read from the TLS ClientHello — the first packet of every TLS/Reality handshake. Fragmentation splits that ClientHello into many tiny TCP segments with micro-delays, so DPI can't reassemble the SNI → can't see the domain → can't cut by domain. It runs entirely on the stock Xray core (a freedom outbound with fragment settings) — with no extra server or infrastructure.
The four parameters below solve different problems and are often used together.
fragment outbound manually: when enabled, the app inserts it and routes all direct TLS/Reality traffic through it on ANY config, leaving multi-hop chains untouched — only the hop the DPI sees gets fragmented.
08.1 · s-fragment — TLS fragmentation
Enabling — the s-fragment
absent / offonpackets=tlshello;length=50-100;interval=10-20;maxsplit=100-200Sent as the HTTP response header s-fragment: or as a body comment line #s-fragment: packets=tlshello;length=50-100;interval=10-20.
s-fragment is honoured only while your Provider ID is active. An inactive or unknown provider can't enable fragmentation on users' devices.
Parameters
packetsWhich packets to fragment. tlshello — only the TLS ClientHello (carries the SNI); recommended, minimal overhead. 1-3 — the first 1–3 outbound packets.
lengthFragment size, random within the range. Smaller = harder to reassemble but more overhead.
intervalDelay between fragments, random within the range. Jitter hinders buffering and reassembly.
maxsplitOptional (newer cores only). Caps the number of fragments; without it, unlimited. Older cores ignore it.
Defaults suit most networks. Tune for a specific DPI: harder blocking → smaller length and/or larger interval; slower speed → larger length and smaller interval.
Parameter names are case-insensitive (maxsplit and maxSplit are the same key), and the packets key is plural — packets=, not packet=. packet= belongs to s-noise, where it means the payload.
⚙️Users can enable it themselves
A user can also enable fragmentation and edit the same parameters manually — in the app: Settings → Bypass → TLS fragmentation, independently of the provider. When enabled manually, their parameters take priority over the header.
08.2 · s-noise — noise packets
Junk traffic sent ahead of the connection so DPI can't recognise its start. It works alongside fragmentation — they share the same egress, and together they cover both the start of the connection and what's inside it.
Values: on / 1 uses the defaults (rand, packet 50-100, delay 10-20); off / 0 disables it. Full form is type=rand;packet=50-100;delay=10-20, or positional rand,50-100,10-20; type accepts rand, str or hex.
Both spellings of the header are accepted — s-noise and s-noises.
08.3 · s-resolve — pre-resolving the server address
Resolves the server address over DoH before the tunnel comes up. Use it where the local DNS returns a forged answer for your node's domain: without it the client gets a wrong IP and simply never connects.
Values: on / 1 uses the default resolver (dnsforge.de); off / 0 disables it; a URL sets your own.
Where even the resolver's own domain is poisoned, add a bootstrap IP with ;ip=… — for example https://dnsforge.de/dns-query;ip=49.12.67.122. The original domain stays as SNI, so TLS/Reality still validate. If the resolver doesn't answer within a second and a half the normal lookup is used — the parameter can only help, never block the connection.
Several resolvers can be listed, separated by commas, each with its own bootstrap IP — they are tried in order until one answers. A single address behaves exactly as before: it is the same format without commas.
s-resolve: https://a.example/dns-query;ip=1.2.3.4, https://b.example/dns-query;ip=5.6.7.8A list beats the app's built-in set: you name the resolvers you know work on your users' networks, which the built-in list cannot know. It also can't be switched on by a header at all — only by the user's own toggle.
08.4 · s-dns — DNS-over-HTTPS in the tunnel
Encrypts ordinary DNS queries inside the tunnel. Values: on / 1 enables the built-in default resolver (dnsforge.de); a URL such as https://dnsforge.de/dns-query sets your own; off / 0 disables it. Resolved DNS travels through the tunnel, not the physical network.
off included. When the user configures it themselves, the order is: fragmentation → noise → DoH.WireGuard/AmneziaWG
WireGuard and AmneziaWG servers behave like any others: they sit in the same list next to VLESS/Trojan/Shadowsocks and the user picks them the same way. There is no separate mode and no extra setup. It runs on the stock Xray core (a wireguard outbound) — nothing new is required on the provider side beyond the config itself.
Option A — in a JSON subscription
A normal Xray config whose outbound has "protocol": "wireguard":
{
"remarks": "🇳🇱 Netherlands — WG",
"outbounds": [{
"tag": "proxy",
"protocol": "wireguard",
"settings": {
"secretKey": "<client private key>",
"address": ["10.0.0.2/32"],
"mtu": 1420,
"peers": [{
"publicKey": "<server public key>",
"preSharedKey": "<optional>",
"endpoint": "example.com:51820",
"allowedIPs": ["0.0.0.0/0", "::/0"],
"keepAlive": 25
}]
}
}]
}The server address shown in the app comes from peers[0].endpoint. WireGuard has no vnext or servers block — the endpoint string is the only place the host lives.
AmneziaWG fields. The same outbound accepts obfuscation parameters — add them next to the usual ones. They are only for AmneziaWG; for plain WireGuard leave them out entirely (any zero value is ignored and the connection behaves exactly as before).
{
"protocol": "wireguard",
"settings": {
"secretKey": "…",
"address": ["10.66.66.17/32"],
"mtu": 1420,
"jc": 4, "jmin": 40, "jmax": 70,
"s1": 15, "s2": 25, "s3": 0, "s4": 0,
"h1": 1111111, "h2": 2222222, "h3": 3333333, "h4": 4444444,
"peers": [
{ "publicKey": "…", "endpoint": "1.2.3.4:51820", "allowedIPs": ["0.0.0.0/0"] }
]
}
}jcjmin / jmaxs1 / s2s3 / s4h1–h4s1–s4 and h1–h4 must be identical on the server and in the client config, otherwise the connection silently fails: the tunnel comes up but no traffic passes. jc, jmin and jmax do not have to match — each side sends its own junk.
Server version — 3.1 and older. The app is built on the AmneziaWG 3.1 branch and stays compatible with every earlier one, so there is nothing to upgrade on your side. Send only the parameters your server actually uses; anything you leave out keeps its default and is ignored.
jc, jmin, jmax, s1, s2, h1–h4s3, s4i1–i5header_protection_key, content_padding_addition, rekey_after_time, rekey_timeout, reject_after_time, keepalive_timeout, max_handshake_attemptsrandom_trailers, disable_cookies, ranges a-b for h1–h4 and PersistentKeepaliveA 3.1-only parameter sent to an older server is simply not understood by it, so keep the two sides in step: configure the client with exactly what the server runs.
Option B — a wireguard:// link
wireguard://<base64 of a standard .conf> — the base64 holds an ordinary WireGuard configuration file, exactly the text you hand to the official client. An optional #Name anchor sets the displayed title:
wireguard://W0ludGVyZmFjZV0K…#🇳🇱%20NetherlandsThe decoded payload is simply:
[Interface]
PrivateKey = <client private key>
Address = 10.0.0.2/32
DNS = 1.1.1.1
[Peer]
PublicKey = <server public key>
PresharedKey = <optional>
Endpoint = example.com:51820
AllowedIPs = 0.0.0.0/0, ::/0This form works everywhere the other share links do: pasting into the add form, inside an encrypted crypt1 link, or as a line in the subscription body.
Paste a .conf into the helper page — it assembles the wireguard:// link and a QR code right in your browser, with no outbound requests.
Keys are passed to the core as-is — the app does not re-encode them.
Client-side import
From Android 379, iOS/macOS 2.0.3 (277+) and desktop 169 the add screen takes a WireGuard/AmneziaWG config in four ways — you can hand configs to users in any of them:
.conf file — the “Choose config file” button (Android, iOS, macOS, Windows, Linux; Apple TV has no file picker — it adds configs from the phone).[Interface]…[Peer] body pasted straight into the add field, on every platform..conf text itself. Many panels hand out configs exactly this way.wireguard:// / amneziawg:// / awg:// / wg:// carrying base64 of the config, as described above.AmneziaWG obfuscation parameters (Jc, Jmin, Jmax, S1, S2, H1–H4) are picked up from [Interface] in all four. Older builds accept the scheme link and QR only.
C. AmneziaWG — WireGuard with obfuscation
AmneziaWG is the same WireGuard plus obfuscation: junk packets and rewritten header types that keep DPI from recognising a WireGuard handshake.
Link schemes. wireguard://, amneziawg://, awg:// and wg:// are all accepted and parsed the same way — the payload is base64 of a standard .conf, with an optional #Name fragment.
amneziawg://<base64url of the .conf>#GermanyObfuscation parameters live in [Interface] of that .conf, next to the usual keys:
[Interface]
PrivateKey = …
Address = 10.66.66.17/32
MTU = 1420
Jc = 4 ; how many junk packets to send before the handshake
Jmin = 40 ; junk packet size, lower bound
Jmax = 70 ; junk packet size, upper bound
S1 = 15 ; init packet prefix size
S2 = 25 ; response packet prefix size
S3 = 0 ; cookie packet prefix size — optional, omit unless the server sets it
S4 = 0 ; transport packet prefix size — same
H1 = 1111111 ; rewritten header types — otherwise recognised by signature (3.1 servers may use a range: H1 = 1000-2000)
H2 = 2222222
H3 = 3333333
H4 = 4444444
[Peer]
PublicKey = …
Endpoint = 194.61.120.25:57932
AllowedIPs = 0.0.0.0/0,::/0
Leave them out and you get plain WireGuard — the app and the core behave exactly as before. The same applies field by field: send only what your server runs. Any AmneziaWG version works, 3.1 and older — see the version table in section A.
Several locations in one file. A subscription URL may return a JSON container instead of links:
{
"type": "amneziawg",
"version": 1,
"servers": [
{ "name": "Germany", "config": "<base64url of the .conf>" },
{ "name": "Netherlands", "config": "<base64url of the .conf>" }
]
}
type is accepted as amneziawg, awg, wireguard or wg. A malformed entry is skipped without taking the rest of the list down with it.
Notes for operators
- IPv6 endpoints are supported, written the standard way:
[2606:4700:d0::a29f:c001]:2408. keepAlive(seconds) — persistent keepalive. Set it if your clients are behind NAT: without it the translation expires and the tunnel goes quiet in one direction.- MTU in the config controls what the client sends. What the server sends is set by the MTU of the
wg0interface on the node — configure both, otherwise a path that can't carry full-size packets will pass the handshake and then stall. - Routing on the node matters:
ip route get <client ip>must resolve viawg0. If it points at the default gateway the handshake still succeeds (the daemon answers it directly) but data never reaches the client. Looks like blocking — it isn't.
Link formats
Everything the client accepts, in one place. All schemes are case-insensitive.
Server links
vless:// vmess:// trojan:// ss:// socks://wireguard:// · amneziawg:// · awg:// · wg://hysteria2:// · hy2://Base64 is read leniently — standard and URL-safe alphabets, with or without padding.
A #fragment sets the display name (percent-encoded). It may also carry a server description — see section 04a.
What “View config” shows
The client applies one simple rule: if the link carries base64, it shows the decoded content; otherwise it shows the link as it is. So wireguard://<base64> displays the readable .conf, while vless://uuid@host:443?… stays a link — there is nothing to decode. JSON payloads are pretty-printed.
Subscriptions
A subscription URL may return:
{"type":"amneziawg","version":1,"servers":[
{"name":"Germany","config":"<base64 of .conf>"}]}type is accepted as amneziawg, awg, wireguard or wg. A broken entry is skipped rather than failing the whole subscription.
Encrypted header values
Header values can be sent in a closed form, so links and settings don't travel around in the open. Any subscription header may carry its value encrypted, using the same crypt1/ prefix as the deep links:
routing-update-url: crypt1/<base64>
routing: crypt1/<base64>
autorouting: crypt1/<base64>
s-dns: crypt1/<base64>
s-fragment: crypt1/<base64>
s-resolve: crypt1/<base64>
s-noise: crypt1/<base64>
new-url: crypt1/<base64>
s-siteurl: crypt1/<base64>The client strips the prefix, decrypts the payload and then treats the result exactly as if it had arrived in the clear. Without the prefix nothing changes — existing subscriptions keep working untouched. Encryption is optional and per-header: a single response can freely mix encrypted and plain values.
The encryption is exactly the one used by smproxy://crypt1/… links — AES-256-GCM, nonce || ciphertext || tag, base64-encoded, under the same shared key. A panel that already issues those links needs no new code: encrypt the value, prefix it with crypt1/, and put it in the header.
To get an encrypted value without writing any code, use the same generator that produces the links — it returns smproxy://crypt1/<encrypted>; take everything after smproxy://, that is crypt1/<base64>, exactly what the header expects.
POST https://provider.smproxy.io/public/crypto-link
{ "url": "https://example.com/routing.json" }
→ { "link": "smproxy://crypt1/AbCd…" }
routing-update-url: crypt1/AbCd…
s-dns: crypt1/AbCd…Both separators are accepted — crypt1/<base64> and crypt1:<base64> — but use the slash: it matches the deep links, so there is one format to remember.
Deep links
Two schemes are accepted: smproxy:// (current) and smartvpn:// (pre-rename, kept working for links already handed out). That applies to clickable links only — the OS hands a link to the app registered for its scheme. Inside the routing / autorouting headers any scheme word is accepted (section 07a).
smproxy://add?url=<encoded URL>smproxy://add/<https://…> · add/<base64 URL>smproxy://crypt1/<base64>smproxy://routing/add/<base64|url>smproxy://routing/onadd/<base64|url>smproxy://autorouting/add|onadd/<url>smproxy://routing/offThe payload may also be passed as ?data=<base64>. For routing links the target may hold the profile JSON, its base64, or another smproxy:// link — all three resolve.
Provider portal
Self-service at provider.smproxy.io — registration, statistics, messages and link generators.
provider.smproxy.io/provider/registerprovider.smproxy.io/provider/loginprovider.smproxy.io/provider/dashboardprovider.smproxy.io/provider/statsprovider.smproxy.io/provider/messagesprovider.smproxy.io/provider/hwid-linkprovider.smproxy.io/provider/crypto-linkRegistration takes a minute and gives you a Provider ID, statistics and message broadcasting. Free.
providerid / s-provideridYour users' apps will start populating the statistics you see in the portal.
Pricing — everything is free right now
🔒Fields gated by provider_active
Locked keys are provider-controlled branding/behaviour (site/Telegram buttons, renewal UI, fallback-url, migration and hide-settings). They apply only when the response contains providerid and it has provider_active == true.
If the provider is not active (no providerid, unknown, or provider_active == false), these fields are force-reset on every update (so a foreign/stale provider can't swap buttons or fallback). They're restored as soon as the provider is active again.
Not gated (always work): profile-title, s-title, profile-update-interval, subscription-userinfo, announce, support-url, profile-web-page-url, providerid, geoipurl/geositeurl.
What needs an active Provider ID
One place to check instead of hunting for the 🔒 marks. Active means the Provider ID sent with the subscription belongs to a provider account that is currently active. Without it the keys in the first table are simply ignored — the subscription itself keeps working.
🔒Requires an active Provider ID
s-fragment s-noise s-noises s-resolve s-dns — and their INCY/Happ equivalents fragmentation-* noises-* server-address-resolve-*new-url new-domain fallback-url fallback-domainss-siteurl s-sitename s-tgbot x-tgbot sort-order serverDescriptionbanner-text banner-button-text banner-button-url banner-bg-color banner-button-color announce-url support-emailsub-expire s-showexpire sub-expire-button-text sub-expire-button-link-site sub-expire-button-link-tg notification-subs-expirehide-settings✓Works with or without a Provider ID
profile-title / s-titleprofile-update-intervalprofile-web-page-urlannounceannounce-url (making it clickable) needs an active providersupport-urlsubscription-userinfosubidrouting autorouting routing-update-url routing-update-interval routing-profilegeoipurl / geositeurlThe same gating applies however the value arrives — an HTTP header, a #key: line in the body, or the provider account.