TyreSoft QMS · API

Open JSON API

The complete description of the API used by the Android TV board and the staff app. Connect your own screen, kiosk, app or integration.

Introduction

TyreSoft QMS has an open JSON API. The Android TV board and the staff app use the same API, so you can connect your own solutions to it: a second screen, a kiosk, a digital signage system, a mobile app or a CRM integration.

  • Base URL: https://<your-server>/qms-api/v1
  • Format: JSON in UTF-8, dates in ISO 8601 with time zone (2026-11-16T10:31:00+01:00).
  • Authentication: a device token in the Authorization: Bearer <token> header.
  • Versioning: the /v1 path. New fields may be added to responses – clients should ignore unknown fields.
  • Privacy: responses never contain customer names, phone numbers or e-mail addresses – only the number plate, time, vehicle and bay.
  • Language: the message texts in errors and the lang field follow the language set for the device or location.

The examples on this page come from a running API with test data (fictitious number plates, 16 Nov 2026, 10:31, 6 bays).

Devices and roles

Every device that connects to the API has its own token and one of two roles:

RoleExamplesPermissions
tvwaiting-room board, second screen, digital signageread only: /config, /board, /heartbeat
operatorstaff phone, counter tablet, integrationread and write: also /reservations, call actions, notes

An operator device is assigned to a staff account in the panel. When the staff member loses the “QMS – staff app” permission or the account is disabled, all of their devices lose access immediately.

Pairing a device

A device needs no login or password. It creates its own token, and an administrator approves it with a code shown on screen.

  1. The device generates a token (32 random bytes encoded as base64url) and sends it in POST /pair/start.
  2. The API responds with a 6-character code valid for 15 minutes. The device shows the code on screen.
  3. The administrator enters the code in the panel (“QMS – Android TV boards” or “QMS – staff app”) and selects the location and name.
  4. The device polls GET /pair/status every 3 seconds. Once approved it receives active and uses its token for all further requests.

The server only stores the SHA-256 hash of the token. Disconnecting a device in the panel takes effect immediately – the next request returns 403 revoked, and the device should return to the pairing screen.

POST /pair/start

No token header. Fields: type (tv or operator), token, name, platform, app_version.

POST /v1/pair/start HTTP/1.1
Host: your-server.com
Content-Type: application/json

{
  "type": "tv",
  "token": "<32 random bytes, base64url>",
  "name": "TV waiting room 1",
  "platform": "androidtv",
  "app_version": "1.0.0"
}
{
  "pair_code": "B8Z8F5",
  "expires_at": "2026-11-16T10:46:00+01:00",
  "approve_in": "panel → QMS – Android TV boards"
}

GET /pair/status

With the token, also before approval. status: pending, active, expired (the code has expired – start pairing again), revoked.

GET /v1/pair/status HTTP/1.1
Host: your-server.com
Authorization: Bearer <token>
{
  "status": "pending",
  "device": {
    "id": 1,
    "type": "tv",
    "name": "TV waiting room 1",
    "status": "pending",
    "location": null
  }
}
{
  "status": "active",
  "device": {
    "id": 1,
    "type": "tv",
    "name": "TV waiting room 1",
    "status": "active",
    "location": "main"
  }
}

Configuration

GET /config

Roles: tv, operator. Returns the device's location, language, number of bays, voice and refresh settings and the offer bar. Call it at start-up and every few minutes.

GET /v1/config HTTP/1.1
Host: your-server.com
Authorization: Bearer <token>
{
  "device": {
    "id": 1,
    "type": "tv",
    "name": "TV waiting room 1",
    "status": "active",
    "location": "main"
  },
  "location": {
    "key": "main",
    "name": "Tyre Service Central, 1 Example Street",
    "title": null
  },
  "lang": "en",
  "bays": 6,
  "slot_minutes": 40,
  "voice": true,
  "blink_seconds": 60,
  "queue_rows": 9,
  "poll_seconds": 3,
  "ad": null,
  "server_time": "2026-11-16T10:31:00+01:00",
  "timezone": "Europe/Warsaw"
}
FieldMeaning
langinterface and announcement language set in the panel for the device or location
baysnumber of bays at the location
slot_minutesbooking slot length in minutes
voicewhether the board should play spoken announcements
blink_secondshow long a tile flashes after a call
queue_rowshow many waiting bookings fit on the board
poll_secondsrecommended interval between /board or /reservations requests
adoffer bar at the bottom of the board (null = none)
server_time, timezoneserver time – use it instead of the device clock

Board state

GET /board?since=&minute=

