API V2: Generating a Public API Key
Last updated: October 5, 2026
Within Housing.Cloud, every API key is associated with an Agent. Currently, Public Agents behave the same as any traditional collection based CRUD API set.
Every Public API Agent has a name, id, key, owner and set of resource scopes. The id, key, and scopes are the most important components of the Agent. The id key are both required to generate a new JWT and the scopes determine which entities within Housing.Cloud the agent has access to.
An admin user may create multiple Public Agents, so it is highly recommended that an institution practices the principle of least privilege.
Steps to Generate a Public Agent
- Navigate to Setup > Integrations > Agent Keys
- Click the Create New API Agent Button
- Enter the name of the Public Agent
- Click the View Details button of the newly created Agent card
- Under the API Scopes section, expand the Accordion associated with the entity class in question
- Find the necessary collection and HTTP action combination and select the "Enabled" radio button
Testing
Here is a handy test script that will execute smoke tests against some of the Entity V2 Endpoints. It can also be used to extract code snippets for your own API client.
Python 3
#!/usr/bin/env python3
"""
Smoke-test every Housing Cloud v2 entity API endpoint.
Authenticates against POST /api/v2/auth with an API key + agent ID, then
hits every collection GET endpoint, timing each request and counting the
entities returned. Streams per-request progress to stdout and writes a
CSV report at the end.
Usage:
python3 v2_api_smoke_test.py \
--api-key <API_KEY> \
--agent-id <AGENT_ID> \
--base-url https://dev.housing.cloud \
[--paginate] \
[--page-size 1000] \
[--timeout 60] \
[--output v2_api_report.csv]
Only depends on the Python 3 standard library.
"""
from __future__ import annotations
import argparse
import csv
import json
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime, timezone
from dataclasses import dataclass, field
from typing import Any
# ANSI colors. Disabled automatically when stdout is not a TTY (e.g. piped to a file).
_COLOR_ENABLED = sys.stdout.isatty()
RED = "\033[31m" if _COLOR_ENABLED else ""
RESET = "\033[0m" if _COLOR_ENABLED else ""
def red(text: str) -> str:
return f"{RED}{text}{RESET}"
# Every v2 collection GET endpoint, derived from
# apps/web/src/app/api/v2/<entity>/route.ts (those that export GET).
ENTITY_ENDPOINTS: list[str] = [
"applications",
"bed-types",
"beds",
"building-types",
"buildings",
"charge-codes",
"door-access",
"meal-plan-assignments",
"meal-plans",
"physical-addresses",
"profile-field-assignments",
"profile-fields",
"profiles",
"related-human-relationships",
"related-humans",
"residencies",
"roles",
"room-types",
"rooms",
"suites",
"tag-categories",
"tags",
"task-categories",
"tasks",
"transactions",
]
@dataclass
class EndpointResult:
endpoint: str
url: str
status: int | None = None
count: int = 0
duration_ms: float = 0.0
pages: int = 0
ok: bool = False
error: str = ""
error_body: str = field(default="", repr=False)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--api-key", required=True, help="HC v2 API key (x-api-key header)")
parser.add_argument("--agent-id", required=True, help="Agent ID associated with the key")
parser.add_argument(
"--base-url",
required=True,
help="Tenant base URL, e.g. https://dev.housing.cloud (no trailing slash)",
)
parser.add_argument(
"--paginate",
action="store_true",
help="Walk every page using offset/limit until the API returns fewer than page-size",
)
parser.add_argument(
"--page-size",
type=int,
default=1000,
help="limit per request (server caps at 1000)",
)
parser.add_argument("--timeout", type=int, default=60, help="HTTP timeout in seconds")
parser.add_argument(
"--output", default="v2_api_report.csv", help="CSV report output path"
)
return parser.parse_args()
def http_request(
url: str,
method: str,
headers: dict[str, str],
timeout: int,
body: bytes | None = None,
) -> tuple[int, bytes, dict[str, str]]:
req = urllib.request.Request(url=url, data=body, method=method, headers=headers)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.status, resp.read(), dict(resp.headers)
except urllib.error.HTTPError as e:
# Surface the server's response body so callers can include it in the report.
return e.code, e.read(), dict(e.headers or {})
class TokenManager:
"""
Wraps the v2 auth flow with proactive + reactive re-auth.
- `get_token()` returns a valid bearer token, refreshing when the current
one is within REFRESH_BUFFER_S of expiry.
- `force_refresh()` mints a new token immediately; called after a 401.
- Requests an extended lifetime via the `token-expiry-override` header
(server caps at 60 minutes) so re-auths are rare in practice.
"""
REFRESH_BUFFER_S = 30
REQUESTED_EXPIRY_MIN = 60
def __init__(self, base_url: str, api_key: str, agent_id: str, timeout: int):
self.base_url = base_url.rstrip("/")
self.api_key = api_key
self.agent_id = agent_id
self.timeout = timeout
self._token: str | None = None
self._expires_at: datetime | None = None
self._refresh_count = 0
@property
def refresh_count(self) -> int:
return self._refresh_count
def get_token(self) -> str:
if self._token is None or self._is_expiring_soon():
self._refresh()
assert self._token is not None
return self._token
def force_refresh(self) -> str:
self._refresh()
assert self._token is not None
return self._token
def _is_expiring_soon(self) -> bool:
if self._expires_at is None:
return True
remaining = (self._expires_at - datetime.now(timezone.utc)).total_seconds()
return remaining <= self.REFRESH_BUFFER_S
def _refresh(self) -> None:
url = f"{self.base_url}/api/v2/auth"
headers = {
"x-api-key": self.api_key,
"agent-id": self.agent_id,
"token-expiry-override": str(self.REQUESTED_EXPIRY_MIN),
"Content-Type": "application/json",
"Accept": "application/json",
}
label = "[auth]" if self._refresh_count == 0 else "[reauth]"
print(f"{label} POST {url}")
started = time.perf_counter()
status, body, _ = http_request(url, "POST", headers, self.timeout)
elapsed = (time.perf_counter() - started) * 1000
if status != 200:
raise SystemExit(
red(
f"{label} failed: status={status} ({elapsed:.0f} ms) "
f"body={body[:500]!r}"
)
)
try:
payload = json.loads(body)
except json.JSONDecodeError as e:
raise SystemExit(
red(f"{label} invalid JSON response: {e}; body={body[:500]!r}")
) from e
token = payload.get("token")
expires = payload.get("expires")
if not token or not expires:
raise SystemExit(red(f"{label} response missing token/expires: {payload}"))
self._token = token
self._expires_at = _parse_iso8601(expires)
self._refresh_count += 1
print(f"{label} OK in {elapsed:.0f} ms (expires {expires})")
def _parse_iso8601(value: str) -> datetime:
# Python's fromisoformat handles trailing "Z" only on 3.11+; normalize for safety.
if value.endswith("Z"):
value = value[:-1] + "+00:00"
dt = datetime.fromisoformat(value)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt
def count_entities(payload: Any) -> int:
"""v2 collection responses are { entities: [...], metaData: {...} }."""
if isinstance(payload, dict):
entities = payload.get("entities")
if isinstance(entities, list):
return len(entities)
if isinstance(payload, list):
return len(payload)
return 0
def _request_with_retry(
url: str, tokens: TokenManager, timeout: int
) -> tuple[int, bytes]:
"""GET `url` with the current bearer token. On 401, refresh once and retry."""
headers = {
"Authorization": f"Bearer {tokens.get_token()}",
"Accept": "application/json",
}
status, body, _ = http_request(url, "GET", headers, timeout)
if status == 401:
print(red("[reauth] 401 received — refreshing token"))
headers["Authorization"] = f"Bearer {tokens.force_refresh()}"
status, body, _ = http_request(url, "GET", headers, timeout)
return status, body
def call_endpoint(
base_url: str,
endpoint: str,
tokens: TokenManager,
page_size: int,
paginate: bool,
timeout: int,
) -> EndpointResult:
base = f"{base_url.rstrip('/')}/api/v2/{endpoint}"
result = EndpointResult(endpoint=endpoint, url=base)
offset = 0
total_count = 0
pages = 0
print(f"[start] GET /api/v2/{endpoint}")
started = time.perf_counter()
while True:
qs = urllib.parse.urlencode({"limit": page_size, "offset": offset})
url = f"{base}?{qs}"
# Proactive refresh + reactive retry on 401.
status, body = _request_with_retry(url, tokens, timeout)
result.status = status
pages += 1
if status != 200:
result.error = f"HTTP {status}"
result.error_body = body[:500].decode("utf-8", errors="replace")
break
try:
payload = json.loads(body)
except json.JSONDecodeError as e:
result.error = f"invalid JSON: {e}"
result.error_body = body[:500].decode("utf-8", errors="replace")
break
page_count = count_entities(payload)
total_count += page_count
if not paginate or page_count < page_size:
result.ok = True
break
offset += page_size
result.duration_ms = (time.perf_counter() - started) * 1000
result.count = total_count
result.pages = pages
if result.ok:
print(
f"[ end ] GET /api/v2/{endpoint} "
f"status={result.status} entities={result.count} "
f"pages={result.pages} time={result.duration_ms:.0f} ms"
)
else:
print(
red(
f"[ ERR ] GET /api/v2/{endpoint} "
f"status={result.status} error={result.error!r} "
f"time={result.duration_ms:.0f} ms"
)
)
if result.error_body:
print(red(f" body: {result.error_body}"))
print()
return result
def write_report(results: list[EndpointResult], output: str) -> None:
with open(output, "w", newline="", encoding="utf-8") as f:
writer = csv.writer(f)
writer.writerow(
[
"endpoint",
"url",
"status",
"count",
"throughput_ms",
"pages",
"ok",
"error",
"error_body",
]
)
for r in results:
writer.writerow(
[
r.endpoint,
r.url,
r.status if r.status is not None else "",
r.count,
f"{r.duration_ms:.2f}",
r.pages,
"true" if r.ok else "false",
r.error,
r.error_body,
]
)
def main() -> int:
args = parse_args()
tokens = TokenManager(
base_url=args.base_url,
api_key=args.api_key,
agent_id=args.agent_id,
timeout=args.timeout,
)
# Mint the first token up front so any auth failure stops the run before
# we start timing endpoints.
tokens.get_token()
results: list[EndpointResult] = []
for endpoint in ENTITY_ENDPOINTS:
result = call_endpoint(
base_url=args.base_url,
endpoint=endpoint,
tokens=tokens,
page_size=args.page_size,
paginate=args.paginate,
timeout=args.timeout,
)
results.append(result)
write_report(results, args.output)
failed = [r for r in results if not r.ok]
total_entities = sum(r.count for r in results)
total_ms = sum(r.duration_ms for r in results)
print()
print("=" * 72)
print(f"Endpoints tested : {len(results)}")
print(f"Successful : {len(results) - len(failed)}")
failed_label = f"Failed : {len(failed)}"
print(red(failed_label) if failed else failed_label)
print(f"Total entities : {total_entities}")
print(f"Total time : {total_ms:.0f} ms")
print(f"Token refreshes : {tokens.refresh_count}")
print(f"Report written to : {args.output}")
print("=" * 72)
if failed:
print(red("\nFailures:"))
for r in failed:
print(red(f" - {r.endpoint}: status={r.status} {r.error}"))
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main())