Skip to main content

Licensed VPN features

Fifteen VPN features are gated by your SDK license. A setter for a feature you are not licensed for fails and persists nothing, so the value can never reach the tunnel. The tunnel checks again when it starts, and falls back to the licensed default for anything it is not licensed for.

Check what your license grants with KapeSdkHandle.hasLicensedFeature(_:) — use it to hide UI rather than letting a control fail when tapped.

The features​

FeatureGated entry pointWithout the license
VpnProtocolLightwayselectVpnProtocol(.lightway)Throws; .automatic skips Lightway endpoints
VpnProtocolWireGuardselectVpnProtocol(.wireguard)Throws; .automatic skips WireGuard endpoints
VpnProtocolOpenVpnselectVpnProtocol(.openvpn)Throws; .automatic skips OpenVPN endpoints
VpnProtocolObfuscation—Obfuscated endpoints are dropped, no error
VpnLightwayBitripplesetBitrippleEnabled(_:)Throws; tunnel uses false
VpnLightwaySnisetLightwaySNI(_:)Throws; tunnel uses the SDK default SNI
VpnHttpsProxysetProxyConfig(_:promoteUnderAutomatic:)Throws; tunnel uses no proxy
VpnKillSwitchAdvancedsetKillSwitchMode(.advanced)Throws; tunnel uses .standard
VpnSplitTunnelsetSplitTunnel(_:)Throws; tunnel routes everything
VpnCustomDnssetCustomDnsServers(_:)Throws; tunnel uses the per-protocol default
VpnAutoConnectRulessetConnectOnDemand(_:), setOnDemandRules(_:)Throws; the VPN profile is left untouched
VpnAdvancedProtectionsetAdvancedProtectionCategories(_:)Throws; tunnel applies no filtering
VpnLightwayHeartbeatsetHeartbeatEnabled(_:)Throws; tunnel uses false
VpnLightwayExpresslanesetExpresslaneEnabled(_:)Throws; tunnel uses false
VpnSessionPauseResumepause(for:)Throws. resume() is never gated, so a pause can always be released

selectVpnProtocol(.automatic) is always allowed — it is narrowed to the protocols you are licensed for when the tunnel builds its endpoint list.

Handling a refusal​

Every gated setter throws KapeFeatureNotLicensedError, which names the feature:

do {
try vpn.setSplitTunnel(rules)
} catch let error as KapeFeatureNotLicensedError {
print("Not licensed: \(error.feature.rawValue)") // "VpnSplitTunnel"
} catch {
// Validation errors — an invalid CIDR, hostname or resolver — still arrive here.
}

Nothing is persisted when a setter refuses, so there is no state to roll back.

Reads report what the tunnel will do​

The matching getters — selectedProtocol(), isBitrippleEnabled(), lightwaySNI(), isHeartbeatEnabled(), isExpresslaneEnabled(), advancedProtectionCategories(), customDnsServers(), proxyConfigs() — never throw. On an unlicensed SDK they return the licensed default, so your UI shows what the tunnel will actually apply rather than a value it will ignore. selectedProtocol() reports .automatic when the persisted protocol is no longer licensed.

This matters after a license downgrade: a value persisted by a build with a broader license is never re-set through a setter, so the getters and the tunnel are what correct it.

Obfuscation is different​

Obfuscation filters rather than fails. Without VpnProtocolObfuscation, obfuscated endpoints are dropped from the connection's pecking order and the connection proceeds on the remaining ones — so .automatic still connects. Nothing throws, and each dropped endpoint is logged.

If a location has only obfuscated endpoints, the connection fails. The tunnel reports it as PacketTunnelError.endpointsExcludedByLicense, which — like every tunnel-side failure — reaches your app as descriptive text rather than a typed case: read KapeVPNManager.connectionError, whose message names the license as the cause instead of claiming the location has no endpoints. There is no KapeVPNError case to catch, because connect(...) has already returned by the time endpoints are fetched in the extension.

setAmneziaEnabled(_:) is a separate, user-facing preference, not a license gate: it neither throws nor is clamped, and isAmneziaEnabled() reports your raw choice even on an SDK without the feature. It can only narrow the pool further, because the license filter runs first. See WireGuard Obfuscation (Amnezia).

Expiry and revocation​

A license is not just a list of features — it also has an expiry, and it can be revoked. Both are honoured: a license carrying any problem grants nothing, so every gated feature refuses exactly as it would on a license that never listed it.

Read the problems from KapeSdkHandle.licenseProblems:

for problem in handle.licenseProblems {
print(problem) // "license expired on 2026-01-01T00:00:00+00:00"
}

KapeLicenseProblem covers .missing, .malformed, .signatureInvalid, .invalidExpiryDate, .expired(validUntil:) and .revoked(licensedTo:).

Both are resolved before KapeSdkHandle construction returns, so licenseProblems is accurate as soon as you hold a handle — there is no window where the gates are open on an expired license.

A license the SDK rejects outright — a bad signature — fails construction instead, so there is no handle at all. Map that error with KapeSdkHandle.licenseProblem(for:) if you want to tell the user which problem it was.

KapeVpnCLI license prints the resolved verdict and any problems, and is the quickest way to see what a given license actually grants.

Scope​

These checks stop a feature being used by mistake, and the verdict now comes from the Low Level SDK's verified view rather than from anything the client parses itself: a hand-edited License fails signature verification when the SdkManager is built, which fails handle construction.

The gates are still not the enforcement boundary — the Low Level SDK's own per-request checks are — but a forged license no longer buys anything: without a handle there is no sign-in, no discovery and no connection.