puddle.town

Scripting the Aruba Instant On API

If you run Aruba (now HPE Networking) Instant On gear at home, you've probably assigned friendly names to your clients in the dashboard. Those names live in Instant On's cloud, not on your LAN — no local API, no SNMP, no config export. But the web portal is backed by a REST API that the community reverse-engineered years ago, and it still works in 2026 with a couple of adjustments. Here's the current state of it, plus a small Python script that dumps a MAC → name table.

The obligatory caveat: HPE's official position is that this API doesn't exist. It's undocumented and unsupported; it can change or disappear at any time. Treat everything below as a snapshot from July 2026.

What's changed since the original writeups

The reverse-engineering groundwork was done around 2022 by Luke Whitelock (PowerShell, in the HuduAutomation repo) with a Python token-flow gist by crockk and a Postman collection floating around. Following those docs verbatim no longer works. What I found:

  1. HPE rebrand moved the portal. portal.arubainstanton.com now answers with a 308 Permanent Redirect to portal.instant-on.hpe.com. The SSO host (sso.arubainstanton.com) is unchanged — for now. Rather than hardcoding hosts, bootstrap from https://portal.instant-on.hpe.com/settings.json, which self-describes everything you need: ssoFqdn (SSO host), ssoClientIdAuthZ (OAuth client id), ssoRedirectUrl (redirect URI), and restApiUrl (API base).
  2. wiredClientSummary is gone. The old docs list separate clientSummary (wireless) and wiredClientSummary (wired) endpoints. The wired one now returns 404. Instead, clientSummary returns all clients, wired and wireless; each record carries both wired* and wireless* fields, and you can classify a client by whether wirelessNetworkId is set.
  3. x-ion-api-version: 7 still works. The API version header from the 2022 writeups is still accepted.

Authentication flow

It's OAuth2 authorization-code with PKCE, with one quirk: instead of a browser login, you POST credentials to an MFA-validation endpoint and pass the resulting session token into the authorization request.

  1. GET https://portal.instant-on.hpe.com/settings.json → grab ssoFqdn, ssoClientIdAuthZ, ssoRedirectUrl, restApiUrl.
  2. Generate a PKCE pair: random code_verifier, and code_challenge = base64url(sha256(code_verifier)) without padding.
  3. POST {ssoFqdn}/aio/api/v1/mfa/validate/full with form fields username and password → JSON response contains an access_token (a session token, not the bearer token yet).
  4. GET {ssoFqdn}/as/authorization.oauth2 with query params client_id, redirect_uri, response_type=code, scope=profile openid, a random state, code_challenge_method=S256, code_challenge, and sessionToken=<token from step 3>. Don't follow the redirect — the authorization code is in the Location header's query string.
  5. POST {ssoFqdn}/as/token.oauth2 with form fields client_id, redirect_uri, code, code_verifier, grant_type=authorization_code → JSON response contains the real bearer access_token.

Because step 3 is a straight username/password POST, the account can't have MFA enabled. Don't strip MFA from your main account — create a dedicated API account (any email alias works), leave MFA off, and invite it to your site from your primary account. A view-only role is enough for all the read endpoints below (verified), and limits the damage if the no-MFA account is ever compromised.

The API

All requests: GET {restApiUrl}/api/... with headers Authorization: Bearer <token> and x-ion-api-version: 7. Collection responses wrap their list in an elements key.

Endpoint Returns
/api/sites/ Your sites and their ids
/api/sites/{site_id}/clientSummary All clients, wired + wireless
/api/sites/{site_id}/inventory Your Instant On devices (APs, switches)
/api/sites/{site_id}/alerts Active and resolved alerts
/api/sites/{site_id}/networksSummary WiFi network configuration

The clientSummary records are rich. Fields of note: name (the friendly name you assigned in the dashboard — this is the prize), macAddress, ipAddress, hostName, detectedOs, signalInDbm, wirelessNetworkName, vlanId, traffic counters, and health metrics. One gotcha: each record has a kind field, but it's the resource type ("clientSummary"), not wired/wireless — use the presence of wirelessNetworkId for that. In my testing, wired clients came back with an empty ipAddress.

The script

Python, single dependency (requests) — the PKCE bits are all stdlib. It reads INSTANT_ON_USER and INSTANT_ON_PASSWORD from the environment (or a .env file in the working directory) and prints an aligned table, or JSON with --json.

#!/usr/bin/env python3
# Dump MAC -> friendly-name pairings from the unofficial Aruba Instant On cloud API.
# The API is undocumented and unsupported by HPE; endpoints reverse-engineered by the
# community (https://mspp.io/documenting-aruba-instant-on-sites-aruba-instant-on-api/).
# Requires a portal account WITHOUT MFA (the scripted login can't answer a challenge).

import argparse
import base64
import hashlib
import json
import os
import secrets
import sys
from urllib.parse import parse_qs, urlparse

import requests

# portal.arubainstanton.com now 308-redirects here (HPE rebrand); settings.json
# self-describes the SSO host, OAuth client id, redirect URI, and API base
PORTAL_BASE = 'https://portal.instant-on.hpe.com'
ION_API_VERSION = '7'

# Load .env file
try:
  with open('.env', 'r') as f:
    for line in f:
      if line.strip() and not line.startswith('#') and '=' in line:
        key, value = line.strip().split('=', 1)
        os.environ[key] = value
