Endpoints
This page lists the available endpoints with copy-paste examples. The full interactive reference, including every parameter, is built in at /api/v1/docs on your portal domain.
All examples assume a base URL of https://portal.example.com/api/v1 and an API key in the Authorization header. Replace both with your own.
Response shape
Successful responses wrap the result in a data field. Lists add a pagination object:
{
"data": [ ... ],
"pagination": { "limit": 50, "offset": 0, "total": 128 }
}
Errors use a consistent envelope with a stable machine-readable code:
{ "error": { "code": "not_found", "message": "Device not found." } }
Lists accept limit (1 to 200, default 50) and offset for paging.
Devices
List devices
curl -H "Authorization: Bearer $KEY" \
"https://portal.example.com/api/v1/devices?limit=50&online=true"
Filters: subAccountId, deviceProfileId, networkId (a network id, or none for devices not in any network; requires the Networks feature), status (inventory, assigned, archived, or all), online (true or false), and search (matches DevEUI, name, or serial number).
Without a status filter, a partner-scoped key lists devices in every status, including devices in inventory; a sub-scoped key lists only its sub-account's assigned devices.
Get one device
A device can be addressed by either its ioX-Pulse id or its DevEUI (16 hex; separators like : or - are accepted). Both of these return the same device:
curl -H "Authorization: Bearer $KEY" \
https://portal.example.com/api/v1/devices/2f1c9b3a-...-d4e5
curl -H "Authorization: Bearer $KEY" \
https://portal.example.com/api/v1/devices/0011223344556677
The response includes the device, its profile, and the profile's field catalog so you can interpret telemetry without a second call.
Devices in inventory
Only assigned devices are live. A device in inventory (or archived) reports no data and cannot be read from or sent to:
- It still appears in the device list and resolves on
/devices/{id}for a partner-scoped key, with its registration details and profile. Its live fields (online,signalQuality,battery,lastSeenAt,lastUplinkAt, andlocation) arenull(onlineisfalse). /latest,/history, and the downlink routes answer404 not_found, the same as an unknown id.- Moving a device to inventory permanently deletes its data and history.
Latest values
The latest value of every field on the device, keyed by field identifier:
curl -H "Authorization: Bearer $KEY" \
https://portal.example.com/api/v1/devices/0011223344556677/latest
{ "data": { "deviceId": "...", "time": "2026-05-30T14:57:10Z",
"values": { "valve_state": "Closed", "battery": 91, "temperature_f": 71.2 },
"signalQuality": 48,
"retained": { "valve_state": "2026-05-30T11:20:00Z" },
"derived": { "temperature_f": 71.2 } } }
Static field values (per-device values and profile defaults, such as an install date) and formulas over them appear here even before the device's first uplink; time is null until it reports. signalQuality is the device's latest Signal Quality % (0-100), derived from RSSI and SNR; null when the device has no uplinks yet. It is also on every device object in the /devices list and detail responses, alongside online. To pull it as a time series, call /devices/{id}/history?field=signal_quality.
Every device object in the /devices list and detail responses also carries battery, the device's latest known battery reading:
{ "battery": { "state": "pct", "value": 54, "at": "2026-07-23T06:05:35Z", "source": "status" } }
state is one of pct (a percentage; value holds a whole number 0-100), external (externally powered, no percentage), or unavailable (the device cannot measure its battery). source tells you where the number came from: field means a decoded payload field that the profile marks with the Battery (%) role; status means LoRaWAN device-status polling, which the network runs on its own schedule (typically a few times per day). Because of that schedule, at can be hours old; treat it as the reading's timestamp, not the request time. The whole object is null for devices that have never reported battery.
retained appears only when one or more values were carried forward from an earlier reading (the Retain last value profile option). It maps each carried field identifier to the time that value was last actually reported. An identifier in values but not in retained was reported fresh. The field is absent when nothing was carried, so the response is unchanged until a profile turns retention on.
derived appears only when the profile has formula or mapping fields, or the device has a calibrated field. It lists the subset of values that are stored derived values, keyed by identifier: formula and mapping outputs, and the calibrated value of a calibrated field. A derived value is computed once when the reading arrives and stored with it, so it is the number every screen and export shows for that reading; a later change to a formula, mapping or calibration applies to new readings only (see Formula fields for how these values are defined).
This endpoint supports conditional requests. Keep the ETag from the response and send it back as If-None-Match to get a cheap 304 Not Modified when nothing has changed. See Rate limits and quotas.
History
A time series for one field:
curl -H "Authorization: Bearer $KEY" \
"https://portal.example.com/api/v1/devices/0011223344556677/history?field=temperature&from=2026-05-30T00:00:00Z&to=2026-05-30T23:59:59Z&resolution=hourly&agg=avg"
Parameters:
field(required): the field identifier (for exampletemperature) or its UUID.from/to(required): the window, as ISO-8601 timestamps.resolution:raw(default; individual readings, newest first, capped at 500), orhourly,daily, or an ISO-8601 interval such asPT15Mfor bucketed values.agg: for bucketed resolutions:avg(default),min,max,sum,count, orlast.siteId: filter the readings by the site the device was at when each was recorded. Useful for a device that has moved between sites: it keeps each reading attributed to where it was actually taken. Works for both raw and bucketed resolutions. Requires the multi-site tier feature (403 multi_site_disabledotherwise).
On raw resolution each point also carries a siteId field, the site the device was at when that reading arrived (null if it had no site, or if Multi-site is not enabled for the device's account). Bucketed points omit it, since a bucket aggregates readings that may span sites.
curl -H "Authorization: Bearer $KEY" \
"https://portal.example.com/api/v1/devices/0011223344556677/history?field=temperature&from=2026-05-30T00:00:00Z&to=2026-05-30T23:59:59Z&siteId=SITE_ID"
The window is bounded by your plan's telemetry retention. A range that starts before the retention cutoff but ends inside it is served from the cutoff forward: the response's from reflects the effective start, and it carries clamped: true plus the cutoff timestamp. Only a range that lies entirely outside retention returns 403 outside_retention.
Gateways
Gateways mirror devices and are addressable by their ioX-Pulse id or their GatewayEUI. They require the devices:read scope.
curl -H "Authorization: Bearer $KEY" \
"https://portal.example.com/api/v1/gateways?online=false"
curl -H "Authorization: Bearer $KEY" \
https://portal.example.com/api/v1/gateways/aabbccddeeff0011
The status filter works the same as for devices. A gateway in inventory (or archived) is still listed for a partner-scoped key, but online is false and lastSeenAt, lastStatsAt, and any reported location are null. A location you set manually is kept.
Device profiles
A profile defines the field catalog, the labels, units, and data types, for a class of device. This is the metadata you need to interpret telemetry values.
curl -H "Authorization: Bearer $KEY" \
https://portal.example.com/api/v1/device-profiles
curl -H "Authorization: Bearer $KEY" \
https://portal.example.com/api/v1/device-profiles/PROFILE_ID
The detail response includes every field with its identifier, label, unit, dataType, and kind. dataType is one of number, string, boolean, date, or location; date fields carry a calendar date as an ISO YYYY-MM-DD string, set manually per device rather than decoded from telemetry.
Downlinks
Queue a downlink to a device using one of its profile's named downlink templates. Sending requires the downlinks:write scope and a plan with API write access.
List a device's downlink templates
curl -H "Authorization: Bearer $KEY" \
https://portal.example.com/api/v1/devices/0011223344556677/downlinks
Each template has a stable key, a name, and the default field values (sampleData) it sends. A device in inventory answers 404 not_found here and on send.
Send a downlink
Identify the template by its key (recommended) or its templateId, and optionally override its field values with data:
curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{ "key": "set-interval", "data": { "minutes": 15 } }' \
https://portal.example.com/api/v1/devices/0011223344556677/downlinks
A successful call returns 202 Accepted once the downlink is queued for delivery. Whether the device must acknowledge it is a property of the template, not a per-send option.
Pass an optional expiresInSeconds to set a time-to-live on the command. If the network can't deliver it within that many seconds, for example because the device stays offline, it discards the command instead of delivering it late, so a stale command never fires when the device reconnects. Omit it to fall back to the device's configured downlink expiry, or no expiry if none is set. The value must be a whole number of seconds, up to 2592000 (30 days).
curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{ "key": "close-valve", "expiresInSeconds": 300 }' \
https://portal.example.com/api/v1/devices/0011223344556677/downlinks
The 202 Accepted response echoes the resolved expiry as expiresAt (an ISO 8601 timestamp), or null when no expiry applies:
{ "data": { "status": "queued", "queueItemId": "…", "fPort": 10,
"byteCount": 1, "expiresAt": "2026-06-18T18:05:00.000Z" } }
Multicast groups
Fan a single downlink out to every device in a multicast group. Reading groups needs devices:read; executing one needs downlinks:write and a plan with API write access.
List and read groups
curl -H "Authorization: Bearer $KEY" \
https://portal.example.com/api/v1/multicast-groups
curl -H "Authorization: Bearer $KEY" \
https://portal.example.com/api/v1/multicast-groups/GROUP_ID
The detail response includes the group's member devices. Each member carries its status; a member in inventory stays listed but receives nothing when the group is executed.
Execute a group
Identify the downlink template by its key or templateId, the same way as a single-device send:
curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{ "key": "open-valve" }' \
https://portal.example.com/api/v1/multicast-groups/GROUP_ID/execute
The downlink goes to every assigned member; members in inventory are skipped and left out of the results. The response reports a per-device outcome, so you can see which devices were queued and which, if any, were rate-limited or skipped:
{ "data": { "deviceCount": 12, "enqueuedCount": 12,
"rateLimitedCount": 0, "failedCount": 0, "perDevice": [ ... ] } }
Networks
A network is a grouping of devices and gateways within a sub-account. List your networks, or read one with its members:
curl -H "Authorization: Bearer $KEY" \
https://portal.example.com/api/v1/networks
curl -H "Authorization: Bearer $KEY" \
https://portal.example.com/api/v1/networks/NETWORK_ID
Every network object carries health rollups, on both the list and the single-network response, so you can see per network how many members are up without fetching each device:
deviceCount/gatewayCount: total members.deviceOnlineCount/deviceOfflineCount: online means the device uplinked within its offline threshold (default 30 minutes). The two add up todeviceCount.deviceAlertingCount: member devices with at least one active alert (any level: info, warning, or alert).gatewayOnlineCount/gatewayOfflineCount: a gateway is online when it is assigned and has reported within the last 30 minutes. The two add up togatewayCount.
{ "data": { "id": "...", "name": "North Plant", "deviceCount": 12,
"deviceOnlineCount": 9, "deviceOfflineCount": 3, "deviceAlertingCount": 2,
"gatewayCount": 2, "gatewayOnlineCount": 2, "gatewayOfflineCount": 0 } }
The single-network response (/networks/{id}) additionally lists the member devices and gateways. Each member reports its health as an online boolean (live reachability: seen within the offline threshold) plus lastSeenAt, the timestamp it was last seen (the same field as the device list). Member devices also carry signalQuality (latest 0-100%), so you can see signal health alongside reachability. Together they tell you not just whether a member is up, but how strong its link is and how long it has been quiet.
{ "data": { "id": "...", "name": "North Plant", "deviceCount": 12,
"devices": [
{ "deviceId": "...", "name": "Pump 3", "devEui": "0011223344556677",
"online": true, "signalQuality": 48, "lastSeenAt": "2026-06-18T16:04:51.000Z" }
],
"gateways": [
{ "gatewayId": "...", "name": "Roof GW", "gatewayEui": "aabbccddeeff0011",
"online": false, "lastSeenAt": "2026-06-17T09:12:30.000Z" }
] } }
To list a network's members in full, filter the device or gateway list by its id. Pass none to find devices or gateways that are not in any network:
curl -H "Authorization: Bearer $KEY" \
"https://portal.example.com/api/v1/devices?networkId=NETWORK_ID"
curl -H "Authorization: Bearer $KEY" \
"https://portal.example.com/api/v1/gateways?networkId=none"
Networks are a tier feature. When Networks is not enabled for an account, its group assignments are hidden everywhere the API reports them: the /networks endpoints and the networkId filter return 403 networks_disabled for that account's keys, and the networkId field on its devices and gateways reads null, even if an item was grouped before the feature was turned off. Nothing is deleted; enabling the feature again restores the same groups.
Sites
A site is a physical location in a sub-account, nested up to three levels (campus, building, floor). Devices, gateways, and networks attach to a site. Sites are read-only over the API (they are authored in the app). This resource requires the multi-site tier feature.
curl -H "Authorization: Bearer $KEY" \
https://portal.example.com/api/v1/sites
curl -H "Authorization: Bearer $KEY" \
https://portal.example.com/api/v1/sites/SITE_ID
Each site carries its parentSiteId, so you can reconstruct the tree client-side (a root site has parentSiteId: null).
Filter the device, gateway, or network list by site. Pass none for items with no site, and add includeDescendants=true to include everything in the site's subtree, not just items attached directly to it:
# Devices attached directly to one site
curl -H "Authorization: Bearer $KEY" \
"https://portal.example.com/api/v1/devices?siteId=SITE_ID"
# Devices anywhere in a site's subtree (the site and its descendants)
curl -H "Authorization: Bearer $KEY" \
"https://portal.example.com/api/v1/devices?siteId=SITE_ID&includeDescendants=true"
# Gateways with no site
curl -H "Authorization: Bearer $KEY" \
"https://portal.example.com/api/v1/gateways?siteId=none"
Device, gateway, and network objects also carry a siteId field (the item's current site, or null). A site id that is not in your key's scope returns 404, the same as a site that does not exist.
Like Networks, Multi-site is a tier feature. When it is not enabled for an account, the /sites endpoints and the siteId filter return 403 multi_site_disabled for that account's keys, and the siteId field on its devices and gateways reads null. Nothing is deleted; enabling the feature again restores the same site assignments.
Sub-accounts
Devices, gateways, and networks carry a subAccountId. Use this resource to turn that id into a sub-account, for example to show the account name in your app. Sub-accounts are read-only over the API (they are authored in the app) and require the devices:read scope.
curl -H "Authorization: Bearer $KEY" \
https://portal.example.com/api/v1/sub-accounts
curl -H "Authorization: Bearer $KEY" \
https://portal.example.com/api/v1/sub-accounts/SUB_ACCOUNT_ID
A partner key lists every sub-account in your partner, so you can resolve any subAccountId you see on a device, gateway, or network. A sub-account key returns only its own sub-account (any other id returns 404, the same as one that does not exist).
Each sub-account carries id, name, status, createdAt, and updatedAt. Billing and other internal fields are never exposed.
Members
Manage the members of one sub-account from your own application, for example a white-label mobile app where your customers sign in and see their own devices. These endpoints are designed for exactly that: validate a login, list the team, add a member, and remove one.
Validate accepts either key flavor: a sub-account key checks the login against that key's own sub-account, and a partner key resolves which sub-account(s) the email belongs to across your whole partner, so your backend can hold one key and never maintain its own email-to-account mapping. Listing, adding, and removing are available only to a sub-account-scoped key and always act on that key's own sub-account; on those routes a partner-scoped key receives 403 subaccount_key_required. Validating and listing need members:read; adding and removing need members:manage. See Authentication.
For management calls, the key is what selects the sub-account. There is no subAccountId request field: a member is added to the sub-account the key belongs to. If your app serves several customers (one sub-account each), create one sub-account key per customer and use the matching key for each call. To mint a sub-account key, scope into that sub-account in the portal and create the key under Integrations. Every members response echoes subAccountId and subAccountName so you always know which sub-account a call acted on; you can also resolve any id you hold with Sub-accounts.
A role is one of subaccount_admin, subaccount_operator, or subaccount_viewer. See Users and roles for what each can do.
Members can optionally be restricted to a single site within the sub-account (requires the multi-site tier feature). A restricted member should see only what is at their site; an unrestricted member sees the whole sub-account. The members endpoints report this as a siteId / siteName pair (both null when unrestricted). Because the API key itself is not site-restricted, your app reads the member's siteId on login and scopes its own views to it, for example GET /api/v1/devices?siteId=SITE_ID&includeDescendants=true.
Validate a member login
Use this as the sign-in gate in your app: send the email and password your user typed, and ioX-Pulse confirms both the password and active membership. The response shape depends on which key you call it with.
With a sub-account key, the login is checked against that key's own sub-account:
curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{ "email": "tech@acme.io", "password": "the-users-password" }' \
https://portal.example.com/api/v1/members/validate
A valid login returns 200 with the member's id, name, and role, the sub-account it was validated against, and the member's site restriction:
{ "data": { "valid": true, "userId": "…", "email": "tech@acme.io",
"name": "Sam Tech", "role": "subaccount_viewer", "mfaEnabled": false,
"subAccountId": "…", "subAccountName": "Acme Facilities",
"siteId": "…", "siteName": "North Plant" } }
subAccountId / subAccountName are the key's own sub-account. siteId / siteName are the member's site restriction: when both are null the member has full sub-account access; when set, scope your app's views to that site (see the note at the top of this section). An unrestricted member of an account without the multi-site feature simply reports null.
With a partner key, ioX-Pulse resolves the sub-account for you. Your user types only their email and password; the platform finds every sub-account in your partner where that email is an active member and returns a memberships array instead of the flat fields:
{ "data": { "valid": true, "userId": "…", "email": "tech@acme.io",
"name": "Sam Tech", "mfaEnabled": false,
"memberships": [
{ "subAccountId": "…", "subAccountName": "Acme Facilities",
"role": "subaccount_viewer", "siteId": "…", "siteName": "North Plant" }
] } }
The array almost always has exactly one entry, and your app signs the user straight into it. When the same email is a member of several of your sub-accounts, every match is returned and your app should show a picker of the subAccountName values and let the user choose; the platform never picks for you, because guessing could show one customer's data to another. In your picker, label the choice in your own words (for example "Select your account"): the people signing in are your customers' end users, so avoid internal terms like "sub-account" in the UI.
The password is verified once, before any membership information is returned, so an attacker cannot use this endpoint to discover which emails exist or where they are members.
Anything else is a rejection, with a stable code you can branch on:
401 invalid_credentials: the email or password is wrong. The same code is returned whether the email is unknown or the password is wrong, so the response never reveals which.403 account_locked: the account is locked after too many failed sign-ins in the app.403 account_inactive: the account exists but is not active (for example, suspended, or invited but not yet set up with a password).403 not_a_member: the credentials are valid but the user is not an active member of this sub-account (or, with a partner key, of any sub-account in your partner).
This endpoint never locks an account itself and never counts a failed attempt against the in-app sign-in lockout. It does honor an existing lockout. Repeated guessing is bounded by your key's rate limit. It also does not enforce MFA; the mfaEnabled flag tells you whether the user has MFA in ioX-Pulse so your app can decide whether to add its own second factor.
List members
curl -H "Authorization: Bearer $KEY" \
https://portal.example.com/api/v1/members
Each member carries userId, email, name, role, status (active, pending_invite, or suspended), mfaEnabled, lastLoginAt, and addedAt, plus subAccountId / subAccountName (the key's sub-account) and the member's siteId / siteName (both null when the member is not restricted to a site).
Add a member
curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{ "email": "tech@acme.io", "name": "Sam Tech", "password": "a-strong-passphrase", "role": "subaccount_viewer" }' \
https://portal.example.com/api/v1/members
For a brand-new email, the account is created active with the password you supply (it is checked against the password policy), so the user can sign in immediately, with no invitation email. role is optional and defaults to subaccount_viewer. The response echoes the sub-account the member was added to:
{ "data": { "userId": "…", "role": "subaccount_viewer", "status": "active", "existing": false,
"subAccountId": "…", "subAccountName": "Acme Facilities",
"siteId": null, "siteName": null } }
If the email already has an account in your workspace, the membership is attached but the password you send is ignored: an existing user's password is never changed through the API. The response sets existing: true. If that user was invited but has not set a password yet, they finish setting one through the normal invitation, not through this call.
Adding a member counts against the sub-account's member limit. At the limit the call returns 422 member_quota_reached. Adding someone who is already a member returns 409 already_member.
Restricting a member to a site
siteId is an optional field on this call. Omit it (or send null, the default shown above) and the member gets full sub-account access. Send a site id and the member is restricted to that site and its descendants:
curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{ "email": "tech@acme.io", "name": "Sam Tech", "password": "a-strong-passphrase", "role": "subaccount_viewer", "siteId": "SITE_ID" }' \
https://portal.example.com/api/v1/members
The response then carries the assigned site in siteId / siteName. A few rules apply when siteId is present:
- The account's tier must have the multi-site feature, otherwise the call returns
403 multi_site_disabled. - The site must belong to this key's sub-account, otherwise the call returns
400(the same as an unknown site). - Only
subaccount_operatorandsubaccount_viewermembers can be restricted. Sending asiteIdwithrole: subaccount_adminreturns409 role_not_restrictable, because an admin always sees the whole sub-account.
Restricting a member is not a substitute for enforcement in your own app: the member's siteId tells your app what to show, and your app scopes its data calls to it (see the note at the top of this section).
Remove a member
curl -X DELETE -H "Authorization: Bearer $KEY" \
https://portal.example.com/api/v1/members/USER_ID
This removes the membership from your sub-account and immediately ends the user's active sessions. The user account itself is not deleted, and any membership they hold in another sub-account is untouched. Removing the only remaining administrator is refused with 409 last_admin.
Next steps
- Rate limits and quotas: staying within your plan and polling efficiently.
- Open
/api/v1/docson your portal to try any of these live.