Reference for the HTTP + WebSocket API exposed by Android Device Manager (ADM) when it shares its
locally-attached devices. ADM's own client (RemoteConnection) speaks this API, and so can anything
else that can make HTTP requests.
- API version: 1.0 (
RemoteHttpServer.VERSION) - Default port: 8765 (
RemoteServerManager.DEFAULT_PORT) - Implemented by
RemoteHttpServer(NanoHTTPD/NanoWSD) - Reference client:
RemoteConnection
For how to start a server and connect to it from the ADM UI, see SERVER.md and CLIENT_SERVER.md.
- Connecting
- Authentication
- Conventions
- HTTP endpoints
- WebSocket:
/ws/logs - WebSocket:
/ws/screen - Data types
- Running a server
- Gotchas
| Base URL | http://<host>:<port> |
| WebSocket URL | ws://<host>:<port> |
| TLS | not provided by the server — put it behind a tunnel (Tailscale, ngrok) or a reverse proxy |
| Socket timeout | 60s, long enough to keep WebSockets alive between messages |
The server has no TLS and no user accounts: a single shared token is the only thing protecting it. Treat it as a LAN/VPN service.
The UI's "Copy Connection String" button produces a single string that carries everything a client needs:
adm://<base64 of {"host":"1.2.3.4","port":8765,"token":"aBc123...","name":"joes-mac"}>
Base64 of a compact JSON object, prefixed with adm://. See RemoteConnectionUtils.generateConnectionString() /
parseConnectionString(). The same string is what the Share Devices dialog renders as a QR code.
Every HTTP request and every WebSocket upgrade must present the server's auth token. Two ways, checked in this order:
- Query parameter —
?token=<token>(this wins if present) - Header —
Authorization: Bearer <token>
Comparison is exact string equality. On failure:
- HTTP →
401withUnauthorizedastext/plain - WebSocket → the upgrade succeeds, then the server immediately sends
{"type":"error","message":"Unauthorized"}and closes with1008(PolicyViolation)
Use the header for HTTP. Browsers and most WebSocket clients can't set headers on an upgrade, which
is why token is accepted in the query string — but note it will then show up in proxy and server
logs.
Sent by ADM clients on every request; used only for the server's "connected clients" list.
| Header | Purpose |
|---|---|
x-client-ip |
client's own view of its IP; fallback when the server can't read the socket address |
x-client-name |
display name, usually the client's hostname |
Both are trimmed, stripped of CR/LF and truncated to 128 characters.
- Device selection — every device-scoped call takes a
serial(query parameter, or a field in the JSON body for POSTs). It must be a serial the server reports from/api/devices. - JSON — responses are
application/json, serialized with Gson. Null fields are omitted, so clients must treat every non-primitive field as optional. - Errors — non-200 responses are
text/plainwith a short message, not JSON.
| Status | Meaning |
|---|---|
200 |
success |
400 |
missing/invalid parameters, or an install that failed on at least one device |
401 |
bad or missing token |
404 |
unknown endpoint, unknown serial, or a device-side error (e.g. Error: permission denied) |
500 |
unhandled exception; message is the exception text |
| Method | Path | Purpose |
|---|---|---|
| GET | /api/info |
server name, version, device count |
| GET | /api/devices |
list devices |
| GET | /api/properties |
full getprop map for a device |
| POST | /api/setproperty |
set an ADM custom property |
| POST | /api/execute |
run a shell command |
| POST | /api/install |
install an APK on one or more devices |
| GET | /api/files/list |
list a directory |
| GET | /api/files/download |
download a file |
| POST | /api/files/upload |
upload a file |
| GET | /api/screenshot |
capture a PNG screenshot |
Health check and handshake. ADM clients poll this every 30 seconds and only re-fetch the device list
when deviceCount changes.
curl -H "Authorization: Bearer $TOKEN" http://HOST:8765/api/info{
"version": "1.0",
"deviceCount": 3,
"serverName": "joes-mac"
}serverName is the user-configured server name, falling back to the machine's hostname.
All devices physically attached to the server, as a JSON array of Device objects.
Devices the server itself reached through another ADM server are filtered out, so servers don't re-advertise each other's devices.
curl -H "Authorization: Bearer $TOKEN" http://HOST:8765/api/devicesEvery Android system property for a device — the equivalent of adb shell getprop, returned as a
flat JSON object.
| Parameter | Required | Notes |
|---|---|---|
serial |
yes |
curl -H "Authorization: Bearer $TOKEN" \
"http://HOST:8765/api/properties?serial=39121FDJG003TR"{
"ro.product.model": "Pixel 7",
"ro.build.version.release": "14",
"ro.build.version.sdk": "34"
}Returns 404 if the device is gone or the property read failed.
Sets an ADM custom property — a user-defined key/value that ADM stores in
/sdcard/android_device_manager.properties on the device and shows in custom columns.
This is not
adb shell setprop. It does not touch Android system properties. Use/api/executefor those.
Body (application/json), all fields required:
{ "serial": "39121FDJG003TR", "key": "team", "value": "lab-a" }curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"serial":"39121FDJG003TR","key":"team","value":"lab-a"}' \
http://HOST:8765/api/setproperty{ "success": true }400 if any field is missing. success: false means the write to the device failed.
Runs a command in the device's shell and returns its output.
Body (application/json):
{ "serial": "39121FDJG003TR", "command": "dumpsys battery" }command is the command as you would type it after adb shell — no adb and no shell
prefix. It is split on whitespace, honouring single and double quotes, so
input text "hello world" arrives as one argument.
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"serial":"39121FDJG003TR","command":"getprop ro.product.model"}' \
http://HOST:8765/api/execute{
"isSuccess": true,
"resultList": ["Pixel 7"]
}isSuccess reports whether ADM could run the command, not whether the command succeeded — a
command that exits non-zero still returns isSuccess: true with the error text in resultList.
Check the output when that distinction matters.
Returns 400 if serial or command is missing, 404 if the device is unknown or is itself a
remote device.
Installs an APK. This is the one endpoint that accepts multiple devices — repeat serial to install
the same APK everywhere in one request.
| Parameter | Required | Notes |
|---|---|---|
serial |
yes | repeatable: ?serial=A&serial=B |
The APK is the request body, either as a multipart/form-data part named file (what ADM's client
sends) or as the raw body.
curl -X POST -H "Authorization: Bearer $TOKEN" \
-F "file=@app-debug.apk" \
"http://HOST:8765/api/install?serial=A&serial=B"Response is text/plain, one entry per device:
✅ 39121FDJG003TR, ✅ R5CT10ABCDE
200 when every install succeeded, 400 when any of them failed — use the status code, not the
emoji, to decide (see Gotchas).
Lists a directory on the device (ls -alZ under the hood).
| Parameter | Required | Notes |
|---|---|---|
serial |
yes | |
path |
no | URL-encoded; omitted or empty lists / |
curl -H "Authorization: Bearer $TOKEN" \
"http://HOST:8765/api/files/list?serial=39121FDJG003TR&path=%2Fsdcard"Returns a JSON array of DeviceFile. On a device-side failure the response is 404
with Error: permission denied or Error: Not a directory.
Root (su) listings are not available through the API.
Pulls one file off the device.
| Parameter | Required | Notes |
|---|---|---|
serial |
yes | |
path |
yes | directory holding the file, URL-encoded |
file |
yes | filename, URL-encoded |
curl -H "Authorization: Bearer $TOKEN" -OJ \
"http://HOST:8765/api/files/download?serial=39121FDJG003TR&path=%2Fsdcard&file=trace.txt"Responds with the file bytes, a MIME type guessed from the extension, and a
Content-Disposition: attachment; filename="..." header.
Directories aren't supported. A missing or zero-length file comes back as 404 File not found on device.
Pushes a file to the device.
| Parameter | Required | Notes |
|---|---|---|
serial |
yes | |
path |
yes | destination directory, URL-encoded |
file |
yes | destination filename, URL-encoded |
Body is the file content — multipart/form-data part named file, or the raw body.
curl -X POST -H "Authorization: Bearer $TOKEN" \
-F "file=@config.json" \
"http://HOST:8765/api/files/upload?serial=39121FDJG003TR&path=%2Fsdcard&file=config.json"{
"success": true,
"message": "File uploaded successfully",
"path": "/sdcard/config.json"
}400 No file data received if the body was empty.
Wakes the device, captures the screen and returns a full-resolution PNG.
| Parameter | Required | Notes |
|---|---|---|
serial |
yes |
curl -H "Authorization: Bearer $TOKEN" -o shot.png \
"http://HOST:8765/api/screenshot?serial=39121FDJG003TR"Responds image/png with Content-Disposition: attachment; filename="screenshot_<serial>.png".
For anything more than the occasional frame, use /ws/screen — it reuses one
connection and can scale and JPEG-compress frames.
Live logcat for one device, filtered server-side.
ws://HOST:8765/ws/logs?token=<token>&serial=<serial>&filter=<filter>
| Parameter | Required | Notes |
|---|---|---|
token |
yes | unless sent as an Authorization header |
serial |
yes | |
filter |
no | URL-encoded filter expression; syntax in LOGS.md |
If the device is unknown or the filter doesn't parse, the socket opens and is then closed with
1008 after an {"type":"error"} message.
Log batches are binary frames: gzip-compressed UTF-8 JSON containing an array of formatted log lines. Batches are flushed every 500ms, and only when there's something to send.
gunzip → ["09-08 14:03:54.308 1234 1300 D MyTag: hello", ...]
Each line is date pid tid level tag: message.
Everything else is a JSON text frame with a type field:
type |
Payload | Meaning |
|---|---|---|
connected |
device, message |
stream is live |
processMap |
map — pid → process name |
lets a client resolve pids to app names |
status |
state: paused | resumed | filter_updated (+ filter) |
acknowledges a control message |
error |
message |
filter error, device error, etc. |
pong |
timestamp |
reply to a client ping action |
The server also sends a WebSocket-level ping every 30 seconds; standard clients answer it automatically.
JSON text frames with an action:
{"action": "pause"}
{"action": "resume"}
{"action": "filter", "filterText": "level:E && tag:*MyTag*"}
{"action": "ping"}pause stops logcat on the device entirely (not just the sending), so nothing is buffered while
paused. filter with an empty or missing filterText clears the filter. Close the socket normally
to stop.
Mirrors a device as a stream of still images, and carries input back the other way. This is what the
built-in mirror window uses for remote devices instead of scrcpy.
ws://HOST:8765/ws/screen?token=<token>&serial=<serial>&compress=true
| Parameter | Required | Notes |
|---|---|---|
token |
yes | unless sent as an Authorization header |
serial |
yes | |
compress |
no | true → JPEG, anything else → PNG (default) |
The device is woken on connect. On disconnect the server clears the screen stay-on setting it used.
Every frame is one binary message:
┌────────────┬──────────────────┬───────────────────┐
│ 4 bytes │ headerLen bytes │ rest of message │
│ headerLen │ header JSON │ image bytes │
│ (int32 BE) │ (UTF-8) │ (PNG or JPEG) │
└────────────┴──────────────────┴───────────────────┘
Header:
{
"type": "frame",
"frameId": 42,
"width": 1080,
"height": 2400,
"timestamp": 1757352000000,
"format": "jpeg",
"size": 84213
}width/height are the device's dimensions. At medium or low quality the transmitted image
is smaller than that — scale input coordinates against the header values, not the decoded bitmap.
{"type": "status", "status": "connected", "message": "Screen stream started"}
{"type": "error", "error": "Capture error: ..."}status values: connected, paused, resumed, interval_changed, compression_changed,
quality_changed.
{"action": "pause"}
{"action": "resume"}
{"action": "close"}
{"action": "setInterval", "intervalMs": 500}
{"action": "setCompression", "useCompression": true}
{"action": "setQuality", "quality": "medium"}-
setInterval— capture interval, 50–5000ms; out of range gets anerrormessage and is ignored. Default is 250ms. -
setQuality—high|medium|low:quality scale JPEG quality high1.0 0.70 medium0.75 0.50 low0.5 0.30 Scale applies to PNG as well; the JPEG quality column only matters when compression is on.
All use "action": "input" plus a type:
{"action": "input", "type": "tap", "x": 540, "y": 1200}
{"action": "input", "type": "swipe", "x1": 540, "y1": 1800, "x2": 540, "y2": 600, "duration": 300}
{"action": "input", "type": "text", "text": "hello world"}
{"action": "input", "type": "keyevent", "keycode": 4}Coordinates are in device pixels. duration is optional (default 300ms). keycode is an Android
KEYCODE_* integer (4 = BACK, 3 = HOME). Text is passed to input text, with spaces escaped as
%s by the server — send the plain string.
Input is fire-and-forget: the server doesn't acknowledge it, and errors come back as error
messages.
Serialized from Device. Null
fields are omitted.
{
"serial": "39121FDJG003TR",
"nickname": "lab phone 3",
"phone": "+15555550123",
"imei": "350000000000000",
"freeSpace": 51539607552,
"totalSpace": 128849018880,
"batteryLevel": 87,
"powerStatus": "POWER_AC",
"batteryInfo": {
"level": 87,
"tempC": 29.4,
"voltageMv": 4210,
"currentUa": -152000,
"powerStatus": "POWER_AC"
},
"model": "Pixel 7",
"os": "14",
"sdk": "34",
"carrier": "Verizon",
"timezone": "America/Chicago",
"status": null,
"isOnline": true,
"isBooted": true,
"lastUpdateMs": 1757352000000,
"bootTimeMs": 1757300000000,
"customPropertyMap": { "team": "lab-a" },
"customAppVersionList": { "com.example.app": "1.4.2" }
}| Field | Type | Notes |
|---|---|---|
serial |
string | the device id used by every other endpoint |
nickname |
string | user-set name, stored on the device |
phone, imei |
string | when available |
freeSpace, totalSpace |
long | bytes |
batteryLevel |
int | 0–100 |
powerStatus |
enum | POWER_NONE, POWER_AC, POWER_USB, POWER_WIRELESS, POWER_DOCK |
batteryInfo |
object | latest temperature/voltage/current reading |
model, os, sdk, carrier, timezone |
string | from getprop |
status |
string | error text when something's wrong |
isOnline, isBooted |
bool | always present |
lastUpdateMs, bootTimeMs |
long | host epoch millis |
customPropertyMap |
map | ADM custom properties (see /api/setproperty) |
customAppVersionList |
map | package → version, for the app-version columns |
{
"name": "trace.txt",
"size": 20481,
"dateMs": 1757352000000,
"permissions": "-rw-rw----",
"user": "shell",
"group": "sdcard_rw",
"security": "u:object_r:sdcardfs:s0",
"isDirectory": false,
"isSymbolicLink": false,
"isReadOnly": false
}{ "isSuccess": true, "resultList": ["line 1", "line 2"] }{ "version": "1.0", "deviceCount": 3, "serverName": "joes-mac" }From the UI — SERVER toolbar button → "Start Server". Port and auth token are editable before starting, and both are remembered, so the server restarts automatically next launch.
Headless — useful for a dedicated device host:
java -jar AndroidDeviceManager.jar --server --port 8765 --token "my-secret-token"--portand--tokenare optional; both are saved to preferences and reused next time.- Without a saved token, one is generated (16 random alphanumeric characters).
- On start the log prints the token, the port and an
adm://connection string per network interface. - ADM also switches to server mode automatically when it can't reach a display.
The server needs adb on PATH like any other ADM instance.
Behaviour worth knowing before you build against this.
intervalMsin the/ws/screenURL is ignored. The server reads onlyserialandcompressat handshake, so a stream always starts at 250ms. SendsetIntervalonce connected. (ADM's own client passes it in the URL and it has no effect.)/api/installmarkers are unreliable. The ✅/❌ per device is written before that device's result is known, so it reflects the run so far rather than that device. The HTTP status is accurate:200= all succeeded,400= at least one failed./api/setpropertyis notsetprop. It writes an ADM custom property to a file on the device.- Rejections arrive after the WebSocket opens. Bad token, unknown device and unknown endpoint all
complete the upgrade first, then send an
errormessage and close with1008. Don't treat "opened then closed immediately" as a transport problem. - Device-scoped GETs don't refuse proxied devices.
/api/executeand/api/setpropertyreturn404for a device the server itself reached remotely, but/api/properties, the file endpoints,/api/screenshotand/ws/logswill chain the request onward (they only log a warning). Since/api/devicesnever lists those serials, this only comes up if a client supplies one itself. - The log stream's
logs/entriesJSON message doesn't exist. ADM's client can parse it, but the current server always sends batches as gzipped binary. Implement the binary path. - A
tokenin a query string ends up in logs. Prefer theAuthorizationheader wherever your client can set one.