ArchACS Log in
REST API for integrations
Reference for server-to-server integrations: ERP, call center (URA) and subscriber-app backends.
Jump to an endpoint

The public API (/api/v1) lets your systems look up a device by serial number, PPPoE username or WAN IP, read its current state, and queue a reboot or a Wi-Fi change. It is server-to-server: your subscriber-facing app must talk to your own backend, and your backend talks to this API — the app should never hold an API token or call this API directly, since every token also carries write access to every device in the tenant.

Authentication

Issue a token under Preferences → API Tokens. Every token grants devices:read (search and read device detail); check "Allow actions" when generating it to also grant devices:write (reboot and Wi-Fi changes). Issue read-only tokens by default, and reserve devices:write for integrations that genuinely need to change the device.

Send the token on every request:

Authorization: Bearer <token>
Accept: application/json

PUT requests also need Content-Type: application/json.

Response envelope and errors

Successful responses carry the payload under data, and searches additionally carry pagination under meta (page, per_page, total). Errors always come back as a single JSON object under error, with a stable code and a human message:

Error response
{
  "error": { "code": "device_not_found", "message": "Device not found in this tenant." }
}

Integrate against error.code, never against error.message: the message is for a human reading a log and may change wording at any time, while the code is the contract.

code HTTP status Meaning
unauthenticated 401 Missing or invalid token.
missing_ability 403 The token does not carry the ability the endpoint requires (for example, calling reboot with a devices:read-only token).
customer_inactive 403 The provider account that owns this token is inactive. Tokens keep existing but stop working, exactly as the web interface stops working for an inactive account.
customer_suspended 403 Only on issuing a share link: the provider account is suspended. Suspension already blocks the device page for that account's own operators, so a link to it is refused too, instead of handing the partner a link to a page that answers 403.
device_not_found 404 No device with that serial number in this tenant.
ssid_not_found 404 The requested band or ssid_id does not match any Wi-Fi network on this device.
not_found 404 No such endpoint under /api/v1 — check the path. A serial number outside the accepted character set (letters, digits, and . _ : -) does not match any route either, and lands here.
method_not_allowed 405 The endpoint exists but does not accept that HTTP method (for example, POST on a search).
no_tenant 409 The customer that owns this token has no ACS tenant configured.
ambiguous_ssid 409 A Wi-Fi change without ssid_id landed on a radio carrying more than one network on the air (a guest network, typically). Nothing was written: choose the network with ssid_id and repeat the call.
upstream_conflict 409 The ACS backend refused the request because the device state conflicts with it. Retrying the same request unchanged will not help.
invalid_parameter 422 A required parameter is missing or malformed; the field-by-field detail comes in error.details.
upstream_rejected 422 The ACS backend rejected this change for this device — for example, a parameter that is not writable on that CPE. It is not an outage: retrying the same request unchanged will keep failing.
rate_limited 429 Too many requests. Limits are per customer (your provider account), not per token — issuing a read-only token and a separate one with actions, as suggested above, does not double your budget: all tokens from the same customer share the same three buckets. Those buckets are 120/min for the three search endpoints, 30/min just for the device detail (stacked on top of the search bucket, since one call to it costs 3 ACS calls), and 30/min for reboot and Wi-Fi changes combined — reading never eats into your write budget or vice versa.
reboot_throttled 429 A reboot was already queued for this device less than 5 minutes ago.
upstream_unavailable 503 The ACS backend could not be reached or failed; not the integrator's fault, retry later. Only for outages — a request the backend actively refused comes back as upstream_rejected or upstream_conflict instead, and retrying those is pointless.
internal_error 500 A bug on our side. The response never carries internal detail, and the failure is always reported to our team. Safe to retry once; if it persists, open a ticket with the timestamp.

This table is the whole list. Any condition not covered by it comes back as the generic code error with the matching HTTP status — if you ever see one, it is worth a ticket.

The asynchronous model behind every device

A CPE is not a server you can query live: it only talks to the platform when it checks in (the TR-069 "Inform"), or when the platform manages to wake it up with a Connection Request. Every value the API returns was collected at some earlier check-in, never in real time — that is why every collectible block in the device detail comes with a collected_at timestamp: it tells the integrator how stale that value is, instead of pretending it is live.

