# Quickstart

Unlock a door through one of your hubs. You need an API key with the `hubs:read` and `hubs:command` scopes.

## Create an API key

Create a key on the [API keys](/api-keys) page and store it as an environment variable on your server. Every request sends it as a bearer token.

```shell
export ONEKEY_API_KEY="1key_live_…"
```

## List your hubs

Ask for the hubs your organization owns.

```curl
curl https://api.1keyplatform.dev/v1/hubs \
  -H "Authorization: Bearer $ONEKEY_API_KEY"
```

```javascript
const res = await fetch("https://api.1keyplatform.dev/v1/hubs", {
  headers: { Authorization: `Bearer ${process.env.ONEKEY_API_KEY}` },
});
const { data: hubs } = await res.json();
```

```python
import os, requests

res = requests.get(
    "https://api.1keyplatform.dev/v1/hubs",
    headers={"Authorization": f"Bearer {os.environ['ONEKEY_API_KEY']}"},
)
hubs = res.json()["data"]
```

```elixir
hubs =
  Req.get!("https://api.1keyplatform.dev/v1/hubs",
    auth: {:bearer, System.fetch_env!("ONEKEY_API_KEY")}
  ).body["data"]
```

```json
{
  "data": [
    {
      "id": "1key-xfy2-nmf4-5xwp",
      "name": "Lobby hub",
      "site_id": "6f1c2a9e-4b7d-4e0a-9c3f-2d8e5a1b7c40",
      "connection": { "status": "online", "since": "2026-10-01T09:14:02Z", "public_ip": "203.0.113.7", "disconnect_reason": null },
      "registered_at": "2026-09-12T15:30:00Z"
    }
  ],
  "has_more": false
}
```

## Find the lock

List the hub's devices and find the one with a writable `lock` channel.

```curl
curl https://api.1keyplatform.dev/v1/hubs/1key-xfy2-nmf4-5xwp/devices \
  -H "Authorization: Bearer $ONEKEY_API_KEY"
```

```json
{
  "data": [
    {
      "id": 7,
      "name": "Front door",
      "manufacturer": "Yale",
      "model": "YRD256",
      "channels": [
        { "key": "lock", "endpoint": 0, "kind": "lock", "writable": true, "available": true, "unit": null, "readable": true, "capabilities": {} }
      ]
    }
  ]
}
```

## Send a command

Unlock it by setting its `lock` channel to `false`.

```curl
curl -X POST https://api.1keyplatform.dev/v1/hubs/1key-xfy2-nmf4-5xwp/devices/7/commands \
  -H "Authorization: Bearer $ONEKEY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"key": "lock", "value": false}'
```

```javascript
const res = await fetch("https://api.1keyplatform.dev/v1/hubs/1key-xfy2-nmf4-5xwp/devices/7/commands", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.ONEKEY_API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({ key: "lock", value: false }),
});
const command = await res.json();
```

```python
import uuid

command = requests.post(
    "https://api.1keyplatform.dev/v1/hubs/1key-xfy2-nmf4-5xwp/devices/7/commands",
    headers={
        "Authorization": f"Bearer {os.environ['ONEKEY_API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={"key": "lock", "value": False},
).json()
```

```elixir
command =
  Req.post!("https://api.1keyplatform.dev/v1/hubs/1key-xfy2-nmf4-5xwp/devices/7/commands",
    auth: {:bearer, System.fetch_env!("ONEKEY_API_KEY")},
    headers: [{"idempotency-key", Ecto.UUID.generate()}],
    json: %{key: "lock", value: false}
  ).body
```

`202` means the hub accepted it: the channel's `status` is `pending`. When the lock reports back, a `channel.observed` event arrives and the channel's `status` becomes `synchronized`.

## Next steps

- [Events](/docs/events): see the lock report back, and every other change, as it happens.
- [Devices](/docs/devices): channels and what each one takes.
- [Errors](/docs/errors): what each status means for a hub command.
- [API reference](/docs/api-reference): every endpoint, from the OpenAPI spec.


---

# Authentication

Every request carries an API key as a bearer token:

```shell
Authorization: Bearer 1key_live_4tj2q7xkn3mzd_…
```

Requests without a valid key get `401 unauthenticated`.

## API keys

Create keys on the [API keys](/api-keys) page. A key belongs to your organization, not to the person who created it, and reaches only your organization's hubs.

| Part | Example | Meaning |
|---|---|---|
| Prefix | `1key_live_` | `live` in production, `dev` in development |
| Key ID | `4tj2q7xkn3mzd` | Shown in the portal; use it to tell keys apart in logs |
| Secret | the rest | Shown once, at creation |

The full key is shown once. 1key stores only a hash of it, so a lost key cannot be recovered: revoke it and create another.

## Scopes

A key can do only what its scopes allow. Give each server the fewest scopes it needs.

| Scope | Allows |
|---|---|
| `sites:read` | List sites and read their details |
| `sites:write` | Create and rename sites |
| `hubs:read` | List hubs, read their details and connection status, list devices |
| `hubs:write` | Add hubs to sites, move and rename them; add, remove, name and place devices |
| `hubs:command` | Send commands and read their outcome |
| `events:read` | Read and stream events |
| `members:read` | List members and pending invitations |
| `members:write` | Invite people, revoke invitations, change admin and member roles, remove members |

