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
| Feature | Gated entry point | Without the license |
|---|---|---|
VpnProtocolLightway | selectVpnProtocol(.lightway) | Throws; .automatic skips Lightway endpoints |
VpnProtocolWireGuard | selectVpnProtocol(.wireguard) | Throws; .automatic skips WireGuard endpoints |
VpnProtocolOpenVpn | selectVpnProtocol(.openvpn) | Throws; .automatic skips OpenVPN endpoints |
VpnProtocolObfuscation | — | Obfuscated endpoints are dropped, no error |
VpnLightwayBitripple | setBitrippleEnabled(_:) | Throws; tunnel uses false |
VpnLightwaySni | setLightwaySNI(_:) | Throws; tunnel uses the SDK default SNI |
VpnHttpsProxy | setProxyConfig(_:promoteUnderAutomatic:) | Throws; tunnel uses no proxy |
VpnKillSwitchAdvanced | setKillSwitchMode(.advanced) | Throws; tunnel uses .standard |
VpnSplitTunnel | setSplitTunnel(_:) | Throws; tunnel routes everything |
VpnCustomDns | setCustomDnsServers(_:) | Throws; tunnel uses the per-protocol default |
VpnAutoConnectRules | setConnectOnDemand(_:), setOnDemandRules(_:) | Throws; the VPN profile is left untouched |
VpnAdvancedProtection | setAdvancedProtectionCategories(_:) | Throws; tunnel applies no filtering |
VpnLightwayHeartbeat | setHeartbeatEnabled(_:) | Throws; tunnel uses false |
VpnLightwayExpresslane | setExpresslaneEnabled(_:) | Throws; tunnel uses false |
VpnSessionPauseResume | pause(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.