Passing ?refresh=1 on the device detail endpoint does not wait for fresh data: it returns the same OLD cached data as always, in that same response, and only queues a new collection in the background (throttled to once every 2 minutes per device). To see the new value, call the device detail again a bit later and compare collected_at against the timestamp you saw before.

Reboot and the Wi-Fi change both respond 202 Accepted, not 200: the request was queued, not applied. The CPE applies it once it receives the command — immediately if the Connection Request succeeds, or on its next scheduled check-in otherwise. Confirming a change works the same way as confirming a refresh: query the device detail again and compare collected_at (for Wi-Fi, collected_at.wifi) to the value from before the change.

A response with connection_request: "failed" is not an error: the command still went into the queue and applies on the device's next Inform. Do not treat it as a failed action — only the HTTP status and error.code decide that. connection_request: "skipped" means no command needed to be sent at all (a Wi-Fi change whose values already match the device), and in that case queued comes back 0.
MCP server (AI agents)

The same operations documented here are also exposed as an MCP server, for customer-service platforms whose AI agent can connect to external MCP servers. Paste the URL below into your platform and authenticate with the very same token from this page — no separate credential, no extra setup on our side.

curl
https://app.archacs.com.br/mcp/acs

Transport is Streamable HTTP; authentication is the Authorization: Bearer header. The tenant always comes from the token, exactly as in the REST API — an agent can only ever reach your own fleet.

Tool Requires ability Notes
search_device_by_login devices:read Finds the CPE from the subscriber PPPoE login. This is the entry point: the agent gets the login from your ERP, and every other tool takes the serial returned here.
search_device_by_serial devices:read Exact lookup by the serial printed on the device label.
search_device_by_ip devices:read Lookup by WAN IP address, for when another system already has it.
get_device devices:read Full state of one CPE: online status, WAN, optical signal, Wi-Fi, LAN, connected hosts and the last diagnostics — each block with its collected_at.
reboot_device devices:write Queues a reboot. Same 5-minute window per device as the REST endpoint.
set_wifi devices:write Changes the network name and/or password, with the same band and ssid_id rules as the REST endpoint.

Each tool returns exactly the payload of the REST endpoint documented below — same fields, same names. Errors carry the same codes, rewritten as instructions the agent can act on.

A read-only token makes reboot_device and set_wifi refuse with missing_ability, while the four read tools keep working. That is how you let an agent look at the CPE without letting it act on the CPE. Deciding whether the subscriber in the conversation actually owns that CPE is your platform's job, not ours: the token is your account's, not the subscriber's.
GET
/api/v1/search/serial

Requires ability: devices:read

Exact match on the device serial number. Returns a list with zero or one item, so the response envelope is the same as the other two searches.

This search never returns more than one device, so it does not accept page or per_page — meta is always { "page": 1, "per_page": 25, "total": 0 or 1 }.

curl
curl -s "https://app.archacs.com.br/api/v1/search/serial?serial=ZTEGC8A1B2C3" \
  -H "Authorization: Bearer $TOKEN" -H "Accept: application/json"
Response
{
  "data": [
    {
      "serial": "ZTEGC8A1B2C3",
      "name": "Casa do João",
      "status": "online",
      "last_inform": "2026-07-30T12:41:02-03:00",
      "first_seen": "2025-11-03T09:12:44-03:00",
      "manufacturer": "ZTE",
      "model": "F670L",
      "firmware": "V1.2.3P1",
      "data_model": "TR-098",
      "wan_ip": "100.64.7.9",
      "pppoe_username": "joe@isp",
      "tags": ["fibra"]
    }
  ],
  "meta": { "page": 1, "per_page": 25, "total": 1 }
}
GET
/api/v1/search/login

Requires ability: devices:read

Exact match on the PPPoE username (case-insensitive). A single login can legitimately have more than one CPE, so the result is paginated.

Accepts page (default 1) and per_page (default 25, capped at 100). A per_page above 100 is silently clamped down to 100, not rejected.

