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.
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.
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": { "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.
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.
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.
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.
/api/v1/search/serialRequires 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 -s "https://app.archacs.com.br/api/v1/search/serial?serial=ZTEGC8A1B2C3" \ -H "Authorization: Bearer $TOKEN" -H "Accept: application/json"
{
"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 }
}
/api/v1/search/loginRequires 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 -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"
{
"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 }
}
/api/v1/search/ipRequires 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 -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"
{
"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 }
}
/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 -s "https://app.archacs.com.br/api/v1/devices/ZTEGC8A1B2C3" \ -H "Authorization: Bearer $TOKEN" -H "Accept: application/json"
{
"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 -s "https://app.archacs.com.br/api/v1/devices/ZTEGC8A1B2C3?refresh=1" \ -H "Authorization: Bearer $TOKEN" -H "Accept: application/json"
{
"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 -s "https://app.archacs.com.br/api/v1/devices/ZTEGC8A1B2C3?refresh=1" \ -H "Authorization: Bearer $TOKEN" -H "Accept: application/json"
{
"data": {
"...": "same device/wan/optical/resources/wifi/lan/hosts/diagnostics/collected_at as above...",
"refresh": {
"requested": false,
"reason": "throttled",
"retry_after_seconds": 87
}
}
}
/api/v1/devices/{serial}/rebootRequires 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 -s -X POST "https://app.archacs.com.br/api/v1/devices/ZTEGC8A1B2C3/reboot" \ -H "Authorization: Bearer $TOKEN" -H "Accept: application/json"
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"
}
}
/api/v1/devices/{serial}/wifiRequires 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 -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"}'
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 -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"}'
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 -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"}'
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"
}
}