Skip to content

deleteKey succeeds (statusCode 100) but expired Keypad passcodes are never removed from keyList #498

Description

@shiromofufactory

Analysis

When deleting an expired passcode from a Keypad / Keypad Touch via the deleteKey
command, the API responds with statusCode: 100 (success), but the passcode is NOT
actually removed from the device. The same key id is still returned by
GET /v1.1/devices on the following day.

We run a nightly cleanup job that:

  1. Calls GET /v1.1/devices and reads keyList for each Keypad
  2. Selects passcodes whose status is expired
  3. Sends deleteKey with {"commandType":"command","command":"deleteKey","parameter":{"id":"<id>"}}

Results from two consecutive nights (2026-07-21 and 2026-07-22, 6 accounts):

  • Night 1: 151 delete commands sent, all returned statusCode: 100, 0 errors
  • Night 2: 150 delete commands sent, all returned statusCode: 100, 0 errors
  • 149 of the 150 keys deleted on night 2 were THE SAME key ids already deleted on night 1

The number of expired passcodes per device did not decrease. For two accounts the
count was completely unchanged (102 -> 102, 70 -> 70).

This is not an isolated device sync failure: it happens uniformly across all
accounts and all Keypad devices we manage.

Some of these expired passcodes were created in March 2023 and are still present
today, so they are clearly never removed automatically either.

Note: on our devices, expired passcodes ARE listed in keyList (unlike the case
reported in #479), so we do have their ids. The problem is that deleting them by
id has no effect.

Expected Behavior

Either:

(a) deleteKey removes the expired passcode from the device, so it no longer
appears in keyList on subsequent GET /v1.1/devices calls, or

(b) if expired passcodes cannot be deleted via the API by design, deleteKey
should return a distinct, documented error code instead of statusCode: 100,
so that callers can detect the situation instead of silently retrying forever.

Additionally, the documentation for deleteKey should state whether expired
passcodes are deletable, and whether expired passcodes are ever removed
automatically by the device or the cloud.

Steps To Reproduce

  1. Create a time-limited passcode on a Keypad via createKey (commandType: command,
    command: createKey, type: timeLimit) with a short validity window.
  2. Wait until the validity window has passed. GET /v1.1/devices now reports the
    passcode in keyList with status: "expired".
  3. Send deleteKey with parameter: {"id": "<the id from keyList>"}.
    -> Response is statusCode: 100 (success).
  4. Wait, then call GET /v1.1/devices again.
    -> The passcode with the same id is STILL present in keyList with
    status: "expired".

Logs

# Nightly cleanup job, run 1 (2026-07-20 22:37 UTC)
# For each expired passcode we send deleteKey and check the response statusCode.

delete stale key deviceId=DEVICE_A keyId=14 name=<redacted> status=expired createTime=2026-04-17T08:50:12.000Z
delete stale key deviceId=DEVICE_A keyId=15 name=<redacted> status=expired createTime=2026-04-25T04:29:56.000Z
delete stale key deviceId=DEVICE_A keyId=16 name=<redacted> status=expired createTime=2026-05-09T07:01:59.000Z
delete stale key deviceId=DEVICE_A keyId=17 name=<redacted> status=expired createTime=2026-05-09T08:10:35.000Z
delete stale key deviceId=DEVICE_A keyId=18 name=<redacted> status=expired createTime=2026-05-28T00:16:12.000Z
... (151 delete commands in total, every one returned statusCode: 100, zero errors)

summary run 1: account=ACCOUNT_1 candidates=102 deleted=50 failed=0 remaining=52
summary run 1: account=ACCOUNT_2 candidates=70  deleted=50 failed=0 remaining=20
summary run 1: account=ACCOUNT_3 candidates=164 deleted=50 failed=0 remaining=114


# Nightly cleanup job, run 2 (2026-07-21 19:30 UTC, ~21 hours later)
# The SAME key ids are still present in keyList and are selected again.

delete stale key deviceId=DEVICE_A keyId=14 name=<redacted> status=expired createTime=2026-04-17T08:50:12.000Z
delete stale key deviceId=DEVICE_A keyId=15 name=<redacted> status=expired createTime=2026-04-25T04:29:56.000Z
delete stale key deviceId=DEVICE_A keyId=16 name=<redacted> status=expired createTime=2026-05-09T07:01:59.000Z
delete stale key deviceId=DEVICE_A keyId=17 name=<redacted> status=expired createTime=2026-05-09T08:10:35.000Z
delete stale key deviceId=DEVICE_A keyId=18 name=<redacted> status=expired createTime=2026-05-28T00:16:12.000Z
... (150 delete commands in total, every one returned statusCode: 100, zero errors)

summary run 2: account=ACCOUNT_1 candidates=102 deleted=50 failed=0 remaining=52
summary run 2: account=ACCOUNT_2 candidates=70  deleted=50 failed=0 remaining=20
summary run 2: account=ACCOUNT_3 candidates=157 deleted=50 failed=0 remaining=107

# 149 of the 150 key ids deleted in run 2 were identical to those already
# "successfully" deleted in run 1.
# For ACCOUNT_1 and ACCOUNT_2 the candidate count did not change at all.


# Snapshot of GET /v1.1/devices keyList for one device (before run 1)
device=DEVICE_B total_keys=64 status_counts={"expired":63,"normal":1} oldest_createTime=2023-03-29T04:03:26.000Z
device=DEVICE_C total_keys=56 status_counts={"expired":52,"normal":4} oldest_createTime=2023-03-07T10:25:07.000Z
device=DEVICE_D total_keys=48 status_counts={"expired":42,"normal":6} oldest_createTime=2026-04-13T02:23:23.000Z

# Note: our logs record only the response statusCode (we treat statusCode != 100
# as a failure). Full response bodies were not retained. We can add full-body
# logging and provide it if needed.

Configuration

We call the Open API directly; there is no config file. The exact requests are:

# 1. Fetch device list (including keyList)
GET https://api.switch-bot.com/v1.1/devices
Headers:
  Authorization: <token>          # redacted
  sign:          <HMAC-SHA256>    # redacted
  t:             <epoch millis>
  nonce:         <random string>
  Content-Type:  application/json

# 2. Delete one expired passcode
POST https://api.switch-bot.com/v1.1/devices/{deviceId}/commands
Headers: (same as above)
Body:
{
  "commandType": "command",
  "command": "deleteKey",
  "parameter": {
    "id": "14"
  }
}

Response we receive:
{
  "statusCode": 100,
  "message": "success",
  "body": {}
}

The `id` value is taken directly from `body.deviceList[].keyList[].id` of the
`GET /v1.1/devices` response, converted to a string.

Question: is `parameter` supposed to contain any field other than `id`
(e.g. `name` or `type`)? The documentation does not make this clear, and since
the API returns statusCode 100 we have no signal that anything is wrong.

Environment

  • OS: Debian 12 (bookworm) — container image node:22-bookworm-slim
  • Runtime: Node.js 22.x
  • HTTP client: axios ^1.7.9 (via @nestjs/axios ^3.1.3)
  • Framework: NestJS 10.4.22
  • Hosting: Google Cloud Platform, App Engine flexible environment (asia-northeast1)
  • SwitchBot Open API version: v1.1
  • Devices: SwitchBot Keypad and Keypad Touch (multiple units across 6 separate
    accounts/tokens)
  • Observed on: 2026-07-21 and 2026-07-22 (JST)

Additional Context

No response

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions