OpenMDM
Concepts

Architecture

How OpenMDM fits into your backend and how data flows between your server and devices.

Architecture

OpenMDM is shaped by one hard constraint: it must run in the same process as your app and read/write the same database. Every other design choice follows from that.

This page is the map. If you want to understand why some API looks the way it does, start here.

The three boundaries

Every MDM deployment has three components, and OpenMDM gives you exactly one of them:

┌─────────────────────┐      ┌─────────────────────┐      ┌─────────────────────┐
│                     │      │                     │      │                     │
│   Your backend      │◄────►│  Push infra         │◄────►│  Android device     │
│   (Node / Hono)     │ HTTPS│  (FCM or MQTT)      │  FCM │  (openmdm-android)  │
│                     │      │                     │      │                     │
│   - Your routes     │      │  You pick one.      │      │  The agent APK you  │
│   - OpenMDM SDK     │      │  Both are real      │      │  build with your    │
│   - Your DB         │      │  production paths.  │      │  secrets compiled   │
│                     │      │                     │      │  in.                │
└─────────────────────┘      └─────────────────────┘      └─────────────────────┘
        ▲                                                          ▲
        │                                                          │
        │                HTTPS requests from device                │
        └──────────────────────────────────────────────────────────┘
  • Your backend is the box OpenMDM lives in. You keep owning your DB, your auth, your observability.
  • Push is a separate service you run or rent. OpenMDM abstracts it behind a PushAdapter so swapping FCM for MQTT is a one-line config change.
  • The Android agent is a separate GitHub repo, openmdm-android. You build it once with your enrollment secret compiled in, then distribute the APK through whatever mechanism you already use (zero-touch, MDM provisioning, sideload, etc.).

OpenMDM does not run a server of its own. There is no @openmdm/server package. If you see that in an older doc, it's wrong.

The SDK shape

A single createMDM(config) call returns an MDMInstance:

const mdm = createMDM({
  database: drizzleAdapter(db),   // how to persist
  push: fcmPushAdapter({ ... }),  // how to wake devices
  enrollment: { deviceSecret },   // how to validate enrollment
  auth: { deviceTokenSecret },    // how to sign device JWTs
  plugins: [kioskPlugin({ ... })],
  webhooks: { endpoints: [...] },
});

The instance exposes managers (mdm.devices, mdm.policies, mdm.commands, mdm.apps, mdm.groups), an event bus (mdm.on, mdm.emit), and a few protocol entrypoints (mdm.enroll, mdm.processHeartbeat, mdm.verifyDeviceToken). That's it. Everything else is either an adapter or a plugin.

Adapters are the pluggable parts: DatabaseAdapter, PushAdapter, and the (optional) StorageAdapter for APK hosting. If you don't like Drizzle, write your own DatabaseAdapter and pass it in. If you want to send push through your own in-house broker, write a PushAdapter.

The three control flows

1. Enrollment

Enrollment is the one-time handshake where a device proves it's allowed to join the fleet and receives a long-lived token.

Device                              Your backend
  │                                      │
  │  POST /mdm/agent/enroll              │
  │  { deviceInfo, method,               │
  │    timestamp, signature }            │
  ├─────────────────────────────────────►│
  │                                      │
  │                                      │  verifyEnrollmentSignature(req)
  │                                      │  checks HMAC against DEVICE_SECRET
  │                                      │
  │                                      │  createDevice(...)
  │                                      │  issueDeviceToken(...)
  │                                      │
  │  201 { deviceId, token }             │
  │◄─────────────────────────────────────┤
  │                                      │

The signature is an HMAC-SHA256 over a canonical pipe-delimited message. If the secret matches, the server writes a new row in mdm_devices, assigns the default policy (if any), and returns a device-scoped JWT. The agent persists that JWT and uses it on every subsequent request.

DEVICE_SECRET lives in two places: your .env and the compiled Android APK. Rotating it means rebuilding the agent and redeploying both sides together.

See Enrollment for the canonical form and Agent Wire Protocol for what happens when the signature fails.

2. Heartbeat loop

After enrollment, the device calls /mdm/agent/heartbeat on a schedule (default: every 60s, configurable per policy). Each heartbeat carries telemetry and returns the next batch of commands plus any policy update.

