Developers

Getting started with the URnetwork SDK

13 min readView as markdown ↗

This guide takes a developer from an empty project to a first connection: get an account JWT, install the binding for your platform, bring up a device, and confirm traffic flows. The SDK is one Go module, github.com/urnetwork/sdk, exposed through four bindings:

  • Android — the core compiled to native code and bound for Kotlin/Java, shipped as an AAR.
  • iOS / macOS — the same gomobile path, bound for Swift, shipped as an xcframework.
  • cgo (Windows, Linux) — the core as a c-shared library behind a C ABI, with a C++17 header over it. Any language with an FFI reaches it here.
  • JavaScript (wasm/web) — the core compiled to WebAssembly and run in the page, driven by your browser framework.

They front one core, so behavior matches across platforms — but they are not interchangeable, and the differences decide your architecture rather than just your syntax. Read Which binding before you pick one, and the SDK tour for how each is built.

URnetwork uses member-run exit devices. Providers do not receive your users' source IPs on relayed paths. Devices built on the SDK also encrypt traffic to the provider by default. Read How URnetwork works for the full model.

What you need

All four bindings front the same core the official URnetwork apps are built on: the device (the connection engine), the API client, the network-space configuration model (which platform deployment you talk to, with what endpoints and flags), and the view controllers, headless state-plus-events objects for the common screens that you can bind your UI to or ignore. The operator API behind it is documented at /docs/api. You are building your own app on URnetwork's hosted platform: the API, relays, and providers are the live service, and your users bring or create accounts.

Platform floors, per binding:

  • Android — API level 24 or newer. The Java package is com.bringyour.sdk.
  • iOS / macOS — iOS 16.0 and macOS 13.5.
  • cgo — Ubuntu 22.04+ (glibc 2.35+) or Windows 10+, amd64 and arm64.
  • JavaScript — browser-only.
  • Building from source — Go 1.26+.

The SDK is MPL-2.0 (Mozilla Public License): link it freely into closed-source apps. The file-level copyleft only obliges sharing changes to the SDK's own files.

Which binding

The one difference that is not a matter of taste: only three of the four can run a device. A browser page cannot own a tun interface, so the JavaScript binding ships the control plane and no packet path. If your product needs to move a user's traffic itself, wasm is not a smaller version of the others — it is a different job.

AndroidiOS / macOScgoJavaScript
You writeKotlin / JavaSwiftC++ (or any FFI)JS / TypeScript
The core arrives asnative code, gomobile-boundnative code, gomobile-boundc-shared library, C ABIwasm in the page
Runs the packet pathyes, in your app processyes, in the NetworkExtensionyes, in a daemon you writeno
Process modelsingle processapp + extension, loopback mTLSUI + privileged service, loopback mTLSpage only
Artifact~37 MB AAR~116 MB xcframework~21–25 MB library~43.5 MB wasm

What that costs you, in the order it will bite:

  • Android is the simplest: one process holds the UI and the VpnService, so your app owns the DeviceLocal directly and IoLoop pumps the tun fd inside Go with no per-packet JNI crossing.
  • iOS / macOS is the same binding with a harder shape. Every Apple VPN runs packet handling in a separate sandboxed process, so the device lives in the extension and your app drives it remotely. Budget for the re-attach lifecycle and the extension memory limit from day one — it is not an edge case, the OS restarts that process routinely.
  • cgo is the most portable and the least ergonomic. The ABI is handles and JSON rather than typed objects, which is what lets Rust, Python and C# reach it; the C++17 header is the one place that ergonomics were spent. You also write the privileged daemon yourself.
  • JavaScript trades the packet path for reach. You get the API surface, the view controllers, and DeviceRemote against a device hosted elsewhere — and a 43 MB payload to lazy-load.

Threading is where all four converge on one rule: non-view objects are concurrent-safe, view controllers are not. Browser JS gets that for free; the other three do not. See the tour for the details.

Sign in

Every snippet below takes a byJwt: the account JWT, a signed login token the URnetwork API issues when an account authenticates. There is no separate API-key system.