except FileNotFoundError:
  pass


def get_bearer_token(settings, username, password):
  sso_base = settings['ssoFqdn']
  redirect_uri = settings['ssoRedirectUrl']

  # PKCE: challenge is base64url(sha256(verifier)) without padding
  code_verifier = secrets.token_urlsafe(48)
  code_challenge = base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest()).rstrip(b'=').decode()

  r = requests.post(f'{sso_base}/aio/api/v1/mfa/validate/full', data={'username': username, 'password': password})
  if r.status_code != 200:
    sys.exit(f'login failed ({r.status_code}): check credentials, and that the account has MFA disabled')
  session_token = r.json()['access_token']

  # the authorization code comes back in the redirect Location, so don't follow it
  r = requests.get(f'{sso_base}/as/authorization.oauth2',
                   params={
                     'client_id': settings['ssoClientIdAuthZ'],
                     'redirect_uri': redirect_uri,
                     'response_type': 'code',
                     'scope': 'profile openid',
                     'state': secrets.token_urlsafe(32),
                     'code_challenge_method': 'S256',
                     'code_challenge': code_challenge,
                     'sessionToken': session_token,
                   },
                   allow_redirects=False)
  location = r.headers.get('Location', '')
  query = parse_qs(urlparse(location).query)
  if 'code' not in query:
    sys.exit(f'authorization failed: no code in redirect ({location or r.status_code})')

  r = requests.post(f'{sso_base}/as/token.oauth2',
                    data={
                      'client_id': settings['ssoClientIdAuthZ'],
                      'redirect_uri': redirect_uri,
                      'code': query['code'][0],
                      'code_verifier': code_verifier,
                      'grant_type': 'authorization_code',
                    })
  r.raise_for_status()
  return r.json()['access_token']


def api_get(settings, token, path):
  r = requests.get(f'{settings["restApiUrl"]}/api/{path}',
                   headers={
                     'Authorization': f'Bearer {token}',
                     'x-ion-api-version': ION_API_VERSION
                   })
  r.raise_for_status()
  data = r.json()
  # collection responses wrap the list in "elements"
  return data.get('elements', data) if isinstance(data, dict) else data


def first_key(record, *candidates):
  # field names vary slightly across API versions, so probe a few
  for key in candidates:
    if record.get(key):
      return record[key]
  return ''


def collect_clients(settings, token, site_id, site_name):
  # clientSummary covers wired and wireless alike (the old wiredClientSummary endpoint is gone)
  rows = []
  for client in api_get(settings, token, f'sites/{site_id}/clientSummary'):
    rows.append({
      'site': site_name,
      'name': first_key(client, 'name', 'hostName'),
      'mac': first_key(client, 'macAddress', 'mac').lower(),
      'ip': first_key(client, 'ipAddress', 'ip'),
      'kind': 'wireless' if client.get('wirelessNetworkId') else 'wired',
    })
  return rows


def main():
  parser = argparse.ArgumentParser(description='List MAC -> friendly-name pairings from Aruba Instant On')
  parser.add_argument('--json', action='store_true', help='output JSON instead of a table')
  args = parser.parse_args()

  try:
    username = os.environ['INSTANT_ON_USER']
    password = os.environ['INSTANT_ON_PASSWORD']
  except KeyError as e:
    sys.exit(f'missing {e.args[0]} — add it to .env or the environment')

  settings = requests.get(f'{PORTAL_BASE}/settings.json').json()
  token = get_bearer_token(settings, username, password)

  rows = []
  for site in api_get(settings, token, 'sites/'):
    rows.extend(collect_clients(settings, token, site['id'], first_key(site, 'name', 'siteName')))
  rows.sort(key=lambda r: (r['site'], r['name'].lower()))

  if args.json:
    print(json.dumps(rows, indent=2))
    return

  widths = {col: max([len(col)] + [len(r[col]) for r in rows]) for col in ('mac', 'name', 'ip', 'kind', 'site')}
  for r in [{col: col.upper() for col in widths}] + rows:
    print('  '.join(r[col].ljust(widths[col]) for col in ('mac', 'name', 'ip', 'kind', 'site')))


if __name__ == '__main__':
  main()

Output looks like:

MAC                NAME         IP            KIND      SITE
aa:bb:cc:dd:ee:01  apple-tv     192.168.1.20  wireless  Home
aa:bb:cc:dd:ee:02  thermostat   192.168.1.31  wireless  Home
aa:bb:cc:dd:ee:03  nas          192.168.1.10  wired     Home

Setup recap

  1. Create a fresh Instant On account at portal.instant-on.hpe.com with an email alias, verify it, and leave MFA off.
  2. From your main account, invite it to your site with a view-only role; accept the invite.
  3. Export INSTANT_ON_USER and INSTANT_ON_PASSWORD (or drop them in a .env next to the script) and run it.

The credentials are still a real portal login for your network, so treat them like any other secret: keep them out of your repo history and public paste destinations.

Closing thoughts

This is exactly the kind of data that's useful to join against DHCP leases, DNS logs, or an nmap sweep — the dashboard names are the only place the "human" identity of a device lives, and now they're one JSON call away. Credit to Luke Whitelock and the others who did the original reverse-engineering; hopefully this post saves someone the hour of discovering what the rebrand broke.

Disclaimer: Claude Fable authored this post after testing the functionality against my Instant On account.