No Events, No Idempotency
Polling is the only inbound path for Lists, there is no upsert, and the caller owns the row-id mapping
Two structural gaps shape everything about integrating with Slack Lists from a Worker: there is no way to be told a list changed, and there is no way to write to a row idempotently. Neither is a maturity gap likely to close later — both are absences confirmed against Slack's own documented surface, not just undocumented corners.
There is no push signal — polling is the only inbound path
The full Slack Events API catalog was checked for list events and has none: slackLists.items.create / items.update / items.delete never fire a corresponding list_item_created, list_item_updated, or any other event. Socket Mode adds nothing, because it delivers the same event catalog over a different transport. slackLists.items.list — plain polling — is the only reliable inbound signal Lists offers.
The practical edge of this: a human dragging a card between board columns emits nothing you can subscribe to. If your Worker needs to know "did anything change since my last tick," the only honest answer comes from calling items.list again and diffing.
Unverified
Workflow Builder has a trigger called "When a list item is updated," which could — in principle — post a channel message on change and offer a push-flavored escape hatch. But whether it also fires on item create is undocumented, there is no separate "created" trigger, and depending on a workflow automation's exact firing rules for programmatic correctness is fragile even where it works. This project's verification spike (which would have settled the create-vs-update firing question empirically) was skipped — treat this trigger as a possible notification convenience, never as the mechanism your sync correctness depends on. Poll items.list for anything that matters.
There is no upsert — the caller owns the row-id mapping
slackLists.items.create's complete argument set is list_id plus optional duplicated_item_id, parent_item_id, and initial_fields. duplicated_item_id clones an existing row — it is not a dedupe key, and nothing resembling one exists. The method's error table has no duplicate_item, already_exists, or conflict code. Two identical items.create calls produce two rows, silently — every time.
items.create returns the new row's id as item.id (a Rec…-prefixed string). That id is the entire relationship between a Slack row and your source record — there is no find-or-create, no caller-supplied external key, nothing that lets you ask "does a row for source-record #482 already exist." You are the only party tracking that mapping, and you must:
Persist the returned
row_idto your database in the same step as the create — before any other work. This narrows the failure window, it does not close it: there is no transaction spanning a Slack API call and your own database write, so a crash or an ambiguous timeout between "row created in Slack" and "id saved to my database" can still orphan a Slack row. Do not treat immediate persistence as a full fix — pair it with the reconcile pass below, which is what actually catches an orphan: mirror your own primary key into a text column on every Slack row, so a reconcile sweep can match an unexpected row back to its source record instead of your next run blindly creating a duplicate for it.Branch create-vs-update on whether your source record already carries a stored
row_id. An additive, nullable column on the source table (plus an index) is enough:NULLmeans "never synced, callitems.create"; a populated value means "callitems.update."Address every cell write by
column_idand typed value key, not the deprecatedkey/ genericvalueforms — Slack has already announced those will be removed.
// The nullable column IS the idempotency key. NULL means never synced;
// populated means items.update, never items.create, for this record.
interface SourceRow {
id: number;
slackRowId: string | null; // set atomically with the create call, never after
}The cron re-run duplication trap
Duplicates are not only a cross-run risk — they happen within a single run too, and the mechanism is subtler than "the cron fired twice." Slack SDKs ship a default retry policy that retries network-level failures (timeouts, connection resets). If an items.create call times out on your side but actually succeeded on Slack's, the SDK's default retry sends the same create again — server-side, that is two full creates, indistinguishable from two real source rows.
Two independent guards, not one
Set retryConfig explicitly on create calls rather than trusting SDK defaults, so a client-observed timeout does not silently become a second row. Separately, guard the cron handler itself against overlapping its own next tick with a run lock or lease — if one tick is still mid-sync when the next fires, both can create rows for the same not-yet-persisted source record. These are different failure modes (retry-level vs scheduler-level) and neither guard substitutes for the other.
Reconcile sweeps — the correctness backstop
Because there is no push signal, treat an infrequent (hourly or daily) full reconcile pass as required, not optional — it is the only way you find out what changed outside your own writes.
Paginate
items.listin full (see Reading Lists) and diff the returnedrow_idset against your database's stored mapping.Run the same pass again with
archived: true. This is the only way to see what auto-archive silently removed — see Item Caps and Auto-Archive for why that matters for a growing source table.items.listreturnsupdated_byon every row — use it to flag rows last touched by someone other than the bot, a signal that a human edited a row the sync otherwise assumes it owns exclusively.Match any row with no corresponding
row_idin your database against your mirrored primary-key text column, not just against source records already marked "never synced." This is what actually closes the create-then-crash gap above — an orphaned row from a failed run looks exactly like this, and matching it here re-attaches it to its source record instead of leaving the next sync tick free to create a second row for the same data.
At roughly 10 paginated calls per 1,000 rows (Tier 2, 20+/min), a reconcile pass is cheap enough to run on every cron tick if the list stays within a bounded working set — see Item Caps and Auto-Archive for why you want that bound regardless.
Related
Reading Lists — the
items.listcursor-pagination mechanics behind every reconcile pass.Item Caps and Auto-Archive — why the
archived: truereconcile pass exists and how it interacts with the per-list row cap.The One-Way Mirror Pattern — the full sync design these mechanics compose into.