Skip to content

Gateway API

Alex Van de Putte edited this page Jul 29, 2026 · 22 revisions

The Gateway API (REST & MQTT)

MQTT is SplitFlap Gateway (physical) only. The Matrix Gateway removed its entire MQTT/Home Assistant surface in firmware 3.0 — its push channel is the SSE stream (GET /api/events) and its only control surface is REST. Rows below marked "both" for MQTT-adjacent endpoints predate that and are annotated.

This is the integrator's reference for driving a gateway programmatically — both the SplitFlap Gateway (driving a real RS-485 wall) and the Matrix Gateway (the same firmware family drawing virtual modules on an LED panel). They speak the same API by design; where they differ, this page says so. (Both products run on ESP32-class boards — the Matrix Gateway is an ESP32-S3 — so this page never says "ESP32" to tell them apart: the distinction is the wall each drives, and the labels are SplitFlap Gateway and Matrix Gateway.) Every display endpoint here ultimately wraps the raw bus frames documented in Bus ProtocolPOST /api/flap/char with {"id":5,"char":"A"} becomes m5-A\n on the wire.

(The Companion exposes a separate, Vestaboard-compatible surface of its own — that's Vestaboard API, not this page.)

Orientation

  • Base URL: http://<gateway-ip> — or http://192.168.4.1 while on the setup AP. The Both gateways advertise mDNS names of the form splitflap-gw-<6 hex digits>.local — the board's own id (SplitFlap Gateway since firmware 3.11; before that, plain splitflap-gw). The Matrix Gateway's hostname is also configurable. mDNS is best-effort — see finding it on your network.
  • JSON in, JSON out. All POST endpoints accept and return application/json (the two binary exceptions — OTA upload and the companion settings blob — are called out below).
  • CORS is open. Every endpoint sends Access-Control-Allow-Origin: *, so a page served from anywhere on your LAN can call the API straight from the browser.
  • No authentication. Anything that can reach the gateway's IP can drive the wall — the API treats your LAN as the trust boundary. The only credential anywhere is the optional OTA password.
  • Which wall am I talking to? GET /api/config reports version — the API level, not the build: the Matrix Gateway deliberately answers 3.1.0 there (clients gate features on it) and puts its real build in fwVersion, next to product. GET /api/capabilities also reports product, fw and api. Version pairings live on Compatibility.

REST endpoints

Bus

Method Path Body Reply Gateway
GET /api/rs485/messages Frames buffered since the last call (up to 64); each call drains the buffer SplitFlap Gateway only
GET /api/log Commands acted on since the last call, up to 64 (REST-only since fw 3.0; REST + MQTT through 2.2) — same drain-cursor idea, but there is no wire to trace on a drawn wall Matrix Gateway only
POST /api/rs485/send {"data":"m5-A\n"} — optional "raw":true {ok,bytes} SplitFlap Gateway
POST /api/frames/send {"data":"m5-A\n"} — optional "raw":true {ok,bytes} Matrix Gateway
POST /api/rs485/batch {"frames":["m00-A\n","m01-B\n",…],"step_ms":15} {ok,sent} SplitFlap Gateway
POST /api/frames/batch {"frames":[…],"step_ms":15} {ok,sent} Matrix Gateway

Matrix Gateway naming. Its raw-frame endpoints are /api/frames/send and /api/frames/batch — bodies and behaviour identical to the SplitFlap Gateway's rs485 paths. Frames on a drawn wall are one-way: the modules act on them and nothing replies.

Framing is normalized for you. Unless "raw":true, the gateway strips/re-adds the trailing newline and trims junk past a complete known command — m5-A and m5-A\n behave identically. raw is the debugging escape hatch that sends bytes verbatim. Frame syntax lives on Bus Protocol.

Batch is how pages get drawn. One batch call — POST /api/rs485/batch, or /api/frames/batch on the Matrix Gateway — carries a whole wall of up to 512 frames in a single HTTP call, with no broker in the display path. On a physical wall it is what the Companion uses for every page; on a Matrix wall the Companion prefers the higher-level /api/display/cells, which skips unchanged cells and reaches lowercase, accents and pictographs. step_ms (0–30) paces the cascade device-side so the wall animates instead of snapping over at once. Since gateway 3.4 paced frames are queued and the call returns immediately: 200 means accepted, not yet on the wire, and the pacing queue holds 127 frames in flight (a longer cascade loses its stagger past that point; the frames still all arrive).

Modules

Method Path Body Reply Gateway
GET /api/flap/modules Array of module objects. SplitFlap Gateway: id, serial, firmware, current character, last-seen. Matrix Gateway: just {id, flapIndex, flapChar} — a drawn module has no serial or firmware both
POST /api/flap/char {"id":5,"char":"A"}id:-1 broadcasts {ok} both
POST /api/flap/index {"id":5,"index":1} {ok} both — 0–63 on a real 64-flap reel, 0–236 on the Matrix Gateway's 237-flap reel
POST /api/flap/text {"text":"HELLO","start":0} {ok,chars} both
POST /api/flap/home {"id":5}id:-1 homes all {ok} both

char and text take UTF-8 and transcode to the single Windows-1252 byte the bus uses, so and accented letters work. ASCII letters are uppercased — except the seven lowercase colour codes r o y g b p w, which address the colour flaps and go through as-is. Characters with no Windows-1252 representation are rejected.

The SplitFlap Gateway also carries a whole hardware-maintenance tier under /api/flap/* — version/EEPROM queries, calibration, provisioning, self-diagnostics, per-module flap-set config, restore-by-serial. The Matrix Gateway omits all of it by design: its modules are drawn, so there is nothing to calibrate or provision. That tier is documented in the SplitFlapGateway README and openapi.yaml (see the footer).

Display

Method Path Body Reply Gateway
GET /api/display/state {rows,cols,cells:[…]} — cell index = row·cols + col = module id; null = no module there. The Matrix Gateway (fw 3.0.1) adds flaps:[index…] (the raw flap index per cell — the only way to tell a colour flap, index 156–162, from a lowercase r o y g b p w, whose cells letter is identical) and mode:"wall"|"pixels" (pixels = canvas/effect/animation/ticker owns the panel; render GET /api/canvas/frame instead of cells) both
GET /api/events (fw 3.0) Server-Sent Events stream (events capability token): a display event with the display-state JSON within ~150 ms of the wall changing (max ~7/s, snapshot on connect, 15 s keepalives, up to 3 streams — a 4th gets 503), plus (fw 3.2) a status event with the /api/status JSON every 5 s. The dashboard's Live Display and status pane ride it, with polling as fallback Matrix Gateway only
GET/POST /api/sound — / {freq,ms,vol} or {notes:[[f,ms],…],vol} or {stop} (fw 3.6, sound token) Tones and chimes on the board's speaker — on-device synthesis. Quiet Time silences it (mid-play too); the settings master enable (403 off) and master volume apply (v3.6.1) Matrix Gateway only
GET /api/environment (fw 3.7, environment token) Onboard SHTC3 temperature + humidity — {available, tempC, tempF, rh, ageMs}; also in /api/status under env Matrix Gateway only
GET /api/sd (fw 3.10, sd token) microSD card presence + capacity — {present, type, sizeMB, usedMB, freeMB}; also in /api/status under sd. Answers 200 even with no card (present:false) Matrix Gateway only
GET /api/sd/list ?path=/ (fw 3.10) One directory level: [{name,dir,size},…]. Paths are card-absolute (must start with /, no ..). 503 when no card is mounted Matrix Gateway only
GET /api/sd/get ?path=/dir/file (fw 3.10) Download a file as raw bytes (application/octet-stream) Matrix Gateway only
PUT /api/sd/mkdir ?path=/dir (fw 3.10) Create a directory (parent must exist) → {ok}; 409 if it already exists Matrix Gateway only
PUT /api/sd/put ?path=/dir/file + raw body (fw 3.10) Upload/overwrite a file → {ok,bytes} Matrix Gateway only
DELETE /api/sd/delete ?path=/dir/file [&recursive=1] (fw 3.10) Remove a file, an empty directory, or (with recursive=1) a directory and everything under it → {ok}. A non-empty dir without the flag returns 409; the card root is protected Matrix Gateway only
POST /api/display/cells {"start":0,"step_ms":15,"cells":[{"ch":"H"},{"color":"red"},{"blank":true},{"skip":true}]} RS-485: {ok,cells,sent,skipped} · Matrix Gateway: {ok,cells,sent} both — gateway 3.8, Matrix 1.6

/api/display/state drives the Live Display. On the SplitFlap Gateway a cell is the tracked character ("?" when unknown, e.g. after a home or an index set); on the Matrix Gateway it is read straight from the module and reported as a code point, so lowercase, accents and a read back as themselves (a colour flap reports as its protocol letter).

/api/display/cells is the index-addressed display API — the same JSON contract on both gateways, so one client can drive either wall through one endpoint. Each cell is exactly one of ch (a character), color (a named flag: red orange yellow green blue purple white), blank (home the module) or skip (leave it alone); step_ms (0–30) paces the cascade without ever blocking the web server. It exists because the one-byte character protocol cannot say everything: the byte for lowercase r already means red, and a heart has no byte at all — see Reaching the extra flaps.

One deliberate difference:

SplitFlap Gateway Matrix Gateway
A cell the wall can't show Lenient — skipped, reported in skipped (real reels differ per module; only structural errors 400) Strict — the whole request is a 400 ("no flap for U+1F600"), resolved before anything is sent
Sent on the (emulated) bus as m<id>-<char> — each module maps the byte against its own reel m<id>+<n> — one shared reel, so an index always names the same flap

A client that needs certainty consults /api/capabilities first.

Status & configuration

Method Path Body Reply Gateway
GET /api/status Uptime, IP, NTP, heap, and the quiet flag (SplitFlap Gateway also maintenance and mqtt). The Matrix Gateway adds a panel object — size, grid, font, and the running bit depth — and per-task stack watermarks (stk, incl. httpd since 3.0) both
GET /api/config Current configuration, passwords excluded; includes version (the API level) both
POST /api/config/wifi {"ssid":"…","pass":"…"} {ok} — reconnects both
POST /api/config/mqtt {"host":"…","port":1883,"user":"…","pass":"…","prefix":"splitflap"} {ok} — reconnects SplitFlap Gateway only (Matrix removed MQTT in 3.0)
POST /api/config/settings Send only what you're changing: posixTZ, ntpServer, serialDebug, haEnabled, otaPassword — the Matrix Gateway adds panel fields (panelW/panelH/panelBitDepth/panelBGR/panelBright), the module-grid layout (gridRows/gridCols) and flip timing (flapMs/flapMax) {ok} both
POST /api/config/rs485 Bus parameters (baud, data bits, parity, stop bits) {ok} SplitFlap Gateway only — the Matrix Gateway removed it (there is no RS-485 bus)
GET /api/capabilities What the wall can show — see below both, identical shape
GET / POST /api/maintenance {"on":true} {ok,on} SplitFlap Gateway only — the Matrix Gateway removed maintenance mode (a drawn wall needs no calibration to protect)
GET / POST /api/quiet {"on":true} {ok,on} both
GET / POST /api/quiet/schedule {"enabled":true,"start":"22:00","end":"07:00","days":127} {enabled,start,end,days} both
POST /api/mqtt/test {"host","port","user","pass"} — all optional, defaults to saved config Broker reachability + credentials, without touching the live connection SplitFlap Gateway only (Matrix removed MQTT in 3.0)

Maintenance mode (SplitFlap Gateway only — the Matrix Gateway dropped it) makes the gateway ignore externally-originated MQTT commands (the web UI keeps working) so calibration work isn't disturbed. Quiet time blanks the wall — every reel homes to its blank flap — and restores it when turned off; the schedule's days is a bitmask, bit 0 = Sunday. Both reset to off on reboot.

Files, display & system (Matrix Gateway)

Method Path Body Reply Gateway
GET /api/fs (fw 2.2) FATFS totals + recursive file list — the storage behind /anim, /fonts and the companion blob Matrix Gateway only
GET /api/fs/file?path=… Download one file (Content-Disposition carries its name) Matrix Gateway only
POST /api/fs/delete {"path":"/anim/x.mpg"} {ok} — deliberately unrestricted; it is your flash Matrix Gateway only
POST /api/fs/upload?name=<file> The file's bytes, raw (multipart in 2.2; raw body since 3.0) {ok,path,bytes} — routed by extension: .mpg/anim/, .fnt/fonts/, else / Matrix Gateway only
GET/POST /api/display/brightness {"brightness":1-255} (fw 2.2) Panel brightness, applied to the next frame and persisted; the brightness capability token advertises it Matrix Gateway only
POST /api/system/reboot (fw 2.2.3) {ok,rebooting:true} — replies first, then restarts Matrix Gateway only

OTA & UI

Method Path Body Reply Gateway
GET /ota Browser firmware-upload page both
POST /api/ota/upload firmware.bin — the plain app image, never the factory image. Multipart on the physical gateway; on the Matrix Gateway (v3.0+) the raw binary body (curl --data-binary @firmware.bin) Flashes and reboots both
GET /lang/{code} The dashboard's translation dictionary for one language (keys are the English strings); 404 for a code the firmware doesn't ship both

/lang/{code} is served with Content-Encoding: gzip — a transfer encoding the client inflates transparently. Note the contrast with the companion settings blob below, where the gzip bytes are the payload.

Canvas (Matrix Gateway only)

The Matrix Gateway can hand its HUB75 panel over as a plain framebuffer, so a client can draw anything on it — raw frames (rgb888/rgb565/QOI), partial rectangles, an on-device animation loop, a scrolling ticker, JSON draw ops, six on-device effects with hue/density, and a framebuffer readback. A physical wall has no framebuffer, so none of these endpoints exist there.

The full surface — GET/POST /api/canvas, PUT/GET /api/canvas/frame, PUT /api/canvas/rect, PUT /api/canvas/qoi, PUT /api/canvas/anim, POST /api/canvas/ticker, POST /api/canvas/ops, POST /api/canvas/effect — is documented on its own page: Canvas API.

/api/capabilities — one contract across walls

GET /api/capabilities answers "what characters can this display show?" in one call, made once when a client connects. Both gateways answer the same URL with the same shape by design — a client never has to know which kind of wall it is talking to. (It is the "what can you show" companion to /api/display/cells' "show this".)

The subtlety it exists for: on a real wall the answer is not one set. Every module owns its reel, and since module firmware v31 each can be told a different one — so the response reports set arithmetic, not a single alphabet:

Field The question it answers
charset.union Can this wall show a Z anywhere? — every character some module can show
charset.common Can I lay this text across arbitrary cells? — every character every module can show
charset.uniform Does every module carry the same reel? (always true on the Matrix Gateway — one drawn reel)
charset.assumed Module ids too old (pre-v31) to report a reel — theirs is assumed to be the firmware default, and the guess is visible rather than folded in silently
charset.unknown Module ids whose reel is genuinely not known yet — excluded from both sets
sets Each distinct reel once: flaps, source (reported/assumed/builtin), the reel chars verbatim in flap-index order, and the ids that carry it as a compressed list ("0-44,50")
colors The colour flaps the wall actually has, by name (["red","orange",…])
maxFlaps Flaps a module can carry — 64 real, 237 drawn
features Named capability flags to check instead of sniffing endpoints. The walls differ: a SplitFlap Gateway advertises e.g. ["colors","index","batch","quiet","maintenance","ha","ota","flapconfig"]; a Matrix Gateway advertises ["cells","colors","index","lowercase","pictographs","quiet","ha","ota","canvas","effects","ticker"] — note canvas/effects/ticker, and no maintenance/flapconfig
motion How the wall moves (Gateway 3.10+, Matrix Gateway 1.12+): {"kind": "drawn" | "mechanical", "settleMs": …}. drawn — a cell is a repaint, interruptible, nothing queues, so sub-second updates are honest; mechanical — motion must physically complete, and settleMs (~4000: a full revolution) is a real constraint. On the drawn wall settleMs is the worst-case flip animation (flapMs × flapMax, live-configurable) — cosmetic pacing, advisory. Stated directly so a client never infers the wall's nature from which endpoints exist.

Plus product, fw, api, grid (rows × cols) and modules. A Matrix Gateway additionally advertises a canvas object (pixel formats including qoi, panel width/height, and the rect/anim/ticker/readback flags), an effects list, and effectParams (["hue","density"]) — the entry points to the Canvas API.

union and common genuinely differ: if module 1 carries A-Z and module 2 carries 0-9, the union is A-Z0-9 but the common set is empty — the wall cannot show HI42 wherever it likes, and only common says so. Reporting each distinct reel once (rather than one entry per module) is what keeps the response small: a uniform wall is a few hundred bytes however large it is.

Two translations are applied to the character sets, matching exactly what the gateway does when it resolves a frame: the seven colour flaps r o y g b p w are kept out of the sets and reported by name under colors, and q — which the classic reel borrows for the double-quote flap — is reported as ". A client that read those as letters would believe a classic reel can show a lowercase w.

The companion contract

Two endpoint pairs make the Companion and the gateway feel like one product — and let a containerized companion run diskless.

Registration: /api/companion

Method Body / reply Behavior
POST {"url":"http://192.168.1.60:8000","status":"Running: Weather","tabs":[{"id":"apps","label":"Apps"},…]} Register and heartbeat. The URL is persisted (debounced, so heartbeats don't wear the flash); status and tabs are runtime-only and clear on reboot. An empty url deregisters. Either field may be sent alone
GET {url,status,tabs,gwTabs} The registered companion's URL, its last reported status, and both sides' tab lists

The tabs advertisement (gateway 3.4) is how the two navs stay in sync without matched releases: the companion sends tabs — the deep links its UI actually has — and the reply always carries gwTabs, the gateway's own. Each side then links exactly what the other really offers. Both halves are optional and independent, so any old/new pairing works: a peer that says nothing simply gets the built-in list.

Settings blob: /api/companion/settings (gateway 3.1)

The gateway is deliberately a dumb blob store for the companion's settings, playlists and triggers. The payload is gzip(minified JSON) whose schema belongs entirely to the companion — the firmware stores the bytes verbatim, hands them back byte-for-byte, and never parses them. A companion container becomes effectively stateless: destroy it, start another on a different host, and it restores its configuration from the gateway on boot.

Method Behavior
GET Returns the stored blob as application/gzip, or 404 when nothing is stored yet
PUT Stores the gzipped request body atomically; replies 200 {"ok":true,"bytes":N}
  • Atomic writes. The body streams to a temp file, renamed over the live one only once the last byte lands — a crash or dropped connection mid-upload can never corrupt settings that were already good.
  • No Content-Encoding: gzip. The gzip bytes are the payload, not a transfer encoding of it — declaring the encoding would make HTTP clients silently decompress the body. The companion decompresses it itself.
  • Bounded. Blobs over 64 KB are rejected with 413; real ones are 1–2 KB, and the companion debounces its writes so a burst of edits becomes one write.
  • Durable. The blob lives on the FATFS partition (/compset.gz), which a firmware update doesn't touch — it survives OTA.
  • Errors: 400 empty or truncated body · 413 too large · 503 filesystem not mounted · 507 write failed.

On the companion side this is COMPANION_SETTINGS_STORE: mirror (default — local file primary, mirrored here), local, or gateway (diskless). The companion gates the feature on GET /api/config reporting version >= 3.1 and quietly falls back to local storage against an older gateway.

MQTT

Optional. Default topic prefix splitflap (configurable in Settings → MQTT), default port 1883. MQTT never carries display frames for the companion — that path is always REST — but it is the transport for Home Assistant and for external automations.

Published

Topic Payload When
splitflap/rx {"ts":…,"wt":"…","command":"m5-A"} Frame received from the bus
splitflap/tx {"ts":…,"wt":"…","command":"m5-A"} Frame transmitted to the bus
splitflap/status Heartbeat with the full diagnostic set — uptime, frame count(s), heap, RSSI, module count, IP, version, quiet flag (SplitFlap Gateway also maintenance) Once per minute
splitflap/flap/adv "AABBCCDD…" Unprovisioned module advertisement
splitflap/flap/ack {"id":5,"sn":"…"} Provisioning acknowledgement
splitflap/flap/version {"id":5,"ver":"12","reportedId":5,"sn":"…"} Version response
splitflap/flap/calibrated {"id":5,"stepsPerRev":4096} Calibration result
splitflap/flap/dump {"id":5,"dump":"…"} EEPROM dump response
splitflap/availability online / offline Retained; offline via MQTT Last Will
splitflap/display/state HELLO WORLD Best-known display contents (? = unknown), on change
splitflap/maintenance/state ON / OFF Maintenance mode
splitflap/quiet/state ON / OFF Quiet time

The rx/tx/status/flap/* topics publish whenever MQTT is connected; availability, display/state, maintenance/state and quiet/state (plus the discovery configs) publish only when Home Assistant integration is enabled on the Settings tab. (Topic names here are the SplitFlap Gateway's; the Matrix Gateway renames and trims them — see the note after the Subscribed table.)

Subscribed

Topic Payload Action
splitflap/send m9h\n or {"data":"m9h\n"} (optional "raw":true) Send a raw frame — normalized like /api/rs485/send
splitflap/flap/set {"id":5,"char":"A"} Show character
splitflap/flap/home {"id":5} Home module
splitflap/flap/provision {"sn":"AABBCC…","id":5} Provision module
splitflap/flap/flapconfig {"id":5,"flapCount":40,"charSet":" ABC…€é"} Configure a module's flap set (fw v31+) — SplitFlap Gateway only; the Matrix Gateway dropped this topic (its reel isn't configurable)
splitflap/display/set HELLO Show a string from module 0 (the Home Assistant text entity)
splitflap/maintenance/set ON/OFF/true/1 Set maintenance mode (reachable even while it's on)
splitflap/quiet/set ON/OFF/true/1 Set quiet time

The Matrix Gateway has no MQTT at all since firmware 3.0. Everything in this section is SplitFlap Gateway (physical) only. (Matrix firmwares 1.22–2.2 carried a trimmed topic tree under frames/; 3.0 removed the broker connection, every topic and the Home Assistant discovery outright — the companion is pure REST and the dashboard is SSE-pushed, so the permanently-open broker socket bought nothing on that RAM-tight board. If you upgraded a Matrix board past 2.2 with HA discovery enabled, delete the stale HA device or purge the retained homeassistant/+/sfgw_* configs on the broker.)

With MQTT configured, the SplitFlap Gateway can also announce itself to Home Assistant via retained MQTT discovery (opt-in on the Settings tab) — a device with a Display text entity, Maintenance and Quiet switches, and diagnostic sensors. That, and what the companion adds on top, is covered on Home Assistant.

Canonical sources

The two repos are the source of truth; each ships a machine-readable OpenAPI 3.1 spec you can import straight into Postman (Import → drop the file, then set a baseUrl collection variable to your gateway's IP), Swagger Editor (editor.swagger.io for interactive try-it-out docs), or Insomnia:

Gateway README (API sections) OpenAPI spec
Split-Flap Gateway (RS-485) README.md openapi.yaml
Matrix Gateway README.md openapi.yaml

Both specs are generated alongside the firmware and track it closely — the Matrix Gateway's documents the full /api/canvas/* surface under its Canvas tag, for instance. Where a spec and its README ever disagree, the README is current.


See also: SplitFlap Gateway · Matrix-Portal-Gateway · Bus-Protocol · Vestaboard-API · Home-Assistant · Compatibility

Clone this wiki locally