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
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.
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-themetakes CSS custom properties, for example{"--embed-text-color":"#ff0000"}, so the viewer can match your product. Only colour values are accepted.dv:stop-sessionwith an empty payload stops the session the viewer knows about, which it only learns oncedv:session-startedhas fired. Passsession_idexplicitly if you need to stop a session that is still connecting.
When it does not work
| What you see | Why |
|---|---|
| The iframe stays blank, and the browser console mentions frame ancestors | The 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_mismatch | The 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
tinside the signature, not from theX-Webhook-Timestampheader. 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
/bfflayer exposes named routes, not an arbitrary path passthrough. - Production origins are registered, over
https, and yourPUTkept 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
429is retried afterRetry-After, not immediately.