A request outside the key's scopes gets `403 insufficient_scope`, naming the scope it needs.

## Keeping keys safe

- Keep keys in a secret manager or environment variable, never in source control or client-side code.
- Use one key per server or job, so revoking one does not break the others.
- Revoke a key from the API keys page as soon as you suspect it leaked. Requests with it fail immediately.
- Keys never expire on their own. Rotate by creating a new key, deploying it, then revoking the old one.


---

# Sites

A site is a building your organization manages. Every hub is at one of your sites, so create the site before you add its hubs.

## Creating a site

```curl
curl -X POST https://api.1keyplatform.dev/v1/sites \
  -H "Authorization: Bearer $ONEKEY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "120 Harbour St"}'
```

```json
{
  "id": "6f1c2a9e-4b7d-4e0a-9c3f-2d8e5a1b7c40",
  "name": "120 Harbour St",
  "hub_count": 0,
  "created_at": "2026-09-12T15:20:00Z"
}
```

Needs `sites:write`. Keep the `id`: you add hubs to a site by it.

## Listing sites

```curl
curl https://api.1keyplatform.dev/v1/sites \
  -H "Authorization: Bearer $ONEKEY_API_KEY"
```

Sites come oldest first, 50 to a page. When `has_more` is `true`, pass the last site's `id` as `starting_after` for the next page. Needs `sites:read`.

## Renaming a site

```curl
curl -X PATCH https://api.1keyplatform.dev/v1/sites/6f1c2a9e-4b7d-4e0a-9c3f-2d8e5a1b7c40 \
  -H "Authorization: Bearer $ONEKEY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "Harbour Tower"}'
```

Needs `sites:write`.


---

# Hubs

A hub is the 1key device in a building that controls its locks, thermostats, switches and sensors. Each hub has a hub ID printed on its label, such as `1key-xfy2-nmf4-5xwp`.

## Adding a hub

Each hub is at one of your organization's [sites](/docs/sites). Once a hub is installed and online, add it to a site by the Serial on its label:

```curl
curl -X POST https://api.1keyplatform.dev/v1/hubs \
  -H "Authorization: Bearer $ONEKEY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"serial": "1KEY-XFY2-NMF4-5XWP", "site_id": "6f1c2a9e-4b7d-4e0a-9c3f-2d8e5a1b7c40", "name": "Lobby hub"}'
```

Needs `hubs:write`. The Serial is accepted in any case, with or without dashes. From then on the hub appears in `GET /hubs`. The [portal](/hubs) does the same.

A hub belongs to one organization at a time: the first to add it keeps it. `422 hub_not_addable` means the Serial is unknown, the hub has not come online yet, or another organization has it. Adding a hub your organization already has moves it to the site you name.

## Reading hubs

```curl
curl https://api.1keyplatform.dev/v1/hubs/1key-xfy2-nmf4-5xwp \
  -H "Authorization: Bearer $ONEKEY_API_KEY"
```

```json
{
  "id": "1key-xfy2-nmf4-5xwp",
  "name": "Lobby hub",
  "site_id": "6f1c2a9e-4b7d-4e0a-9c3f-2d8e5a1b7c40",
  "connection": {
    "status": "online",
    "since": "2026-10-01T09:14:02Z",
    "public_ip": "203.0.113.7",
    "disconnect_reason": null
  },
  "firmware": { "version": "0.4.2" },
  "network": {
    "type": "wifi",
    "interface": "wlan0",
    "ip_address": "192.168.1.40",
    "ipv6_address": null,
    "mac_address": "b8:27:eb:12:34:56",
    "ssid": "Lobby",
    "signal_dbm": -61,
    "carrier": null,
    "radio": null,
    "metered": false,
    "reported_at": "2026-10-01T09:14:03Z"
  },
  "registered_at": "2026-09-12T15:30:00Z"
}
```

`connection.status` is `online` or `offline` as AWS IoT sees the hub's connection, or `unknown` when the hub has not connected since it was added. `hub.connected` and `hub.disconnected` events report changes.

`firmware` and `network` are what the hub reported when it last connected, and `null` until it does. `network.type` is `wifi`, `ethernet` or `cellular`.

A hub that is not your organization's returns `404`, the same as one that does not exist.

## Devices

A hub's devices are read and commanded live through the hub. See [devices](/docs/devices) and [commands](/docs/commands).

## Listing hubs

`GET /hubs` lists your hubs by hub ID, 50 to a page; pass the last hub's `id` as `starting_after` for the next. Add `site_id` to list one site's hubs.

## Resetting a hub's link

A hub that was factory reset, or lost its key, can't reconnect with its old certificate. Reset its link and it requests a new certificate on its own:

```curl
curl -X POST https://api.1keyplatform.dev/v1/hubs/1key-xfy2-nmf4-5xwp/reset-link \
  -H "Authorization: Bearer $ONEKEY_API_KEY"
```