curl
curl -s "https://app.archacs.com.br/api/v1/search/login?login=joe@isp&page=2&per_page=50" \
  -H "Authorization: Bearer $TOKEN" -H "Accept: application/json"
Response
{
  "data": [
    {
      "serial": "ZTEGC8A1B2C3",
      "name": "Casa do João",
      "status": "online",
      "last_inform": "2026-07-30T12:41:02-03:00",
      "first_seen": "2025-11-03T09:12:44-03:00",
      "manufacturer": "ZTE",
      "model": "F670L",
      "firmware": "V1.2.3P1",
      "data_model": "TR-098",
      "wan_ip": "100.64.7.9",
      "pppoe_username": "joe@isp",
      "tags": []
    }
  ],
  "meta": { "page": 2, "per_page": 50, "total": 57 }
}
GET
/api/v1/search/ip

Requires ability: devices:read

Exact match on the WAN IP address. Under CGNAT the same IP can legitimately show up on more than one CPE, so the result is paginated too.

Accepts page (default 1) and per_page (default 25, capped at 100). A per_page above 100 is silently clamped down to 100, not rejected.

curl
curl -s "https://app.archacs.com.br/api/v1/search/ip?ip=100.64.7.9&per_page=100" \
  -H "Authorization: Bearer $TOKEN" -H "Accept: application/json"
Response
{
  "data": [
    {
      "serial": "ZTEGC8A1B2C3",
      "name": "Casa do João",
      "status": "online",
      "last_inform": "2026-07-30T12:41:02-03:00",
      "first_seen": "2025-11-03T09:12:44-03:00",
      "manufacturer": "ZTE",
      "model": "F670L",
      "firmware": "V1.2.3P1",
      "data_model": "TR-098",
      "wan_ip": "100.64.7.9",
      "pppoe_username": "joe@isp",
      "tags": []
    }
  ],
  "meta": { "page": 1, "per_page": 100, "total": 1 }
}
GET
/api/v1/devices/{serial}

Requires ability: devices:read

Pre-mapped detail of a single device: identification, WAN, optical signal (when present), CPU and memory (when the model reports them), Wi-Fi radios and SSIDs, LAN, connected hosts and the latest completed diagnostics. Every block is best-effort — if the ACS backend fails to collect one of them, that block comes back empty and the rest of the response is still returned. resources is null on models that do not expose the TR-069 memory and CPU leaves, and each field inside it can be null on its own; memory is reported in KiB and both percentages are integers from 0 to 100. The values are a snapshot refreshed about once an hour, not a live reading. optical is null on devices with no optical interface (any non-PON CPE) and its fields carry the unit in the name — rx_power_dbm and tx_power_dbm in dBm, temperature_c in degrees Celsius, voltage_mv in millivolts. Only rx/tx and up are reported by every PON vendor we map today; temperature_c and voltage_mv come back null unless the vendor exposes them.

curl
curl -s "https://app.archacs.com.br/api/v1/devices/ZTEGC8A1B2C3" \
  -H "Authorization: Bearer $TOKEN" -H "Accept: application/json"
