Reading Lists
items.list cursor pagination, items.info as the only schema read-back, and async export via download.start/download.get
Four of the twelve slackLists.* methods are read-only, and they answer three different questions:
| Method | Answers | Needs | Rate tier |
|---|---|---|---|
items.list | "What rows exist?" | list_id | Tier 2 (20+/min) |
items.info | "What is the schema, and what are the limits?" | list_id + an existing row's id | not stated in the source research |
download.start / download.get | "Give me everything as a file" | list_id | not stated in the source research |
There is no fifth option. slackLists.list (enumerate a workspace's lists) and slackLists.info (read schema without a row) do not exist — confirmed by a live probe against slack. that returned unknown_method for both, backed by a soft-404 byte-size control on their would-be docs pages. Whatever you need to read back, it comes through one of the three rows above, and list_id is never rediscoverable — you own persisting it from the moment slackLists.create returns it.
items.list — every row, no query language
items.list takes exactly three arguments beyond list_id: limit, cursor, and archived. That is the whole surface — no filter, no search, no sort, no "find the row where X equals Y." Pagination follows the standard Slack cursor shape: pass limit, read response_metadata.next_cursor off the response, and pass that cursor back until it comes back empty.
const MAX_PAGES = 100;
export async function listAllItems(
botToken: string,
listId: string,
archived = false,
): Promise<ListItem[]> {
const items: ListItem[] = [];
const seenCursors = new Set<string>();
let cursor: string | undefined;
let pageCount = 0;
do {
const res = await fetch("https://slack.com/api/slackLists.items.list", {
method: "POST",
headers: {
authorization: `Bearer ${botToken}`,
"content-type": "application/json; charset=utf-8",
},
body: JSON.stringify({ list_id: listId, limit: 100, cursor, archived }),
});
const body = (await res.json()) as {
ok: boolean;
items?: unknown;
response_metadata?: { next_cursor?: string };
};
if (!body.ok) throw new Error("slackLists.items.list failed");
if (!Array.isArray(body.items)) {
throw new Error("slackLists.items.list: items is not an array");
}
items.push(...(body.items as ListItem[]));
pageCount += 1;
if (pageCount > MAX_PAGES) {
throw new Error(`slackLists.items.list exceeded ${MAX_PAGES} pages`);
}
const next = body.response_metadata?.next_cursor ?? "";
if (next === "") {
cursor = undefined; // explicit end-of-list, not merely a missing field
} else if (seenCursors.has(next)) {
throw new Error("slackLists.items.list: repeated cursor");
} else {
seenCursors.add(next);
cursor = next;
}
} while (cursor);
return items;
}Bounding response size inside a Worker
MAX_PAGES and the per-page limit: 100 together cap this at 10,000 rows accumulated in memory. That cap is deliberate, not a nicety — a Cloudflare Worker isolate has a fixed memory ceiling per request, and an unbounded items.push(...) loop against a list that keeps growing (or a server bug that never returns an empty cursor) turns into an out-of-memory kill rather than a clean error. If a list can legitimately exceed the page ceiling, process pages incrementally in the caller instead of accumulating everything into one array, or usedownload.start / download.get (below) for a full-file export instead of an in-memory sweep.
A full sweep of 1,000 rows is roughly 10 paginated calls at a limit of 100 — comfortably inside Tier 2 (20+/min) for a once-per-cron-tick reconcile pass. items.list also returns updated_by on each row, which is the only signal for "did a human touch this row" — useful for flagging out-of-band edits on a list a bot is supposed to own exclusively (see No Events, No Idempotency).
What items.list does not return: row counts. There is no total, no has_more beyond the cursor, nothing. If you need to know how full the list is, that comes from items.info instead (next section).
There is no "does this row exist" call
Because items.list has no filter parameter, the only way to check whether a specific row exists is to already know its row_id (from your own database) or to paginate the entire list and look. Persist row_id per source row at write time — seeNo Events, No Idempotency — so you never need the second option in normal operation. Reserve a full paginated scan for the recovery case: if yourrow_id mapping is ever lost, the only way to rebuild it is one full items.list sweep, matched against a value you also wrote into a text column on each row (your own primary key). There is no faster path — no filter, no search, no query parameter exists to narrow the scan.
The archived flag
archived is a boolean, not a filter value — it switches which set of rows the call returns (active vs archived), it does not let you mix a query with an archived condition. Pass archived: true specifically to see what auto-archive removed; see Item Caps and Auto-Archive for why that pass matters and how often to run it.
items.info — the only schema read-back, and it needs a row first
items.info returns one row plus the full list metadata: name, schema (every column and its type), views, and limits. It is the only place to read the schema back after creation — besides the response of slackLists.create itself — and the catch is baked into its argument list: besides list_id, it takes an existing row's id. You cannot ask "what does this list look like" without already holding a row to ask about.
That makes items.info a recovery tool, not a discovery tool. The intended flow is:
Persist
list_idand everycolumn_idatslackLists.createtime. The creation response is the only place these ever come back without needing a row first.If that persistence is ever lost,
items.infoagainst any known row rebuilds the schema — but you still need one row id to start from. The lifecycle below is how you keep one on hand without pinning a permanent row.
The sentinel-row pattern
Two things only items.info can tell you — the schema, and list_limits (row_count, row_count_limit, archived_row_count) — both require a row id as the price of admission. Spending one of your mirrored data rows on this works but is fragile on its own: that row could get deleted by your own cap-eviction logic, taking your handle on the list with it. That risk is not an argument for pinning one permanent throwaway row and never deleting it, though — it is an argument for resolving the anchor row dynamically, on every run, with a fall-through chain. Losing any individual row then costs one extra items.list call, not the handle.
A permanent, never-deleted sentinel is also the wrong shape for a List that operators actually look at: a blank row that sits there forever is operator-visible junk. Operators delete it by hand, your recovery logic recreates it on the next tick, and the integration looks like it is spawning junk rows. It is unnecessary besides — the schema items.info returns is list-level (list.list_metadata.schema), so any row, including an archived one, is an equally valid handle. The lifecycle below treats the sentinel as a last resort, not a fixture.
Anchor order. Resolve the row to call
items.infoagainst, trying each of these in turn and stopping at the first one that resolves: the stored sentinel row id, if one is recorded; otherwise any row fromitems.list(active rows first, thenarchived: truerows); and only if both come up empty, create a blank row and record its id as the new sentinel.Missing-row race fall-through. A row can be deleted between an
items.listcall and theitems.infocall that follows it.items.infosurfaces that asrecord_deleted,record_not_found,row_not_found, orinvalid_row_id— not as a schema-read failure. Catch that family specifically and walk to the next candidate in the anchor order, bounded to a small number of probes (~5) so a pathological run cannot loop forever. These four codes are distinct fromlist_not_found, which stays fail-closed: that means access to the List itself is gone, and no amount of retrying a different row id fixes it.Eviction. Once the List carries any real row, the sentinel has served its purpose: delete it and clear its stored id. Order matters — persist-first: leave the sentinel id recorded while you delete the row in Slack, and clear the stored id only after that delete call succeeds. Clearing the id first and then failing the delete orphans a blank row whose id you no longer know, forever. Persist-first recovers in both failure directions on the next tick — a failed delete simply retries against the same known id, and a failed id-clear simply re-deletes a row that is already gone (a
record_deletedyou can treat as success).Don't recreate what an operator removed by hand. If an operator deletes the sentinel while real rows remain, leave it deleted — anchor order already falls through to a real row on the next run. A List that later empties out re-creates a sentinel on its own, via step 1 above, on the next tick that needs one. The blank row then exists only for as long as nobody is looking at an empty List.
Name the argument correctly.
items.infotakes the row id asid, alongsidelist_id— notrow_id, which is whatitems.update'scells[]entries use for the same concept. Mixing the two up silently drops the argument in some client shapes rather than raising a type error, so it is worth getting right the first time.
// Stored per list. Recorded when a sentinel is created (step 1), cleared once
// the List carries a real row (step 3), and re-created on demand if the List
// empties out again (step 4) — never a fixed, permanent id.
let sentinelRowId: string | null = await loadSentinelRowId(listId);
// items.info takes the row id as `id`, not `row_id` — see step 5 above.
const res = await fetch("https://slack.com/api/slackLists.items.info", {
method: "POST",
headers: {
authorization: `Bearer ${botToken}`,
"content-type": "application/json; charset=utf-8",
},
body: JSON.stringify({ list_id: listId, id: sentinelRowId }),
});Note
The exact shape of list_limits (which fields it carries beyond row_count /row_count_limit / archived_row_count) comes from the source research pass over Slack's documentation, not from a live probe against a real list — the verification spike for this project was skipped (no test token was available). Treat the field list as reported, and confirm it against a real items.info response before depending on a field this page does not name explicitly. See The Activation-Gate Probe for the reversible, per-List probe pattern this kind of open question is meant to be closed with before production.
download.start / download.get — async bulk export
For a full dump rather than an incremental read, download.start kicks off an asynchronous CSV or JSON export and download.get fetches the result once it is ready. This is the better tool than paginating items.list when you want an ad hoc full snapshot (a manual audit, a one-off backfill check) rather than a programmatic per-row read — it costs one export job instead of ~10 paginated calls per 1,000 rows, and it does not compete with your regular items.list reconcile-pass rate budget. Both methods are read scope (lists:read), same as items.list and items.info.
Related
No Events, No Idempotency — why
items.listis the only inbound signal, and how to persistrow_idso you rarely need a full scan.Item Caps and Auto-Archive — what the
archivedflag onitems.listis for, and howlist_limitsfromitems.infofits into cap policy.The One-Way Mirror Pattern — where the sentinel-row pattern and the reconcile pass come together in a full sync design.
The Activation-Gate Probe — closing this page's unverified reads empirically, one prepared List at a time.