A hub that was refused retries every six hours, so it reconnects within six hours, or at once if you restart it. It stays in your organization, at its site. Needs `hubs:write`.

## Renaming and moving

```curl
curl -X PATCH https://api.1keyplatform.dev/v1/hubs/1key-xfy2-nmf4-5xwp \
  -H "Authorization: Bearer $ONEKEY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "Lobby hub", "site_id": "6f1c2a9e-4b7d-4e0a-9c3f-2d8e5a1b7c40"}'
```

Send `name`, `site_id` or both. A `null` name clears it. Needs `hubs:write`.


---

# Members

Members are the people in your organization. Each is an owner, admin or member. Owners and admins manage members in the [portal](/organization); API keys with the `members:*` scopes do the same, except that an API key cannot change or remove owners, or make someone an owner.

## Inviting someone

```curl
curl -X POST https://api.1keyplatform.dev/v1/invitations \
  -H "Authorization: Bearer $ONEKEY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"email": "dev@example.com", "role": "member"}'
```

1key emails the address a link that works for 7 days. Opening it adds them to your organization, creating their account if they have none. `role` is `admin` or `member`, and defaults to `member`. Needs `members:write`.

`GET /invitations` lists the pending ones; `DELETE /invitations/{id}` revokes one, so its link stops working.

## Listing and changing members

```curl
curl https://api.1keyplatform.dev/v1/members \
  -H "Authorization: Bearer $ONEKEY_API_KEY"
```

```json
{
  "data": [
    { "id": "4b0f…", "email": "owner@example.com", "role": "owner", "joined_at": "2026-09-12T15:20:00Z" },
    { "id": "9c2e…", "email": "dev@example.com", "role": "member", "joined_at": "2026-10-01T10:02:11Z" }
  ],
  "has_more": false
}
```

`PATCH /members/{id}` with `{"role": "admin"}` or `{"role": "member"}` changes a role; `DELETE /members/{id}` removes someone. Both need `members:write`, and both answer `403 forbidden` for an owner.


---

# Devices

A hub's devices are the locks, thermostats, switches and sensors joined to it. Their state lives on the hub: the API asks the hub live, so these calls need the hub online.

## Devices and channels

Each device has **channels**: the things it reads or sets. A lock has a `lock` channel and usually `battery`; a thermostat has `temperature`, `mode` and `heating_setpoint`; a two-gang switch has a `switch` channel on endpoint 1 and on endpoint 2.

```curl
curl https://api.1keyplatform.dev/v1/hubs/1key-xfy2-nmf4-5xwp/devices/7 \
  -H "Authorization: Bearer $ONEKEY_API_KEY"
```

```json
{
  "id": 7,
  "name": "Front door",
  "manufacturer": "Yale",
  "model": "YRD256",
  "channels": [
    { "key": "battery", "endpoint": 0, "kind": "battery", "unit": "%", "readable": true, "writable": false, "capabilities": {}, "available": true,
      "value": 82, "observed_at": "2026-10-03T13:58:00.000000Z", "desired": null, "desired_at": null, "status": "synchronized" },
    { "key": "lock", "endpoint": 0, "kind": "lock", "unit": null, "readable": true, "writable": true, "capabilities": {}, "available": true,
      "value": true, "observed_at": "2026-10-03T14:00:00.000000Z", "desired": null, "desired_at": null, "status": "synchronized" }
  ]
}
```

`GET /hubs/{hub_id}/devices` lists every device with its channels, without values. Get one device for its values. Both need `hubs:read`. Each device also carries `room`, set by you (below).

A channel's `value` is only ever what the device last reported. `desired` is what the last command asked for, and `status` compares them:

| `status` | Meaning |
|---|---|
| `unknown` | Nothing asked, nothing reported yet |
| `synchronized` | The device reports what was asked, or nothing was asked |
| `pending` | A command is under way |
| `diverged` | The device has since reported something else |
| `failed` | The command was refused or abandoned |

## Channel keys

| Key | Value | Writable |
|---|---|---|
| `lock` | `true` locked, `false` unlocked | yes |
| `barrier` | `true` open, `false` closed (garage doors, gates) | yes |
| `switch` | `true` on, `false` off | yes |
| `level` | 0–99 % (dimmers) | yes |
| `position` | 0 closed – 99 open % (shades) | yes |
| `color` | `{"red": 0–255, "green": …, "warm_white": …}` | yes |
| `mode` | `off`, `heat`, `cool`, `auto`, `eco`, … | yes |
| `heating_setpoint`, `cooling_setpoint` | °C; `capabilities` has `min` and `max` | yes |
| `fan_mode` | `auto_low`, `low`, `high`, … | yes |
| `local_protection` | `true` while the device's own buttons are locked out | yes |
| `operating_state`, `fan_state` | `idle`, `heating`, `cooling`, … | no |
| `temperature`, `humidity`, `luminance`, `co2`, … | a number, in `unit` | no |
| `contact`, `motion`, `leak`, `smoke`, `co`, `heat`, `tamper` | `true` while triggered | no |
| `battery` | % | no |
| `energy`, `power`, `voltage`, `current`, `water`, `gas` | meter readings, in `unit` | no |
| `alarm` | `{"type", "event", "active", "user"}` | no |
| `scene` | `{"scene": 1, "attribute": "pressed"}` | no |

