Managed IT · September 16, 2026 · intSignal Team

ConnectWise Automate API: Customer Device Health Without Cross-Client Leaks

Share this article

We wanted our clients to see the health of their own machines — online status, disk space, antivirus, patches — read live from ConnectWise Automate. The Automate REST API makes the reading part straightforward.

The hard part was something else entirely. The credential our integration uses can see every client we manage. Build the obvious version of this feature and any customer can read another customer's devices by changing a number in a URL.

This is how we built it, the quirks we hit against a production Automate server with a few hundred machines, and exactly how the code below was tested.

Why is a customer portal on the Automate API a cross-client risk?

Your integration authenticates as an Automate user, and a typical MSP integration account can see all of your clients. So GET /computers/{id} returns any client's machine for a valid ID. If your portal passes a requested device ID straight through, a customer can read other customers' devices just by changing that ID.

This is a classic insecure direct object reference, and nothing on the Automate side prevents it, because from Automate's point of view the request is authorized: your integration user is allowed to see that machine. The customer isn't. That distinction exists only in your code.

Authenticating and scoping requests

The API issues a bearer token from /cwa/api/v1/apitoken. Three details matter.

  • Send a ClientId header. This identifies your integration, and ConnectWise has started enforcing it. Send it on the token request and on every API call.
  • Tokens expire. The response includes an ExpirationDate, and tokens grant up to about an hour of access. Cache the token and renew it shortly before it expires, rather than logging in on every request.
  • Two-factor accounts need a passcode. The widely used community AutomateAPI PowerShell module sends a TwoFactorPasscode field and reads IsTwoFactorRequired from the response. Our integration uses an account without 2FA, so we haven't exercised that path ourselves.

To list one client's machines, filter with condition=Client.Id=N and page through with page and pagesize:

// Minimal ConnectWise Automate REST client (Node 18+, uses global fetch).
const BASE = process.env.CWA_BASE_URL; // e.g. https://rmm.example.com
const CLIENT_ID = process.env.CWA_CLIENT_ID; // integrator ClientId
let token = null;

async function getToken() {
  if (token && Date.now() < token.expiresAt) return token.value;
  const res = await fetch(`${BASE}/cwa/api/v1/apitoken`, {
    method: "POST",
    headers: { "Content-Type": "application/json", ClientId: CLIENT_ID },
    body: JSON.stringify({
      UserName: process.env.CWA_USERNAME,
      Password: process.env.CWA_PASSWORD,
    }),
  });
  if (!res.ok) throw new Error(`Automate auth failed: ${res.status}`);
  const data = await res.json();
  if (!data.AccessToken) throw new Error("Automate returned no AccessToken");
  // Renew a minute early; assume 50 minutes if no expiry comes back.
  const expiresAt = data.ExpirationDate
    ? new Date(data.ExpirationDate).getTime() - 60_000
    : Date.now() + 50 * 60_000;
  token = { value: data.AccessToken, expiresAt };
  return token.value;
}

async function cwa(path, params = {}) {
  const url = new URL(`${BASE}/cwa/api/v1${path}`);
  for (const [k, v] of Object.entries(params)) {
    url.searchParams.set(k, String(v));
  }
  const res = await fetch(url, {
    headers: {
      Authorization: `Bearer ${await getToken()}`,
      ClientId: CLIENT_ID,
      Accept: "application/json",
    },
  });
  if (!res.ok) throw new Error(`Automate ${path} failed: ${res.status}`);
  return res.json();
}

// Accept only a plain integer id. Rejecting beats stripping characters,
// which can quietly turn junk like "7 or 1=1" into a different real id.
const asId = (v) => {
  const s = String(v ?? "").trim();
  return /^\d+$/.test(s) ? s : "";
};

export async function computersForClient(automateClientId) {
  const clientId = asId(automateClientId);
  if (!clientId) throw new Error("expected a numeric Automate client id");
  const pageSize = 1000;
  const all = [];
  for (let page = 1; page <= 100; page++) {
    const batch = await cwa("/computers", {
      condition: `Client.Id=${clientId}`,
      page,
      pagesize: pageSize,
    });
    all.push(...batch);
    // Stops on a short page, so the server must honour pagesize.
    if (batch.length < pageSize) break;
  }
  return all;
}

