# Netrinos Account API v1

> **Version 1**, 2026-09-05

Read-only REST API for Netrinos accounts on the Edge plan. It exposes the
account's users, devices, virtual devices, and port forwards, plus a short
record of devices going online and offline. The intended use is keeping an
external system, such as an ERP or asset database, synchronized with the
devices registered in Netrinos.

## Availability

| Item      | Value                             |
| --------- | --------------------------------- |
| Plan      | Edge only                         |
| Base URL  | `https://app.netrinos.com/api/v1` |
| Transport | HTTPS, `GET` only                 |
| Format    | JSON                              |

Netrinos support issues the account's API key on request. Once issued, it
is visible on the account page in the portal.

## What this is not

- **No push.** The API is polled. There are no webhooks and no streaming
  endpoint.
- **No writes.** Every endpoint is `GET`. Devices, users, and virtual
  devices are managed in the Netrinos portal.
- **Not the device alerts webhook.** A device can POST connect and
  disconnect notifications directly to a URL you configure on it. That
  feature is separate and remains available. It reports what one device
  sees of its peers and delivers each notice once, with no retry. This API
  reports the account-wide view held by Netrinos, is authenticated per
  account, and can be re-read at any time. For keeping an external record
  in step with the account, poll this API.

## Versioning

The major version is in the path. Fields may be added to a response and new
values may appear in enumerated fields without a version change, so ignore
fields you do not recognize. A breaking change would ship as `/api/v2`.

## Authentication

The API key is required on every request. There is no health check to call
without one, and an empty or absent key is never valid. The one thing you
can learn without a key is that a path does not exist: an unknown path, or
a method other than `GET`, returns 404 before the key is checked.

```bash
curl -H "Authorization: Bearer 3f2a1c7e-9b44-4d21-8c6f-0a1e2d3b4c5d" \
  https://app.netrinos.com/api/v1/account
```

One key is active per account, and it grants read access to that account
only. It is shown on your account page in the portal, so you can retrieve
it whenever you need it. An account with no key set has no API access at
all; ask support to issue one.

Ask support to replace the key if it is exposed. A new key takes effect
immediately and the old one stops working, so plan the swap.

Treat the key as a credential. Send it only over HTTPS, keep it out of
source control, and do not embed it in a browser client.

## Conventions

Field names are camelCase. Timestamps in responses are RFC 3339 in UTC and
may carry fractional seconds, for example `2026-09-04T14:22:31.582Z` or
`2026-09-04T14:22:31Z`. Parse them as RFC 3339 rather than matching a fixed
layout. A timestamp that is unknown or does not apply is `null` rather than
a zero date. A string field with no value is `""`, never `null`. Array
fields are always present, and empty rather than `null`.

Lists have a fixed order. Devices are sorted by `name`, users by
`username`, and events newest first.

### Time parameters

`since` accepts either form and matches rows strictly after the instant:

| Form     | Example                | Meaning                            |
| -------- | ---------------------- | ---------------------------------- |
| RFC 3339 | `2026-09-01T00:00:00Z` | After that instant, any offset     |
| Duration | `25h`, `7d`, `90m`     | That long before now, on our clock |

A duration is a number followed by `m`, `h`, or `d`. `d` is exactly 24
hours, not a calendar day, and cannot be combined with other units;
`1h30m` is accepted, `1d12h` is not. Durations save a daily job from
keeping a watermark: `since=25h` covers the last day with an hour of
overlap, every time, with nothing to compute.

### Responses

List endpoints return the matching rows and a count.

```json
{
  "data": [],
  "total": 0
}
```

There is no paging. Responses are capped at 1000 rows, which covers any
normal account in one request. `total` is the number of rows that matched,
so if it ever exceeds the number returned you have hit the cap and hold the
first 1000 in list order; contact support if that happens.

### Rate limit

Two requests per second sustained per account, with bursts of up to 20.
The limit is per account, so every integration using the key shares it.
Beyond that the API returns 429 with a `Retry-After` header. A daily sync
uses a handful of requests; the limit exists to stop a runaway loop, not to
constrain normal use.

### Errors

Errors use HTTP status codes and a single body shape.

```json
{
  "error": {
    "code": "plan_required",
    "message": "The Account API requires the Edge plan."
  }
}
```

| Status | Code                 | Meaning                                 |
| ------ | -------------------- | --------------------------------------- |
| 400    | `invalid_request`    | A query parameter was malformed         |
| 401    | `unauthorized`       | Key missing, unknown, or cleared        |
| 403    | `account_inactive`   | Account suspended, canceled, or deleted |
| 403    | `plan_required`      | Account is not on the Edge plan         |
| 404    | `not_found`          | No such endpoint, device, or not a `GET`|
| 429    | `rate_limited`       | Too many requests, see `Retry-After`    |
| 500    | `internal`           | Server error, safe to retry             |

The API does not send CORS headers. Call it from a server, not a browser.

## GET /account

Confirms the key works and identifies the account it belongs to.

**Response:**

```json
{
  "id": "acmecorp",
  "name": "Acme Corporation",
  "plan": "edge",
  "created": "2024-03-11T09:14:02Z"
}
```

| Field     | Type   | Notes                     |
| --------- | ------ | ------------------------- |
| `id`      | string | Account identifier        |
| `name`    | string | Account name              |
| `plan`    | string | Always `edge` on this API |
| `created` | string | Account creation time     |

## GET /users

Users in the account.

**Response:**

```json
{
  "data": [
    {
      "id": "jsmith.acmecorp",
      "username": "jsmith",
      "email": "jsmith@acme.example",
      "name": "J. Smith",
      "role": "owner",
      "created": "2024-03-11T09:14:02Z"
    }
  ],
  "total": 4
}
```

| Field      | Type   | Notes                                    |
| ---------- | ------ | ---------------------------------------- |
| `id`       | string | `username.account`, stable               |
| `username` | string | Login name, unique in the account        |
| `email`    | string | Contact address, can change or be empty  |
| `name`     | string | Display name                             |
| `role`     | string | `owner`, `admin`, `editor`, or `user`    |
| `created`  | string | When the user was added                  |

Each account has exactly one `owner`. Use `username` to join a device to
its user, and `id` as a durable key.

## GET /devices

Devices registered in the account. Each record embeds its virtual devices
and port forwards, so one pass over the list is a complete picture.

**Query parameters:**

| Parameter | Values                | Effect                             |
| --------- | --------------------- | ---------------------------------- |
| `since`   | time, see Conventions | Only devices registered after this |

**Response:**

```json
{
  "data": [
    {
      "id": "8f14e45f-ceea-467a-9f0a-1c2d3e4f5a6b",
      "name": "host0001",
      "dnsName": "host0001.acmecorp.2ho.ca",
      "type": "edge",
      "description": "Site gateway, warehouse",
      "platform": "linux arm64",
      "version": "1.3.2",
      "build": "260905.1121",
      "online": true,
      "lastSeen": "2026-09-04T14:21:58Z",
      "secureIp": "100.96.4.17",
      "lanIp": "192.168.20.10",
      "isp": "Rogers",
      "owner": "jsmith",
      "tags": ["warehouse"],
      "created": "2025-11-02T16:40:11Z",
      "virtualDevices": [
        {
          "name": "nvr",
          "dnsName": "nvr.host0001.acmecorp.2ho.ca",
          "description": "Site NVR",
          "secureIp": "100.96.4.31",
          "lanIp": "192.168.20.40",
          "created": "2025-11-04T10:02:44Z"
        }
      ],
      "portForwards": [
        {
          "name": "camera-lobby",
          "hostName": "cam-lobby",
          "vendor": "Axis",
          "ip": "192.168.20.51",
          "port": 8080,
          "protocol": "tcp",
          "service": "http",
          "enabled": true,
          "updated": "2026-09-01T03:12:00Z"
        }
      ]
    }
  ],
  "total": 137
}
```

### Device fields

| Field            | Type    | Notes                                     |
| ---------------- | ------- | ----------------------------------------- |
| `id`             | string  | Device GUID, see below                    |
| `name`           | string  | Short DNS label, lowercase                |
| `dnsName`        | string  | Full name on the Netrinos network         |
| `type`           | string  | `edge` or `client`                        |
| `description`    | string  | Free text set in the portal               |
| `platform`       | string  | OS and architecture, see below            |
| `version`        | string  | Netrinos client version                   |
| `build`          | string  | Client build stamp, `YYMMDD.HHMM`         |
| `online`         | boolean | See Device online state                   |
| `lastSeen`       | string  | Last contact, `null` if never             |
| `secureIp`       | string  | Address on the Netrinos network           |
| `lanIp`          | string  | Address on its local network, if reported |
| `isp`            | string  | Internet provider short name, see below   |
| `owner`          | string  | Username of the owning user               |
| `tags`           | array   | Strings, empty if none                    |
| `created`        | string  | When the device registered                |
| `virtualDevices` | array   | See below, empty if none                  |
| `portForwards`   | array   | See below, empty if none                  |