A device has only the channels it reported supporting. Check `writable` before sending a command.

## Names and rooms

A device's `name` is what the hub reports unless you set one; `room` is yours alone. 1key keeps both in the cloud, so they survive a hub restart:

```curl
curl -X PATCH https://api.1keyplatform.dev/v1/hubs/1key-xfy2-nmf4-5xwp/devices/7 \
  -H "Authorization: Bearer $ONEKEY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "Main entrance", "room": "Entrance"}'
```

Send either or both; `null` clears one. Needs `hubs:write`.

## Adding a device

Open the hub's network, then put the device in pairing mode (usually three presses of its button, or as its manual says):

```curl
curl -X POST https://api.1keyplatform.dev/v1/hubs/1key-xfy2-nmf4-5xwp/pairing \
  -H "Authorization: Bearer $ONEKEY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"setup_code": "12345"}'
```

```json
{ "status": "open", "expires_in": 120 }
```

The hub listens for one device for two minutes. When it joins, a `device.added` event arrives with its `device_id`. `setup_code` is optional: the 5-digit PIN, DSK or QR text printed on the device or its box. Locks need it to join securely enough to be driven. `DELETE /hubs/{hub_id}/pairing` closes the window early. Needs `hubs:write`.

## Removing a device

A device still on the hub's network leaves it with a hand on the device: open the removal window, then press the device's button.

```curl
curl -X POST https://api.1keyplatform.dev/v1/hubs/1key-xfy2-nmf4-5xwp/removal \
  -H "Authorization: Bearer $ONEKEY_API_KEY"
```

A `device.forgotten` event confirms it. `DELETE /hubs/{hub_id}/removal` closes the window.

A device the hub can no longer reach (its channels show `available: false`) can't press a button. Forget it instead:

```curl
curl -X DELETE https://api.1keyplatform.dev/v1/hubs/1key-xfy2-nmf4-5xwp/devices/8 \
  -H "Authorization: Bearer $ONEKEY_API_KEY"
```

The hub refuses to forget a device still on its network (`400`): remove that one with the removal window. Both need `hubs:write`.

## Trying it in the portal

Each hub's **Devices** tab in the [portal](/hubs) is built on these endpoints. Its console shows every request, response and event, can copy any request as curl, and can act with one of your API keys' scopes to see permission errors.

## When the hub can't answer

| Status | `type` | Meaning |
|---|---|---|
| 503 | `hub_offline` | The hub is not connected. Sent nothing |
| 503 | `hub_unavailable` | The hub is connected but can't do it now: its radio is down, or it doesn't know the time yet |
| 504 | `hub_timeout` | The hub did not answer within 10 seconds |

To track state over time, follow [events](/docs/events) rather than polling devices.


---

# Commands

A command sets one writable channel on a device: lock or unlock a door, set a temperature, turn on a switch.

## Sending a command

```curl
curl -X POST https://api.1keyplatform.dev/v1/hubs/1key-xfy2-nmf4-5xwp/devices/7/commands \
  -H "Authorization: Bearer $ONEKEY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 6f1c2a1e-1b7d-4a3e-9c51-0f1d2b8e7a44" \
  -d '{"key": "lock", "value": false}'
```

```json
{
  "hub_id": "1key-xfy2-nmf4-5xwp",
  "device_id": 7,
  "channel": {
    "key": "lock", "endpoint": 0, "kind": "lock", "unit": null,
    "value": true, "observed_at": "2026-10-03T14:00:00.000000Z",
    "desired": false, "desired_at": "2026-10-03T14:03:10.000000Z",
    "status": "pending", "available": true
  }
}
```

Needs `hubs:command`. `endpoint` defaults to 0; set it for a device with several of one channel, such as a two-gang switch.

`202` means the hub accepted the command and handed it to the device. It does not mean the door is unlocked yet: `value` is still what the device last reported, and `status` is `pending`.

## Values

| Channel | `value` |
|---|---|
| `lock` | `false` unlocks, `true` locks |
| `barrier` | `true` opens, `false` closes |
| `switch` | `true` on, `false` off |
| `level`, `position` | 0–99 |
| `heating_setpoint`, `cooling_setpoint` | degrees C, such as `21.5` |
| `mode` | a mode name, such as `"heat"` |
| `fan_mode` | a fan mode name |
| `color` | `{"red": 255, "green": 80, "blue": 0}` |

The [devices guide](/docs/devices) lists every channel.

## Getting the outcome

The device reports back after it acts. Its report updates the channel, and `status` becomes:

| `status` | Meaning |
|---|---|
| `synchronized` | The device reports the value asked for: done |
| `diverged` | The device reported something else, for example a lock that jammed |
| `failed` | The hub or radio gave up |

Two ways to learn it:

