Skip to main content

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.selectedProtocol to KapeVPNProtocol.Lightway (or Automatic) 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 CategoryDedicatedIp licensed 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:

KapeDedicatedIpKindWhat it isLicense
AnonymousUnlocked on the device with an access code, set up through the stages belowCategoryDedicatedIp
SimpleReserved on the account. Needs no setup and carries real coordinatesCategorySimpleDedicatedIp
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 id and dip.id are 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 == 0 means the subscription is not entitled. The same zeros come back as Success without 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, so available can be too high. SlotsExhausted from reserveSimpleDip stays the authoritative answer.

Setup stages​

Setup happens once per device. KapeDipAccountService.fetchSetupStage reports where the subscription stands:

KapeDipSetupStageMeaningNext call
NotEntitledThe subscription has no DIP licenses—
BackupPasswordRequiredNot 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 againKapeDipAccountService.assignLocation
DoneAssigned and unlocked — ready to connectKapeVpnManager.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:

KindLicenseProtocol and proxyRenewal
AnonymousCategoryDedicatedIpLightway only, no proxy or obfuscationYes
SimpleCategorySimpleDedicatedIpWhatever its endpoints serve, with the proxy and the shared-pool rulesNone needed

connect(location) resolves the Dedicated IP before starting the tunnel service, so a problem comes back synchronously as ConnectResult.DipError carrying a KapeVpnTunnelError:

KapeVpnTunnelErrorCauseFix
DipExpiredThe address expired, or the subscription was reset by supportRe-run setup: register the access code and assign a location again
DipUnderMaintenanceThe DIP server is offlineRetry later
DipUnavailableHealth unknown, no access token cached, or no IPv4 configuration. Simple: no longer reservedRetry; re-run setup (Anonymous) or reserve again (Simple) if it persists
DipUnsupportedProtocolThe Dedicated IP does not serve the selected protocol. Anonymous serves Lightway onlySwitch to KapeVPNProtocol.Lightway or Automatic
DipNotEntitledThe SDK license does not grant CategoryDedicatedIp (Anonymous) or CategorySimpleDedicatedIp (Simple)Nothing at runtime — hide the feature; see Unlicensed SDKs
EndpointsExcludedByLicenseThe 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:

  1. At connect time — resolving the endpoints renews first, and refuses to connect on an expired address.
  2. 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.
  3. At app launch — constructing KapeVpnManager renews every DIP subscription, best-effort and silent. Call KapeDipAccountService.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:

CallResult
fetchStatusSuccess with all counters 0
fetchSetupStageSuccess(KapeDipSetupStage.NotEntitled)
fetchAvailableLocations, fetchAssignedDipsSuccess with an empty list
fetchDedicatedIpsSuccess with Simple Dedicated IPs only, if CategorySimpleDedicatedIp is granted
fetchConnectionStatusesSuccess, every subscription NotEntitled
registerBackupPassword, assignLocationDipSetupResult.NotEntitled
KapeVpnManager.connect(location) on a Dedicated IPConnectResult.DipError(KapeVpnTunnelError.DipNotEntitled)
renewAllno-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; KapeDipStatus deliberately 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's dip command group and VpnDemo's Settings → Dedicated IP screen exercise the full setup and connect flow on a device.