Roles: tv, operator. Everything the waiting-room board shows: calls, the queue, occupied bays and delay notes. All devices at a location receive the same state, so several boards always show the same.

GET /v1/board HTTP/1.1
Host: your-server.com
Authorization: Bearer <token>
{
  "changed": true,
  "last_event_id": 28,
  "minute": "202611161031",
  "server_time": "2026-11-16T10:31:00+01:00",
  "lang": "en",
  "bays": 6,
  "reservations": [
    {
      "id": 87817,
      "time": "10:00",
      "plate": "RA72 ZJR",
      "bay": 2,
      "status": "urgent",
      "since": "2026-11-16T10:31:00+01:00"
    },
    {
      "id": 87818,
      "time": "10:00",
      "plate": "GX21 UNZ",
      "bay": 1,
      "status": "invite",
      "since": "2026-11-16T10:31:00+01:00"
    },
    {
      "id": 87819,
      "time": "10:00",
      "plate": "JB22 TJG",
      "bay": 4,
      "status": "invite",
      "since": "2026-11-16T10:31:00+01:00"
    },
    {
      "id": 87820,
      "time": "10:40",
      "plate": "AN21 AHH",
      "bay": 6,
      "status": "invite",
      "since": "2026-11-16T10:31:00+01:00"
    }
  ],
  "busyBays": [
    3,
    5
  ],
  "notes": {
    "10:00": {
      "entering": true
    },
    "10:40": {
      "entering": true
    },
    "11:20": {
      "delay_min": 10
    }
  },
  "ad": null
}
FieldMeaning
reservations[]bookings to show: id, time (slot time), plate, bay, status, since (in this status since)
busyBaysbays occupied by cars being serviced
notesnotes per slot time: entering (cars driving in), delay_min (delay), text
last_event_id, minutemarkers for lightweight refreshing (see below)

Lightweight refreshing

Send since=<last_event_id>&minute=<minute> from the previous response. If nothing has changed, the API replies briefly:

{
  "changed": false,
  "last_event_id": 28,
  "minute": "202611161031"
}

The full list only comes after a change or once a minute (as the queue moves on). Polling every 3 seconds is therefore light even on shared hosting. The API also supports ETag / If-None-Match with a 304 Not Modified response.

Spoken announcements: new calls follow from the difference between consecutive responses – a booking that has changed to invite or urgent should be announced.

Bookings of the day

GET /reservations?date=YYYY-MM-DD

Role: operator. The full list of the day's bookings (today by default) with vehicle, status, bay and version number, the state of all bays and the notes. The staff app works on this list.

GET /v1/reservations?date=2026-11-16 HTTP/1.1
Host: your-server.com
Authorization: Bearer <token>
{
  "date": "2026-11-16",
  "location": "main",
  "lang": "en",
  "server_time": "2026-11-16T10:31:00+01:00",
  "last_event_id": 28,
  "bays": [
    {
      "nr": 1,
      "state": "invite",
      "reservation_id": 87818,
      "plate": "GX21 UNZ"
    },
    {
      "nr": 2,
      "state": "urgent",
      "reservation_id": 87817,
      "plate": "RA72 ZJR"
    },
    {
      "nr": 3,
      "state": "in_service",
      "reservation_id": 87814,
      "plate": "XF19 EZW"
    }
  ],
  "items": [
    {
      "id": 87812,
      "time": "08:40",
      "slot": "2026-11-16 08:40:00",
      "plate": "WP17 XXU",
      "vehicle": {
        "type": "car",
        "make": "BMW",
        "model": "320d",
        "color": null
      },
      "deposit": null,
      "remarks": null,
      "online": true,
      "status": "done",
      "bay": 2,
      "version": 3,
      "called_at": "2026-11-16T10:31:00+01:00",
      "urgent_at": null,
      "started_at": "2026-11-16T10:31:00+01:00",
      "finished_at": "2026-11-16T10:31:00+01:00"
    },
    {
      "id": 87813,
      "time": "08:40",
      "slot": "2026-11-16 08:40:00",
      "plate": "DR73 TNS",
      "vehicle": {
        "type": "car",
        "make": "Opel",
        "model": "Astra",
        "color": null
      },
      "deposit": null,
      "remarks": null,
      "online": false,
      "status": "done",
      "bay": 3,
      "version": 3,
      "called_at": "2026-11-16T10:31:00+01:00",
      "urgent_at": null,
      "started_at": "2026-11-16T10:31:00+01:00",
      "finished_at": "2026-11-16T10:31:00+01:00"
    }
  ],
  "notes": [
    {
      "time": "11:20",
      "delay_min": 10,
      "text": null
    }
  ]
}

