Summary
td label update <name> --no-favorite always fails with HTTP 400. The --favorite (true) variant works against the same label. The bug is upstream of the CLI/SDK — Todoist's REST endpoint POST /api/v1/labels/{id} rejects bodies whose only field is is_favorite: false.
Reproduce (CLI)
Repro on td 1.47.0 and again on a freshly installed td 1.62.0:
$ td label update "+manuel" --no-favorite
Error: API_ERROR
HTTP 400: Bad Request
$ echo $?
1
$ td label update "+manuel" --favorite
Updated: @+manuel (id:2183662375)
Same result for any personal label. td filter update <name> --no-favorite is not affected — see "Why filters don't hit this" below.
Reproduce (raw REST)
Same endpoint and same OAuth token, four probes:
--- Probe A: false rejected ---
POST https://api.todoist.com/api/v1/labels/2183662375
Content-Type: application/json
body: {"is_favorite": false}
{"error":"At least one of name, order, color or is_favorite fields should be set","error_code":42,"error_tag":"BAD_REQUEST","http_code":400}
HTTP_STATUS=400
--- Probe B: true accepted ---
POST https://api.todoist.com/api/v1/labels/2183662375
body: {"is_favorite": true}
{"id":"2183662375","name":"+manuel","color":"charcoal","order":16,"is_favorite":true}
HTTP_STATUS=200
--- Probe C: workaround (false + name no-op) ---
POST https://api.todoist.com/api/v1/labels/2183662375
body: {"is_favorite": false, "name": "+manuel"}
{"id":"2183662375","name":"+manuel","color":"charcoal","order":16,"is_favorite":false}
HTTP_STATUS=200
--- Probe D: stringified false rejected ---
POST https://api.todoist.com/api/v1/labels/2183662375
body: {"is_favorite": "false"}
{"error":"At least one of name, order, color or is_favorite fields should be set","error_code":42,"error_tag":"BAD_REQUEST","http_code":400}
HTTP_STATUS=400
Observation: the server rejects {"is_favorite": false} with the quoted error, accepts {"is_favorite": true}, and accepts {"is_favorite": false, "name": "<same>"}. Stringified "false" does not unblock.
Likely cause (hypothesis, not asserted as fact): the "at least one of name/order/color/is_favorite must be set" guard evaluates falsy is_favorite as absent, so the request fails the field-count check. This is the same shape as Doist/todoist-api-python#46 (closed 2023-03-29), where update_project(favorite=False) had analogous behavior on the projects REST endpoint:
The REST API documentation indicates that when updating a project, the favorite parameter should be supplied as a Boolean true or false value. In practice, this parameter expects the integer 1 or the string "false".
— Doist/todoist-api-python#46
The labels REST endpoint apparently retained an analogous validator after the v1 API migration.
CLI / SDK side is fine
The CLI sends a syntactically correct payload. Excerpt from Doist/todoist-cli@main src/commands/label/update.ts (1.62.0):
if (options.favorite === true) args.isFavorite = true
if (options.favorite === false) args.isFavorite = false
…
const updated = await api.updateLabel(label.id, args)
The installed dist/commands/label/update.js (1.47.0) is functionally the same. The vendored SDK's POST transport applies snake_case conversion before sending — excerpt from @doist/todoist-sdk dist/esm/transport/http-client.js:
import { camelCaseKeys, snakeCaseKeys } from '../utils/case-conversion.js';
…
case 'POST':
if (payload) {
const convertedPayload = snakeCaseKeys(payload);
const body = JSON.stringify(convertedPayload);
fetchOptions.body = body;
}
So the wire payload from td label update --no-favorite is exactly {"is_favorite": false}, matching probe A.
Why filters don't hit this
td filter update --no-favorite works. The SDK updateFilter routes through the Sync command list (filter_update), not the bare REST endpoint:
export async function updateFilter(id, args) {
const api = await getApi();
await api.sync({
commands: [
createCommand('filter_update', {
id,
...pickDefined({
name: args.name,
query: args.query,
color: args.color,
isFavorite: args.isFavorite,
}),
}),
],
});
}
Confirmed empirically:
$ td filter update "<some filter>" --no-favorite
Updated: <some filter> (id:…)
The Sync command path apparently doesn't share the labels REST endpoint's validator.
Workaround
Pass any non-empty second field together with --no-favorite. Re-using the current color is the cheapest no-op:
color=$(td label view "+manuel" --json | jq -r '.color')
td label update "+manuel" --no-favorite --color "$color"
Or fix server-side: change the validator to check field presence (e.g. "is_favorite" in body) rather than truthiness — same fix shape that closed Doist/todoist-api-python#46.
Versions
td 1.62.0 (also reproduces on 1.47.0)
- Vendored SDK:
@doist/todoist-sdk 10.1.1 (and 9.1.1 on the older CLI)
- Endpoint:
POST https://api.todoist.com/api/v1/labels/{id}
- macOS, Node 20+
Search of existing reports
Spot-checked Doist/todoist-cli, Doist/todoist-sdk-typescript, Doist/todoist-api-python for is_favorite, no-favorite, BAD_REQUEST, label update (issues + PRs). No matching open or closed report. Closest is todoist-api-python#46 referenced above (different repo, different endpoint, same bug class).
Summary
td label update <name> --no-favoritealways fails withHTTP 400. The--favorite(true) variant works against the same label. The bug is upstream of the CLI/SDK — Todoist's REST endpointPOST /api/v1/labels/{id}rejects bodies whose only field isis_favorite: false.Reproduce (CLI)
Repro on
td 1.47.0and again on a freshly installedtd 1.62.0:Same result for any personal label.
td filter update <name> --no-favoriteis not affected — see "Why filters don't hit this" below.Reproduce (raw REST)
Same endpoint and same OAuth token, four probes:
Observation: the server rejects
{"is_favorite": false}with the quoted error, accepts{"is_favorite": true}, and accepts{"is_favorite": false, "name": "<same>"}. Stringified"false"does not unblock.Likely cause (hypothesis, not asserted as fact): the "at least one of name/order/color/is_favorite must be set" guard evaluates falsy
is_favoriteas absent, so the request fails the field-count check. This is the same shape asDoist/todoist-api-python#46(closed 2023-03-29), whereupdate_project(favorite=False)had analogous behavior on the projects REST endpoint:The labels REST endpoint apparently retained an analogous validator after the v1 API migration.
CLI / SDK side is fine
The CLI sends a syntactically correct payload. Excerpt from
Doist/todoist-cli@mainsrc/commands/label/update.ts(1.62.0):The installed
dist/commands/label/update.js(1.47.0) is functionally the same. The vendored SDK's POST transport applies snake_case conversion before sending — excerpt from@doist/todoist-sdkdist/esm/transport/http-client.js:So the wire payload from
td label update --no-favoriteis exactly{"is_favorite": false}, matching probe A.Why filters don't hit this
td filter update --no-favoriteworks. The SDKupdateFilterroutes through the Sync command list (filter_update), not the bare REST endpoint:Confirmed empirically:
The Sync command path apparently doesn't share the labels REST endpoint's validator.
Workaround
Pass any non-empty second field together with
--no-favorite. Re-using the current color is the cheapest no-op:Or fix server-side: change the validator to check field presence (e.g.
"is_favorite" in body) rather than truthiness — same fix shape that closedDoist/todoist-api-python#46.Versions
td1.62.0 (also reproduces on 1.47.0)@doist/todoist-sdk10.1.1 (and 9.1.1 on the older CLI)POST https://api.todoist.com/api/v1/labels/{id}Search of existing reports
Spot-checked
Doist/todoist-cli,Doist/todoist-sdk-typescript,Doist/todoist-api-pythonforis_favorite,no-favorite,BAD_REQUEST,label update(issues + PRs). No matching open or closed report. Closest istodoist-api-python#46referenced above (different repo, different endpoint, same bug class).