`platform` is reported by the device as operating system and architecture
separated by a space, for example `linux arm64`, `linux amd64`,
`windows amd64`, `darwin arm64`, `android arm64`, or `ios arm64`. Treat it
as free text; new values can appear as platforms are added.

`dnsName` is empty for a moment after registration, before the device is
placed in a network. Every other string field that can be empty says so in
its notes.

`isp` is the short name of the provider behind the device's public address
at its last sync, for example `Rogers`, `Bell`, or `TELUS`. It is
looked up from the address, so it identifies the connection the device is
on rather than the account holder's contract. It is empty when the device
has not synced since registration or the address is not attributed to a
provider.

Use `id` as the foreign key in your system. It survives renames, and
`name` and `dnsName` do not. It also survives deletion: a device deleted in
the portal is logged out, and if someone logs it in again locally it
registers with the same `id`. See Detecting additions and removals.

### Virtual device fields

A virtual device is a LAN device published on the Netrinos network under
its own address.

| Field         | Type   | Notes                                     |
| ------------- | ------ | ----------------------------------------- |
| `name`        | string | DNS label on the Netrinos network         |
| `dnsName`     | string | Full name, `name.<device dnsName>`        |
| `description` | string | Free text                                 |
| `secureIp`    | string | Address on the Netrinos network           |
| `lanIp`       | string | LAN address it maps to, see below         |
| `created`     | string | When the virtual device was allocated     |

`lanIp` is reported by the hosting device on each sync from client 1.3.2
onward. It is empty for a device on an older client or one that has not
synced since the virtual device was added.

### Port forward fields

Port forwards are reported by EdgeNodes from the LAN they sit on. They are
observed data, refreshed on each device sync, and are not present on client
devices.

| Field      | Type    | Notes                              |
| ---------- | ------- | ---------------------------------- |
| `name`     | string  | Rule name as reported              |
| `hostName` | string  | Host the rule points at            |
| `vendor`   | string  | Vendor inferred from the hardware  |
| `ip`       | string  | LAN address of the target          |
| `port`     | number  | Forwarded port                     |
| `protocol` | string  | `tcp` or `udp`                     |
| `service`  | string  | Service inferred from the port     |
| `enabled`  | boolean | Whether the rule is active         |
| `updated`  | string  | Last change to the rule, or `null` |

## GET /devices/{id}

One device by its `id`, exactly as returned in the list, in the same shape
as a list entry and not wrapped in an envelope.

Returns 404 if the id is unknown, belongs to another account, or names a
device that has been removed. The three cases are not distinguished.

## GET /devices/events

What changed, newest first: devices added and removed, and devices going
online and offline. One feed, one shape.

**Query parameters:**

| Parameter | Values                | Effect                              |
| --------- | --------------------- | ----------------------------------- |
| `since`   | time, see Conventions | Only events after this              |
| `type`    | comma list, see below | Only these events; default is all   |

An unknown `type` name is a 400, not an empty list. Names are matched
without regard to case.

**Response:**

```json
{
  "data": [
    {
      "id": "8f14e45f-ceea-467a-9f0a-1c2d3e4f5a6b",
      "name": "host0001",
      "event": "offline",
      "at": "2026-09-04T02:14:09Z"
    },
    {
      "id": "1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed",
      "name": "oldnvr",
      "event": "removed",
      "at": "2026-09-03T11:07:20Z"
    }
  ],
  "total": 2
}
```

| Field   | Type   | Notes                                       |
| ------- | ------ | ------------------------------------------- |
| `id`    | string | Device id; for `removed`, the id it had     |
| `name`  | string | Name at the time                            |
| `event` | string | `added`, `removed`, `online`, or `offline`  |
| `at`    | string | When it happened                            |

| Event     | Fires when                                 |
| --------- | ------------------------------------------ |
| `added`   | A device logs in and registers             |
| `removed` | A device is deleted in the portal          |
| `online`  | A device makes contact after being offline |
| `offline` | A device has been silent for 15 minutes    |

`added` and `removed` cover every device. `online` and `offline` cover
EdgeNodes unless tagged `noalert` in the portal, and client devices only
when tagged `alert`.

Events are kept for **at least 7 days**; the purge runs a few times a day,
so rows can linger a few hours past that. They are for keeping up, not for
history; copy what you need. `removed` rows carry the identity the device
had when it went, so they still name it after it is gone. An `offline` row
means the device had been silent for the threshold described under Device
online state, not that it failed at that instant.

