Client API

Try it

The Client API is how an app β€” a guest app on the phone, an app on the TV, a partner's server β€” talks to the hotel:

It is plain HTTPS and JSON, so it works from any language; a Flutter example is at the end.

Base URL

https://pcr-api.pages.dev/v1

Send one header, X-Api-Key (a partner) or X-Guest-Token (a guest's phone) β€” nothing else is needed. The same API is also reachable at the long address shown in the console's Settings β†’ Integrations; that address additionally needs the platform's public key in an Authorization header (the short address adds it for you).

Try every request here. Each request below has a Try it panel: paste your key in the bar at the top of the page, fill in the room and press Send. The key stays in this browser tab only. The requests are real β€” a command to a Live site moves real devices, so try it on a simulated site first ("mode": "mock" in the answer says which).

The contract is kept stable on purpose: apps built by other companies rely on it. It began as the Direct API of the IoT Simulator and was renamed when it grew beyond devices. Nothing changed for existing apps: every request of the original is here (the device requests below), and the old address /api/direct/v1 still works. Anything not described as new is unchanged from the original.

An app that runs inside the TV as a partner TV app does not need a key at all: the TV answers the same questions over messages.

Who may call it

Every request carries one credential, in a header:

Header Who Reaches
X-Api-Key: pcr_… A partner's app or server Every room of one site. Rooms are addressed by room number
X-Guest-Token: … A guest's phone Only the room the phone is paired with, until check-out

A request without a valid credential gets 401 Unauthorized.

API keys are created by a site administrator in Settings β†’ Integrations, one per partner (name them after the partner β€” each key is logged by name, never in full β€” so one can be revoked without touching the others). A key is shown once, when it is created; revoking it stops the apps that use it at once. Keep it out of app binaries that guests can unpack: use a key from your own server, and give phones the guest token instead.

Guest tokens come from pairing. The TV shows a QR code and a 6-digit code; the phone redeems either one and receives a token that is valid for the stay and is revoked at check-out:

POST https://<project>.supabase.co/rest/v1/rpc/redeem_pairing
apikey: <public key>
Authorization: Bearer <public key>
Content-Type: application/json

{ "p_code": "123456" }            // or { "p_token": "<from the QR code>" }

The QR code holds a link <app address>/?t=<token>; take the value of t as p_token. The answer lists room_number and guest_token. A wrong or expired code returns an empty list; after 30 wrong attempts from one address in ten minutes, redeeming is refused for a while.

Request and response format

Request bodies are Content-Type: application/json; every answer is JSON.

Success

{ "status": "success", "data": { … } }

Error

{ "status": "error", "error": { "statusCode": 404, "message": "Room 999 not found" } }

Errors and what to do

Code Meaning
200 The request succeeded β€” for a command, the device changed
400 Body fields missing, or a value the device does not accept
401 No credential, or an invalid, revoked or expired one. A guest token also stops working at check-out
403 The component is read-only, or the call is not allowed (a guest token cannot rename a thing)
404 No such room, device or component β€” a guest asking for another room gets this too
405 The address exists but not with that method (for example POST /channels)
429 Too many commands: 60 a minute per room for guests, 240 for a partner key. Wait Retry-After seconds
502 The building refused the command; the device did not change
504 The building did not answer in time and the device did not hold the new value when it was read back; the device did not change

A command that did not happen is reported as a failure. A 200 means the device changed. If the building refused the command or did not answer, the answer is 502 or 504, the stored value is left exactly as it was, and no real-time event is sent. A 504 carries what you need to decide what to do β€” and nothing about the building itself:

{ "status": "error", "error": {
  "statusCode": 504, "message": "The room did not respond in time. Try again.",
  "reason": "timeout", "durationMs": 8012, "checked": "not-applied"
} }

reason is timeout or refused; durationMs is how long the server waited; checked is not-applied when the point was read back and did not hold the value, or unverified when it could not be read back to be sure.

What to do with a 502 or 504: treat it as "not applied yet", not as "the room is broken" β€” these are usually transient. Retry once, and show the control in its previous state rather than the requested one. A retry is safe: every settable component takes an absolute value, so sending it twice is the same as sending it once. To confirm a command took effect independently of the answer, read the device with ?live=true after a short delay.

Commands to one component are carried out one at a time. A component has one write in flight and one value waiting behind it. Send a new value for the same component while one is outstanding and it takes that seat; send another and it replaces the one waiting, which is answered 200 without having been sent β€” the caller replaced their own value before it left. Every settable component takes an absolute value, so this is not a dropped instruction: the room ends at the newest value the caller asked for, which is what a guest holding a dimmer means.

A slow building is not a failed command. The building applies a write and answers afterwards, so a command can land at five seconds while the answer is still outstanding. A command is given about 8 seconds, and when even that runs out the point is read back before anything is called a failure. A write that arrived late is answered 200, because the guest's lamp is on. durationMs in a successful command answer is how long it took end to end: around 250 ms is ordinary, over a second means the building is running behind β€” showing or logging the slow ones is how a client notices a site getting slower before it starts failing.

A guest may set the thermostat only between the limits the hotel chose (normally 20–25 Β°C); a partner key may use the whole range the equipment accepts. Error messages never contain the building's own details.

How a room and a thing are identified

A room has one address, its room number: roomId in every path (632). Room numbers are unique inside one site, and a key reaches one site, so the number is enough. The answer also carries roomType (Suite, Deluxe) and displayName (null β€” the number is what everybody reads).

A thing has four fields, never interchangeable:

Field Example What it is
id 632_desk-lamp The address. Derived from the room and the current alias, unique across the site. Put this in a URL β€” but it is not permanent (below)
type DIMMABLE_LIGHT What it is, and therefore what it can do. Branch on this
alias Desk Lamp What a person calls it. Free text chosen by the hotel. Changing it also changes id
description BR30 RGBW A note about this particular one. Informational, often empty

An id moves when the alias does. It is {room}_{slug(alias)}, made unique. Renaming a thing (below) gives it a new id; the old one stops resolving. If your integration stores ids, refresh them from GET /rooms/{roomId}/things before relying on them, or treat a 404 on a previously-working id as a sign it was renamed and read the list again β€” a rename you make yourself hands you the new id back in the answer.

Grouping and presentation

Four things the hotel sets about a device that do not change how it is controlled. They are what lets one app render a room it has never seen:

Field Where What to do with it
tags thing Ids from the tag dictionary (GET /tags) saying what a fixture is for and which things belong with it. Group controls by them and label the group with the tag's alias, never its id. Any tag may appear on any type
presets thing (room list only) Named component values the thing reads under a name (bright, dimmed, cool …). A preset holds every component the state depends on β€” a thermostat's carries power, setpoint and fan speed. Apply one by sending its values as ordinary commands
hidden thing Served in full, with every component and value, and simply not listed among the controls a guest browses β€” a light on an automation the guest does not operate by hand. Honour it in the list; keep reading its state
disabled β€” Not a field. A device the hotel has disabled is absent from every answer and 404 at its own address β€” what the API serves is what a guest may use

Ids are constants; names are not. A tag id, a preset name and a thing type never change and are safe to branch on. alias β€” of a thing or of a tag β€” is wording the hotel edits, and a client that switches on it breaks the first time somebody renames a fixture.

Endpoints

List the things in a room

GET /rooms/{roomId}/things[?live=true]

Every enabled thing of the room with its last-known values. Add ?live=true to read the building again before answering β€” slower, but the freshest possible value; without it the answer may be up to a few seconds old (it is shared between everyone looking at the room). A room with eleven lights returns eleven entries, distinguished by id and named by alias β€” do not assume a fixed set, and do not assume they are all one type: a lamp that dims is DIMMABLE_LIGHT and one on an on/off circuit is ONOFF_LIGHT.

{
  "status": "success",
  "data": {
    "roomId": "632",
    "roomType": "Suite",
    "iotGroupName": "Suite",
    "displayName": null,
    "vendorSeenAt": "2026-09-29T14:37:32Z",
    "vendorCheck": null,
    "mode": "live",
    "things": [
      {
        "id": "632_desk-lamp",
        "type": "DIMMABLE_LIGHT",
        "wireType": "Dimming",
        "alias": "Desk Lamp",
        "description": "BR30 RGBW",
        "tags": ["BED_LIGHT"],
        "hidden": false,
        "presets": { "bright": { "Dimming": "100" }, "dimmed": { "Dimming": "30" } },
        "components": [
          { "componentId": "OnOff", "value": "1", "valueType": "Boolean", "settable": true },
          { "componentId": "Dimming", "value": "40", "valueType": "Int8", "settable": true }
        ]
      }
    ]
  }
}
GET/rooms/{roomId}/thingsTry it
press Send to run

Read one thing

GET /rooms/{roomId}/things/{thingId}[?live=true]

One thing's current state β€” the read a client repeats, so it carries state and not configuration: presets is on the room list instead, while tags and hidden are here as well. For a scent diffuser ?live=true also asks the scent service for the real power state and the occasions its capsules can produce.

{ "status": "success", "data": {
  "id": "632_desk-lamp", "type": "DIMMABLE_LIGHT", "wireType": "Dimming", "alias": "Desk Lamp",
  "description": "BR30 RGBW", "tags": ["BED_LIGHT"], "hidden": false,
  "components": [
    { "componentId": "OnOff", "value": "1", "valueType": "Boolean", "settable": true },
    { "componentId": "Dimming", "value": "50", "valueType": "Int8", "settable": true }
  ] } }
GET/rooms/{roomId}/things/{thingId}Try it
press Send to run

Control a thing

PUT /rooms/{roomId}/things/{thingId}
Content-Type: application/json

{ "componentId": "OnOff", "value": "1" }

Sends a command for one component of one thing. The service forwards it to the building and answers 200 only once the building has accepted it. componentId and value are required, and value is a string; the component's own definition supplies the type, so a valueType hint (accepted by the original) is not needed.

The answer is the device as it now stands, not an echo β€” switching a dimmable light on also moves its brightness:

{ "status": "success", "data": {
  "roomId": "632", "thingId": "632_desk-lamp", "componentId": "OnOff", "value": "1",
  "durationMs": 248,
  "components": [ { "componentId": "OnOff", "value": "1", … }, { "componentId": "Dimming", "value": "100", … } ]
} }

A read-only component answers 403:

{ "status": "error", "error": { "statusCode": 403, "message": "Component Presence is read-only" } }
PUT/rooms/{roomId}/things/{thingId}Try it
press Send to run

Rename a thing

PATCH /things/{thingId}
Content-Type: application/json

{ "alias": "Table Lamp", "description": "BR30 RGBW", "everywhere": false }

As in the original. Changes what a thing is called, or the note on it β€” send alias, description, or both (at least one). Only an API key may do this; a guest token is answered 403.

{ "status": "success", "data": { "id": "632_table-lamp", "alias": "Table Lamp", "description": "BR30 RGBW", "things": ["632_table-lamp"] } }
PATCH/things/{thingId}Try it β€” API key only
press Send to run

Thing types

GET /types

Every kind of thing this service supports, the components each exposes, the tag ids a thing may carry and the preset values a thing of the type starts with. What a client sees here is what it can actually reach β€” read it instead of copying the reference below into your app. tags is the whole dictionary and is the same on every type; range and enum tell you what a component accepts (limits is the narrower range a guest is offered).

{ "status": "success", "data": [
  { "type": "DIMMABLE_LIGHT", "wireType": "Dimming", "vendor": "se", "tags": ["ACCENT_LIGHT", "BED_LIGHT"],
    "defaultPresets": { "bright": { "Dimming": "100" }, "dimmed": { "Dimming": "50" } },
    "components": [
      { "componentId": "Dimming", "valueType": "Int8", "settable": true, "range": { "min": "0", "max": "100" } },
      { "componentId": "OnOff", "valueType": "Boolean", "settable": true, "enum": [{ "value": "0", "label": "Off" }, { "value": "1", "label": "On" }] }
    ] }
] }
GET/typesTry it
press Send to run

Tags

GET /tags

Every tag id of the site, with the wording to put on a control grouped by it. One dictionary for the whole property β€” the same ids are offered on every type. id is the constant to branch on; alias is the hotel's wording and is what a guest must read, because a hotel that calls reading lights something else changes the alias and not the id.

{ "status": "success", "data": [
  { "id": "BED_LIGHT", "alias": "Reading lights", "description": "Bedside and headboard reading lights" },
  { "id": "BEDSIDE", "alias": "Bedside Lamps", "description": "The pair of bedside lamps, presented as one control" }
] }
GET/tagsTry it
press Send to run

The stay

GET /rooms/{roomId}/stay

New. Who is staying in the room, so your app can greet them and use their language. Check-in is done in the console (or, later, sent by the hotel's property management system) β€” see Check-in.

{ "status": "success", "data": {
  "hotel": { "id": "…", "name": "Pullman Singapore Orchard" },
  "room": { "number": "632", "status": "occupied" },
  "guest": { "name": "Kim Minjun", "language": "ko", "checkinAt": "2026-09-30T10:00:00Z" }
} }

guest is null when the room is empty (room.status is vacant). language is a code such as en, ko, zh β€” use English when your app has no translation for it. A partner key can ask for any room of its site; a guest token only for the room it is paired with (another room answers 404).

GET/rooms/{roomId}/stayTry it
press Send to run

TV channels

GET /channels

New. The channels the hotel shows guests, in channel-number order. Switched-off channels are not listed.

{ "status": "success", "data": [
  { "id": "5c1e…", "number": 1, "name": "BBC One", "kind": "tv",
    "icon": "https://upload.wikimedia.org/…/BBC_One_logo_2021.svg.png", "encrypted": false,
    "tune": { "channelType": "rf", "rfBroadcastType": "terrestrial", "frequency": 618000000, "programNumber": 4 } },
  { "id": "9ab2…", "number": 7, "name": "City TV", "kind": "tv", "icon": null, "encrypted": false,
    "tune": { "channelType": "ip", "ip": "239.1.1.7", "port": 5000, "ipBroadcastType": "udp" } }
] }
GET/channelsTry it
press Send to run

Smart apps

GET /apps

New. The apps the hotel shows guests, in the order the hotel chose.

{ "status": "success", "data": [
  { "id": "youtube.leanback.v4", "title": "YouTube", "description": "Videos and music", "icon": "https://upload.wikimedia.org/…/YouTube_logo.png" },
  { "id": "netflix", "title": "Netflix", "description": null, "icon": null }
] }

id is the webOS app id the TV launches (idcap://application/launch). description is the hotel's line under the name, or null; icon may be null too β€” show the title.

GET/appsTry it
press Send to run

Real-time events

GET /rooms/{roomId}/events

A Server-Sent Events stream. The service reads the room every few seconds and sends only what changed:

data: {"type":"values","method":"GET","source":"poll","roomId":"632",
       "data":[{"id":"632_desk-lamp","type":"DIMMABLE_LIGHT",
                "components":[{"componentId":"Dimming","value":"50","valueType":"Int8"}]}]}

Comment lines (: keepalive) keep the connection open. The service closes it after about two minutes β€” reconnect when it ends (most SSE clients do it themselves), and read the room once after reconnecting. This is polling on the server, not a push from the building: a change made at a wall panel shows up within a few seconds, not instantly (the original pushed within 3–15 seconds from the building's own event stream; the difference is in the same range).

The browser EventSource cannot send headers, so for browsers that use it the key may go in the query string instead (?key=…) β€” a key in a URL ends up in logs, so use the header wherever you can (fetch with a stream reader can).

GET/rooms/{roomId}/eventsTry it
press Connect to run

Recommended integration pattern

  1. GET /rooms/{roomId}/things β€” fetch the current snapshot.
  2. Render the devices from it.
  3. Open /rooms/{roomId}/events.
  4. On each event, patch the matching component in the UI β€” no need to read the whole room again.
  5. On a command, show the answer's components (the device as it now stands); on 502/504 put the control back.
  6. When a path starts answering 404, or the stream ends, read the list again.

This avoids polling from your side and keeps the UI in step with other clients and with changes made at the room's panels.

Thing types and components

A room holds as many of each type as were provisioned into it β€” eleven lights across two light types is normal β€” so read the room's list rather than assuming a fixed set. GET /types returns the same information at runtime.

DIMMABLE_LIGHT β€” a light that dims (wireType Dimming)

componentId valueType Settable Accepted values
OnOff Boolean yes "0" off, "1" on
Dimming Int8 yes "0"–"100" (percent)

A dimmable light takes one number, so OnOff is translated into a level: OnOff = "1" sends the light's last brightness (a light stored at 0 goes to 100, since it has no level to return to); OnOff = "0" sends 0. To turn on at a given brightness, set Dimming directly.

ONOFF_LIGHT β€” a light that does not dim (wireType Switch)

componentId valueType Settable Accepted values
OnOff Boolean yes "0" off, "1" on

Branch on type, not on the room: the lamps over the bed, the reading lights, the floor lamp are wired as on/off circuits while the cove and the downlights in the same room dim.

THERMOSTAT β€” a fan-coil unit (wireType Thermostat)

componentId valueType Settable Accepted values
OnOff Boolean yes "0" / "1"
TargetTemperature Double yes "16"–"32" Β°C accepted; offer "20"–"25" (the guest limit)
CurrentTemperature Double no read-only sensor value
Speed String yes "1" High, "2" Mid, "3" Low, "4" Auto

Speed is passed through unchanged, so a client showing the raw number is always right.

CURTAIN β€” a motorised blind (wireType Curtain)

componentId valueType Settable Accepted values
OpenClose Boolean yes "0" close, "1" open

There is no stop component. A curtain has one motor but two points behind it β€” one that is written and one that is read β€” which is why its reported position can lag a command briefly.

COURTESY_PANEL β€” the door panel (wireType MultiLevelSensor)

componentId valueType Settable Accepted values
Level String yes "off", "MUR" (Make Up Room), "DND" (Do Not Disturb), "STC" (Skip the Clean) β€” one at a time

SCENT_DIFFUSER β€” a scent diffuser (wireType MultiLevelSensor)

Two physical diffusers per room, each addressable on its own β€” read the room's list, an id follows the name.

componentId valueType Settable Accepted values
OnOff Boolean yes "0" off, "1" on
Occasion String yes relax, refresh, deepsleep, focus, exercise, romantic, normal β€” see below

The thing also carries availableOccasions β€” the scents its capsules allow; choosing another is refused (502 when the scent service refuses, for example because the diffuser is off β€” it did not change). Choosing a scent switches the diffuser on. The current scent is the last one chosen.

OCCUPANCY_SENSOR β€” room occupancy (wireType BinarySensor)

componentId valueType Settable Description
Presence Boolean no "1" occupied, "0" vacant

curl examples

Replace {API_KEY} with your key. Add | jq . to read the answers more easily.

# List the things in room 632
curl -s "https://pcr-api.pages.dev/v1/rooms/632/things" -H "X-Api-Key: {API_KEY}"

# Turn on a light
curl -s -X PUT "https://pcr-api.pages.dev/v1/rooms/632/things/632_desk-lamp" \
  -H "X-Api-Key: {API_KEY}" -H "Content-Type: application/json" \
  -d '{"componentId":"OnOff","value":"1"}'

# Set the thermostat to 22 Β°C
curl -s -X PUT "https://pcr-api.pages.dev/v1/rooms/632/things/632_thermostat" \
  -H "X-Api-Key: {API_KEY}" -H "Content-Type: application/json" \
  -d '{"componentId":"TargetTemperature","value":"22"}'

# Open a curtain
curl -s -X PUT "https://pcr-api.pages.dev/v1/rooms/632/things/632_main-curtain" \
  -H "X-Api-Key: {API_KEY}" -H "Content-Type: application/json" \
  -d '{"componentId":"OpenClose","value":"1"}'

# Choose a scent
curl -s -X PUT "https://pcr-api.pages.dev/v1/rooms/632/things/632_scent-diffuser" \
  -H "X-Api-Key: {API_KEY}" -H "Content-Type: application/json" \
  -d '{"componentId":"Occasion","value":"romantic"}'

# Rename a thing everywhere it appears β€” the answer lists every id that changed
curl -s -X PATCH "https://pcr-api.pages.dev/v1/things/632_desk-lamp" \
  -H "X-Api-Key: {API_KEY}" -H "Content-Type: application/json" \
  -d '{"alias":"Reading Lamp","everywhere":true}'

# Who is staying, the channels and the apps
curl -s "https://pcr-api.pages.dev/v1/rooms/632/stay" -H "X-Api-Key: {API_KEY}"
curl -s "https://pcr-api.pages.dev/v1/channels" -H "X-Api-Key: {API_KEY}"
curl -s "https://pcr-api.pages.dev/v1/apps" -H "X-Api-Key: {API_KEY}"

# Follow changes live (-N turns off buffering; Ctrl+C to stop)
curl -N "https://pcr-api.pages.dev/v1/rooms/632/events" -H "X-Api-Key: {API_KEY}"

Subscribe from a browser or Node 18+ with a stream reader (so the key stays in a header):

const res = await fetch('https://pcr-api.pages.dev/v1/rooms/632/events', { headers: { 'X-Api-Key': key } })
const reader = res.body.getReader(), decoder = new TextDecoder()
let rest = ''
for (;;) {
  const { done, value } = await reader.read()
  if (done) break                      // the service closes after ~2 minutes β€” read the room, then reconnect
  const lines = (rest + decoder.decode(value, { stream: true })).split('\n')
  rest = lines.pop()
  for (const line of lines) if (line.startsWith('data: ')) {
    const event = JSON.parse(line.slice(6))
    for (const thing of event.data) console.log('thing update:', thing.id, thing.components)
  }
}

Limits

Commands are limited to 60 a minute per room for guest tokens and 240 a minute per site for a partner key; above that the answer is 429 with Retry-After. A command returns when the building has answered, usually within a second; a slow one can take several. A guest token works until the guest checks out; a partner key until it is revoked.

Example: Flutter

import 'dart:convert';
import 'package:http/http.dart' as http;

const base = 'https://pcr-api.pages.dev/v1';

Map<String, String> headers(String guestToken) => {
      'X-Guest-Token': guestToken,
      'Content-Type': 'application/json',
    };

Future<Map<String, dynamic>> readRoom(String room, String token) async {
  final res = await http.get(Uri.parse('$base/rooms/$room/things'), headers: headers(token));
  final body = jsonDecode(res.body) as Map<String, dynamic>;
  if (res.statusCode != 200) throw Exception(body['error']['message']);
  return body['data'] as Map<String, dynamic>;
}

/// Who is staying, and the language to show β€” new in the Client API
Future<Map<String, dynamic>?> readStay(String room, String token) async {
  final res = await http.get(Uri.parse('$base/rooms/$room/stay'), headers: headers(token));
  final body = jsonDecode(res.body) as Map<String, dynamic>;
  if (res.statusCode != 200) throw Exception(body['error']['message']);
  return (body['data'] as Map<String, dynamic>)['guest'] as Map<String, dynamic>?;
}

Future<List<dynamic>> readChannels(String token) async {
  final res = await http.get(Uri.parse('$base/channels'), headers: headers(token));
  return (jsonDecode(res.body) as Map<String, dynamic>)['data'] as List<dynamic>;
}

Future<void> setComponent(String room, String thing, String component, String value, String token) async {
  final res = await http.put(
    Uri.parse('$base/rooms/$room/things/$thing'),
    headers: headers(token),
    body: jsonEncode({'componentId': component, 'value': value}),
  );
  if (res.statusCode != 200) throw Exception(jsonDecode(res.body)['error']['message']);
}

For the stream, use any SSE package (for example http.Client().send with an Accept: text/event-stream request, or a package such as eventsource) and reconnect when it ends.