1. **Events.** A `channel.observed` event arrives when the device reports. Follow the hub's [events](/docs/events).
2. **Read.** `GET /hubs/{hub_id}/devices/{device_id}` returns the channel's current `status`.

## When the hub refuses

| Status | `type` | Meaning |
|---|---|---|
| 400 | `invalid_request` | The hub refused the arguments: a read-only channel, or a value the channel doesn't take. The message says which |
| 404 | `not_found` | No such device or channel on the hub |
| 422 | `command_refused` | The hub won't drive it, for example a lock that did not join securely enough |
| 503 | `hub_offline`, `hub_unavailable` | The hub is not connected, or can't act now. Nothing was done |
| 504 | `hub_timeout` | The hub did not answer within 10 seconds. It may still act; retry with the same `Idempotency-Key` |

## Retrying safely

Send an `Idempotency-Key` header with every command: a new UUID per command, reused on retries. A retry with the same key within 24 hours returns the first response instead of sending the command again, and the hub runs it once even if two retries race. Reusing a key with a different body returns `409 idempotency_conflict`.

Retry on network errors, `429`, `502`, `503` and `504`. Do not retry `400`, `401`, `403`, `404` or `422`: the same request fails the same way.

Commands are not queued for an offline hub: they fail at once with `503 hub_offline`.


---

# Events

Events tell you what happened at a hub: a door unlocked, a temperature changed, a device joined, the hub went offline. Each hub keeps its own ordered log of events, and 1key keeps the last 24 hours of it. Needs `events:read`.

## Reading events

Read a hub's events with a cursor. Start without one to get the oldest retained events, then pass `next_cursor` as `after` each time.

```curl
curl "https://api.1keyplatform.dev/v1/hubs/1key-xfy2-nmf4-5xwp/events?after=1042&wait=30" \
  -H "Authorization: Bearer $ONEKEY_API_KEY"
```

```json
{
  "data": [
    {
      "cursor": "1043",
      "type": "channel.observed",
      "hub_id": "1key-xfy2-nmf4-5xwp",
      "device_id": 7,
      "time": "2026-10-03T14:03:11.143000Z",
      "clock_trusted": true,
      "data": { "key": "lock", "endpoint": 0, "value": false }
    }
  ],
  "next_cursor": "1043",
  "has_more": false
}
```

`wait=30` holds the request until an event arrives or 30 seconds pass (up to 60), so a loop of requests gets events as they happen without busy-polling. `limit` is 1–500, default 100; when `has_more` is `true`, read again at once.

## A reader loop

```python
import os, requests

API = "https://api.1keyplatform.dev/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['ONEKEY_API_KEY']}"}

def follow(hub_id, cursor=None):
    while True:
        params = {"wait": 30, **({"after": cursor} if cursor else {})}
        res = requests.get(f"{API}/hubs/{hub_id}/events", headers=HEADERS, params=params, timeout=70)
        if res.status_code == 410:
            cursor = resync(hub_id)  # read current state, then continue from the newest event
            continue
        res.raise_for_status()
        page = res.json()
        for event in page["data"]:
            handle(event)       # must tolerate seeing the same event twice
        cursor = page["next_cursor"]
        save_cursor(hub_id, cursor)  # persist after handling
```

```javascript
async function follow(hubId, cursor) {
  for (;;) {
    const url = new URL(`https://api.1keyplatform.dev/v1/hubs/${hubId}/events`);
    url.searchParams.set("wait", "30");
    if (cursor) url.searchParams.set("after", cursor);
    const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.ONEKEY_API_KEY}` } });
    if (res.status === 410) { cursor = await resync(hubId); continue; }
    if (!res.ok) throw new Error(`events: ${res.status}`);
    const page = await res.json();
    for (const event of page.data) await handle(event);
    cursor = page.next_cursor;
    await saveCursor(hubId, cursor);
  }
}
```

## Streaming

For a held-open connection, use Server-Sent Events:

```curl
curl -N https://api.1keyplatform.dev/v1/hubs/1key-xfy2-nmf4-5xwp/events/stream \
  -H "Authorization: Bearer $ONEKEY_API_KEY" \
  -H "Last-Event-ID: 1042"
```

```text
id: 1043
event: channel.observed
data: {"cursor":"1043","type":"channel.observed","hub_id":"1key-xfy2-nmf4-5xwp","device_id":7,"time":"2026-10-03T14:03:11.143000Z","clock_trusted":true,"data":{"key":"lock","endpoint":0,"value":false}}

: keepalive
```

Each message's `id` is the event cursor. Without `Last-Event-ID`, the stream starts with the next new event. A `: keepalive` comment arrives every 30 seconds, and the server ends the stream after 15 minutes. Reconnect with `Last-Event-ID` set to the last `id` you processed; SSE clients do this automatically.

## Delivery rules

| Rule | What to do |
|---|---|
| Order is per hub | Events from one hub arrive in order. There is no order across hubs. |
| At least once | You may see an event again after a reconnect or retry. Deduplicate on (`hub_id`, `cursor`). |
| 24-hour retention | A cursor older than 24 hours gets `410 cursor_expired`. |
| Gaps | If 1key could not get some of a hub's events, a `hub.events_skipped` event says so. Re-read devices for current state. |

