Skip to content

Device Sync Protocol

This is a reference for the protocol RomM uses for bidirectional sync with companion apps. End-user view in Saves & States, with operator-side SSH transport in SSH Sync.

Primitives

  • Device: a registered endpoint, bound to a Client API Token.
  • Sync session: one atomic bidirectional run (pull, push, conflict reconcile, play-session ingest).
  • Play session: per-ROM playtime record, posted standalone or batched at sync end.

Authentication

Every call: Authorization: Bearer rmm_.... Required scopes:

Endpoint family Scope
/devices/* devices.read, devices.write
/sync/* assets.read, assets.write, devices.write
/play-sessions/* me.read, me.write (read own), users.read (read others')
/assets/* (save/state I/O) assets.read, assets.write

Registering a device

After pairing:

POST /api/devices
Authorization: Bearer <token>
Content-Type: application/json

{
  "name": "RG35XX - Living Room",
  "platform": "muos",
  "hostname": "rg35xx-livingroom.local",
  "mac": "aa:bb:cc:dd:ee:ff",
  "sync_mode": "push_pull",
  "paths": { "roms": "/roms", "saves": "/saves", "states": "/saves/states" }
}

Response includes id, which the device caches for subsequent calls. sync_mode can be pull_only (server → device), push_only (device → server), or push_pull (bidirectional, default).

Sync negotiation

The device sends what it has and RomM returns what to do:

POST /api/sync/negotiate
{
  "device_id": 17,
  "roms": [
    {
      "rom_id": 1234,
      "saves": [
        { "file": "mario.srm",   "mtime": "2026-04-18T09:42:01Z", "sha1": "abc..." },
        { "file": "mario.state", "mtime": "2026-04-18T09:45:00Z", "sha1": "def..." }
      ]
    }
  ]
}

Response is a list of operations:

{
    "session_id": "550e8400-e29b-41d4-a716-446655440000",
    "operations": [
        {
            "type": "upload",
            "rom_id": 1234,
            "file": "mario.srm",
            "destination": "/api/saves"
        },
        {
            "type": "download",
            "rom_id": 5678,
            "file": "zelda.srm",
            "source": "/api/saves/42/content",
            "dest_path": "/saves/zelda.srm"
        },
        {
            "type": "conflict",
            "rom_id": 9999,
            "file": "tetris.srm",
            "resolution": "keep_both"
        },
        { "type": "noop", "rom_id": 1111, "file": "goldeneye.srm" }
    ]
}
Op Meaning
upload Device POSTs the file to destination.
download Device GETs source and writes to dest_path.
conflict Both sides newer, resolution must be one of keep_both (default), server_wins, device_wins.
noop Hashes match, nothing to do.

Upload (POST /api/saves, multipart) and download (GET /api/saves/{id}/content) both require the bearer token.

Completing a session

POST /api/sync/sessions/{session_id}/complete
{
  "operations_completed": 15,
  "operations_failed": 1,
  "play_sessions": [
    { "rom_id": 1234, "start": "2026-04-18T09:00:00Z", "end": "2026-04-18T09:45:00Z", "duration_seconds": 2700 }
  ]
}

Closes the session and ingests batched play sessions in one call.

Rate limits and polling

  • Sync once per session, not per save
  • Don't poll /api/sync/negotiate tightly
  • No push channel yet, so polling is the only model

See also