Your app runs a login flow once through the SDK's API client. authLogin reports an identifier's auth methods; authLoginWithPassword completes it (authVerify finishes an emailed or SMS'd code), and wallet auth and networkCreate are parallel entry points. Wallet auth is signature-only: you send wallet_address, wallet_message, and wallet_signature, never a key, and no URnetwork surface asks for a wallet's private key or mnemonic. networkCreate with no auth method at all is the instant-account path: it mints a permanent account and returns a seedphrase, URnetwork's own recovery phrase for that account, generated server-side and handed back exactly once. Surface it to the user then, or the account has no recovery path.

Every path returns by_jwt. Persist it, call setByJwt, and pass it to the device constructor. You write no refresh code; the Api's token manager rotates the JWT. Treat a hard auth failure as "run login again."

The account is also the billing unit: the account whose JWT the device holds is the one whose plan is metered. Free is a daily data allowance, Pro a large monthly one; current numbers at ur.io/products. Intend one account per end user, which the instant-account path makes frictionless, rather than one embedded account pooling every user's usage and conduct into a single actor. When an account runs out of data, transfer stalls until the allowance refreshes, so say "out of data" in your UX, not a generic network error.

The SDK also owns the paid-plan flow, because the client must never name its own price: register an intent with createSolanaPaymentIntent (reference from createPaymentReference, plan "monthly" or "yearly"), take amountUsd from the result, and pass it to buildSolanaPaymentUrl. The C ABI has the intent call, not yet the URL builder.

Install

Prebuilt artifacts are release assets on github.com/urnetwork/build. Versions are date-based, vYYYY.M.D- (e.g. v2026.7.22-999364023), not SemVer: pin a release and upgrade deliberately, since the version says when it was cut, not whether the API moved.

Android. Download URnetworkSdk-.aar (plus -sources.jar for IDE navigation) and drop them into a directory your Gradle module already scans, e.g.:

dependencies {
    implementation fileTree(dir: 'libs', include: ['*.aar'])
}

iOS / macOS. Download URnetworkSdk.xcframework.zip, unzip it, and reference it from a local Swift package as a binaryTarget:

// Package.swift
targets: [ .binaryTarget(name: "URnetworkSdk", path: "URnetworkSdk.xcframework")
]

All types are Sdk-prefixed (SdkDeviceLocal, SdkNetworkSpace, ...). The xcframework carries ios/arm64, iossimulator/arm64, macos/arm64, and macos/amd64; there is no Intel iOS-simulator slice.

cgo (Windows, Linux). C-shared libraries, the same layer the shipping URnetwork Linux and Windows apps are built on, with two curated headers alongside them:

  • libURnetworkSdk.so — Linux.
  • URnetworkSdk.dll (+ urnetwork_sdk.def) — Windows.
  • urnetwork_sdk.h — the plain C ABI.
  • urnetwork_sdk.hpp — a header-only C++17 RAII wrapper over it, requiring nlohmann/json on your include path.

Anything with a C foreign-function interface (Rust bindgen, Python ctypes, C# P/Invoke) maps onto this handles-and-JSON ABI; the C++ header is the one language convenience. With MSVC, generate the import library from the .def file first:

lib /def:urnetwork_sdk.def /machine:x64 /out:URnetworkSdk.lib

JavaScript (wasm/web). The npm package is @urnetwork/sdk-js, a browser-only loader that fetches Go's wasm_exec.js plus the SDK wasm and instantiates them in the page. Install the nightly tag; it is cut from the current SDK on the same dated version scheme and ships the wasm. latest is months behind and carries no wasm at all, so a plain npm install leaves you a loader with nothing to load.

npm install @urnetwork/sdk-js@nightly   # then pin the version it resolved to

The wasm is roughly 43 MB, the entire Go core, so it will not shrink, but it compresses well: serve it as a static asset (gzip or brotli, cached hard) and load it lazily, only when the user reaches a connect surface. Never let a bundler inline or transform it, and always ship sdk.wasm and wasm_exec.js from the same build; the glue is ABI-paired with the Go toolchain that compiled the wasm, and the SDK's build gates on the two matching byte for byte.

From source. You need Go 1.26+, and for mobile make init first: it pins the exact gomobile the bindings are cut with and installs the checksec the Android target runs. build_android also needs ANDROID_NDK_HOME set, since it strips .comment with the NDK's llvm-objcopy. sdk/build-android.sh and sdk/build-ios.sh wrap both. From sdk/build:

make init             # pinned gomobile + checksec; run before the mobile targets
make build_android    # AAR
make build_apple      # xcframework (build_ios is an alias)
make build_js         # wasm + loader
make build_linux      # c-shared .so, cross-compiled with zig
make build_windows    # c-shared .dll; not in `all` — the shipped one builds in a VM

build_android gates on gomobile's skip list, so a missing symbol is a version mismatch, not a silent binding drop; gomobile binds no slices of structs (lists cross as SdkStringList, SdkIdList, ...) and no context.Context.

Third-party security assessments, and their limits, are covered in the threat model.

The tunnel permission

The operating-system consent, and the tunnel itself, belong to your app. The SDK begins at the packet layer:

  • On Android, the VpnService is yours: your app declares it, obtains VPN consent with VpnService.prepare(), and calls establish(). The SDK takes over at that file descriptor.
  • On Apple platforms, the split is Apple's requirement, not the SDK's: packet tunnels run in a separate NetworkExtension with tight memory limits, so the tunnel provider owns the device while your app process attaches to it remotely.
  • In a browser, a page cannot own a network interface, so there is no permission to ask, and nothing in the JavaScript layer tunnels the page's own traffic. Both JavaScript device models drive a device on the platform instead. The URnetwork extension covers browser tabs; a native app covers the whole machine.

For what URnetwork records about connections, read the threat model.

Connect

A new device starts on the network defaults. The client-provider session is sealed out of the box: setPerformanceProfile ships with PostQuantumEncryption on, the flag behind the apps' "Post Quantum Encryption" control, and every current provider build enables the responder side. With the flag on the client is fail-closed: it will not carry application data in the clear, and it skips a provider it cannot seal to rather than using it unsealed. Turn the flag off and traffic can take the standard path again. AllowDirect is off by default; it is the opt-in speed setting that removes the anonymizing hop and hands that provider your user's real IP, the apps surface it inverted as "Strong Anonymization", and hosted device profiles force it off. Leak protection is yours to wire: the kill switch is the primitive setRouteLocal, a device starts with local routing allowed, and setRouteLocal(false) makes traffic stop rather than fall back when the tunnel is down. The SDK embeds no analytics or crash reporting; its only connections are the platform endpoints and the relays and providers the device uses.

Android

Bootstrap order: create a NetworkSpaceManager pointed at app-private storage (the directory holds local state, including credentials), create the network space, then set the account JWT on its API client. The key is a host name plus an environment name; production is ("ur.network", "main"), and updateNetworkSpace is what creates one, while getNetworkSpace only reads back a space that exists, so it returns null on a fresh install. URLs derive from the key (https://api., wss://connect.; a non-main env prefixes the service), but the derivation prefers migrationHostName, which production sets to bringyour.com: the shipping apps reach api.bringyour.com, and api.ur.network does not resolve. Then create the device and hand it the tunnel's file descriptor:

import com.bringyour.sdk.Sdk

val manager = Sdk.newNetworkSpaceManager(context.filesDir.absolutePath)
val key = Sdk.newNetworkSpaceKey("ur.network", "main")
val networkSpace = manager.updateNetworkSpace(key) { it.migrationHostName = "bringyour.com" }
networkSpace.api.setByJwt(byJwt)

val device = Sdk.newDeviceLocalWithMemoryTarget(
networkSpace, byJwt,
    deviceDescription,   // free-form, shown in the account's device list
    deviceSpec,          // e.g. Build.MODEL
appVersion, Sdk.newId(),         // instanceId; persist and reuse per install
    /* enableRpc */ false,
    keyMaterial,         // persisted DeviceLocalKeyMaterial, or null for ephemeral
memoryTargetByteCount, )

// inside your VpnService, after establish():
val detachedFd = pfd.detachFd()          // ParcelFileDescriptor -> raw fd
val ioLoop = Sdk.newIoLoop(device, detachedFd) { /* done callback */ }

newIoLoop takes ownership of the detached fd and pumps packets in both directions until you close it. Do not touch the fd again from Java after detaching.

Three constructor arguments deserve care. instanceId: generate one Sdk.newId() on first run, persist it, and reuse it for the life of the install (one live device per process); a fresh id every launch adds a phantom entry to the account's device list. keyMaterial: null is fine for a pure client, but if the device will ever provide capacity, persist getKeyMaterial() in platform secure storage. It is the device's provider identity, and losing it resets the provider's reliability history. memoryTargetByteCount: a byte budget the device sizes its buffers and GC pacing against; pick one that fits your process's real ceiling (see the tour). DeviceLocal is in-process: if your process dies the tunnel dies with it, so run the VpnService as a foreground service and recreate the device on restart with the same instanceId and key material.

iOS / macOS

The NEPacketTunnelProvider owns the SdkDeviceLocal and packet flow, while the app process attaches to it as a remote device over loopback:

// in the packet tunnel provider (owns the device):
var err: NSError? let device = SdkNewDeviceLocalWithMemoryTarget(
    networkSpace, byJwt, deviceDescription, deviceSpec, appVersion,
    instanceId, /* enableRpc */ true, keyMaterial, memoryTarget, &err)

// in the app process (attaches to it):
let remote = SdkNewDeviceRemoteWithDefaults(networkSpace, byJwt, instanceId, &err)
try remote?.setRpcServer(clientPem, serverCertPem: serverCertPem, hostPort: hostPort)

The app mints the key material and RPC PEMs (SdkGenerateDeviceRpcKeyMaterial) and hands them to the extension in the NETunnelProviderProtocol provider configuration; the extension reads rpc_server_pem, rpc_client_pem, and rpc_listen_hostport from there. There is no app group on this path. See the tour for why the split exists and how reconnection works.

cgo (Windows, Linux)

The ABI contract, in short:

  • Objects are opaque uint64_t handles. urnet_release(h) frees the handle without stopping the object, so call its *_close/*_stop first where one exists.
  • Returned char* strings are caller-owned; free them with urnet_free_string.
  • Structured data crosses the boundary as UTF-8 JSON strings; ids are UUID strings, times are Unix epoch milliseconds (0 = none).
  • Callbacks fire on arbitrary Go-managed threads; marshal to your own thread. Their strings and buffers live only for the call, and handles they hand you are yours to release.
  • Fallible calls take a char** out_error; on failure it is set to a message you free with urnet_free_string. Pass NULL to ignore the text.

The header carries one platform split: urnet_new_io_loop, the fd pump the Linux app uses, sits inside #if !defined(_WIN32) and is absent from the Windows .def. On Windows you move packets with urnet_device_local_send_packet and urnet_device_local_add_receive_packet.

A working end-to-end example lives at cgo/smoke; make smoke_hpp in sdk/cgo builds the C++ wrapper smoke test against a host build and runs it.

JavaScript (wasm/web)

import { URNetwork } from "@urnetwork/sdk-js";

const sdk = await URNetwork.init({
  wasmUrl: "/wasm/sdk.wasm",
  wasmExecUrl: "/wasm/wasm_exec.js",
});

The wasm registers its exports on window, so run one module instance per page. init is idempotent within a module (a second call returns the same instance), but two copies of the loader, say two bundles or two frames sharing a realm, race over the same globals. Initialize once in a module-level singleton, never inside a component lifecycle.

Two device models:

  • sdk.createProxyDevice(...) — a lightweight client of hosted proxy URLs, for when all you need is "a proxy URL that exits through URnetwork".
  • sdk.createPlatformDeviceRemote({...}) — a full DeviceRemote speaking device-RPC to a hosted device over a websocket, for a real connect UI (locations, listeners, stats):
const device = sdk.createPlatformDeviceRemote({
  apiUrl: "api.bringyour.com",
  platformUrl: "connect.bringyour.com",
byJwt, proxyUrl,        // from the platform's proxy config endpoint
  signedProxyId,   // HMAC auth token, not the JWT — see the tour
});

const unsub = device.addConnectLocationChangeListener((loc) => {
  console.log("location:", loc?.name);
}); device.setConnectLocation({ bestAvailable: true });

createPlatformDeviceRemote throws if the loaded wasm predates the DeviceRemote binding; that is the symptom of an old npm tag.

Confirm it works

Bring the device up and check the exit: any "what is my IP" check through the tunnel should now report a provider's address, not the machine's. The same state is visible in code and in the account: listeners fire as the connection changes (the JavaScript snippet above logs each location change as it lands), and the device appears in the account's device list under the deviceDescription you passed. On the C ABI, urnet_live_handle_count() reports live handles; assert it returns to baseline in leak tests.

If something fails

Support is the open channels: issues on the repos (github.com/urnetwork), product feedback at feedback.ur.io, and security reports to [email protected] (disclosure policy at ur.io/vdp). There is no paid SDK support tier today. Two stalls have easy explanations: a transfer stall on a working tunnel is usually an account out of data, and a JavaScript loader with nothing to load is the latest npm tag. The tour covers the failure modes to know before you ship.

What next

  • Take the SDK tour. The architecture behind each binding: process splits, threading, auth, reconnection, and sizing the memory target.
  • Tell your users what they are joining. If your app routes traffic through URnetwork, the overview is the canonical account of the path and what each party can see, and the threat model is the full record behind it.
  • See the finished apps for the experience your users will get: Android, iOS, macOS, Windows, Linux, and the browser.