## Resyncing after 410

`410 cursor_expired` means events you had not read are gone. Read current state instead of replaying history: call `GET /hubs/{hub_id}/devices`, then read events without `after` and continue from the newest one.

## Cursors

A cursor looks like `1043`. Treat it as an opaque string: store it, compare it for equality, pass it back. Do not parse it or do arithmetic on it.

Event types and their data are listed in the [event reference](/docs/event-reference).


---

# Errors

Errors return a JSON body with a machine-readable `type`, a human-readable `message`, and a `request_id` to quote to 1key support.

```json
{
  "error": {
    "type": "insufficient_scope",
    "message": "This key needs hubs:command.",
    "request_id": "req_5LqW2v"
  }
}
```

Branch on `type`, not on `message`: messages may change, types do not.

| Status | `type` | Meaning | Retry? |
|---|---|---|---|
| 400 | `invalid_request` | Malformed JSON, unknown action, bad parameter | No: fix the request |
| 401 | `unauthenticated` | Missing, malformed, revoked or unknown key | No: check the key |
| 403 | `insufficient_scope` | The key lacks a scope; the message names it | No: use a key with the scope |
| 403 | `forbidden` | An API key tried to change or remove an owner, or make one | No: an owner does it in the portal |
| 404 | `not_found` | No such site, hub or device in your organization | No |
| 409 | `idempotency_conflict` | `Idempotency-Key` reused with a different body | No: use a new key |
| 410 | `cursor_expired` | Event cursor older than 24 hours, or from before a hub reset | No: [resync](/docs/events) |
| 422 | `command_refused` | The hub won't drive that device, for example a lock that did not join securely | No |
| 422 | `hub_not_addable` | The Serial is unknown, the hub has not come online, or another organization has it | Yes, once the hub is online |
| 429 | `rate_limited` | Too many requests | Yes, after `Retry-After` seconds |
| 500 | `internal_error` | A 1key fault | Yes, with backoff |
| 502 | `hub_error` | The hub failed, or the request could not be sent to it | Yes, with backoff |
| 503 | `hub_offline` | The hub is not connected | Yes, later; watch for `hub.connected` |
| 503 | `hub_unavailable` | The hub is connected but can't do it now | Yes, with backoff |
| 504 | `hub_timeout` | The hub did not answer in time | Yes, with backoff |

## Hubs you do not own

A hub that exists but belongs to another organization returns `404`, exactly like a hub that does not exist. The API never reveals other organizations' hubs.

## Failed commands

A command can be accepted (`202`) and still not happen: the device may not respond, or may report something else. That is not an HTTP error. The channel's `status` becomes `failed` or `diverged`; see [commands](/docs/commands).


---

# Rate limits

Limits apply per API key.

| Limit | Value |
|---|---|
| Requests | 50 per second, bursts up to 100 |

Every response carries the remaining budget:

```text
RateLimit-Limit: 50
RateLimit-Remaining: 47
RateLimit-Reset: 1
```

Past a limit, requests get `429 rate_limited` with a `Retry-After` header in seconds. Wait that long, then retry. If you keep hitting limits, spread requests out or use separate keys for separate jobs.


---

# Build with AI assistants

Point your AI coding assistant at these docs and it can write 1key integrations for you. This page has the files assistants read, instructions to give them, and prompts to start from.

## Files for assistants

| File | Contents |
|---|---|
| [/llms.txt](/llms.txt) | Index of these docs, with links to each page as Markdown |
| [/llms-full.txt](/llms-full.txt) | All of these docs in one Markdown file |
| [/openapi.yaml](/openapi.yaml) | The OpenAPI 3.1 contract |
| `/docs/<page>.md` | Any page as Markdown, for example [/docs/events.md](/docs/events.md) |

Give the assistant `llms-full.txt` and `openapi.yaml` when it should write code; `llms.txt` is enough for it to find answers.

## Instructions for your assistant

Paste this into your assistant's project instructions (`CLAUDE.md`, `AGENTS.md`, `.cursorrules` or similar):

```markdown
## 1key API

Docs: https://developers.1keyplatform.dev/llms-full.txt · Contract: https://developers.1keyplatform.dev/openapi.yaml

- Base URL: https://api.1keyplatform.dev/v1. Auth: `Authorization: Bearer $ONEKEY_API_KEY`. Read the key from the environment; never hardcode, log or commit it.
- Hub IDs look like `1key-xfy2-nmf4-5xwp`. A hub outside our organization returns 404.
- Device state lives on the hub. Devices have channels ({key, endpoint}); GET /hubs/{hub_id}/devices/{device_id} returns each channel's value, desired and status, read live. Follow changes with events. Do not build a local cache of device state and treat it as truth.
- Commands: POST /hubs/{hub_id}/devices/{device_id}/commands with {"key", "value", "endpoint"?}, only on writable channels. Always send an Idempotency-Key header (a new UUID per logical command, reused on retries). 202 means the hub accepted it (status pending), not done: the outcome is the channel's next channel.observed event, or its status (synchronized, diverged, failed) on GET device.
- Events are per hub. Read GET /hubs/{hub_id}/events?after={cursor}&wait=30 in a loop and persist next_cursor after handling each page. Cursors are opaque strings. Deduplicate on (hub_id, cursor). On 410 cursor_expired, re-read devices and continue from the newest event.
- Retry 429 (honor Retry-After), 500, 503 and 504 with exponential backoff and jitter. Never retry 400, 401, 403, 404 or 409.
- Branch on error.type, never on error.message.
- Ignore event types you do not handle; new ones appear without a version change.
```

