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

  1. Navigate to Setup > Integrations > Agent Keys
  2. Click the Create New API Agent Button
  3. Enter the name of the Public Agent
  4. Click the View Details button of the newly created Agent card
  5. Under the API Scopes section, expand the Accordion associated with the entity class in question
  6. 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())