Select Columns — Values, Not Labels
Why slackLists.items.update rejects labels, and how to design select-column slugs
Overview
Select columns (single- and multi-select) are the most common write target for a status board — a Kanban-style "todo / doing / done" column, for example. Getting a write accepted depends entirely on sending the right string, and that string is not what a human sees on the chip.
The select Array Carries Values, Never Labels
The select array carries the column schema's options.choices[].value — the machine slug — never choices[].label, the chip text a human reads. Writing a label returns invalid_option_id.
Slack's docs describe select values in prose as "an array of List encoded option IDs" and use OptHIGH123-style placeholders in examples, which reads as if choices[] had a separate id key. It does not: no choice object anywhere in the API has an id key. Choices are exactly {value, label, color} in every surface checked — slackLists.create's request and response, the items.info schema, the node SDK's SlackListsSchemaColumnChoice, and the Java SDK's ListColumnOptions.Choice. The value field is the option ID. The docs' own worked example proves it: column Col018AL7649G has {"value": "in_progress", "label": "In Progress"}, and the update sample for that column writes select: ["in_progress"].
The ASCII-Slug-Under-Localized-Label Pattern
Because value and label are independent fields, a select option can carry a stable ASCII slug as its value while its label is any human-facing text, including non-English text — the write path never touches the label at all.
Production evidence this is the working pattern:
navikt/saape-slackapp declares Norwegian chips as
{value: 'pending', label: 'Avventer'}, {value: 'in_progress', label: 'Pågår'}, ...and flips them withselect: [listStatus]— the value stays ASCII, only the label is localized.The n8n community node's README states "Select columns accept and return the option value (e.g.
day_1,OptXXXXXX)."SirMaiquis/deno-mr-poc ships a label→value resolver (
choices.find(c => c.label === ...)→ sendoption.value) — code that would not exist if labels worked directly.
Degenerate edge
If a column was defined with value === label, sending the label text happens to work — because it is, in that case, also the value. Don't rely on this by accident; choose valueand label independently so a later label rename (translation, wording tweak) can never break a write.
The Bot-Creates-the-List Pattern
Have the bot create the list itself via slackLists.create, choosing every option's value up front. This resolves two problems at once:
Slug control. You pick neutral ASCII values (
todo/doing/done) under any display label, so the label↔ID mapping problem disappears entirely — the slugs are compile-time constants in your Worker code.Access. How a bot gets write access to a list it did not create via the API alone is genuinely undocumented:
slackLists.access.setaccepts onlychannel_idsanduser_ids— there is noapp_ids/bot argument anywhere. Yetitems.updateclearly enforces access (access_denied,no_permission,permission_denied). Creating the list sidesteps this API gap entirely: Slack's docs describe a new list as "owned by the acting user," and the acting user for a bot-tokenslackLists.createcall is the bot itself. When the list is already operator-owned instead, the production-exercised route is a manual Share-UI edit grant rather thanaccess.set— see The One-Way Mirror Pattern for both routes side by side, and mandate a live probe (see The Activation-Gate Probe) before enabling writes through either one.
Unverified
That the creator automatically retains write access — i.e., that a bot's own xoxb token is treated as the list's owner after it calls slackLists.create — is inferred, not confirmed. Slack's docs say a new list is "owned by the acting user" but never say "bot user" explicitly for a bot-token call, and the verification spike that would have settled this against a live workspace was skipped (no test token available). Confirm ownership empirically — for example, by having the bot attempt an owner-only action such as access.set withaccess_level: "owner" — before depending on it in a design.
If a human already made the list, there is no slackLists.info and no slackLists.list to discover it by. Discovery is slackLists.items.info (needs lists:read), which returns the whole list object including list.list_metadata.schema[] — every select column with its id and options.choices[]. Call it once by hand, then hardcode the values.
Matching Emoji Labels: Unicode vs. :shortcode:
A select option authored in the Slack UI can carry an emoji in its label — a manager typing "👍 Approved" as a chip's display text, for example. The API does not guarantee that comes back the same way it was typed: the same label can round-trip as the Unicode character (👍 Approved) on one read and as its colon-shortcode form (:+1: Approved) on another. This matters specifically for label-based resolution — matching a known label string against options.choices[].label to find the corresponding value — which is the only route available on a list a human created by hand (see The Bot-Creates-the-List Pattern above): there is no id key to key off of, and the value slugs cannot be assumed to be anything predictable.
Match both representations, not just the one you happened to see when you wrote the resolver — normalize (or check both) the Unicode emoji and its shortcode alias before comparing against a stored label. If a column's options.choices[] ever contains the Unicode form and the shortcode form as two separate options — rather than one option whose label merely renders differently across reads — treat that as a configuration error: it is an ambiguous schema, not a resolution problem your code can solve, since both options exist to mean the same status.
This reinforces the page's core advice: persist the opaque value, not the label, from the moment you first resolve it. Label-matching should only ever run once, at setup time, to bridge a human-created list into your stored slugs — never on every write.
Single- and Multi-Select Are Written Identically
There is no separate multi_select cell key. Both single-select and multi-select columns are written through the same select: [...] array — the column's options.format (single_select vs multi_select) is what decides how many values are accepted, not the write shape.
Unverified
Sending two values (select: ["a", "b"]) to a single_select-format column is undefined behavior in the docs — untested whether it errors, keeps one value, or is silently accepted.
Replace vs. Append on an Existing Cell
Unverified
No page in Slack's docs states that writing select: ["B"] to a cell already holding "A"replaces the value rather than appending to it. For a single_select-format column, replace is the only coherent semantics, and production apps depend on it — but it is inference, not a documented contract. It matters doubly for multi_select-format columns, where append-vs-replace genuinely diverge in effect. Confirm with two consecutiveitems.update calls on one row (write ["A"], then ["B"]), then read back withitems.info and assert exactly ["B"] before depending on it — repeat against amulti_select-format column separately, since the two formats could behave differently.
Clearing a Selection with select: []
Unverified
Whether select: [] clears a populated select cell is undocumented. At least one production codebase, Kero46/slack-review-reminder, deliberately omits empty cells from its update payload rather than rely on this. If your design needs an explicit "no status" state, model it as a real option value (e.g. "none") instead of depending on an empty-array clear until this is confirmed against the live API.
Related
For the full write contract (list_id + cells[], typed value keys, batching), see Writing List Items. For why the option set itself is effectively closed once the list is created, see Schema Mutability. For the error a bad slug produces, see Errors Reference.