Two things in that loop deserve attention.

It stops on a short page, so your server must honour pagesize. If a server ignored pagesize and returned a smaller default page, the loop would stop after the first page and silently return a partial list. The client we run this against has well under 1,000 machines, so in production we've only exercised the single-page case. Check the length of a real page before relying on the loop.

It never builds a condition from anything a user typed. The client ID comes from our own mapping of customer to Automate client, and even then the function accepts only a plain integer. Rejecting junk is safer than stripping characters out of it: stripping would turn 7 or 1=1 into 711, a different, valid-looking client ID.

The gate: check ownership twice

We check that a device belongs to the signed-in customer before calling Automate, against our own synced inventory for that customer. Then we check again after, against the Client.Id on the record Automate returns. The first check depends on your own data store, so the code below shows the second one:

// customerClientId: the signed-in customer's Automate client id, read from
// YOUR database. deviceId: whatever the browser asked for. Never trust it.
export async function getDeviceForCustomer(customerClientId, deviceId) {
  const id = asId(deviceId);
  if (!id) return null;
  let computer;
  try {
    computer = await cwa(`/computers/${id}`);
  } catch {
    return null;
  }
  // The API token can see every client, so the id alone proves nothing.
  // Fail closed: a record with no client is treated as not found.
  const owner = computer?.Client?.Id;
  if (owner == null || String(owner) !== String(customerClientId)) return null;
  return computer;
}

The second check is defence in depth. It means a bug in inventory sync, a stale mapping, or an ID collision can't leak a machine, because the final decision is made on the data Automate actually returned. The version above also fails closed: a record with no client is treated as not found, never as allowed.

Two more habits that follow from the same principle:

  • Scope the integration user as narrowly as your Automate permissions allow — but don't make that your only control. The ownership check in your code is the one you can test.
  • Return "not found" for devices a customer doesn't own, not "forbidden." A different response for other clients' valid IDs tells an attacker which IDs are real.

Quirks we hit with real data

These came from running against a production Automate server, not from documentation.

  • LoggedInUsers came back as an array of objects, each with a LoggedInUserName, not as a string. Code that expects a username string needs to map over the array first. We also accept a plain string defensively, but the array is what we actually received.
  • Some volumes come back with a size of zero — typically card readers and empty optical drives. Left in, they clutter the drive list, and depending on how free space is reported for them, they can drag a "lowest free space" check down.
  • Dates can come back as 0001-01-01. We treat that as a "no date recorded" sentinel for an agent's enrollment date. Without handling it, the date renders as the year 1.
  • The list endpoint returns less than the detail endpoint. /computers gave us fewer health fields than /computers/{id}. We build the overview from the list, then fetch the full record and /computers/{id}/drives only when someone opens a device.
  • There was no remote-session link on the computer record. If you hope to deep-link a device to its ScreenConnect session, the computer record fields we checked didn't carry that identifier, so you'll need another source for it.

One more thing we deliberately don't do is assume a schema for health metrics. Automate versions differ, so inspect a real response from your own server before mapping anything, treat every health field as optional, and compute percentages from ratios so the units don't matter.

These helpers handle the first two quirks and turn raw metrics into a status:

// We received an array of { LoggedInUserName } objects here, not a string.
export function loggedInUser(computer) {
  const v = computer.LoggedInUsers;
  if (Array.isArray(v)) {
    const names = [...new Set(v.map((u) => u?.LoggedInUserName).filter(Boolean))];
    if (names.length) return names.join(", ");
  } else if (typeof v === "string" && v.trim()) {
    return v.trim(); // tolerated defensively
  }
  return null;
}

// Some volumes (typically card readers, empty optical drives) report size 0.
// The size field's name varies by version: inspect one response first.
export function realDrives(drives) {
  return drives.filter((d) => (d.Size ?? d.Total ?? 0) > 0);
}