## GET /devices/{id}/events

The same feed narrowed to one device, in the same shape, with the same
`since` and `type` parameters and the same retention. A device that was
deleted and logged in again shows its whole history under the one `id`,
including the earlier `removed`.

Returns 404 for an id that is unknown, belongs to another account, or has
been removed, exactly as `GET /devices/{id}` does.

## Detecting additions and removals

Call `GET /devices/events?since=25h&type=added,removed` once a day. Each
`added` row names a device to create; fetch it with `GET /devices/{id}`
for the full record. Each `removed` row names a device to retire. The 25
hours give an hour of overlap, so nothing is missed if the job runs late,
and re-applying a change you already applied should be a no-op on your
side.

Deleting a device in the portal logs it out. If someone then logs it in
again on the device itself, it registers under the same `id` and an `added`
row follows the `removed` one. So treat `removed` as deactivate rather than
delete, treat `added` for an `id` you already hold as reactivate, and when
one `id` has both rows in a window apply them in `at` order, oldest first;
the feed itself is newest first.

Once a day, also pull `GET /devices` in full and compare it against what
you hold, keyed on `id`. The event feed is what keeps you current; the
full list is what makes you certain. A diff cannot miss anything, and it
is one request.

`GET /devices?since=25h` is the same idea for additions only, and
returns full device records rather than event rows.

## Device online state

`online` is true when the device's Netrinos agent has reached Netrinos
within the last 15 minutes. How it connects does not matter: a device on a
direct path and one going through a relay report the same way.

Detection is asymmetric by design. Going offline takes at least 15 minutes
of silence, because two to five minute outages are routine on cellular and
carrier-grade NAT links and a shorter window would report working sites as
down. Coming back online is immediate on the next contact. Each response is
a snapshot rather than a history, so a device that failed and recovered
between two polls reads as online in both. `GET /devices/events` has the
transitions a poll cannot show you, for at least the last 7 days.

A device that comes up may start on a relayed path and settle to a direct
one moments later. That transition is invisible here. `online` measures
whether the device reaches Netrinos, not how, so settling changes nothing
about the flag and produces no transition in the history.

### Scope

`online` reports that the network path to the device is up. Netrinos has no
visibility above that layer, so it says nothing about the health of the
device or of anything attached to it.

A gateway pegged at 100 percent CPU still answers Netrinos, because the
agent needs almost nothing to reply. The device reads as online while the
cameras behind it are timing out. A full disk, a stopped service, and a
dead camera all look identical from here. The reverse case is just as real:
a transit outage marks a device offline while every piece of equipment at
the site is working.

So use each source for what it can observe. Netrinos is authoritative for
whether a site is reachable, which is the one failure equipment inside the
site cannot report, because a dark site has nothing left to send with.
Monitor cameras and services from the video management system, inside the
network, where they can actually be seen.

## Recommended integration

Once a day is the right cadence for an inventory. Nothing in the device
record changes faster than that except `online`, and the events feed keeps
a week, so a daily job never misses anything. The whole run is two or
three requests, with no state to keep between runs beyond your own records.

1. **Changes.** `GET /devices/events?since=25h&type=added,removed`.
   Create or reactivate a record for each `added` id, fetching
   `GET /devices/{id}` for the details. Deactivate the record for each
   `removed` id.

    ```bash
    curl -H "Authorization: Bearer $NETRINOS_KEY" \
      "https://app.netrinos.com/api/v1/devices/events?since=25h&type=added,removed"
    ```

2. **Reconcile.** `GET /devices`. Compare by `id` against your records and
   correct any drift. Refresh `online`, `version`, `build`, and the other
   fields while you are there.

3. **Owners**, if you track them. `GET /users`.

If you also want outage visibility from this API, read `online` from the
device list or poll `GET /devices/events?type=offline,online` with a
`since` matching your interval. Every 15 minutes is as fast as is useful,
because that is the detection window. Monitoring inside the site remains
the better source for equipment health; see Scope.

Notes:

- Key on `id`. `name` and `dnsName` change when a device is renamed, and a
  rename is not a create plus a delete. A delete followed by a local login
  keeps the `id`; see Detecting additions and removals.
- Treat an unknown value in `platform`, `type`, `role`, or `event` as
  unhandled rather than invalid. New values can appear without a version
  change.
- On a 429, wait for `Retry-After`. On a 500, retry with backoff. Do not
  retry a 4xx without changing the request.

If your integration needs a field the API does not return, raise it with
support rather than working around it.
