AndroidNexus API
The AndroidNexus API enables programmatic management of your Android device fleet. It covers what you can do from the AndroidNexus console: devices and their quick actions, policies, enrollment, remote control, webhooks, private apps and their staged rollouts, and the audit log.
Two base URLs are published below. Build against staging
(https://staging-api.androidnexus.ai), which carries its own enterprises and
devices, and move to production when you are ready.
Authentication
Create an API key in the AndroidNexus console and send it as a bearer token:
curl https://api.androidnexus.ai/api/v1/devices \
-H "Authorization: Bearer nxk_..."
Organization owners and admins create and revoke API keys under Settings.
Every operation in this reference accepts an API key.
An API key holds full administrator authority over your enterprise. It is not scoped by who created it, and the scopes shown when a key is created do not narrow what it can reach: a key can read and change every device, policy and private app, start remote control, and factory reset hardware. Keep it on a server you control and never ship it to a browser or a mobile app.
The one thing a key cannot do is change who has access. Managing API keys,
users and team members admits a signed-in person and nothing else, so those
endpoints answer an API key with 403 and the code
unauthorized_principal_kind. A leaked key therefore cannot mint a
replacement for itself or add users.
Authorization
Authentication establishes who is calling. Authorization decides what that caller may do, and the two are separate checks.
Permissions are named androidnexus.<resource>:<verb>, for example
androidnexus.devices:read, androidnexus.policies:write or
androidnexus.content:write. They are granted per organization.
A caller missing the permission an endpoint requires receives 403 with the
code missing_permission, and the message names the permission that was
missing. This is a grant problem, not a credential problem: the same call
repeated with the same credential keeps failing until an owner or admin
widens what it holds.
GET /auth/me reports the effective set in authority.permissions. It is
the authoritative answer to "may this caller do X"; prefer it to inferring
capability from a role name.
These checks bound a signed-in person. An API key resolves to the whole
catalog, so a call made with a key never fails with missing_permission.
Private apps
A private APK is uploaded once and then delivered through a policy.
Upload takes three calls whatever the file size:
POST /uploadswithtotal_size,purpose: "private_apk"and an optionalfilename. The answer carriessession_id,chunk_size_bytesandtotal_chunks.PUT /uploads/{id}/chunks/{n}with each chunk's raw bytes,nstarting at- Every chunk except the last is exactly
chunk_size_byteslong, so a file smaller than one chunk is a singlePUT.
- Every chunk except the last is exactly
POST /uploads/{id}/completewith no body. AndroidNexus reads the package name, version code, version name, label and signing certificate from the APK itself and answers the app undercontent, including itscontent_id. Uploading the same bytes again answers the same app withcreated: false.
Delivery depends on whether the app is new to the policy:
- To install an app on a policy's devices, including devices that have not
enrolled yet, enable
configuration.companion_app, add thecontent_idto itscustom_app_content_ids, save withPUT /policies/{id}and deploy withPOST /policies/{id}/deploy. Do not add a private APK toconfiguration.applications; AndroidNexus derives its install declaration from the content id. - To ship a new version to enrolled devices, create a staged rollout with
POST /app-rollouts(policy_id,content_id,stages,start: true). The rollout adds the version to the policy itself. PollGET /app-rollouts/{id}and act onnext_action.
The private APK guide walks through both with examples.
Rate limits
Two budgets apply, and they bound different things.
Your API key may make 1000 requests a minute by default. The budget belongs to the key, so it follows your integration whatever address it calls from, and each key has its own: giving each integration its own key keeps one from spending another's. Ask your DeviceNexus contact if you need a key raised.
Starting remote-control sessions is bounded separately, at 120 a minute for the whole organization, across every key and every operator in the console. Each session mints a viewer token good for an hour, so the limit is there to stop a loop rather than to bound ordinary support work; a hundred agents each starting a session every other minute sits well inside it.
A request over either budget is answered 429 with a Retry-After header
giving whole seconds, and an error.code saying which budget you hit:
| Code | Budget |
|---|---|
api_key_rate_limited | the key's requests per minute |
remote_control_rate_limited | the organization's remote-control sessions per minute |
Wait for Retry-After and retry. Retrying sooner is refused again and buys
nothing, so back off rather than spinning.
Separately, requests are bounded per source address to protect the service
from a single abusive host. Ordinary use does not reach those, but an
integration that bursts from one address may: the same 429 and Retry-After
apply, under the code RATE_LIMIT_EXCEEDED.
Remote control
Remote control runs in an iframe on your own site, and the credential that reaches the browser is not your API key.
- Your backend calls
POST /devices/{id}/remote-controlwith the API key. The answer carriesembed_url, which already has a short-lived viewer token in its fragment, plusexpires_atand the grantedpermissions. - Your frontend frames
embed_urlexactly as returned. A URL fragment is never sent to any server, so the token does not travel to your own backend or appear in logs.
The browser will not frame the viewer from an arbitrary site. Register each
origin that may with PUT /enterprise/embed-origins, which replaces the
whole list, so send every origin you want to keep. Origins must be https,
except http://localhost for local development.
Frame it with:
<iframe
src="<embed_url>"
allow="autoplay; fullscreen; clipboard-read; clipboard-write; microphone"
sandbox="allow-scripts allow-same-origin allow-downloads"></iframe>
allow-scripts and allow-same-origin are both required. Drop microphone
unless you want audio from the device.
A session lasts an hour. Minting succeeds whether or not the device is awake, so a device that is asleep or off the network shows up as a timeout inside the viewer rather than as an error on this call.
Webhooks
Subscribe with POST /webhooks/subscriptions, naming a url AndroidNexus can
reach and the events you want. The response carries the signing secret
once; store it then, and use
POST /webhooks/subscriptions/{id}/signing-secret/rotate if you lose it.
Every delivery is signed:
X-Webhook-Signature: t=1762800000,v1=9f86d081884c7d659a2feaa0c55ad015...
t is a Unix timestamp and v1 is an HMAC-SHA256, hex encoded, over the
timestamp, a literal dot, and the raw bytes of the request body:
HMAC-SHA256(secret, "<t>.<rawBody>"). To verify, parse t and v1 out of
this header, reject anything whose t is more than five minutes from your own
clock, recompute over the body exactly as received, and compare in constant
time.
Two things to get right:
- Take the timestamp from
tinside the signature. AnX-Webhook-Timestampheader is also sent, but it is generated separately and can differ by a second, so verifying against it fails on a minority of deliveries. - Verify before parsing. Re-serialising the parsed JSON changes the bytes and the signature will never match.
POST /webhooks/subscriptions/{id}/test sends a delivery on demand, and
GET /webhooks/deliveries shows what was attempted.
Response Format
Responses share one envelope:
{
"status": "success",
"data": { },
"metadata": { "timestamp": "2026-01-01T00:00:00Z", "request_id": "..." }
}
Errors carry a machine-readable code:
{
"status": "error",
"error": { "code": "VALIDATION_ERROR", "message": "...", "details": { } },
"metadata": { "timestamp": "2026-01-01T00:00:00Z", "request_id": "..." }
}
Error Codes
Two naming conventions are in use, and a client must match both. The
general codes are upper-case; the authorization codes are lower-case. A
switch on error.code that knows only one convention will fall through on
the other, so match case-sensitively against both sets below.
General codes:
| Code | Status | Meaning |
|---|---|---|
VALIDATION_ERROR | 400 | The request body or parameters failed validation |
INVALID_REQUEST | 400 | The request was malformed |
AUTHENTICATION_FAILED | 401 | The API key was missing, unreadable, revoked or expired |
TOKEN_EXPIRED | 401 | The credential was valid and has expired |
SUBSCRIPTION_REQUIRED | 402 | The organization has no active subscription; every endpoint below the gate returns this until billing is resolved |
PERMISSION_DENIED | 403 | The caller's role does not admit this operation |
CROSS_TENANT_ACCESS | 403 | The resource belongs to another organization |
RESOURCE_NOT_FOUND | 404 | No such resource in this organization |
CONFLICT | 409 | The request conflicts with current state |
DUPLICATE_RESOURCE | 409 | A resource with that identity already exists |
INTERNAL_ERROR | 500 | An unexpected server-side failure |
Authorization codes:
| Code | Status | Meaning | What to do |
|---|---|---|---|
not_authenticated | 401 | No credential was resolved for a guarded route | Send a valid API key |
unauthorized_principal_kind | 403 | This kind of credential may not reach this endpoint at all | Manage API keys, users and team members from the console |
missing_permission | 403 | The credential is valid but does not hold the required permission | Have an owner or admin grant the key's creator the permission named in the message |
impersonation_ceiling | 403 | A support session is acting on your organization's behalf and this operation is outside what that session may do | Nothing is wrong with your own permissions; the support session is bounded |
tenant_not_carried | 503 | Your organization is not yet provisioned on the platform identity service | Do not retry; retrying cannot succeed. Contact support |
Two refusals deserve special handling:
SUBSCRIPTION_REQUIRED(402) gates nearly the whole API. A lapsed or never-started subscription turns almost every call into a 402 carrying aredirect_urlto billing. Treat it as terminal until a human resolves it.tenant_not_carried(503) is the one authorization refusal returned as503rather than4xx. It is a provisioning gap, so a backoff-and-retry loop will never clear it.
Pagination
List endpoints accept page (starting at 1) and per_page (at most 100), and return
page, per_page, total and total_pages alongside the results.
App rollouts and a rollout's devices page by cursor instead: they accept
limit (at most 100) and cursor, and return next_cursor, which is null
on the last page.
Authentication
- API Key: Bearer
API key as Bearer nxk_...
Security Scheme Type: | apiKey |
|---|---|
Header parameter name: | Authorization |