WebSocket API
The API the frontend uses. How a connection works, the core commands, error handling, and how to add your own commands from an integration.
Home Assistant serves a WebSocket API at /api/websocket. It is the main API: the frontend and the companion apps use it for nearly everything, and it can stream changes as they happen. Integrations add their own commands to it, so there are far more commands than this page lists.
For JavaScript, use the home-assistant-js-websocket library. It handles authentication, reconnecting, and subscriptions, and it is what the frontend uses.
- The client connects. The server sends
auth_required. - The client sends an
authmessage with an access token. - The server answers
auth_ok, orauth_invalidand closes the connection. - The command phase starts. The client sends commands, and the server sends results and subscription messages.
- Either side closes the connection.
Every message is a JSON object with a type. In the command phase, every message from the client also has an id: a number that increases with each command. The server puts that id on everything it sends back, so you can match answers to commands.
Right after connecting, the server sends:
{
"type": "auth_required",
"ha_version": "2026.10.0"
}Send an access token. See the authentication API for how to get one.
{
"type": "auth",
"access_token": "ABCDEFGH"
}If the token is valid:
{
"type": "auth_ok",
"ha_version": "2026.10.0"
}If not, you get auth_invalid with a message, and the server disconnects.
Right after authenticating, a client can turn on optional features with supported_features. Today, the one feature is coalesce_messages: the server then bundles messages and sends them in bulk, as a JSON list, instead of one by one.
{
"id": 1,
"type": "supported_features",
"features": {
"coalesce_messages": 1
}
}The server answers each command with a result, except ping, which gets a pong:
{
"id": 6,
"type": "result",
"success": true,
"result": null
}When something goes wrong, success is false and error has a code and a message:
{
"id": 12,
"type": "result",
"success": false,
"error": {
"code": "invalid_format",
"message": "Message incorrectly formatted: required key not provided"
}
}Error codes include invalid_format, not_found, not_allowed, not_supported, unauthorized, unknown_command, id_reuse, home_assistant_error, service_validation_error, template_error, timeout, and unknown_error. When the error comes from a HomeAssistantError with translation information, the error also has translation_domain, translation_key, and translation_placeholders, so the frontend can show it in the user's language.
These are the commands of the websocket_api integration itself.
{
"id": 18,
"type": "subscribe_events",
"event_type": "state_changed"
}Leave out event_type to get all events. That, and most event types, need an admin. Every event comes in as a message of type event, with the id of the subscription:
{
"id": 18,
"type": "event",
"event": {
"event_type": "state_changed",
"data": {
"entity_id": "light.kitchen",
"old_state": {},
"new_state": {}
},
"origin": "LOCAL",
"time_fired": "2026-10-10T10:37:24.265429+00:00",
"context": {
"id": "01J9Z7K1M3S8TQ0Y6E4H2B5N7C",
"parent_id": null,
"user_id": null
}
}
}The states are shortened here. To follow states, subscribe_entities is more efficient.
Ends any subscription. subscription is the id of the command that started it.
{
"id": 19,
"type": "unsubscribe_events",
"subscription": 18
}subscribe_entities is how the frontend follows states. It sends all states the user may read first, then only the changes, in a compact format. Pass entity_ids to limit it.
{
"id": 20,
"type": "subscribe_entities",
"entity_ids": ["light.kitchen"]
}Each event message holds a map with up to three keys: a for added entities with their full compressed state, c for changed entities with only what changed (under +, and removed attributes under -), and r for the IDs of removed entities. In a compressed state, s is the state, a the attributes, c the context, lc last changed, and lu last updated.
Admin. Subscribes to one or more triggers, in the same format as in automations. You get an event message each time a trigger fires, with the trigger variables.
{
"id": 2,
"type": "subscribe_trigger",
"trigger": {
"trigger": "state",
"entity_id": "binary_sensor.motion",
"to": "on"
}
}{
"id": 24,
"type": "call_service",
"domain": "light",
"service": "turn_on",
"service_data": {
"brightness": 255
},
"target": {
"entity_id": "light.kitchen"
}
}service_data and target are optional. The result has the context of the call. To get response data, add "return_response": true. The result then also has a response:
{
"id": 24,
"type": "result",
"success": true,
"result": {
"context": {
"id": "01J9Z7K1M3S8TQ0Y6E4H2B5N7C",
"parent_id": null,
"user_id": "31ddb597e03147118cf8d2f8fbea5553"
},
"response": {}
}
}Admin. Fires an event, with optional event_data. The result has the context.
{
"id": 25,
"type": "fire_event",
"event_type": "my_integration_button_pressed",
"event_data": {
"button_id": "front_door"
}
}Each of these takes only an id and a type, and returns its data in the result:
get_states: all states the user may read.get_config: the core configuration.get_services: all actions, with their descriptions.get_panels: the registered panels. This one comes from thefrontendintegration.
Send {"id": 19, "type": "ping"} and the server answers {"id": 19, "type": "pong"}. Use it as a heartbeat to check the connection is alive.
validate_config checks triggers, conditions, and actions as if they were part of an automation. Each key is optional, and only the keys you send come back.
{
"id": 30,
"type": "validate_config",
"triggers": [],
"conditions": [],
"actions": []
}{
"id": 30,
"type": "result",
"success": true,
"result": {
"triggers": {"valid": true, "error": null},
"conditions": {"valid": false, "error": "Invalid condition"},
"actions": {"valid": true, "error": null}
}
}These commands take a target in the same format as an action call, with entity_id, device_id, area_id, floor_id, and label_id:
extract_from_target: resolves the target intoreferenced_entities,referenced_devices, andreferenced_areas, and lists what does not exist undermissing_devices,missing_areas,missing_floors, andmissing_labels. With"expand_group": true, groups are replaced by their members. Defaults tofalse.get_triggers_for_target,get_conditions_for_target, andget_services_for_target: return the triggers, conditions, or actions that apply to at least one entity of the target, likelight.turn_on. Here,expand_groupdefaults totrue.
The core commands also include render_template, subscribe_condition, test_condition, execute_script, entity/source, manifest/list, manifest/get, and integration/setup_info. Beyond those, almost every integration with a UI registers commands of its own. A few that apps and other clients rely on are described below, under integration commands. For the rest, the frontend source is the best place to see how they are called.
These commands don't come from websocket_api itself, but from integrations that are always loaded when the frontend is. Companion apps and other clients use them, so their format is a contract.
config/entity_registry/list_for_display comes from the config integration. It returns the entity registry in a compact format: just what a client needs to show entities, with short keys to keep the message small. Any authenticated user can call it. It takes no parameters.
{
"id": 40,
"type": "config/entity_registry/list_for_display"
}{
"id": 40,
"type": "result",
"success": true,
"result": {
"entity_categories": {"0": "config", "1": "diagnostic"},
"entities": [
{
"ei": "light.living_room",
"pl": "hue",
"ai": "living_room",
"lb": [],
"di": "a1b2c3d4e5f6",
"np": "area",
"hn": true,
"en": "Ceiling"
},
{
"ei": "sensor.living_room_signal",
"pl": "esphome",
"lb": [],
"di": "f6e5d4c3b2a1",
"np": "device",
"tk": "signal",
"ec": 1,
"hn": true,
"dp": 0
}
]
}
}entity_categories maps a number to an entity category. Entities refer to their category with that number, in ec, instead of repeating the string. JSON keys are always strings, so the numbers arrive as "0" and "1".
entities holds every entity in the registry that is not disabled. Disabled entities are left out, whoever disabled them. Entities without a registry entry, like entities without a unique ID, are not in the list either. Each entity has these keys. Only ei, pl, and lb are always there. The others are left out when they have no value.
| Key | Type | Meaning |
|---|---|---|
ei |
string | The entity ID. |
pl |
string | The platform: the integration that provides the entity, like hue. |
lb |
list of strings | The IDs of the labels of the entity. An empty list when it has none. |
ai |
string | The ID of the area the entity itself is assigned to. Not set when the entity only gets its area from its device. |
di |
string | The ID of the device the entity belongs to. |
np |
string | Which name comes next when you build the full name of the entity: area when the entity has its own area, otherwise device when it belongs to a device. |
ic |
string | The icon the user picked, like mdi:lightbulb. It overrides the icon of the state. |
tk |
string | The translation key of the entity, used to translate its name and state. |
ec |
number | The entity category, as a key of entity_categories. |
hb |
boolean | true when the entity is hidden, by the user or by the integration. |
hn |
boolean | true when the entity uses has_entity_name, so its name doesn't include the device name. |
en |
string | The entity name. The name the user set, otherwise the name from the integration. For an integration that doesn't use has_entity_name, the name from the integration has the device name stripped from its start, when it starts with it. |
dp |
number | Sensors only: the number of decimals to show. The precision the user set, otherwise the precision the integration suggests. |
For the full registry entries, with all fields, use config/entity_registry/list (all entries) or config/entity_registry/get_entries (the entries for a list of entity_ids).
The homeassistant integration keeps track of which entities are exposed to which voice assistant. These commands manage that. All of them need an admin.
The assistants are identified by these IDs, and the commands accept no others:
conversation: Assistcloud.alexa: Amazon Alexa through Home Assistant Linkcloud.google_assistant: Google Assistant through Home Assistant Link
homeassistant/expose_entity exposes or unexposes entities to one or more assistants. The change applies right away.
{
"id": 41,
"type": "homeassistant/expose_entity",
"assistants": ["conversation", "cloud.alexa"],
"entity_ids": ["light.living_room", "sensor.outside"],
"should_expose": true
}| Field | Type | Meaning |
|---|---|---|
assistants |
list of strings | Required. The assistants to change the setting for. |
entity_ids |
list of strings | Required. The entities to change. The IDs are not checked against existing entities. |
should_expose |
boolean | Required. true to expose, false to unexpose. |
The result is null.
homeassistant/expose_entity/list takes no parameters and returns the entities that are exposed to at least one assistant. Each entity maps to the assistants it is exposed to, always with the value true. An assistant the entity is not exposed to is left out, and so is an entity that is exposed to none.
{
"id": 42,
"type": "homeassistant/expose_entity/list"
}{
"id": 42,
"type": "result",
"success": true,
"result": {
"exposed_entities": {
"light.living_room": {
"conversation": true,
"cloud.alexa": true
},
"sensor.outside": {
"conversation": true
}
}
}
}The list holds what is stored. Home Assistant stores the setting of an entity when someone exposes or unexposes it, and also the first time an assistant asks whether it may use the entity. Until then, an entity is not in the list, even when the default rules below would expose it.
Each assistant has a setting that decides whether new entities are exposed to it automatically. homeassistant/expose_new_entities/get reads it for one assistant:
{
"id": 43,
"type": "homeassistant/expose_new_entities/get",
"assistant": "conversation"
}{
"id": 43,
"type": "result",
"success": true,
"result": {
"expose_new": true
}
}Until it is changed, the setting is true for conversation and false for the other assistants.
homeassistant/expose_new_entities/set changes it. Both assistant and expose_new (a boolean) are required. The result is null.
{
"id": 44,
"type": "homeassistant/expose_new_entities/set",
"assistant": "cloud.alexa",
"expose_new": true
}With the setting on, not every new entity is exposed. An entity is exposed by default when it has no entity category and isn't hidden, and one of these is true:
- Its domain is
climate,cover,fan,humidifier,light,media_player,scene,switch,todo,vacuum, orwater_heater. - It is a binary sensor with the device class
door,garage_door,lock,motion,opening,presence, orwindow. - It is a sensor with the device class
aqi,carbon_monoxide,carbon_dioxide,humidity,pm10,pm25,temperature, orvolatile_organic_compounds.
An integration registers commands with websocket_api.async_register_command, usually in async_setup. Name the command after your domain, like my_integration/devices.
from typing import Any
import probatio
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.typing import ConfigType
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up my integration."""
websocket_api.async_register_command(hass, websocket_list_devices)
return True
@websocket_api.websocket_command(
{
probatio.Required("type"): "my_integration/devices",
probatio.Optional("include_offline", default=False): bool,
}
)
@websocket_api.async_response
async def websocket_list_devices(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Return the devices of my integration."""
devices = await async_get_devices(hass, msg["include_offline"])
connection.send_result(msg["id"], {"devices": devices})websocket_commandsets the schema. The message is validated before your handler runs.async_responseis for handlers that are coroutine functions. A handler that does not await can be a@callbackinstead, withoutasync_response.require_adminlimits the command to administrators. Put it above the other decorators.connection.useris the user. Useconnection.context(msg)as the context for anything you do on their behalf. See permissions.connection.send_result(msg["id"], result)answers the command.connection.send_error(msg["id"], code, message)reports an error, with one of the codes fromwebsocket_api, likewebsocket_api.ERR_NOT_FOUND.
For a command that streams updates, store a function that ends the subscription in connection.subscriptions[msg["id"]]. Home Assistant calls it when the client unsubscribes or disconnects. Send updates with connection.send_message(websocket_api.event_message(msg["id"], data)).
@callback
@websocket_api.websocket_command(
{probatio.Required("type"): "my_integration/subscribe"}
)
def websocket_subscribe(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Send updates of my integration to the client."""
@callback
def forward(data: dict[str, Any]) -> None:
connection.send_message(
websocket_api.event_message(msg["id"], data)
)
connection.subscriptions[msg["id"]] = async_dispatcher_connect(
hass, SIGNAL_UPDATE, forward
)
connection.send_result(msg["id"])