Dedicated IP
A Dedicated IP (DIP) is an address reserved for one subscription. In the SDK it is just another
location: KapeServerLocation.dip carries the DIP identity, and KapeVpnManager.connect(location)
routes it.
Three things are specific to an Anonymous DIP:
- It is Lightway-only. Set
KapeVpnManager.selectedProtocoltoKapeVPNProtocol.Lightway(orAutomatic) before connecting; anything else is rejected. - It lives on its own entitlement. Use
KapeDipAccountService.subscriptionIds()for its subscription IDs — the VPN subscription (activeSubscriptionId) is a different one. - It requires the
CategoryDedicatedIplicensed feature. Without it the whole surface reports as unentitled — see Unlicensed SDKs below.
val dipAccount = KapeDipAccountService(handle)
val dipSubscriptionId =
when (val result = dipAccount.subscriptionIds()) {
is SubscriptionIdsResult.Success -> result.subscriptionIds.firstOrNull()
is SubscriptionIdsResult.SdkError -> null
}
Listing Dedicated IPs
There are two kinds of Dedicated IP, and KapeDipAccountService.fetchDedicatedIps() lists both in
one call, so the app does not need to work out which kind an account has:
KapeDedicatedIpKind | What it is | License |
|---|---|---|
Anonymous | Unlocked on the device with an access code, set up through the stages below | CategoryDedicatedIp |
Simple | Reserved on the account. Needs no setup and carries real coordinates | CategorySimpleDedicatedIp |
when (val result = dipAccount.fetchDedicatedIps()) {
is FetchDipLocationsResult.Success ->
result.locations.forEach { location ->
// location.dip?.kind is Anonymous or Simple; location.dip?.ip is the address.
}
is FetchDipLocationsResult.SdkError -> showError(result.error.message)
}
- A kind your license does not grant contributes nothing. The call still succeeds.
- An account with no Dedicated IP gets an empty list, not an error.
- When both kinds report the same address, it appears once, as
Anonymous. - A Simple Dedicated IP's
idanddip.idare its reservation ID.
Reserving a Simple Dedicated IP
A Simple Dedicated IP needs no setup: pick a location and reserve it. Both calls need
CategorySimpleDedicatedIp.
val locations = (dipAccount.fetchSimpleDipLocations() as FetchDipLocationsResult.Success).locations
// Catalog entries: `id` is a location ID, `dip.ip` is null. Locations with nothing left are omitted.
when (val result = dipAccount.reserveSimpleDip(locations.first())) {
is SimpleDipReservationResult.Success -> refreshList() // now in fetchDedicatedIps()
SimpleDipReservationResult.NotEntitled -> hideFeature()
SimpleDipReservationResult.SlotsExhausted -> showError("Every Simple Dedicated IP is already reserved")
SimpleDipReservationResult.NoCapacity -> showError("Nothing left at this location")
SimpleDipReservationResult.InvalidLocation -> refreshLocations()
SimpleDipReservationResult.EntitlementEnded -> showError("The entitlement has ended")
SimpleDipReservationResult.TemporarilyUnavailable -> retryLater()
is SimpleDipReservationResult.SdkError -> showError(result.error.message)
}
Without the license, or with no active subscription, fetchSimpleDipLocations returns an empty list
and reserveSimpleDip returns NotEntitled without calling the backend.
How many can be reserved
when (val result = dipAccount.fetchSimpleDipSlots()) {
is FetchSimpleDipSlotsResult.Success -> {
val slots = result.slots
// slots.total: slots the subscription grants. slots.reserved: addresses already reserved.
// slots.available: total - reserved, never negative.
}
is FetchSimpleDipSlotsResult.SdkError -> showError(result.error.message)
}
total == 0means the subscription is not entitled. The same zeros come back asSuccesswithout the license or an active subscription.- It reads the cached subscription token, so it makes no network call while the token is valid. A change made by billing or support appears once the token refreshes. A successful reservation refreshes it.
- A reservation the token does not list yet is not counted in
reserved, soavailablecan be too high.SlotsExhaustedfromreserveSimpleDipstays the authoritative answer.
Setup stages
Setup happens once per device. KapeDipAccountService.fetchSetupStage reports where the
subscription stands:
KapeDipSetupStage | Meaning | Next call |
|---|---|---|
NotEntitled | The subscription has no DIP licenses | — |
BackupPasswordRequired | Not unlocked on this device yet (first-time setup, or a DIP assigned on another device) | KapeDipAccountService.registerBackupPassword |
LocationAssignmentRequired(pendingDipAssignment) | Unlocked, but no address assigned. pendingDipAssignment means a previous attempt did not finish — assign the same location again | KapeDipAccountService.assignLocation |
Done | Assigned and unlocked — ready to connect | KapeVpnManager.connect(location) |
// 1. Register the subscription's access code on this device.
when (dipAccount.registerBackupPassword(dipSubscriptionId, accessCode)) {
DipSetupResult.Success -> Unit
DipSetupResult.WrongBackupPassword -> showError("Wrong access code")
DipSetupResult.SubscriptionLocked -> showError("Too many attempts — try again later")
DipSetupResult.NotEntitled -> showError("No Dedicated IP on this subscription")
is DipSetupResult.SdkError -> showError(result.error.message)
}
// 2. Pick a location and assign an address to it.
val available = dipAccount.fetchAvailableLocations(dipSubscriptionId)
// available.locations are catalog entries: `id` is a location ID, `dip.ip` is null.
dipAccount.assignLocation(dipSubscriptionId, available.locations.first())
Connecting
vpnManager.setSelectedProtocol(KapeVPNProtocol.Lightway)
val assigned = dipAccount.fetchAssignedDips(dipSubscriptionId)
// assigned.locations carry the address in both `id` and `dip.ip`.
when (val result = vpnManager.connect(assigned.locations.first())) {
is ConnectResult.Success -> Unit
is ConnectResult.DipError -> showError(result.error.describe())
ConnectResult.NoLocationsAvailable -> Unit
is ConnectResult.SdkError -> showError(result.error.message)
}
Pass any location from fetchDedicatedIps() to connect(location). It routes on dip.kind:
| Kind | License | Protocol and proxy | Renewal |
|---|---|---|---|
Anonymous | CategoryDedicatedIp | Lightway only, no proxy or obfuscation | Yes |
Simple | CategorySimpleDedicatedIp | Whatever its endpoints serve, with the proxy and the shared-pool rules | None needed |
connect(location) resolves the Dedicated IP before starting the tunnel service, so a problem
comes back synchronously as ConnectResult.DipError carrying a KapeVpnTunnelError:
KapeVpnTunnelError | Cause | Fix |
|---|---|---|
DipExpired | The address expired, or the subscription was reset by support | Re-run setup: register the access code and assign a location again |
DipUnderMaintenance | The DIP server is offline | Retry later |
DipUnavailable | Health unknown, no access token cached, or no IPv4 configuration. Simple: no longer reserved | Retry; re-run setup (Anonymous) or reserve again (Simple) if it persists |
DipUnsupportedProtocol | The Dedicated IP does not serve the selected protocol. Anonymous serves Lightway only | Switch to KapeVPNProtocol.Lightway or Automatic |
DipNotEntitled | The SDK license does not grant CategoryDedicatedIp (Anonymous) or CategorySimpleDedicatedIp (Simple) | Nothing at runtime — hide the feature; see Unlicensed SDKs |
EndpointsExcludedByLicense | The SDK license does not grant the protocol the Dedicated IP serves (VpnProtocolLightway for Anonymous) | Nothing at runtime — see Licensed VPN features |
The same values arrive on KapeVpnManager.lastTunnelError when a session fails after it started
— for example a DIP that expires mid-session. The session keeps retrying with backoff, so this flow
is how the app learns it has to prompt for re-setup. It clears on the next connect and on a
successful connection.
viewModelScope.launch {
vpnManager.lastTunnelError.collect { error ->
if (error == KapeVpnTunnelError.DipExpired) promptDipReassignment()
}
}
connect(location) also accepts regular locations — it behaves exactly like
connect(KapeConnectionMode.Manual(location.id)) when location.dip is null, so an app can use
the single overload everywhere.
Status overview
KapeDipAccountService.fetchConnectionStatuses() reports every DIP subscription at once, keyed by
subscription ID, followed by each Simple Dedicated IP as Unlocked, keyed by simple: plus its reservation ID. It
renews each subscription's tokens as part of the check, so it also detects expiry:
when (val result = dipAccount.fetchConnectionStatuses()) {
is FetchDipConnectionStatusesResult.Success ->
result.statuses.forEach { (subscriptionId, status) ->
when (status) {
KapeDipConnectionStatus.NotEntitled -> Unit
KapeDipConnectionStatus.NeedsSetup -> startSetup(subscriptionId)
KapeDipConnectionStatus.Locked -> startSetup(subscriptionId)
KapeDipConnectionStatus.Expired -> startSetup(subscriptionId)
is KapeDipConnectionStatus.Unlocked -> connect(status.location)
}
}
is FetchDipConnectionStatusesResult.SdkError -> showError(result.error.message)
}
fetchStatus exposes the raw licence counters behind those stages — totalLicenses,
licensesAssigned, unlockedLicenses, pendingDipAssignment.
Subscriptions with more than one Dedicated IP
The setup stage is a per-subscription aggregate: it reaches Done as soon as one address is
assigned and unlocked on this device, even when licences remain. Use KapeDipStatus to spend the
rest:
val status = (dipAccount.fetchStatus(dipSubscriptionId) as FetchDipStatusResult.Success).status
if (status.hasUnassignedLicenses) {
// status.unassignedLicenses left — assign another location
dipAccount.assignLocation(dipSubscriptionId, anotherLocation)
}
pendingDipAssignment is likewise a single flag for the whole subscription — it does not say which
assignment stalled, so the only safe prompt is "assign the same location again".
Choosing between several addresses happens on the connect side, which is per-address:
fetchAssignedDips returns one location per assigned-and-unlocked DIP, each carrying its own
dip.ip, and connect(location) targets exactly that one. Note that
fetchConnectionStatuses reports only the first (Unlocked(location)) — call fetchAssignedDips
directly when a subscription can hold several.
Renewal
This applies to Anonymous Dedicated IP; a Simple Dedicated IP has no access token to renew. The Dedicated IP access token expires after three days. The SDK keeps it fresh in three places, none of which the app has to call:
- At connect time — resolving the endpoints renews first, and refuses to connect on an expired address.
- While connected — a 12-hour poll runs for the lifetime of a DIP session, and stops permanently if the address expires or the subscription is reset.
- At app launch — constructing
KapeVpnManagerrenews every DIP subscription, best-effort and silent. CallKapeDipAccountService.renewAll()to repeat that at moments the app cares about (returning to the foreground, for instance); it swallows failures the same way.
Unlicensed SDKs
Dedicated IP requires CategoryDedicatedIp on your SDK license. Without it the feature reports
exactly what a subscription holding no Dedicated IP licenses reports, so an app written against the
flow above needs no extra error handling — it just never leaves the unentitled state:
| Call | Result |
|---|---|
fetchStatus | Success with all counters 0 |
fetchSetupStage | Success(KapeDipSetupStage.NotEntitled) |
fetchAvailableLocations, fetchAssignedDips | Success with an empty list |
fetchDedicatedIps | Success with Simple Dedicated IPs only, if CategorySimpleDedicatedIp is granted |
fetchConnectionStatuses | Success, every subscription NotEntitled |
registerBackupPassword, assignLocation | DipSetupResult.NotEntitled |
KapeVpnManager.connect(location) on a Dedicated IP | ConnectResult.DipError(KapeVpnTunnelError.DipNotEntitled) |
renewAll | no-op |
No Dedicated IP endpoint is generated and no token is renewed. Observe
KapeSdkHandle.licenseState for LicensedFeature.CategoryDedicatedIp if you want to hide the
Dedicated IP UI outright rather than showing an unentitled state — see
Licensed features for why that is a flow rather than a
synchronous read.
VpnProtocolLightway gates Anonymous Dedicated IP too, since it is Lightway-only: without it the connect
fails with KapeVpnTunnelError.EndpointsExcludedByLicense rather than DipNotEntitled, because the
subscription is fine and the protocol is not.
Notes
- The subscription's backup password is a secret the SDK manages;
KapeDipStatusdeliberately does not expose it. - HTTPS proxies and obfuscation do not apply to Anonymous Dedicated IP connections and are ignored. Lightway settings (SNI, heartbeat, ExpressLane, defer timeout) and Advanced Protection do apply — a Dedicated IP connection resolves the account's filtering DNS and uses it for the tunnel, exactly like a shared-pool one.
KapeVpnCLI'sdipcommand group and VpnDemo's Settings → Dedicated IP screen exercise the full setup and connect flow on a device.