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:
- Calls
GET /v1.1/devices and reads keyList for each Keypad
- Selects passcodes whose
status is expired
- 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
- Create a time-limited passcode on a Keypad via
createKey (commandType: command,
command: createKey, type: timeLimit) with a short validity window.
- Wait until the validity window has passed.
GET /v1.1/devices now reports the
passcode in keyList with status: "expired".
- Send
deleteKey with parameter: {"id": "<the id from keyList>"}.
-> Response is statusCode: 100 (success).
- 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
Analysis
When deleting an expired passcode from a Keypad / Keypad Touch via the
deleteKeycommand, the API responds with
statusCode: 100(success), but the passcode is NOTactually removed from the device. The same key id is still returned by
GET /v1.1/deviceson the following day.We run a nightly cleanup job that:
GET /v1.1/devicesand readskeyListfor each KeypadstatusisexpireddeleteKeywith{"commandType":"command","command":"deleteKey","parameter":{"id":"<id>"}}Results from two consecutive nights (2026-07-21 and 2026-07-22, 6 accounts):
statusCode: 100, 0 errorsstatusCode: 100, 0 errorsThe 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 casereported in #479), so we do have their ids. The problem is that deleting them by
id has no effect.
Expected Behavior
Either:
(a)
deleteKeyremoves the expired passcode from the device, so it no longerappears in
keyListon subsequentGET /v1.1/devicescalls, or(b) if expired passcodes cannot be deleted via the API by design,
deleteKeyshould 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
deleteKeyshould state whether expiredpasscodes are deletable, and whether expired passcodes are ever removed
automatically by the device or the cloud.
Steps To Reproduce
createKey(commandType: command,command: createKey, type: timeLimit) with a short validity window.
GET /v1.1/devicesnow reports thepasscode in
keyListwithstatus: "expired".deleteKeywithparameter: {"id": "<the id from keyList>"}.-> Response is
statusCode: 100(success).GET /v1.1/devicesagain.-> The passcode with the same id is STILL present in
keyListwithstatus: "expired".Logs
Configuration
Environment
node:22-bookworm-slimaccounts/tokens)
Additional Context
No response