Skip to main content

Build your own frontend

You can run AndroidNexus entirely from your own product, without sending anyone to the AndroidNexus console. Everything the console does is an API call, and remote control embeds in an iframe on your own site.

This guide covers the four parts that are easy to get wrong: where the API key lives, enrollment, embedding remote control, and webhooks.

Build against staging first. Its base URL is https://staging-api.androidnexus.ai, it carries its own enterprises and devices, and it behaves exactly like production.

The one rule: the key stays on your server​

Your API key is an administrator credential

An API key holds full administrator authority over your enterprise. It can read and change every device, policy and private app, start remote control, and factory reset hardware. It is not scoped by whoever created it. See Roles and Permissions.

So the key never goes to the browser. Put a thin layer of your own backend in front of it:

browser -> your server -> AndroidNexus API
(holds nxk_) (api.androidnexus.ai)

Your frontend calls your own endpoints. Your server attaches the key and calls AndroidNexus. In Node that is about ten lines:

// Your server. The key is read from the environment and attached here, and
// nowhere else.
const API_URL = process.env.ANDROIDNEXUS_API_URL; // https://staging-api.androidnexus.ai
const API_KEY = process.env.ANDROIDNEXUS_API_KEY; // nxk_...

async function callApi(method, path, body) {
const res = await fetch(API_URL + path, {
method,
headers: {
Authorization: `Bearer ${API_KEY}`,
Accept: 'application/json',
...(body ? { 'Content-Type': 'application/json' } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
// Not every refusal is JSON. A rate limit applied at our edge answers in
// plain text, so parsing unconditionally throws on exactly the response you
// most want to handle.
const isJson = (res.headers.get('content-type') || '').includes('application/json');
return {
status: res.status,
body: isJson ? await res.json() : await res.text(),
retryAfter: Number(res.headers.get('retry-after')) || null,
};
}

Expose one route per call you actually need rather than forwarding whatever path the browser asks for. A passthrough proxy hands anyone who can reach your server the full authority of your key.

Responses​

Every response shares 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": "..." },
"metadata": { "timestamp": "2026-01-01T00:00:00Z", "request_id": "..." }
}

Branch on error.code, never on the message text. Note that two casing conventions are in use: general codes are upper-case, authorization codes are lower-case. Match both, case-sensitively.

One response does not carry this envelope: a rate limit applied at our network edge, which never reaches the API. See Rate limits below.

Devices​

GET /devices takes search, filter_status, filter_tags, page and limit (maximum 100), and returns a page of devices plus a pagination object.

A device has two shapes

GET /devices and GET /devices/{id} do not return the same object.

The list carries the display fields you would put in a table (name, state, compliance, build, policy_name) and the two flags that say whether remote control is possible: deviceview_installed and deviceview_linked.

The detail endpoint returns the underlying record (status, model, os_version, compliance_status, serial_number) and carries no remote control fields at all.

So decide whether to offer a remote control button from the list row. If you look for those fields on the detail response you will never find them, and the button will never appear.

Tags are replaced, not merged: PATCH /devices/{id}/tags sets the device's whole tag set and caps it at ten, so send every tag you want to keep. POST /devices/bulk/add-tags and .../bulk/remove-tags adjust many devices at once.

For telemetry, GET /devices/{id}/telemetry/latest gives the most recent reading, /history gives a page of them for charting, and /aggregate gives min, max, average and current per metric over a period.

Enrollment​

Enrolling a device takes three calls and a QR code.

1. Pick a policy. GET /policies lists them. A device enrolls onto the policies you name.

2. Create an enrollment token.

curl -X POST https://staging-api.androidnexus.ai/api/v1/enrollment_tokens \
-H "Authorization: Bearer $ANDROIDNEXUS_API_KEY" \
-H 'Content-Type: application/json' \
--data '{
"name": "warehouse rollout",
"enrollment_type": "amapi_enrollment",
"default_policy_ids": ["<policy-id>"]
}'

target_tag_slugs is optional and tags every device that enrolls with this token.

3. Fetch the QR payload. GET /enrollment_tokens/{token_id}/qr_data returns a qr_code_data object and a manual_enrollment_token for typing in by hand.

Serialise qr_code_data with JSON.stringify and render that exact string as a QR code. Do not reformat or pretty-print it.

const { data } = await callApi('GET', `/api/v1/enrollment_tokens/${id}/qr_data`);
const payload = JSON.stringify(data.qr_code_data); // about 530 bytes

At that size the result is a version 18 symbol at error-correction level M, so render it large enough to scan: around 300 by 300 pixels on screen is comfortable. Any maintained QR library handles it; qrcode on npm is three lines.

On the device, factory reset it and tap the welcome screen six times to open the QR reader.

To know when the device arrives, subscribe to the device.enrolled webhook. You can also poll GET /devices and watch for a new id, which is simpler to build and fine while you are getting started.

Embedding remote control​

Remote control runs in an iframe on your own page. The credential that reaches the browser is a short-lived token scoped to one device, never your API key.

Register your origins first​

The browser refuses to frame the viewer from an unregistered site, before any of your code runs. Register each origin that may frame it:

curl -X PUT https://staging-api.androidnexus.ai/api/v1/enterprise/embed-origins \
-H "Authorization: Bearer $ANDROIDNEXUS_API_KEY" \
-H 'Content-Type: application/json' \
--data '{"origins":["https://app.example.com","http://localhost:5173"]}'

This replaces the whole list, so include every origin you want to keep. Read the current list with GET /enterprise/embed-origins first.

Origins must be https, except http://localhost for local development. An origin is scheme, host and port: http://localhost:5174 is not http://localhost:5173.

Mint a session, then frame it​

Your server mints:

const { body } = await callApi('POST', `/api/v1/devices/${deviceId}/remote-control`);
// body.data: { embed_token, embed_url, expires_at, permissions }

Your frontend frames embed_url exactly as returned:

<iframe
src="<embed_url>"
allow="autoplay; fullscreen; clipboard-read; clipboard-write; microphone"
sandbox="allow-scripts allow-same-origin allow-downloads"></iframe>

embed_url already carries the viewer token in its URL fragment. A fragment is never sent to any server, so the token does not reach your backend or your access logs. Do not move it into a query parameter.

allow-scripts and allow-same-origin are both required: the viewer is a script application and needs its own origin for storage and for the media session. allow-downloads is what lets a file pulled off the device reach the browser. Drop microphone unless you want audio. Do not add allow-top-navigation.

A session lasts one hour; expires_at tells you exactly when.

Talking to the viewer​

The viewer and your page exchange postMessage events. Every message is an envelope:

{ type: 'dv:...', payload: { }, version: 1 }

The handshake is the part you must implement. The viewer posts dv:ready when it has loaded; if you framed the URL with its fragment intact it authenticates itself and you need do nothing further. Listen for dv:session-started to know the device has connected, and for dv:error to show a failure.

const viewerOrigin = new URL(embedUrl).origin;

window.addEventListener('message', (event) => {
// Ignore anything that is not the viewer.
if (event.origin !== viewerOrigin) return;
const { type, payload } = event.data || {};

switch (type) {
case 'dv:ready': /* viewer loaded */ break;
case 'dv:session-started': /* device connected */ break;
case 'dv:error': console.error(payload); break;
}
});

// When you post to the viewer, name its exact origin. Never use "*".
function send(type, payload) {
iframe.contentWindow.postMessage({ type, payload, version: 1 }, viewerOrigin);
}

Two details worth knowing before you build on them:

  • dv:set-theme takes CSS custom properties, for example {"--embed-text-color":"#ff0000"}, so the viewer can match your product. Only colour values are accepted.
  • dv:stop-session with an empty payload stops the session the viewer knows about, which it only learns once dv:session-started has fired. Pass session_id explicitly if you need to stop a session that is still connecting.

When it does not work​

What you seeWhy
The iframe stays blank, and the browser console mentions frame ancestorsThe page's origin is not registered. See above, and check the port.
The viewer sits on "Waiting for authentication"It never received a token. Check you framed embed_url with its fragment intact.
"Device did not respond within timeout"The device is asleep or off the network. Minting succeeds either way, so this only shows up inside the viewer.
dv:error with embed_device_mismatchThe session you addressed does not belong to the device the token is bound to. Usually a session id from an older mint.

Webhooks​

Polling works, but a webhook is the right answer for enrollment and device state.

Subscribe with POST /webhooks/subscriptions:

{
"name": "our receiver",
"url": "https://app.example.com/webhooks/androidnexus",
"events": ["device.enrolled"],
"is_active": true
}

The response carries the signing secret once. Store it then. If you lose it, POST /webhooks/subscriptions/{id}/signing-secret/rotate issues a new one.

Verifying a delivery​

Every delivery carries:

X-Webhook-Signature: t=1762800000,v1=9f86d081884c7d659a2feaa0c55ad015...

t is a Unix timestamp, v1 is an HMAC-SHA256 in hex. The signed message is the timestamp, a literal dot, and the raw bytes of the request body:

const crypto = require('crypto');

// rawBody MUST be the unparsed body. Parsing the JSON and re-serialising it
// changes the bytes, and the signature will never match again.
function verify(rawBody, signatureHeader, secret) {
const parts = {};
for (const piece of String(signatureHeader || '').split(',')) {
const eq = piece.indexOf('=');
if (eq > 0) parts[piece.slice(0, eq).trim()] = piece.slice(eq + 1).trim();
}
const { t, v1 } = parts;
if (!t || !v1) return false;

// Reject replays outside the five minute window.
if (Math.abs(Math.floor(Date.now() / 1000) - Number(t)) > 300) return false;

const expected = crypto.createHmac('sha256', secret)
.update(`${t}.`)
.update(rawBody)
.digest('hex');

const a = Buffer.from(expected, 'utf8');
const b = Buffer.from(v1, 'utf8');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Two traps, both of which fail in ways that are hard to read:

  • Take the timestamp from t inside the signature, not from the X-Webhook-Timestamp header. That header is sent as well, but it is generated at a different moment and can be a second out, so verifying against it fails on a minority of deliveries and looks like a network problem.
  • Verify before parsing. Most frameworks hand you a parsed body. Capture the raw bytes first.

POST /webhooks/subscriptions/{id}/test sends a delivery on demand, which is the quickest way to confirm your receiver works. GET /webhooks/deliveries shows what was attempted.

Rate limits​

Your API key gets 1000 requests a minute. The budget belongs to the key and follows it whatever address you call from, so giving each integration its own key keeps one from spending another's.

Starting remote-control sessions is bounded separately, at 120 a minute for the whole organization, across every key and every operator in the console. It is there to stop a loop rather than to bound real support work.

Two more budgets apply at our network edge, before a request reaches the API at all: 100 a minute on the sign-in and token routes, and 3000 a minute on everything else. Those belong to the address you call from rather than to your key, so your server's egress address is what spends them. Running your proxy behind one address is fine at these volumes; running every customer's traffic through one shared address is what would not be.

Device agent traffic is deliberately outside the edge budgets, so a customer with thousands of devices behind one corporate address is never throttled by fleet size.

Every refusal is a 429 carrying Retry-After in whole seconds. Honour the header and back off; retrying sooner is refused again.

The two layers answer differently, and your client has to survive both. A budget we apply answers with the normal envelope and an error.code of api_key_rate_limited or remote_control_rate_limited:

HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 37

{"status":"error","error":{"code":"api_key_rate_limited","message":"..."},"metadata":{...}}

A budget applied at the edge never reaches the API, so there is no envelope and nothing to parse:

HTTP/2 429
Content-Type: text/plain; charset=UTF-8
Retry-After: 51
Server: cloudflare

error code: 1015

Read the status and Retry-After before you touch the body. The sketch below does that; a client that calls response.json() on every reply throws on the plain-text form.

async function callApiWithBackoff(method, path, body, attempt = 0) {
const res = await callApi(method, path, body);
if (res.status !== 429 || attempt >= 3) return res;

// Retry-After is on the header for both kinds of refusal, in whole seconds.
// Do not look for it in the body: on an edge refusal there is no body to
// read it from.
const wait = Math.max(1, res.retryAfter ?? 1);
await new Promise(r => setTimeout(r, wait * 1000));
return callApiWithBackoff(method, path, body, attempt + 1);
}

Three attempts is a choice, not a rule. What matters is that you wait the time you were given rather than retrying immediately.

A working example​

There is a complete sandbox you can clone and run: a small dependency-free server that holds the key and proxies it, a reference app covering devices, enrollment and settings, and a console that documents every embed protocol message with a "try it" box. Ask your DeviceNexus contact for access.

Checklist before you ship​

  • The API key is on your server, in configuration, and appears in no frontend bundle and no browser request.
  • Your /bff layer exposes named routes, not an arbitrary path passthrough.
  • Production origins are registered, over https, and your PUT kept every origin you still need.
  • Your webhook receiver verifies signatures against the raw body and rejects anything outside the five minute window.
  • You branch on error.code, matching both casing conventions.
  • A 429 is retried after Retry-After, not immediately.