Skip to main content
Version: 1.0.0

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:

  1. POST /uploads with total_size, purpose: "private_apk" and an optional filename. The answer carries session_id, chunk_size_bytes and total_chunks.
  2. PUT /uploads/{id}/chunks/{n} with each chunk's raw bytes, n starting at
    1. Every chunk except the last is exactly chunk_size_bytes long, so a file smaller than one chunk is a single PUT.
  3. POST /uploads/{id}/complete with no body. AndroidNexus reads the package name, version code, version name, label and signing certificate from the APK itself and answers the app under content, including its content_id. Uploading the same bytes again answers the same app with created: 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 the content_id to its custom_app_content_ids, save with PUT /policies/{id} and deploy with POST /policies/{id}/deploy. Do not add a private APK to configuration.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. Poll GET /app-rollouts/{id} and act on next_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:

CodeBudget
api_key_rate_limitedthe key's requests per minute
remote_control_rate_limitedthe 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.

  1. Your backend calls POST /devices/{id}/remote-control with the API key. The answer carries embed_url, which already has a short-lived viewer token in its fragment, plus expires_at and the granted permissions.
  2. Your frontend frames embed_url exactly 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 t inside the signature. An X-Webhook-Timestamp header 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:

CodeStatusMeaning
VALIDATION_ERROR400The request body or parameters failed validation
INVALID_REQUEST400The request was malformed
AUTHENTICATION_FAILED401The API key was missing, unreadable, revoked or expired
TOKEN_EXPIRED401The credential was valid and has expired
SUBSCRIPTION_REQUIRED402The organization has no active subscription; every endpoint below the gate returns this until billing is resolved
PERMISSION_DENIED403The caller's role does not admit this operation
CROSS_TENANT_ACCESS403The resource belongs to another organization
RESOURCE_NOT_FOUND404No such resource in this organization
CONFLICT409The request conflicts with current state
DUPLICATE_RESOURCE409A resource with that identity already exists
INTERNAL_ERROR500An unexpected server-side failure

Authorization codes:

CodeStatusMeaningWhat to do
not_authenticated401No credential was resolved for a guarded routeSend a valid API key
unauthorized_principal_kind403This kind of credential may not reach this endpoint at allManage API keys, users and team members from the console
missing_permission403The credential is valid but does not hold the required permissionHave an owner or admin grant the key's creator the permission named in the message
impersonation_ceiling403A support session is acting on your organization's behalf and this operation is outside what that session may doNothing is wrong with your own permissions; the support session is bounded
tenant_not_carried503Your organization is not yet provisioned on the platform identity serviceDo 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 a redirect_url to billing. Treat it as terminal until a human resolves it.
  • tenant_not_carried (503) is the one authorization refusal returned as 503 rather than 4xx. 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 as Bearer nxk_...

Security Scheme Type:

apiKey

Header parameter name:

Authorization

Contact

AndroidNexus Support: [email protected]

URL: https://androidnexus.ai/support