Open-source Node.js client library for the ACS Monitor REST API — the network and infrastructure monitoring platform from Anglia Computer Solutions.
Zero-dependency Node.js client using the built-in fetch from Node 18+.
ESM module, drop-in for any modern Node project (TypeScript or
JavaScript).
Licensing: this Node.js client is open source under the MIT licence. The ACS Monitor application it talks to is licensed commercial software with a free 100-device tier — see acsmon.com for tiers and pricing.
- Requirements
- Installation
- Quick start
- Configuration
- Authentication
- Method reference
- Returned data shapes
- Pagination & async iterators
- Filtering, sorting, searching
- Working with monitors
- Working with alerts
- Error handling
- Custom fetch options
- Token storage best practice
- TypeScript usage
- Recipes
- Troubleshooting
| Requirement | Minimum | Notes |
|---|---|---|
| Node.js | 18.0 | Uses native fetch and AbortSignal.timeout |
| Module system | ESM | The client is shipped as .mjs. CommonJS users can import() it dynamically. |
| Network | Outbound to ACSMON_BASE_URL |
Whatever host runs your ACS Monitor install |
No npm dependencies. No build step. No transpiler.
There is nothing to publish to npm — this client is a single file.
Option A — copy the file into your project:
cp api-clients/nodejs/src/client.mjs path/to/your-app/lib/acsmon.mjsOption B — git submodule (so you can git pull future updates):
git submodule add https://github.com/jonth93/acsmon-nodejs-client.git vendor/acsmon
import { AcsMonitorClient } from './vendor/acsmon/src/client.mjs';Option C — run the examples directly from this folder:
cd api-clients/nodejs
node examples/list-devices.mjscd api-clients/nodejs
export ACSMON_BASE_URL=https://monitoring.example.com
export [email protected]
export ACSMON_PASSWORD='your-strong-password'
node examples/list-devices.mjs
node examples/poll-device.mjs 42If ACSMON_TOKEN is set, the example scripts skip the login step.
import { AcsMonitorClient } from './src/client.mjs';
const api = new AcsMonitorClient(
'https://monitoring.example.com', // base URL (no /api/v1 suffix needed)
process.env.ACSMON_TOKEN ?? null, // optional pre-existing bearer token
{ timeoutMs: 30000 }, // optional per-request timeout
);| Constructor arg | Type | Default | Purpose |
|---|---|---|---|
baseUrl |
string |
required | Root URL of your ACS Monitor install. The client appends /api/v1. Trailing slashes stripped. |
token |
string | null |
null |
Long-lived bearer token. Skip if you intend to call login(). |
options.timeoutMs |
number |
30000 |
Aborts each request after N ms via AbortSignal.timeout. |
The client never persists state to disk — the bearer token only lives
inside the AcsMonitorClient instance.
The API uses bearer token authentication. There are two ways to get one:
1. Trade email + password for a token:
const api = new AcsMonitorClient('https://monitoring.example.com');
const token = await api.login('[email protected]', 'secret');
// `api` will now send Authorization: Bearer <token> on every call.
// `token` is also returned so you can persist it.2. Reuse a token you've already obtained (preferred for daemons, cron jobs, serverless functions):
const api = new AcsMonitorClient('https://monitoring.example.com', process.env.ACSMON_TOKEN);
// no login() call needed — the token is sent immediately.To revoke the token server-side:
await api.logout(); // POST /auth/logout, then clears the in-memory tokenFor long-running integrations, create a dedicated API user in the
ACS Monitor UI (Settings → Users) with the minimum role needed
(viewer for read-only, operator for ack/resolve, admin for full
write access). Tokens live until logout or user deletion.
Methods that return an array of rows are async iterators —
for await (...) {} walks every page automatically. Single-resource
methods return a Promise<object>.
| Method | HTTP | Returns |
|---|---|---|
login(email, password) |
POST /auth/login |
Promise<string> (token) |
logout() |
POST /auth/logout |
Promise<void> |
| Method | HTTP | Returns |
|---|---|---|
devices(query?) |
GET /devices |
async iterator |
device(id) |
GET /devices/{id} |
Promise<Device> |
pollDevice(id) |
POST /devices/{id}/poll |
Promise<{queued, job_id}> |
deviceMetrics(id, query?) |
GET /devices/{id}/metrics |
Promise<Metrics> |
deviceAlerts(id, query?) |
GET /devices/{id}/alerts |
Promise<Page<AlertEvent>> |
| Method | HTTP | Returns |
|---|---|---|
monitors(query?) |
GET /monitors |
async iterator |
monitor(id) |
GET /monitors/{id} |
Promise<Monitor> |
createMonitor(payload) |
POST /monitors |
Promise<Monitor> |
checkMonitor(id) |
POST /monitors/{id}/check |
Promise<{queued, job_id}> |
monitorResults(id, query?) |
GET /monitors/{id}/results |
Promise<Page<Result>> |
monitorUptime(id, query?) |
GET /monitors/{id}/uptime |
Promise<UptimeStats> |
| Method | HTTP | Returns |
|---|---|---|
alertEvents(query?) |
GET /alert-events |
async iterator |
acknowledgeAlertEvent(id, note?) |
POST /alert-events/{id}/acknowledge |
Promise<AlertEvent> |
resolveAlertEvent(id, note?) |
POST /alert-events/{id}/resolve |
Promise<AlertEvent> |
| Method | HTTP | Returns |
|---|---|---|
systemHealth() |
GET /system/health |
Promise<HealthReport> |
Every list response follows the same envelope:
The client's async iterators consume this envelope and yield
individual rows from data, then follow next_page_url until
exhausted.
A typical Device object:
{
"id": 42,
"name": "core-switch-01",
"ip_address": "10.0.0.1",
"snmp_version": "2c",
"device_type": "cisco_ios",
"status": "up", // up | down | unknown
"last_polled_at": "2026-04-22T10:14:33Z",
"poll_interval_seconds": 60,
"is_active": true,
"device_group": { "id": 3, "name": "Core network", "color": "#3b82f6" }
}A typical Monitor object:
{
"id": 117,
"name": "Public website",
"type": "https",
"host": "example.com",
"port": 443,
"status": "up", // up | down | degraded | unknown
"response_time_ms": 142,
"ssl_expiry_at": "2026-09-14T00:00:00Z",
"uptime_percent_7d": 99.97,
"uptime_percent_30d": 99.81,
"consecutive_failures": 0,
"last_checked_at": "2026-04-22T10:15:01Z",
"config": { "method": "GET", "path": "/", "expected_status": 200, "ssl_verify": true }
}A typical AlertEvent:
{
"id": 9821,
"severity": "critical", // info | warning | critical
"status": "open", // open | acknowledged | resolved
"triggered_at": "2026-04-22T09:55:12Z",
"acknowledged_at": null,
"resolved_at": null,
"current_value": "down",
"monitor": { "id": 117, "name": "Public website", "type": "https" },
"device": null
}The two-line loop:
for await (const device of api.devices()) {
console.log(device.id, device.name);
}…is equivalent to:
let page = 1;
while (true) {
const res = await fetch(`${baseUrl}/api/v1/devices?page=${page}`, ...);
for (const d of res.data) console.log(d.id, d.name);
if (!res.next_page_url) break;
page++;
}You can pass any query string to the iterator — page, per_page,
search, sort, and filter[*] are all forwarded:
for await (const m of api.monitors({ per_page: 100, sort: '-response_time_ms' })) {
if (m.response_time_ms > 1000) console.warn(`${m.name} is slow: ${m.response_time_ms}ms`);
}To collect into an array instead of streaming:
const downDevices = [];
for await (const d of api.devices({ 'filter[status]': 'down' })) {
downDevices.push(d);
}| Query param | Example | Effect |
|---|---|---|
search |
search=core |
Free-text search across name/host fields |
filter[<field>] |
filter[status]=down |
Exact-match filter (chainable) |
sort |
sort=-last_polled_at |
Sort column. Prefix - for descending. |
per_page |
per_page=100 |
Page size (cap depends on endpoint) |
page |
page=3 |
Start page (use the iterator instead unless paging manually) |
Combine freely:
for await (const ev of api.alertEvents({
'filter[severity]': 'critical',
'filter[status]': 'open',
'sort': '-triggered_at',
'per_page': 50,
})) {
handle(ev);
}Create:
const monitor = await api.createMonitor({
name: 'Login API health',
type: 'https',
host: 'api.example.com',
port: 443,
check_interval_seconds: 60,
timeout_ms: 5000,
config: {
method: 'GET',
path: '/health',
expected_status: 200,
expected_body_regex: '"ok":true',
ssl_verify: true,
alert_ssl_expiry_days: 14,
},
});
console.log(`Created monitor ${monitor.id}`);Other supported type values: ping, tcp, http, https, ssh,
smtp, ftp, pop3, imap, dns, mysql, redis, snmp. Each
type has its own config schema — see the main project README.
Force an immediate re-check:
await api.checkMonitor(monitor.id); // returns { queued: true, job_id: '...' }The check runs asynchronously on the probe service. Poll
monitor.status or monitorResults(id) to see the outcome.
Read the last N results:
const recent = await api.monitorResults(monitor.id, { per_page: 20 });
for (const r of recent.data) {
console.log(r.checked_at, r.status, `${r.response_time_ms} ms`);
}Uptime stats for SLA reporting:
const uptime = await api.monitorUptime(monitor.id, { window: '30d' });
// { uptime_percent: 99.91, total_checks: 43200, failed_checks: 39, ... }// Stream every open critical alert
for await (const ev of api.alertEvents({
'filter[status]': 'open',
'filter[severity]': 'critical',
})) {
await api.acknowledgeAlertEvent(ev.id, `Auto-ack from ${process.env.HOSTNAME}`);
}
// Mark resolved when your downstream system resolves it
await api.resolveAlertEvent(ev.id, 'Closed by INC-9421');note is optional on both ack and resolve — pass a short string and it
shows up in the audit log for that event.
Any non-2xx response throws AcsMonApiError with the HTTP status and
parsed body attached. Network errors (DNS failure, TLS failure,
timeout) throw with status = 0.
import { AcsMonApiError } from './src/client.mjs';
try {
await api.pollDevice(99999);
} catch (err) {
if (err instanceof AcsMonApiError) {
console.error(`HTTP ${err.status}:`, err.body);
if (err.status === 404) return; // gone, skip
if (err.status === 403) throw new Error('Token lacks devices.edit permission');
if (err.status === 0) console.warn('Network problem, will retry');
}
throw err;
}| Status | Meaning | Recovery |
|---|---|---|
401 |
Token missing/expired | Call login() again or refresh the secret |
403 |
Authenticated but lacks the permission slug for that route | Grant the role via the UI |
404 |
Resource doesn't exist (or wrong ID) | Check the ID, no retry |
422 |
Validation error — see err.body.errors |
Fix the payload |
429 |
Rate-limited | Back off (the server returns Retry-After) |
500+ |
Server bug or upstream outage | Retry with exponential backoff |
0 |
Network/DNS/TLS/timeout | Retry with exponential backoff |
A simple retry helper:
async function withRetry(fn, { attempts = 4, baseMs = 500 } = {}) {
for (let i = 0; i < attempts; i++) {
try { return await fn(); }
catch (err) {
const retriable = err instanceof AcsMonApiError
&& (err.status === 0 || err.status === 429 || err.status >= 500);
if (!retriable || i === attempts - 1) throw err;
await new Promise(r => setTimeout(r, baseMs * 2 ** i));
}
}
}
await withRetry(() => api.pollDevice(42));The client uses native fetch. To inject custom behaviour (proxy,
mTLS, custom CA, custom headers) wrap or subclass AcsMonitorClient,
or pre-set globals via undici:
import { setGlobalDispatcher, ProxyAgent } from 'undici';
setGlobalDispatcher(new ProxyAgent('http://corp-proxy:3128'));
const api = new AcsMonitorClient('https://monitoring.example.com');To accept a self-signed cert in a lab environment (don't do this in production):
import { setGlobalDispatcher, Agent } from 'undici';
setGlobalDispatcher(new Agent({ connect: { rejectUnauthorized: false } }));To override the request timeout per call, construct a second client:
const slowOps = new AcsMonitorClient(baseUrl, token, { timeoutMs: 120000 });
await slowOps.pollDevice(42); // poll can take a while on big devices- Server-side daemons: read
ACSMON_TOKENfrom environment, never commit it. Store in your secrets manager (HashiCorp Vault, AWS Secrets Manager, Doppler, Infisical). - CLI tools: read from
~/.config/acsmon/tokenwith0600permissions, falling back toprocess.env.ACSMON_TOKEN. - Serverless / edge: inject via the platform's secret store (Cloudflare secrets, Vercel env vars, AWS Lambda env).
- Never put the token into a public Git repo, browser-side bundle, or client-side JS — it grants the same access as the user account it was issued to.
The client is plain ESM JavaScript with JSDoc, so TypeScript projects
can import it directly with allowJs enabled, or you can declare
types:
declare module './lib/acsmon.mjs' {
export class AcsMonitorClient {
constructor(baseUrl: string, token?: string | null, opts?: { timeoutMs?: number });
login(email: string, password: string): Promise<string>;
logout(): Promise<void>;
devices(query?: Record<string, string | number>): AsyncIterableIterator<any>;
device(id: number): Promise<any>;
pollDevice(id: number): Promise<any>;
// … and so on
}
export class AcsMonApiError extends Error {
status: number;
body: any;
}
}For a fully typed API surface you can also generate one from your ACS Monitor's OpenAPI export (Settings → API → OpenAPI spec).
import { AcsMonitorClient } from './src/client.mjs';
const api = new AcsMonitorClient(process.env.ACSMON_BASE_URL, process.env.ACSMON_TOKEN);
const upserts = [];
for await (const d of api.devices()) {
upserts.push({
external_id: `acsmon-${d.id}`,
hostname: d.name,
ip: d.ip_address,
status: d.status,
last_seen: d.last_polled_at,
});
}
await myCmdb.bulkUpsert(upserts);
console.log(`Synced ${upserts.length} devices`);import { AcsMonitorClient } from './src/client.mjs';
const api = new AcsMonitorClient(process.env.ACSMON_BASE_URL, process.env.ACSMON_TOKEN);
for await (const ev of api.alertEvents({
'filter[status]': 'open',
'filter[severity]': 'critical',
})) {
await fetch('https://events.pagerduty.com/v2/enqueue', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
routing_key: process.env.PAGERDUTY_KEY,
event_action: 'trigger',
dedup_key: `acsmon-${ev.id}`,
payload: {
summary: `${ev.monitor?.name ?? ev.device?.name}: ${ev.current_value}`,
severity: 'critical',
source: 'ACS Monitor',
custom_details: ev,
},
}),
});
await api.acknowledgeAlertEvent(ev.id, 'Forwarded to PagerDuty');
}// fail the pipeline if the production health monitor isn't green within 5 min
import { AcsMonitorClient } from './src/client.mjs';
const api = new AcsMonitorClient(process.env.ACSMON_BASE_URL, process.env.ACSMON_TOKEN);
const monitorId = Number(process.env.HEALTH_MONITOR_ID);
await api.checkMonitor(monitorId);
const deadline = Date.now() + 5 * 60 * 1000;
while (Date.now() < deadline) {
const m = await api.monitor(monitorId);
if (m.status === 'up') {
console.log(`✓ ${m.name} is up (${m.response_time_ms}ms)`);
process.exit(0);
}
await new Promise(r => setTimeout(r, 10_000));
}
console.error('Health monitor did not return to "up" within 5 minutes');
process.exit(1);import { AcsMonitorClient } from './src/client.mjs';
const api = new AcsMonitorClient(process.env.ACSMON_BASE_URL, process.env.ACSMON_TOKEN);
const rows = [];
for await (const m of api.monitors()) {
const u = await api.monitorUptime(m.id, { window: '7d' });
rows.push({ name: m.name, uptime_pct: u.uptime_percent, failed: u.failed_checks });
}
rows.sort((a, b) => a.uptime_pct - b.uptime_pct);
console.table(rows);AcsMonApiError ... → HTTP 401 — The token is missing, expired,
or belongs to a deleted user. Re-issue it from Settings → Users.
AcsMonApiError ... → HTTP 403 — Authentication worked but the
user/role doesn't carry the permission slug for that endpoint. The
endpoint table in the project's main CLAUDE.md lists each route's
required permission slug.
AbortError: This operation was aborted — Hit the per-request
timeout (default 30s). Construct the client with a higher
timeoutMs, or check whether the API server is overloaded.
fetch failed / ENOTFOUND / ECONNREFUSED — Network or DNS
problem reaching ACSMON_BASE_URL. Verify the URL with
curl -v "$ACSMON_BASE_URL/api/v1/system/health".
SELF_SIGNED_CERT_IN_CHAIN — Your install uses a self-signed TLS
cert. Either install a real cert, add the CA to Node's trust store
(NODE_EXTRA_CA_CERTS=/path/to/ca.pem), or use the undici override
shown above (lab only).
Iterator yields nothing — The query returned no data. Check the
filter values and confirm the user has *.view on that resource.
SyntaxError: Cannot use import statement outside a module — Your
project is CommonJS. Either rename the consumer to .mjs, set
"type": "module" in package.json, or use dynamic import:
const { AcsMonitorClient } = await import('./client.mjs');.
© Anglia Computer Solutions Ltd. — ACS Monitor is a product of Anglia Computer Solutions. Visit acsmon.com for documentation, demos and licensing.
{ "data": [ /* rows */ ], "links": { "first": "https://.../api/v1/devices?page=1", "last": "https://.../api/v1/devices?page=12", "prev": null, "next": "https://.../api/v1/devices?page=2" }, "meta": { "current_page": 1, "from": 1, "to": 50, "per_page": 50, "total": 593 }, "next_page_url": "https://.../api/v1/devices?page=2" }