## Suggested prompts

Start from one of these, then adjust.

**List hubs and devices**

```text
Using the 1key API (https://developers.1keyplatform.dev/llms-full.txt), write a script that lists every hub in my organization with its connection status, then lists the devices of each online hub. Read the API key from ONEKEY_API_KEY. Handle pagination and 503 hub_offline.
```

**Unlock a door safely**

```text
Write a function unlock(hubId, deviceId) for the 1key API that sets the device's lock channel to false with an Idempotency-Key, then polls the device for up to 10 seconds until the lock channel's status is synchronized, diverged or failed, and returns that or still-pending. Retry only on network errors, 429, 502, 503 and 504, reusing the same Idempotency-Key.
```

**Follow events from all hubs**

```text
Build a long-running worker for the 1key API that follows events from every hub in my organization. One reader loop per hub using GET /hubs/{hub_id}/events with wait=30. Persist each hub's cursor in SQLite after handling a page, deduplicate on (hub_id, cursor), handle 410 cursor_expired by resyncing devices, and pick up hubs that are added later.
```

**Generate a typed client**

```text
Generate a typed TypeScript client from https://developers.1keyplatform.dev/openapi.yaml with one method per operationId, typed errors keyed on error.type, automatic Idempotency-Key on sendCommand, and retries that follow the 1key retry rules.
```

**Explain an error**

```text
I got this response from the 1key API: <paste response>. Using https://developers.1keyplatform.dev/docs/errors.md, explain what went wrong and whether I should retry.
```

## Letting an AI agent operate devices

An agent that sends commands acts on real doors and thermostats. Set it up so a mistake is contained.

- **Give agents their own keys with the fewest scopes.** An agent that reports status needs only `hubs:read` and `events:read`. Leave out `hubs:command` unless it must act.
- **Confirm before physical actions.** Have a person approve unlocks and any action on a door before the agent sends it, and show which hub and device it will touch.
- **Send an Idempotency-Key with every command,** so an agent retrying in a loop cannot unlock a door twice.
- **Check the outcome before reporting success.** `202` means accepted, not done.
- **Revoke the agent's key** from the API keys page to stop it at once.


---

# API reference

Base URL: https://api.1keyplatform.dev/v1. Full contract: https://developers.1keyplatform.dev/openapi.yaml

## Sites

The buildings your organization manages. Every hub is at a site.

### GET /sites

List sites. Scopes: `sites:read`.

Your organization's sites, oldest first.

### POST /sites

Create a site. Scopes: `sites:write`.



### GET /sites/{site_id}

Get a site. Scopes: `sites:read`.



### PATCH /sites/{site_id}

Rename a site. Scopes: `sites:write`.



## Hubs

Hubs your organization owns.

### GET /hubs

List hubs. Scopes: `hubs:read`.

Hubs your organization owns, ordered by hub ID.

### POST /hubs

Add a hub to a site. Scopes: `hubs:write`.

Adds a hub to one of your sites by the Serial on its label. The hub
must be installed and online. The first organization to add a hub
keeps it. Adding a hub your organization already has moves it to the
given site.


### GET /hubs/{hub_id}

Get a hub. Scopes: `hubs:read`.



### PATCH /hubs/{hub_id}

Update a hub. Scopes: `hubs:write`.

Renames the hub, moves it to another of your sites, or both.

### POST /hubs/{hub_id}/reset-link

Reset a hub's cloud link. Scopes: `hubs:write`.

Revokes the hub's certificate for the 1key cloud and disconnects it,
so the hub can be issued a new one. Use it after a hub is factory
reset or lost its key and cannot reconnect. The hub asks again within
six hours, or at once when restarted. It stays in your organization,
at its site.


## Members

People in your organization, and invitations to join it.

### GET /invitations

List pending invitations. Scopes: `members:read`.

Invitations not yet accepted, revoked or expired, newest first.

### POST /invitations

Invite someone. Scopes: `members:write`.

Emails the address a link to join your organization, valid for 7
days. Opening it creates their account if they have none.


### DELETE /invitations/{invitation_id}

Revoke an invitation. Scopes: `members:write`.



### GET /members

List members. Scopes: `members:read`.

Everyone in your organization, owners first.

### DELETE /members/{member_id}

Remove a member. Scopes: `members:write`.

API keys cannot remove owners.

### GET /members/{member_id}

Get a member. Scopes: `members:read`.



### PATCH /members/{member_id}

Change a member's role. Scopes: `members:write`.

Between `admin` and `member`. API keys cannot change owners or make owners.

## Devices

