DEV Community

Cover image for I built an in-browser Roku TV remote with ~80 lines of TypeScript. Here's how Roku's ECP API actually works
hisuperdev
hisuperdev

Posted on

I built an in-browser Roku TV remote with ~80 lines of TypeScript. Here's how Roku's ECP API actually works

Roku ships an HTTP API on every device they sell. It has no authentication, no API key, no documentation page on a marketing site — but it powers every third-party Roku remote app on the App Store and Play Store. It's called ECP (External Control Protocol) and once you've seen it, you'll wonder why the rest of the smart-TV world isn't this simple.

I needed an in-browser remote for HiRemote's Hisense Roku TV landing page — the idea being a visitor who lands on the page can press buttons in the page itself without first installing the iOS app. Three quirks made it harder than the marketing-page version suggests; here's the actual implementation.

1. Discovery — SSDP is overrated, the user types the IP

Every Roku tutorial starts with "use SSDP multicast on 239.255.255.250:1900". This is true but useless from a browser: browsers can't send UDP. You can't run SSDP from JavaScript.

For a browser-based remote, the pragmatic solution is: ask the user for their TV's IP. On the iPhone app side we use Bonjour. On the web page side we just show a one-time input box, then localStorage it. Roku TVs always run ECP on port 8060, so once you have the IP, the base URL is fixed:

const baseUrl = `http://${tvIp}:8060`
Enter fullscreen mode Exit fullscreen mode

The only gotcha here is that the user's browser is HTTPS but the TV is HTTP. Modern browsers block mixed-content requests, so you have to either accept this and let the request fail gracefully, or proxy through your own backend. We chose the first option — the input box explains the limitation and tells the user to also try the iOS app which doesn't have this restriction.

2. Buttons — every command is a POST to a path

The control surface is one of the cleanest API designs I've seen in a consumer device:

POST http://<tv-ip>:8060/keypress/<KeyName>
Enter fullscreen mode Exit fullscreen mode

<KeyName> is one of about 30 documented strings: Home, Up, Down, Left, Right, Select, Back, Play, Pause, Rev, Fwd, VolumeUp, VolumeDown, VolumeMute, PowerOn, PowerOff, plus a few platform-specific ones for Roku TV (channel up/down, input switching).

No body, no headers, no auth. Just the POST:

async function sendKey(key: RokuKey) {
  return fetch(`${baseUrl}/keypress/${key}`, {
    method: "POST",
  })
}
Enter fullscreen mode Exit fullscreen mode

For typing into search boxes (Netflix login, YouTube search), there's /keypress/Lit_<urlEncodedChar> — one POST per character. Cleaner than building a virtual keyboard, ugly that it isn't batched, but it works.

3. Direct-app-launch — the surprisingly useful one

The endpoint nobody talks about:

POST http://<tv-ip>:8060/launch/<channel-id>
Enter fullscreen mode Exit fullscreen mode

Channel IDs are stable Roku-assigned numbers. 12 is Netflix, 13 is Prime Video, 837 is YouTube, 291097 is Disney+. Posting to /launch/12 boots Netflix on the TV — no D-pad navigation needed.

This is the killer feature for a remote that lives on a phone or in a browser: you skip the entire "navigate the home screen" UX that makes physical Roku remotes annoying. One tap → on Netflix.

Full list of channel IDs is in the device's response to GET /query/apps (returns XML, so use DOMParser not JSON.parse).

4. Putting it together

type RokuKey =
  | "Home" | "Back" | "Select"
  | "Up" | "Down" | "Left" | "Right"
  | "VolumeUp" | "VolumeDown" | "VolumeMute"
  | "PowerOn" | "PowerOff"
  | "Play" | "Pause" | "Rev" | "Fwd"

class RokuRemote {
  constructor(private tvIp: string) {}
  private base() { return `http://${this.tvIp}:8060` }

  press(key: RokuKey) {
    return fetch(`${this.base()}/keypress/${key}`, { method: "POST" })
  }

  type(text: string) {
    return Promise.all(
      [...text].map(ch =>
        fetch(`${this.base()}/keypress/Lit_${encodeURIComponent(ch)}`, { method: "POST" })
      )
    )
  }

  launchApp(channelId: number) {
    return fetch(`${this.base()}/launch/${channelId}`, { method: "POST" })
  }
}
Enter fullscreen mode Exit fullscreen mode

That's the whole remote. Render a D-pad with onClick={() => remote.press("Up")} and you have a working web-based Roku remote in 80 lines.

TL;DR

Roku's HTTP control protocol is plain POST /keypress/<KeyName>. No auth. 80 lines of TypeScript = working remote. Discovery is the only genuinely hard part for a browser context, and "ask the user for the IP" is the right answer there.

The end result is live at hiremote.app/hisense-roku-tv-remote — bring your own Roku-TV IP and try it without installing anything.

Top comments (0)