Response
{
  "data": {
    "device": {
      "serial": "ZTEGC8A1B2C3",
      "name": "Casa do João",
      "status": "online",
      "last_inform": "2026-07-30T12:41:02-03:00",
      "first_seen": "2025-11-03T09:12:44-03:00",
      "manufacturer": "ZTE",
      "model": "F670L",
      "firmware": "V1.2.3P1",
      "data_model": "TR-098",
      "wan_ip": "100.64.7.9",
      "pppoe_username": "joe@isp",
      "tags": ["fibra"],
      "hardware_version": "V1.0",
      "product_class": "F670L"
    },
    "wan": [
      {
        "name": "WAN1",
        "kind": "pppoe",
        "up": true,
        "ipv4": "100.64.7.9",
        "netmask": "255.255.255.255",
        "gateway": null,
        "dns": [],
        "ipv6": [],
        "pppoe_username": "joe@isp"
      }
    ],
    "optical": {
      "technology": "GPON",
      "vendor": "ZTE",
      "up": true,
      "rx_power_dbm": -22.52,
      "tx_power_dbm": 2.58,
      "temperature_c": null,
      "voltage_mv": null
    },
    "resources": {
      "cpu_percent": 3,
      "mem_total_kib": 119128,
      "mem_free_kib": 49884,
      "mem_used_percent": 58
    },
    "wifi": [
      {
        "band": "2.4GHz",
        "enabled": true,
        "channel": 6,
        "auto_channel": true,
        "ssids": [
          {
            "id": "a1b2c3d4e5f6",
            "name": "Casa-Joao",
            "enabled": true,
            "hidden": false,
            "security": "WPA2-PSK",
            "bssid": "AA:BB:CC:DD:EE:FF"
          }
        ]
      }
    ],
    "lan": {
      "ipv4": "192.168.1.1",
      "netmask": "255.255.255.0",
      "ports": [
        { "label": "LAN1", "connected": true, "speed_mbps": 1000 }
      ]
    },
    "hosts": [],
    "diagnostics": { "ping": null, "download": null, "upload": null },
    "collected_at": {
      "wan": "2026-07-30T12:00:00-03:00",
      "wifi": "2026-07-30T12:00:00-03:00",
      "optical": "2026-07-30T12:00:00-03:00",
      "hosts": null
    }
  }
}

Forcing a fresh collection: ?refresh=1

The rest of data is exactly the same shape as above — still the cached copy. refresh is the only new field, and it never carries fresh values itself: poll again shortly and compare collected_at to know when the new data has landed.

curl
curl -s "https://app.archacs.com.br/api/v1/devices/ZTEGC8A1B2C3?refresh=1" \
  -H "Authorization: Bearer $TOKEN" -H "Accept: application/json"
Response
{
  "data": {
    "...": "same device/wan/optical/resources/wifi/lan/hosts/diagnostics/collected_at as above...",
    "refresh": {
      "requested": true,
      "connection_request": "ok",
      "hint": "poll again in ~30s and compare collected_at"
    }
  }
}

Second ?refresh=1 within 2 minutes of the previous one: throttled, still 200

The read itself never fails because of this throttle — only the new collection is declined. retry_after_seconds tells the integrator how long to wait before asking again. refresh.reason has three possible values: throttled (shown here), nothing_to_refresh (the device has no WAN, Wi-Fi or optical object to collect yet — typically a CPE that has only just checked in) and upstream_error (the ACS backend refused the queueing; the read still succeeded). Only throttled carries retry_after_seconds.

curl
curl -s "https://app.archacs.com.br/api/v1/devices/ZTEGC8A1B2C3?refresh=1" \
  -H "Authorization: Bearer $TOKEN" -H "Accept: application/json"
Response
{
  "data": {
    "...": "same device/wan/optical/resources/wifi/lan/hosts/diagnostics/collected_at as above...",
    "refresh": {
      "requested": false,
      "reason": "throttled",
      "retry_after_seconds": 87
    }
  }
}
POST
/api/v1/devices/{serial}/reboot

Requires ability: devices:write

Queues a reboot. Always responds 202: the CPE restarts when it receives the command, not at the time of this call. A second reboot on the same device within 5 minutes is rejected with reboot_throttled, because it drops the subscriber's connection and the integrator needs to know it did not happen — unlike ?refresh=1, this never degrades silently.

curl
curl -s -X POST "https://app.archacs.com.br/api/v1/devices/ZTEGC8A1B2C3/reboot" \
  -H "Authorization: Bearer $TOKEN" -H "Accept: application/json"
Response
HTTP/1.1 202 Accepted
{
  "data": {
    "accepted": ["reboot"],
    "rejected": [],
    "queued": 1,
    "connection_request": "ok",
    "hint": "the device reboots when it receives the command; confirm via GET /api/v1/devices/ZTEGC8A1B2C3"
  }
}
PUT
/api/v1/devices/{serial}/wifi

Requires ability: devices:write

Changes the SSID and/or password of one Wi-Fi network. Target the network either by band (2.4GHz, 5GHz, 6GHz or all) or by the id returned in the device detail's wifi.*.ssids[].id — never by TR-069 path. Leaving password out keeps the current one. All target networks are written in a single batch, so one CWMP session applies every change instead of one session per network.

