I needed to deploy WireGuard VPN to 48 Windows machines on an Active Directory domain. Creating clients one by one in the UniFi UI was not an option, so I reverse engineered the API using Firefox DevTools. Sharing everything here since there is almost no documentation on this.
UniFi has no official bulk client creation feature. The documented API paths
(/proxy/network/api/v2/...) return HTTP 400 on firmware Network 10.x.
It took a lot of trial and error to find the correct endpoints.
Without these two headers, the login endpoint returns HTTP 403 — no error message, just a flat rejection. This is an undocumented CSRF protection at the nginx reverse proxy level.
session.post("https://YOUR_UDM_IP/api/auth/login",
json={"username": "local_user", "password": "password"},
headers={
"Content-Type": "application/json",
"Origin": "https://YOUR_UDM_IP",
"Referer": "https://YOUR_UDM_IP/"
},
verify=False)Your account must be a local account (no email, "Restrict to Local Access Only" checked in UniFi OS → Settings → Admins). A cloud/SSO Ubiquiti account will always return 403 on the local API.
This wasted hours. On firmware 10.x the correct base path is:
/proxy/network/v2/api/site/default/wireguard/
Not /proxy/network/api/v2/... which is what most community posts reference and
which returns HTTP 400 on this firmware.
Discovered by watching Firefox DevTools Network tab while manually creating a client in the UI:
POST /proxy/network/v2/api/site/default/wireguard/{SERVER_ID}/users/batch
The payload must be a JSON array (not an indexed object). Each item includes the keypair generated client-side:
[
{
"allowed_ips": [],
"interface_ip": "192.168.6.20",
"name": "MACHINE-NAME",
"preshared_key": "",
"private_key": "BASE64_PRIVATE_KEY",
"public_key": "BASE64_PUBLIC_KEY"
}
]The UDM Pro never stores the private key. It must be generated locally and kept for the .conf file. The UI will show a warning "The configuration file needs to be manually created for clients with a custom public key" — this is expected and normal.
WireGuard uses Curve25519. The Python cryptography library generates
compatible keypairs:
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
from cryptography.hazmat.primitives.serialization import (
Encoding, PublicFormat, PrivateFormat, NoEncryption)
import base64
def generate_wireguard_keypair():
pk = X25519PrivateKey.generate()
priv = base64.b64encode(
pk.private_bytes(Encoding.Raw, PrivateFormat.Raw, NoEncryption())).decode()
pub = base64.b64encode(
pk.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)).decode()
return priv, pubThe server public key, DNS, subnet and port are in the classic v1 API:
GET /proxy/network/api/s/default/rest/networkconf
Look for the entry with "vpn_type": "wireguard-server":
{
"wireguard_public_key": "SERVER_PUBLIC_KEY",
"ip_subnet": "192.168.6.1/24",
"local_port": 51820,
"dhcpd_dns_1": "192.168.X.X",
"dhcpd_dns_2": "192.168.X.X"
}The public endpoint IP is shown in UniFi UI → Settings → VPN → VPN Server → Server Address.
import requests, urllib3, json, os, base64, time, subprocess, sys
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
# ============================================================
# CONFIGURATION
# ============================================================
UDM_IP = "YOUR_UDM_IP"
USERNAME = "YOUR_LOCAL_USER"
PASSWORD = "YOUR_PASSWORD"
SERVER_ID = "YOUR_SERVER_ID" # _id from GET /rest/networkconf
SITE = "default"
BASE = f"https://{UDM_IP}"
OUTPUT_DIR = "./wg_configs"
SERVER_PUBLIC_KEY = "YOUR_SERVER_PUBLIC_KEY"
SERVER_ENDPOINT = "YOUR_PUBLIC_IP:51820"
SERVER_DNS = "192.168.X.X,192.168.X.X"
ALLOWED_IPS = "0.0.0.0/0" # full tunnel, or "192.168.0.0/16" for split
IP_PREFIX = "192.168.6."
START_SUFFIX = 10
clients = [
"MACHINE-001",
"MACHINE-002",
# add your machine names here
]
# ============================================================
# KEY GENERATION
# ============================================================
def generate_keypair():
try:
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
from cryptography.hazmat.primitives.serialization import (
Encoding, PublicFormat, PrivateFormat, NoEncryption)
except ImportError:
subprocess.check_call([sys.executable, "-m", "pip", "install", "cryptography", "-q"])
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
from cryptography.hazmat.primitives.serialization import (
Encoding, PublicFormat, PrivateFormat, NoEncryption)
pk = X25519PrivateKey.generate()
priv = base64.b64encode(
pk.private_bytes(Encoding.Raw, PrivateFormat.Raw, NoEncryption())).decode()
pub = base64.b64encode(
pk.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)).decode()
return priv, pub
# ============================================================
# MAIN
# ============================================================
def run():
os.makedirs(OUTPUT_DIR, exist_ok=True)
s = requests.Session()
r = s.post(f"{BASE}/api/auth/login",
json={"username": USERNAME, "password": PASSWORD},
headers={"Origin": BASE, "Referer": f"{BASE}/"},
verify=False)
csrf = r.headers.get("X-Csrf-Token") or r.headers.get("x-updated-csrf-token") or ""
s.headers.update({
"X-Csrf-Token": csrf,
"Origin": BASE,
"Referer": f"{BASE}/",
"Content-Type": "application/json"
})
print(f"Login: {r.status_code}")
batch_url = f"{BASE}/proxy/network/v2/api/site/{SITE}/wireguard/{SERVER_ID}/users/batch"
users_url = f"{BASE}/proxy/network/v2/api/site/{SITE}/wireguard/{SERVER_ID}/users"
existing = s.get(users_url, verify=False).json()
used_ips = {u["interface_ip"] for u in existing}
used_names = {u["name"] for u in existing}
print(f"Existing users: {len(existing)}")
ok = 0; errs = 0; skipped = 0
current_suffix = START_SUFFIX
for name in clients:
while f"{IP_PREFIX}{current_suffix}" in used_ips:
current_suffix += 1
if name in used_names:
print(f" SKIP {name} (already exists)")
skipped += 1
continue
target_ip = f"{IP_PREFIX}{current_suffix}"
priv_key, pub_key = generate_keypair()
payload = [{
"allowed_ips": [],
"interface_ip": target_ip,
"name": name,
"preshared_key": "",
"private_key": priv_key,
"public_key": pub_key
}]
r2 = s.post(batch_url, json=payload, verify=False)
if r2.status_code in (200, 201):
resp_list = r2.json()
created = resp_list[0] if isinstance(resp_list, list) else {}
print(f" OK: {name} -> {target_ip} | _id: {created.get('_id','?')[:12]}")
conf = f"""[Interface]
PrivateKey = {priv_key}
Address = {target_ip}/32
DNS = {SERVER_DNS}
[Peer]
PublicKey = {SERVER_PUBLIC_KEY}
Endpoint = {SERVER_ENDPOINT}
AllowedIPs = {ALLOWED_IPS}
PersistentKeepalive = 25
"""
with open(os.path.join(OUTPUT_DIR, f"{name}.conf"), "w") as f:
f.write(conf)
ok += 1
else:
print(f" ERROR {name}: HTTP {r2.status_code} | {r2.text[:150]}")
errs += 1
used_ips.add(target_ip)
current_suffix += 1
time.sleep(0.3)
final = s.get(users_url, verify=False).json()
print(f"\nUsers in UDM Pro: {len(final)}")
print(f"DONE: {ok} created | {errs} errors | {skipped} skipped")
if __name__ == "__main__":
run()| Endpoint | Method | Description |
|---|---|---|
/api/auth/login |
POST | Login (Origin + Referer required) |
/proxy/network/api/s/default/rest/networkconf |
GET | Server config, public key, DNS |
/proxy/network/v2/api/site/default/wireguard/{id}/users |
GET | List existing clients |
/proxy/network/v2/api/site/default/wireguard/{id}/users/batch |
POST | Create clients |
| Error | Cause | Fix |
|---|---|---|
| HTTP 403 on login | Missing Origin/Referer headers | Add both headers |
| HTTP 403 with valid credentials | Cloud/SSO account | Create a local account (no email) |
HTTP 400 on /users/batch |
Payload is object {} not array [] |
Use a JSON array |
HTTP 405 on /users/batch |
Wrong HTTP method | Use POST only |
HTTP 400 on /proxy/network/api/v2/... |
Wrong path order | Use /proxy/network/v2/api/... |
- Firmware: UniFi Network 10.1.85 on UDM Pro
- Python 3.x with
requestsandcryptographylibraries - Tested on 48 Windows machines with Active Directory GPO deployment
Happy to answer questions. Let me know if it works on other firmware versions.