Devices connected to a hub and their channels, read live from the hub.

### GET /hubs/{hub_id}/devices

List a hub's devices. Scopes: `hubs:read`.

Asked of the hub live, so it fails with `503 hub_offline` while the
hub is disconnected and `504 hub_timeout` if it does not answer within
10 seconds. Channels are listed without their values; get a device for
those.


### DELETE /hubs/{hub_id}/devices/{device_id}

Forget a device the hub can't reach. Scopes: `hubs:write`.

A device still on the hub's network is refused with `400`; remove it with the removal window.

### GET /hubs/{hub_id}/devices/{device_id}

Get a device and its current state. Scopes: `hubs:read`.

Each channel with the value the device last reported, asked of the hub live.

### PATCH /hubs/{hub_id}/devices/{device_id}

Name a device and set its room. Scopes: `hubs:write`.

Kept in the cloud. Send `name`, `room` or both; `null` clears one.

### DELETE /hubs/{hub_id}/pairing

Close the pairing window. Scopes: `hubs:write`.



### POST /hubs/{hub_id}/pairing

Open the hub's network for a device to join. Scopes: `hubs:write`.

The hub listens for one device for two minutes; put the device in
pairing mode. It arrives as a `device.added` event.


### DELETE /hubs/{hub_id}/removal

Close the removal window. Scopes: `hubs:write`.



### POST /hubs/{hub_id}/removal

Open the hub's network for a device to leave. Scopes: `hubs:write`.

Press the device's button within two minutes. It leaves as a `device.forgotten` event.

## Commands

Actions sent to a hub's devices.

### POST /hubs/{hub_id}/devices/{device_id}/commands

Set a channel. Scopes: `hubs:command`.

Asks the hub to set one of the device's writable channels: lock or
unlock, set a setpoint, turn a switch on. `202` means the hub
accepted it and handed it to the device: the channel's `desired` is
set and its `status` is `pending`. The outcome is the channel's next
reading: `synchronized` once the device reports the value, `failed`
or `diverged` otherwise. Follow `channel.observed` events, or get the
device.


## Events

Events from a hub, kept for 24 hours and read with a per-hub cursor.

### GET /hubs/{hub_id}/events

Read a hub's events. Scopes: `events:read`.

Events after `after`, oldest first. Without `after`, from the oldest
kept (events are kept for 24 hours). Pass `next_cursor` as `after`
next time.


### GET /hubs/{hub_id}/events/stream

Stream a hub's events. Scopes: `events:read`.

Server-Sent Events. Each message's `id` is the event's cursor and its
`event` the event type. Starts after `Last-Event-ID`, or with the
next new event without it. A `: keepalive` comment arrives every 30
seconds. The server ends the stream after 15 minutes; reconnect with
`Last-Event-ID`.


---

# Event reference

Every event has the same envelope; `data` depends on `type`.

| Field | Type | Meaning |
|---|---|---|
| `cursor` | string | Position in the hub's events. Unique per hub |
| `type` | string | Event type, below |
| `hub_id` | string | The hub |
| `device_id` | integer or null | The device the event is about |
| `time` | timestamp | When it happened, by the hub's clock (the cloud's for `hub.*`) |
| `clock_trusted` | boolean or null | `false` when the hub stamped it before it knew the time |
| `data` | object | Type-specific fields |

## Hub

| Type | `data` |
|---|---|
| `hub.connected` | `{}` |
| `hub.disconnected` | `{"reason": "mqtt_keep_alive_timeout"}`; `reason` is AWS IoT's disconnect reason, lowercased, or null |
| `hub.events_skipped` | `{"from": 1, "to": 899}`: some events could not be delivered. Re-read devices for current state |

## Channels

| Type | `data` |
|---|---|
| `channel.observed` | `{"key": "lock", "endpoint": 0, "value": false}`: the device reported a value. Sent on every change; for `alarm` and `scene` channels, on every report |
| `channel.desired` | `{"key": "lock", "endpoint": 0, "value": false}`: a command asked for a value |

A command's outcome is the `channel.observed` that follows its `channel.desired`.

## Devices

| Type | `data` |
|---|---|
| `device.added` | `{"name": "Front door"}` |
| `device.updated` | `{"name": "Front door", "removed_channels": […]}` |
| `device.available`, `device.unavailable` | `{}`: the radio can reach the device again, or no longer can |
| `device.forgotten` | `{"name": "Front door"}`: removed from the hub |

## Credentials and rules

| Type | `data` |
|---|---|
| `credential.created` | `{"slot": 1, "label": "Unit 3"}` |
| `credential.applied` | `{"state": "active"}` |
| `credential.revoked` | `{}` |
| `rule.created`, `rule.updated` | `{"name": "…", "enabled": true}` |
| `rule.fired`, `rule.deleted` | `{}` |

New event types are added without a version change. Ignore types you do not handle.


---

# Changelog

## 1.0.0

The 1key API v1: sites, hubs (added to sites by Serial, with connection, firmware and network), members and invitations, live device reads, commands with idempotency keys, and per-hub events by cursor or Server-Sent Events.