Device                              Your backend
  │                                      │
  │  POST /mdm/agent/heartbeat           │
  │  Bearer <device token>               │
  │  { battery, storage, apps,           │
  │    location, security }              │
  ├─────────────────────────────────────►│
  │                                      │
  │                                      │  deviceAuth middleware
  │                                      │  processHeartbeat(id, body)
  │                                      │    ├─ updates mdm_devices row
  │                                      │    ├─ writes mdm_events
  │                                      │    └─ fires 'device.heartbeat'
  │                                      │
  │                                      │  commands.getPending(id)
  │                                      │  policies.get(device.policyId)
  │                                      │
  │  200 {                               │
  │    pendingCommands: [...],           │
  │    policyUpdate: { ... } | null      │
  │  }                                   │
  │◄─────────────────────────────────────┤

The heartbeat is the only reliable way policies reach devices. Push wakes the device up, but the actual policy and command delivery happens on the next heartbeat. This matters when you're debugging why a freshly assigned policy hasn't taken effect — it will, on the next heartbeat.

3. Command delivery

When your app calls mdm.devices.sendCommand(), three things happen:

Your app                     Server                     Push infra              Device
  │                              │                          │                      │
  │ sendCommand(id, {type,…})    │                          │                      │
  ├─────────────────────────────►│                          │                      │
  │                              │  INSERT INTO              │                      │
  │                              │  mdm_commands             │                      │
  │                              │  (status='pending')       │                      │
  │                              │                          │                      │
  │                              │  push.send(id, {         │                      │
  │                              │    type: 'command.sync'  │                      │
  │                              │  })                      │                      │
  │                              ├─────────────────────────►│                      │
  │                              │                          │   FCM data message   │
  │                              │                          ├─────────────────────►│
  │                              │  UPDATE mdm_commands      │                      │
  │                              │  SET status='sent'        │                      │
  │  Command { id, status='sent'}│                          │                      │
  │◄─────────────────────────────┤                          │                      │
  │                              │                          │                      │
  │                              │◄─────────────────────────┼──────────────────────┤
  │                              │       POST /mdm/agent/heartbeat                  │
  │                              │       (triggered by push)                        │
  │                              │                                                  │
  │                              │  response includes                               │
  │                              │  pendingCommands: [{…}]                          │
  │                              │                                                  │
  │                              │◄─────────────────────────┼──────────────────────┤
  │                              │       POST /mdm/agent/commands/:id/ack           │
  │                              │                                                  │
  │                              │  UPDATE mdm_commands SET status='acknowledged'   │
  │                              │                                                  │
  │                              │◄─────────────────────────┼──────────────────────┤
  │                              │       POST /mdm/agent/commands/:id/complete      │
  │                              │                                                  │
  │                              │  UPDATE mdm_commands SET status='completed'      │

The command moves through pending → sent → acknowledged → completed (or failed). The push notification is a wake-up hint, not the command itself; the command body is always pulled by the device on the heartbeat.

This is "MDM is pull and push, not just push" — and it's why the SDK has no concept of a persistent open connection from the server to the device. The DB row is the source of truth; push is best-effort delivery of a wake-up.

Why the heartbeat is pulled, not pushed

If you're coming from a web background, you might expect something like WebSockets. The reason MDM doesn't work that way:

  • Android aggressively kills long-lived network connections to save battery. A "persistent" socket lasts minutes, not days.
  • Carrier NAT timeouts are in the same ballpark.
  • Android Enterprise expects agents to be silent most of the time and only work on demand — persistent connections would burn battery and set off battery-optimization heuristics.
  • FCM is already the official "wake up this app in the background" primitive.

Pull-with-push-wakeup is the only pattern that survives contact with real fleets. OpenMDM doesn't try to fight it.

What you own vs what OpenMDM owns

ConcernYouOpenMDM
HTTP server, TLS, reverse proxy
Admin authentication (who can call mdm.devices.list)
Database schema + migrations✅ via drizzle-kitschema definition only
Device enrollment + token issuance
Heartbeat processing
Command queue + state machine
Policy storage + delivery
Push delivery✅ via adapter
Webhook signing + retry
Business logic that reacts to device events✅ via event handlers
APK hosting✅ or S3 adapteradapter only

The short version: OpenMDM owns the MDM protocol. You own everything that makes your app your app.

Where to go next