// h: { online, diskFreePercent, memoryPercent, cpuPercent,
//      antivirusOk, pendingPatches } — any metric may be null (unknown).
export function healthStatus(h) {
  if (!h.online) return { status: "offline", issues: [] };
  const issues = [];
  let status = "healthy";
  const warn = (msg) => {
    issues.push(msg);
    if (status !== "critical") status = "warning";
  };
  const disk = h.diskFreePercent;
  if (disk != null && disk < 5) {
    issues.push(`Disk critically low (${disk}% free)`);
    status = "critical";
  } else if (disk != null && disk < 15) {
    warn(`Low disk space (${disk}% free)`);
  }
  if (h.memoryPercent != null && h.memoryPercent >= 95) {
    warn(`High memory use (${h.memoryPercent}% used)`);
  }
  if (h.cpuPercent != null && h.cpuPercent >= 95) {
    warn(`High CPU (${h.cpuPercent}%)`);
  }
  if (h.antivirusOk === false) warn("Antivirus not reporting healthy");
  if (h.pendingPatches != null && h.pendingPatches >= 10) {
    warn(`${h.pendingPatches} pending patches`);
  }
  const known = [disk, h.memoryPercent, h.cpuPercent, h.antivirusOk, h.pendingPatches]
    .some((v) => v != null);
  if (!known) status = "unknown"; // no data is not the same as healthy
  return { status, issues };
}

Deciding what counts as unhealthy

The thresholds are judgment calls. The principle behind them matters more than the exact numbers: a customer-facing dashboard that shows red for normal behaviour trains people to ignore it.

  • Disk space is the one hard "critical" — under 5% free. Under 15% is a warning. A full disk stops real work, and it rarely fixes itself.
  • High memory and CPU only ever warn, at 95% or more. Whether a reading reflects pressure depends on how it was calculated. If memory use is derived from free rather than available memory, the file cache Windows keeps in RAM counts as used, so high numbers are routine. And a single CPU reading is a snapshot, not proof of sustained load.
  • Antivirus not reporting healthy, and 10 or more pending patches, are warnings.
  • Missing data is "unknown," not "healthy." If no metrics came back, the honest answer is that we don't know. Showing green would be a false all-clear.
  • Offline overrides everything else, and we sort the list worst-first, so problems are at the top rather than buried on page three.

Keeping it fast and resilient

  • Cache briefly. We cache the overview for about a minute and device detail for a little less. A room full of customers refreshing a dashboard shouldn't turn into a load test against your RMM server.
  • Degrade instead of failing. When Automate is unreachable, we fall back to the last synced inventory — name, online status, last seen — and mark it as not live. The page still works; it just doesn't pretend to be current.

We run this as part of managed endpoint and device management, where clients see agent health and patch status for their own machines.

How this code was tested

Being precise about this matters, because it's easy to overstate.

The request shapes — the token call, the ClientId header, condition paging, the detail and drives endpoints — are taken from our integration, which runs against a real Automate server. The code on this page is a simplified, standalone version of that integration. This exact code has not been run against a real Automate server.

Instead we verified it against a local mock of the behaviour described above, including an MSP-wide /computers/{id} that returns any client's machine. That mock checks for the ClientId header and bearer token on every request and honours pagesize. Twenty checks cover paging across three pages, token reuse and renewal, the ownership gate (refusing another client's machine, failing closed on a missing client, rejecting junk IDs), the data quirks, and every status rule.

We also confirmed the tests can fail. Removing the ownership check, stripping characters from IDs instead of rejecting them, and stopping after page one each broke the suite. A test that can't fail proves nothing.

Frequently asked

Does the ConnectWise Automate API require a ClientId?

Yes. ConnectWise has started enforcing a ClientId that identifies your integration. Send it as a header on the /cwa/api/v1/apitoken request and on every subsequent API call.

How do I get all computers for one client from the Automate API?

Call /cwa/api/v1/computers with condition=Client.Id=N, and page through results with page and pagesize until a page comes back shorter than the page size. Make sure your server honours pagesize, or that stopping rule will truncate the list.

Why does LoggedInUsers return objects instead of a username?

On the server we integrated with, LoggedInUsers came back as an array of objects, each with a LoggedInUserName. Map over the array and pull out the names rather than treating the field as a string.

Why do some Automate computers show 0 GB drives?

Some volumes come back with a size of zero, typically card readers and empty optical drives. Filter out zero-size volumes before calculating free space so they don't clutter the list or distort a lowest-free-space check.

Can customers see other clients' machines through the Automate API?

Only if your code lets them. The integration's credential can typically see every client, so the API itself won't stop it. Verify ownership in your own code — before the call and again on the Client.Id of the record that comes back.

Share this article