Which networks get written: with ssid_id, exactly that one. Without it, the primary network of each radio the band selects (band steering keeps 2.4 GHz and 5 GHz identical, so both are written). "Primary" means the single network on the air on that radio. If a radio has more than one network on the air — a guest network switched on is the usual case — the request is refused with 409 ambiguous_ssid and nothing is written: neither data model exposes a reliable "this is the guest network" flag, so the API will not guess which one the subscriber meant. Pick the network explicitly with ssid_id (from the device detail) and repeat the call.

field Accepted values Notes
band all | 2.4GHz | 5GHz | 6GHz Optional. Mutually exclusive with ssid_id. Defaults to all.
ssid_id string, max 32 Optional. The id from the device detail (wifi[].ssids[].id). Mutually exclusive with band.
ssid 1–32 characters Optional, but at least one of ssid or password is required.
password 8–63 printable ASCII Optional. The WPA-Personal passphrase range, validated here rather than on the CPE — a device that silently rejects an invalid passphrase leaves the subscriber without Wi-Fi and nobody knows why.
curl
curl -s -X PUT "https://app.archacs.com.br/api/v1/devices/ZTEGC8A1B2C3/wifi" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"band":"all","password":"novaSenha123"}'
Response
HTTP/1.1 202 Accepted
{
  "data": {
    "accepted": ["password"],
    "rejected": [],
    "networks": [
      { "ssid_id": "a1b2c3d4e5f6", "band": "2.4GHz", "name": "Casa-Joao",
        "accepted": ["password"], "rejected": [] },
      { "ssid_id": "f6e5d4c3b2a1", "band": "5GHz", "name": "Casa-Joao",
        "accepted": ["password"], "rejected": [] }
    ],
    "queued": 2,
    "connection_request": "ok",
    "hint": "confirm via GET /api/v1/devices/ZTEGC8A1B2C3 — compare collected_at.wifi"
  }
}

Nothing to change: the SSID sent is the one already configured

Still 202, with accepted: [] and queued: 0 — a change that would be a no-op is not queued, because an empty CWMP session wakes the CPE up for nothing. connection_request comes back "skipped" for the same reason: no session was needed. Check queued to know whether anything was actually sent to the device.

curl
curl -s -X PUT "https://app.archacs.com.br/api/v1/devices/ZTEGC8A1B2C3/wifi" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"band":"all","ssid":"Casa-Joao"}'
Response
HTTP/1.1 202 Accepted
{
  "data": {
    "accepted": [],
    "rejected": [],
    "networks": [
      { "ssid_id": "a1b2c3d4e5f6", "band": "2.4GHz", "name": "Casa-Joao",
        "accepted": [], "rejected": [] }
    ],
    "queued": 0,
    "connection_request": "skipped",
    "hint": "confirm via GET /api/v1/devices/ZTEGC8A1B2C3 — compare collected_at.wifi"
  }
}

One network accepts the change and another refuses it

The top-level accepted and rejected are the union across every network written, so the same item can appear in both when one radio accepts it and another does not (some vendors lock the SSID on one radio only). networks is what disambiguates: it reports, per network, exactly what was accepted and what was refused.

curl
curl -s -X PUT "https://app.archacs.com.br/api/v1/devices/ZTEGC8A1B2C3/wifi" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"band":"all","ssid":"Casa-Nova"}'
Response
HTTP/1.1 202 Accepted
{
  "data": {
    "accepted": ["ssid"],
    "rejected": ["ssid"],
    "networks": [
      { "ssid_id": "a1b2c3d4e5f6", "band": "2.4GHz", "name": "Casa-Joao",
        "accepted": ["ssid"], "rejected": [] },
      { "ssid_id": "f6e5d4c3b2a1", "band": "5GHz", "name": "Casa-Joao",
        "accepted": [], "rejected": ["ssid"] }
    ],
    "queued": 1,
    "connection_request": "ok",
    "hint": "confirm via GET /api/v1/devices/ZTEGC8A1B2C3 — compare collected_at.wifi"
  }
}