The list is shortened to two bookings and three bays. bays[].state: free, invite, urgent, in_service.

Booking statuses

StatusOn the boardSet by
waitingin the “Upcoming bookings” queuedefault, actions show and reset
invite“Please drive in” tile with the bay numbercall
urgentred “Final call” tileurgent
in_serviceleaves the tiles, the bay is occupiedstart
donedisappearsfinish or automatically a few hours after drive-in
hiddennot shownhide
no_showdisappearsno_show
latedisappears (only in /reservations)not called long after the booking time

Call actions

POST /reservations/{id}/{action}

Role: operator. Actions: call, urgent, start, finish, hide, show, no_show, reset, undo.

Body fieldMeaning
baybay number – required for call, optional for urgent
versionbooking version known to the client; if it does not match, the API returns 409 conflict
forcetrue = call even though the bay is occupied

Calling to a bay:

POST /v1/reservations/87817/call HTTP/1.1
Host: your-server.com
Authorization: Bearer <token>
Content-Type: application/json

{
  "bay": 2
}
{
  "item": {
    "id": 87817,
    "time": "10:00",
    "slot": "2026-11-16 10:00:00",
    "plate": "RA72 ZJR",
    "vehicle": {
      "type": "car",
      "make": "Skoda",
      "model": "Octavia",
      "color": null
    },
    "deposit": null,
    "remarks": null,
    "online": true,
    "status": "invite",
    "bay": 2,
    "version": 1,
    "called_at": "2026-11-16T10:31:00+01:00",
    "urgent_at": null,
    "started_at": null,
    "finished_at": null
  },
  "last_event_id": 21
}

Final call with version check:

POST /v1/reservations/87817/urgent HTTP/1.1
Host: your-server.com
Authorization: Bearer <token>
Content-Type: application/json

{
  "version": 1
}

undo restores the state before the last action on this booking – on all boards too.

Several staff at once

When two staff members act at the same time, the API never overwrites someone else's change:

Occupied bay – another car has already been called to or is standing in this bay:

POST /v1/reservations/87818/call HTTP/1.1
Host: your-server.com
Authorization: Bearer <token>
Content-Type: application/json

{
  "bay": 2
}
HTTP/1.1 409 Conflict

{
  "error": {
    "code": "bay_busy",
    "message": "Bay 2 is occupied (RA72 ZJR).",
    "bay": 2,
    "by": {
      "reservation_id": 87817,
      "plate": "RA72 ZJR",
      "status": "invite"
    }
  }
}

Version conflict – someone changed the booking after you loaded it. The response contains the current state:

HTTP/1.1 409 Conflict

{
  "error": {
    "code": "conflict",
    "message": "Someone else has changed this booking. Refresh the list.",
    "current": {
      "status": "invite",
      "bay": 2,
      "version": 1
    }
  }
}

Delay notes

PUT /notes

Role: operator. Adds “approx. +10 min” or a short custom text next to a slot time.

PUT /v1/notes HTTP/1.1
Host: your-server.com
Authorization: Bearer <token>
Content-Type: application/json

{
  "time": "11:20",
  "delay_min": 10
}

DELETE /notes?time=HH:MM

Removes the note for that time.

Heartbeat

POST /heartbeat

Roles: tv, operator. Body: {"app_version": "1.0.0", "platform": "androidtv"}. This lets the panel show which devices are online and which version they run. Once a minute is enough – every other request also updates the last-seen time.

Errors

An error always has the form {"error": {"code": "...", "message": "..."}}. message is ready to show to the user, code is meant for handling in code.

HTTPerror.codeWhen
401no_token, unknown_devicemissing or unknown token – pair again
403not_approved, revoked, forbidden, no_rightsdevice not approved or disconnected, wrong role, staff member without permission
404not_found, bad_actionbooking from another location or unknown action
409conflict, bay_busy, nothing_to_undochange by another staff member, occupied bay, nothing to undo
422bay_required, bad_bay, bad_timeinvalid request data
{
  "error": {
    "code": "forbidden",
    "message": "This device has no access to this function."
  }
}

Best practice

  • Keep the token in the device's secure storage (Android Keystore, iOS Keychain). Never put it in a URL.
  • Poll /board every poll_seconds with the since and minute parameters.
  • Send version with actions; after a 409, reload the list and show the user the message.
  • After 401 or 403 revoked, delete the token and start pairing again.
  • Use server_time for displaying the time, not the device clock.

Need a test key or help with an integration? Write to biuro@tyresoft.pl.