-
Notifications
You must be signed in to change notification settings - Fork 4
Operator API Headers
Source of truth: this page mirrors
docs/operator-api/headers.mdin the repo. Edit the repo file; this wiki page is a copy.
Status legend:
- v1 — implemented now (or planned for the current branch)
- v2 — planned next wave (extended operator info + policy display)
- v3 — later (operator-controlled UI simplification)
- proposed — accepted into the spec, not yet scheduled
All headers are matched case-insensitively. Empty / blank values are treated
as "header not present". String fields may be plain UTF-8 or base64:-prefixed
(X-Brand-Name: base64:U3dpZnRWUE4=) — the client decodes both.
ClashFest's primary theme is dark. Wherever a "light" alternative exists, it's the optional override.
| Type | boolean |
| Status | v1 |
| Required for |
Any cosmetic branding to apply. Without this header set to true, every X-Brand-* identity/tab/info field is ignored and the client shows the default ClashFest UI. Exception: operator policy headers (§4b, e.g. X-Brand-Hide-Global-Mode) apply without this switch. |
| Default | absent / false / null → branding off
|
| Notes | Branding is explicit opt-in per subscription. Setting X-Brand-Name, X-Brand-Logo-URL, etc. without also sending X-Branding-Enabled: true is a no-op — the headers are parsed and persisted, but the UI stays default. To roll back a misconfigured deployment, drop this header (or set false); brand state on the client reverts immediately after the next subscription refresh. |
Example to enable branding:
X-Branding-Enabled: true
X-Brand-Name: SwiftVPN
X-Brand-Logo-URL: https://cdn.example.com/logo-dark.png
X-Brand-Accent-Color: #5E35B1
Example to disable / kill switch:
X-Branding-Enabled: false
| Type | string |
| Max length | 32 characters |
| Status | v1 |
| Applied to | Main screen header title (replaces "ClashFest"); About screen |
| Fallback | "ClashFest" |
| Validation | trimmed; control characters stripped; blank → ignored |
Example:
X-Brand-Name: SwiftVPN
| Type | string |
| Max length | 64 characters |
| Status | v1 |
| Applied to | About screen subtitle; optional small subtitle under the brand name in the main header |
| Fallback | empty (no tagline shown) |
Example:
X-Brand-Tagline: Fast and private since 2024
| Type | URL (https only) |
| Status | v1 |
| Applied to | Round logo tile in main header (left of brand name); About screen icon |
| Image formats | PNG, WebP, JPEG. No SVG (see security.md) |
| Recommended size | 256×256, ≤200KB |
| Max size enforced | 512KB hard cap |
| Cache | disk at <filesDir>/brand/<sha256(url)>, atomic write |
| Validation | https-only, content-type whitelist, SSRF guard (no private IPs / redirects to private IPs), size cap |
| Fallback | app launcher icon |
| Notes | This is the primary logo, used by default. It's expected to look right on a dark background since dark is the app's default theme. |
Example:
X-Brand-Logo-URL: https://swiftvpn.example.com/static/logo-256.png
| Type | URL (https only) |
| Status | v1 |
| Applied to | Same as X-Brand-Logo-URL but used when the user is on the light theme |
| Validation | same as X-Brand-Logo-URL
|
| Fallback |
X-Brand-Logo-URL (so a dark-tuned logo is still used on light theme if the operator didn't ship a light variant) |
| Notes | Optional. Most operators only need to ship one logo. |
| Type | hex color #RRGGBB
|
| Status | v1 |
| Applied to | Global colorPrimary runtime override — affects power button, toggles, switches, progress bars, filled chips, selected states, accent surfaces across the app |
| Validation | regex ^#[0-9A-Fa-f]{6}$; rejected if luminance contrast with surface is < 3:1 (WCAG AA minimum for large text) |
| Fallback | built-in theme accent |
| Notes | This is a "full white-label" knob. Pick a color that works in both the dark and the light theme (the contrast filter will reject one-side-only colors). Recommended saturation 30–70% so accent stays visible without being garish. |
Example:
X-Brand-Accent-Color: #5E35B1
All URL fields share the same validation: must be https://, tg://,
mailto:, or t.me/ (auto-promoted to https://t.me/). Anything else
is ignored.
| Type | URL |
| Status | v1 |
| Applied to | About screen → "Visit website" button |
| Type | URL |
| Status |
v1 (extension of existing support-url parsing) |
| Applied to | Support icon in profile card and bottom sheet; About screen |
Backwards compatibility: also accepts
support-url,Profile-Support-URL, andSubscription-Support-URL. If bothX-Brand-Support-URLand a legacy form are present,X-Brand-Support-URLwins.
| Type | URL (https / tg / t.me) |
| Status | v1 |
| Applied to | About screen → "Telegram channel" button |
| Type | URL (https / tg / t.me) |
| Status | v1 |
| Applied to | About screen → "Telegram bot" button (separate from channel) |
| Type | URL |
| Status | v1 |
| Applied to | About screen → "Privacy policy" link |
| Type | URL |
| Status | v1 |
| Applied to | About screen → "Terms of service" link |
| Type | URL |
| Status | v1 |
| Applied to | About screen → "FAQ" / "Help center" button |
| Type | URL |
| Status | v2 |
| Applied to | Connection-lost dialog / failed-fetch dialog → "Check service status" button |
| Type | URL |
| Status | v2 |
| Applied to | When subscription expiry is critical (<3 days or already expired): • tap on the existing critical-expiry chip on the profile card • "Renew" button in the profile bottom sheet near the expiry info • "Renew subscription" entry in the profile overflow menu About screen also gets a Renew button when this URL is set. |
| Notes | All entry points appear only when this URL is provided. No URL → no Renew UI anywhere. |
| Type | URL (https / tg / mailto) — almost always built with a panel template variable |
| Status | v3 |
| Applied to | "My account" button on the Operator tab, rendered as a tonal-secondary button under the Renew CTA (or alone if no Renew URL). |
| Notes | The operator builds a per-user URL using their panel's identifier template — e.g. {{SHORT_UUID}}, {{ID}}, {{USERNAME}}. Examples:• Telegram Mini App: https://t.me/<bot>?startapp={{SHORT_UUID}} (requires the bot to be registered as a Mini App in BotFather).• Web cabinet: https://billing.example.com/account?ref={{ID}}• Bot chat with start payload: https://t.me/<bot>?start={{SHORT_UUID}} (operator must handle the payload in /start and surface the cabinet button).Client opens the URL via ACTION_VIEW — Android routes tg:// / https://t.me/... to Telegram (Mini App or chat depending on the URL form), other https:// to the browser. |
The client uses only one field for user-facing identity. Per-user
personalisation lives in profile-title — operators put whatever they
want there (vasya@example.com — Premium, John (Trial, 2d left), etc).
Free-form, panel-controlled.
| Type | string |
| Status | v1 (already parsed) |
| Applied to | Profile card title |
| Notes | Carries the subscription plan label. For per-user identity / greeting, see the new headers below — modern panels support template variables that substitute user data into headers at request time. |
| Type | string |
| Max length | 64 characters |
| Status | v2 |
| Applied to | "Logged in as " line in About sheet (under the brand identity block) |
| Notes | Designed to be filled via a panel template variable, e.g. X-Brand-User-Display-Name: {{USERNAME}}. See template-variables.md for the full list per panel. |
| Type | string |
| Max length | 120 characters |
| Status | v2 |
| Applied to | Hero line on the Operator tab, between the brand-identity block and the Renew CTA |
| Notes | Free-form. Operators typically wire dynamic content here via panel templates, e.g. X-Brand-Greeting: Welcome back, {{USERNAME}}! {{DAYS_LEFT}} days remaining. If absent but X-Brand-User-Display-Name is set, the client falls back to a built-in "Hello, !".
|
These let an operator hide advanced sections of the app from their users. None of them is enforcement — the user can always re-enable an advanced section through deep settings, the operator just chooses what to surface by default.
We deliberately do not ship operator-controlled default-mode,
recommended-group, locale, or theme headers — mihomo already
handles tunnel mode and group selection via the YAML config, and locale /
theme should follow user preference, not operator preference.
| Type | boolean |
| Status | v1 |
| Applied to | Adds a dedicated "Operator" entry to the bottom navigation with logo + name + tagline + Renew CTA + operator-info link list. |
| Notes | Explicit opt-in. Sending brand identity (name / logo / accent) alone is enough to brand the visuals — it does NOT auto-add a tab. Operators that want the consolidated info page choose it consciously. Pair with X-Brand-Hide-Routing to replace Routing instead of adding a 5th tab. |
| Type | boolean |
| Status | v1 |
| Applied to | When paired with X-Brand-Show-Operator-Tab=true, the Operator tab replaces Routing in the bottom-nav slot (still 4 tabs, just different middle). Alone, this header has no effect — hiding Routing without something to replace it would just remove a section the user needs. |
Everything above (identity, operator tab, info links, greeting, hide-routing)
is cosmetic branding and requires X-Branding-Enabled: true. The headers
below are operator policy — restrictions on user behaviour, not a look —
so they apply on header presence alone, do not require branding to be
enabled, and survive the X-Branding-Enabled: false kill-switch (which only
wipes cosmetic branding).
| Type | boolean |
| Status | v4 |
Needs X-Branding-Enabled? |
No — this is the one header that works fully unbranded. |
| Applied to | Hides the Home Global mode button and pins the app to Rule (if the user was in Global, it flips back to Rule). The "Mode" row and the Rule button stay visible. |
| Notes | Operator control, not branding: stops users from routing all traffic through the proxy and bypassing rules. Because it's policy, it takes effect whether X-Branding-Enabled is absent, true, or false. Every other X-Brand-* header still requires X-Branding-Enabled: true. |
| Format | upload=N; download=N; total=N; expire=UNIX |
| Status | v1 (already parsed) |
| Applied to | Quota progress on profile card, critical-expiry chip |
| Type | integer (hours) |
| Status | v1 (already parsed) |
| Applied to | Auto-update interval, coerced to ≥15 min |
| Type | boolean (true/1/yes/on = disable sharing) |
| Status | v1 (already parsed) |
| Applied to | Hides "Copy node link" / "Share" actions in the picker AND locks subscription URL editing for that subscription only |
| Notes | Stored per-profile (subscriptionShareLinksLockedFor(uuid)). Different subscriptions can have different share policies — one operator's lock does not affect another's subscription on the same device. |
| Type | enum: system | gvisor | mixed | auto
|
| Status | v1 |
Needs X-Branding-Enabled? |
No — operator policy, applies unbranded. |
| Applied to | The TUN network stack handed to the VpnService. system/gvisor/mixed lock the client to that stack for this subscription, overriding the user's manual Stack Mode setting. auto = operator does not lock (defer to the user setting / the system default). |
| Default | Header absent → client default (system). |
| Notes | Stored per-profile (subscriptionNetworkStackFor(uuid)) like the share-links policy — different subscriptions can force different stacks on the same device. Precedence: operator header > user's manual setting > Auto (follow the subscription's tun.stack) > system default (see TunStackResolver). The app default is system; the user can pick auto to follow the subscription's declared tun.stack, and this header overrides both. system is recommended — the kernel stack is lower-latency on teardown and cheaper on battery than gVisor's userspace netstack; choose gvisor/mixed only when a device/network needs it. Accepted spellings (case-insensitive): X-Network-Stack, Network-Stack, X-NetworkStack, X-NetworkStack-enabled. |
Example (lock all users of this subscription to the kernel stack):
X-Network-Stack: system
| Type | preset id: ru | ir | cn (any id bundled in assets/bypass_presets/), or none
|
| Status | v1 |
Needs X-Branding-Enabled? |
No — operator policy, applies unbranded. |
| Applied to | Recommends a regional per-app bypass preset (apps excluded from the VPN at the system level — local banks, government, marketplaces get the user's real ISP connection). On the next VPN start the client shows a confirm dialog: "Your provider recommends the <region> bypass preset. Found N installed apps — route them outside the VPN tunnel?" |
| Default | Header absent → keep whatever is stored; none → withdraw the recommendation. |
| Notes |
Recommendation only — never auto-applied. Per-app bypass exposes the user's real IP to the bypassed apps, so it stays an explicit user decision; the header cannot silently move traffic out of the tunnel. Re-offer rules: the client remembers the answer per profile keyed by preset id + the bundled list version. If the user applied and a later app update ships a larger version of that list (its JSON version bumped), the client re-offers once ("list updated, +N apps — add?"). If the user skipped, that region is never offered again regardless of list growth. A different preset id (ru→cn) always offers. Applying is additive: the user's manual per-app selection is preserved, and the user can always re-apply or edit via Routing → Per-app → menu → "Apply bypass preset…". Unknown preset ids and presets matching zero installed apps are consumed silently. Stored per-profile (subscriptionBypassPresetFor(uuid)), cleared on profile delete. Accepted spellings (case-insensitive): X-Bypass-Preset, bypass-preset, bypass_preset. |
Example (suggest the Russian bypass list to this subscription's users):
X-Bypass-Preset: ru
| Type | boolean |
| Status | v1 (already parsed) |
| Applied to | HWID enforcement warnings on profile card |
| Type | string (UTF-8 or base64:) |
| Max length | 256 chars |
| Status | v1 (already parsed) |
| Applied to | Inline announcement bar on profile card |
| Type | URL |
| Status | v1 (already parsed) |
| Applied to | Tap target for the announcement bar |
| Wave | Adds |
|---|---|
| v1 | Section 1 (brand identity, all five) + section 2 entries (Website / Support / Telegram / Bot / Privacy / Terms / Help) + already-parsed sections 5 + 6 |
| v2 | Section 2 v2 entries (Status-URL, Renew-URL) |
| v3 | Section 4 (Hide-Routing) |
| proposed | Anything added by future PRs |
Anything not listed as a defined header is ignored. Operators are free to send other custom headers — the client doesn't care.
Headers that were considered and rejected during the v1 design review:
-
X-Brand-User-Display-Name/User-Tier/User-Greeting/User-Flair— per-user personalisation isn't possible on most panels through static response headers. The operator can already shapeprofile-titlefreely to carry user name, tier, and any other personal context. -
X-Brand-Trial-Until— covered by the existingSubscription-Userinfoexpire=field plus the critical-expiry chip; no need for a parallel trial-specific channel. -
X-Brand-Default-Mode— mihomo already readsmode:from the YAML config, no need for a header equivalent. -
X-Brand-Recommended-Group— mihomo's Selector default already picks the first proxy in the configured list. Operators control this through the YAML, not headers. -
X-Brand-Locale/X-Brand-Theme— these are user preferences. Letting an operator override them silently is hostile UX, even when well-meant. -
X-Brand-Max-Devices/X-Brand-Current-Devices— without a current count the static chip read as filler; current count isn't reliably available across panels via response headers. Out of scope until the panel ecosystem catches up.
If a future use case actually demands one of these, it can be added under
proposed first and discussed.
📱 User Guide
- Getting Started
- Profiles & Nodes
- Routing & Rules
- Settings
- Deep Links
- Encrypted Subscriptions
- Troubleshooting
🏢 Operator API
📺 Companion