Skip to content

td label update --no-favorite always returns HTTP 400 #316

Description

@moritzketzer

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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions