Skip to content

Public API Reference

Vexillum has no bundled public dashboard - the reflector ships as an API-only public surface. This page describes the public, unauthenticated JSON REST + Server-Sent Events (SSE) API for reading reflector status data: instance inventory, per-mode runtime stats, connected clients, and recent call history. It’s intended for building independent dashboards or integrations against a running Vexillum node.

Field Value
API status Beta
Contract version v1

GET / redirects to the interactive docs described below rather than serving a page. (An earlier Beta build shipped a server-rendered HTML dashboard at / and /public/<mode>/<instance>; it was retired once this API existed, since it added nothing a caller of this API couldn’t already get for itself.)

Interactive docs: /api/public/v1/docs serves a Swagger UI browser against the machine-readable spec at /api/public/v1/openapi.json (hand-maintained OpenAPI 3.0 - see the source-of-truth note under Endpoints below for what it does and doesn’t capture). This page remains the fuller narrative reference, in particular for SSE framing details OpenAPI can’t fully express.

All endpoints are served under:

/api/public/v1

The v1 segment is a stable contract version. Breaking changes (removed fields, changed types, changed semantics) will ship under a new version prefix rather than altering v1 in place - but as Beta software, the set of fields may still grow and minor behavioral details may be refined before a 1.0 stability commitment.

  • No authentication. This API only ever exposes read-only reflector status data - nothing scoped to an admin account.

  • CORS is an operator-configured allowlist, off by default. Configure it in the reflector’s config file, all under one [public_api] section (see Configuration):

    [public_api]
    enabled = true
    listen = "127.0.0.1:8081"
    cors_allowed_origins = ["https://dashboard.example.com"]
    # or ["*"] to allow any origin (the old Beta-default behavior)
    rate_limit_per_minute = 120
    rate_limit_burst = 20

    enabled/listen are the same section as the CORS/rate-limit settings since there’s only one public-facing listener - set enabled = false to disable the public API entirely.

    With cors_allowed_origins empty (the default), the API is same-origin/ non-browser only - curl, server-to-server calls, and same-origin pages are unaffected; only cross-origin browser JS is blocked from reading the response. List the exact origin(s) (scheme://host[:port], no path) your dashboard is served from to allow it, or use the literal "*" entry to allow any origin.

  • Rate limiting is on by default - 120 requests/minute per client IP, burst 20, shared across every route in this API (REST, SSE, and the docs/spec routes). Exceeding it returns HTTP 429 with a Retry-After header and a JSON {"error": "...", "status": 429} body. Set rate_limit_per_minute = 0 to disable it, or raise/lower the numbers to taste. Per-IP identification uses the same trusted-proxy configuration as the rest of the server (VEX_ADMIN_TRUSTED_PROXIES) - if you’re behind a reverse proxy, make sure that’s configured correctly or every request will appear to come from the proxy’s IP and share one budget.

Method Path Description
GET /api/public/v1/overview One-shot snapshot of all instances across all modes.
GET /api/public/v1/instances/:mode/:instance One-shot snapshot of a single instance.
GET /api/public/v1/liveness One-shot supervisor liveness snapshot, all instances.
GET /api/public/v1/liveness/:mode/:instance One-shot supervisor liveness snapshot of a single instance.
GET /api/public/v1/activity One-shot aggregated live calls + last-heard, all instances.
GET /api/public/v1/events/overview SSE stream of the overview snapshot.
GET /api/public/v1/events/instances/:mode/:instance SSE stream of a single instance’s snapshot.
GET /api/public/v1/events/activity SSE stream of the aggregated activity feed.
GET /api/public/v1/openapi.json Machine-readable OpenAPI 3.0 spec (see note below).
GET /api/public/v1/docs Swagger UI browser for the spec above.

:mode is one of dstar, dmr, m17, nxdn, p25, ysf, vafm (case-insensitive). :instance is the instance name as configured on the reflector (case-insensitive lookup; the response always reports the canonical stored casing).

Note on openapi.json: it is hand-maintained, not generated from the Go handlers, so treat this page and the actual server behavior as the source of truth if the two ever disagree. It also collapses per-mode differences in the runtime/clients shapes (documented below) into generic, loosely-typed schemas - the mode-specific field lists here are more precise than what the spec can express without seven near-duplicate schemas.

Returns totals across all instances, a per-mode summary, and a flat list of every configured instance with its current state.

{
  "title": "Public Server Overview",
  "runtime_name": "VEX Test Runtime",
  "generated_at": "2026-07-09T14:32:01Z",
  "totals": {
    "instances": 7,
    "running": 5,
    "stopped": 2,
    "failed": 0,
    "disabled": 0,
    "active_clients": 3
  },
  "modes": [
    { "mode": "dmr", "instances": 1, "running": 1, "active_clients": 1 },
    { "mode": "dstar", "instances": 1, "running": 1, "active_clients": 0 }
  ],
  "instances": [
    {
      "mode": "dmr",
      "mode_value": "dmr",
      "instance": "main",
      "detail_path": "/api/public/v1/instances/dmr/main",
      "state": "running",
      "state_class": "ok",
      "active_clients": 1,
      "active_call_count": 1,
      "active_calls": [
        { "callsign": "KC1AWV", "module": "A", "started_at": "2026-07-09T14:31:52Z", "frames": 340 }
      ],
      "last_heard": [
        { "timestamp": "2026-07-09 14:31:52 UTC", "callsign": "KC1AWV", "module": "A" }
      ]
    }
  ]
}

detail_path is a convenience - it’s exactly what you’d build yourself from mode/instance via /api/public/v1/instances/{mode}/{instance} (below).

active_calls and last_heard are a bounded “at a glance” preview (up to 5 entries each, newest first) - enough for a dashboard’s main instance list to show who’s on without a per-instance drill-down. active_call_count is the true total, which can exceed the preview length if more calls are active than are being previewed. For the fuller per-instance history (up to 200 entries) see the instance-detail endpoint below; for a genuinely merged feed across every instance, see the activity endpoint.

GET /api/public/v1/instances/:mode/:instance

Section titled “GET /api/public/v1/instances/:mode/:instance”

Returns detailed runtime state, stats, connected clients, and recent call history for one instance. The mode-specific block (dmr, dstar, m17, nxdn, p25, ysf, or vafm) is only present for the instance’s actual mode; the other six are omitted.

{
  "title": "DMR main Public Detail",
  "runtime_name": "VEX Test Runtime",
  "generated_at": "2026-07-09T14:32:01Z",
  "found": true,
  "mode": "dmr",
  "mode_label": "DMR",
  "instance": "main",
  "state": "running",
  "state_class": "ok",
  "active_clients": 1,
  "public_runtime": {
    "rows": [
      { "label": "Connect Endpoint", "endpoint": "reflector.example.net:62031" }
    ]
  },
  "last_heard": [
    { "timestamp": "2026-07-09 14:31:52", "callsign": "KC1AWV", "module": "A" }
  ],
  "dmr": {
    "instance": "main",
    "runtime": {
      "running": true,
      "listen_addr": "0.0.0.0:62031",
      "client_count": 1,
      "stats": {
        "packets_in": 1024,
        "packets_out": 980,
        "control_in": 12,
        "control_out": 10,
        "voice_in": 900,
        "voice_out": 860,
        "active_streams": 0,
        "stream_starts": 4,
        "stream_ends": 4,
        "connects": 1,
        "disconnects": 0,
        "session_timeouts": 0,
        "parse_errors": 0
      }
    },
    "clients": [
      { "callsign": "KC1AWV", "module": "A", "remote_addr": "203.0.113.5:62031", "repeater_id": 312000 }
    ],
    "streams": []
  }
}

If the instance doesn’t exist, the response is HTTP 404 with found: false:

{
  "found": false,
  "message": "Instance not found",
  "mode": "dmr",
  "mode_label": "DMR",
  "instance": "does-not-exist",
  "public_runtime": { "rows": [] },
  "last_heard": []
}

If :instance resolves to a configured instance but doesn’t exactly match its canonical stored name, the response is HTTP 307 with a Location header pointing at the canonical /api/public/v1/instances/... resource. Follow it like any other HTTP redirect (fetch and curl -L do this automatically).

GET /api/public/v1/liveness and /liveness/:mode/:instance

Section titled “GET /api/public/v1/liveness and /liveness/:mode/:instance”

A lean, monitoring-focused view of instance state - purpose-built for third-party availability checkers (think uptime monitors, status-page integrations, or a directory site polling to confirm a listed reflector is actually up) rather than for building a dashboard. It’s deliberately decoupled from /overview and /instances/:mode/:instance above: those endpoints can grow new dashboard-oriented fields over time without changing what a monitoring integration parses here.

Bulk (/liveness):

{
  "title": "Public Liveness",
  "runtime_name": "VEX Test Runtime",
  "generated_at": "2026-07-09T14:32:01Z",
  "totals": {
    "instances": 7,
    "running": 5,
    "stopped": 2,
    "failed": 0,
    "disabled": 0,
    "active_clients": 3
  },
  "instances": [
    {
      "mode": "DMR",
      "mode_value": "dmr",
      "instance": "main",
      "detail_path": "/api/public/v1/liveness/dmr/main",
      "state": "running",
      "state_class": "ok",
      "reason": "started",
      "state_changed_at": "2026-07-09T14:30:01Z",
      "uptime_seconds": 120,
      "active_clients": 1
    }
  ]
}

Per-instance (/liveness/:mode/:instance) returns one entry in the same shape, plus found/message, and 404s with found: false if the instance doesn’t exist - same not-found and canonical-name-redirect behavior as the instance- detail endpoint above.

reason is the last recorded state transition for that instance - e.g. "started", "restarted", "stopped", "disabled", or failure detail when state is "failed". It’s omitted when empty.

state_changed_at is when that transition was recorded - present regardless of state, useful for flapping/staleness detection even when not running. uptime_seconds is derived from it and only appears when state is "running". Both reset whenever the Vexillum process itself restarts - reflector instances run in-process rather than as separate child processes, so there’s no independent reflector uptime to preserve continuity against; if Vexillum is down, everything it hosts is down too, and “since when running” restarts along with it.

A genuinely merged, cross-instance feed - for a real “live call wall” view, as opposed to the per-instance previews in the overview response above. Every instance/mode’s active calls and last-heard history are tagged with mode/ instance and merged into two flat, time-ordered lists.

{
  "title": "Live Activity",
  "runtime_name": "VEX Test Runtime",
  "generated_at": "2026-07-09T14:32:01Z",
  "active_calls": [
    {
      "mode": "dmr",
      "instance": "main",
      "callsign": "KC1AWV",
      "module": "A",
      "started_at": "2026-07-09T14:31:52Z",
      "frames": 340
    }
  ],
  "last_heard": [
    {
      "mode": "dmr",
      "instance": "main",
      "timestamp": "2026-07-09 14:31:52 UTC",
      "callsign": "KC1AWV",
      "module": "A"
    }
  ]
}

active_calls is sorted by started_at descending and capped at 200 entries (defensive - in practice bounded by however many calls are actually active system-wide). last_heard is sorted by capture time descending and capped at 50 entries across the whole system. Like the overview endpoint, hitting /activity (or its SSE counterpart) also drives last-heard capture for every instance - you don’t need to have called /instances/:mode/:instance first for an instance’s activity to show up here.

/api/public/v1/events/overview, /api/public/v1/events/instances/:mode/:instance, and /api/public/v1/events/activity all use the same framing:

  • On connect, the current snapshot is sent immediately.
  • The snapshot is re-sent every 2 seconds.
  • Every 20 seconds, a : heartbeat comment line is sent to keep idle connections alive through proxies.
  • Each snapshot is one event: snapshot SSE event whose data: payload is the same JSON shape as the corresponding REST endpoint above.

The instance-events stream additionally sets redirect_path, mode, and instance (to the canonical values) instead of the normal payload when the requested instance needs to be resolved to a different canonical name - since SSE can’t send an HTTP redirect mid-stream, clients should treat a non-empty redirect_path as “resubscribe using mode/instance from this payload.”

Example client (JavaScript):

function connect() {
  const source = new EventSource("/api/public/v1/events/overview");
  source.addEventListener("snapshot", (ev) => {
    const payload = JSON.parse(ev.data);
    render(payload);
  });
  source.onerror = () => {
    source.close();
    window.setTimeout(connect, 3000); // reconnect with a fixed backoff
  };
}
connect();

CORS and rate limiting are configurable (see Access above) with production- appropriate defaults (CORS locked down, rate limiting on). What’s still Beta: the exact set of fields on each mode’s runtime/stats/client blocks may grow as new modes gain features, and minor response-shape details may still be refined before a 1.0 stability commitment. If you’re integrating against this API, expect to tolerate new fields appearing over time.