Web API with fetch
Calling the Slack Web API from a Worker with plain fetch — Bearer auth, JSON vs form encoding, and honoring Retry-After.
Overview
A Worker calls slack. with the Workers runtime's native fetch — no SDK required. This isn't just "fewer dependencies for its own sake": the official @slack/web-api package depends on axios, which relies on Node.js APIs the Workers runtime doesn't provide by default. It only works with the nodejs_compat compatibility flag enabled, and even then it's a lot of dependency weight for what is, underneath, an HTTP POST with a bearer token. Community fetch-based clients exist for Workers specifically (e.g. slack-edge, @sagi.), but plain fetch is enough for most integrations and keeps the Worker dependency-free. See slackapi/node-slack-sdk#1335 for the compatibility discussion.
Bearer auth
Every Web API call authenticates with the bot token in an Authorization header:
const SLACK_API_BASE_URL = "https://slack.com/api";
async function callSlackApi<T>(
method: string,
body: Record<string, unknown>,
botToken: string,
): Promise<T> {
const res = await fetch(`${SLACK_API_BASE_URL}/${method}`, {
method: "POST",
headers: {
authorization: `Bearer ${botToken}`,
"content-type": "application/json; charset=utf-8",
},
body: JSON.stringify(body),
});
const json = (await res.json()) as { ok: boolean; error?: string } & T;
if (!json.ok) {
throw new Error(`${method} rejected: ${json.error}`);
}
return json;
}JSON vs form encoding — use JSON, always
The Web API accepts both application/x-www-form-urlencoded and application/json bodies. Form encoding is the older path and it breaks down the moment an argument is structured data: an array of objects has no clean form-encoded representation, and Slack's own guidance is to use JSON-encoded bodies for exactly this reason.
Arrays of objects require JSON — form-encoding them fails
Any method whose payload includes an array of objects —chat.postMessage's blocks, slackLists.items.update's cells, and similar — must be sent as application/json. Form-encoding an array of objects produces invalid_array_arg. This isn't a style choice: sendcontent-type: application/json and a JSON.stringify'd body for any call whose arguments include arrays or nested objects, and there's no need to special-case simpler methods differently — JSON works for every Web API method, not just the ones that require it.
// slackLists.items.update -- cells is an array of objects with typed value
// keys. Sending this as application/x-www-form-urlencoded raises
// invalid_array_arg; it must be application/json.
await callSlackApi("slackLists.items.update", {
list_id: listId,
cells: [
{ row_id: rowId, column_id: statusColumnId, select: [statusValue] },
],
}, botToken);Honoring Retry-After
Web API methods are grouped into rate-limit tiers (Tier 1 through Tier 4, roughly 1+ to 100+ requests/minute per method per workspace), plus a special tier for a few high-traffic methods like chat.postMessage. When a call is rate-limited, Slack responds 429 Too Many Requests with a Retry-After header giving the number of seconds to wait before retrying that specific method against that specific workspace.
async function callSlackApiWithRetry<T>(
method: string,
body: Record<string, unknown>,
botToken: string,
maxRetries = 3,
): Promise<T> {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const res = await fetch(`${SLACK_API_BASE_URL}/${method}`, {
method: "POST",
headers: {
authorization: `Bearer ${botToken}`,
"content-type": "application/json; charset=utf-8",
},
body: JSON.stringify(body),
});
if (res.status === 429) {
const retryAfterSeconds = Number(res.headers.get("retry-after") ?? "1");
if (attempt === maxRetries) {
throw new Error(`${method} rate-limited after ${maxRetries} retries`);
}
await new Promise((resolve) => setTimeout(resolve, retryAfterSeconds * 1000));
continue;
}
const json = (await res.json()) as { ok: boolean; error?: string } & T;
if (!json.ok) {
throw new Error(`${method} rejected: ${json.error}`);
}
return json;
}
throw new Error(`${method}: unreachable`);
}A rate limit is per method, per workspace
Getting 429 from chat.postMessage says nothing about whetherslackLists.items.update is also limited. There's no global backoff to apply — back off the specific method that returned 429, and let every other call proceed normally.
A long Retry-After can outlive ctx.waitUntil()'s budget
Sleeping in-isolate is fine for the short delays Slack typically sends, butRetry-After has no documented upper bound, and background work registered with ctx.waitUntil() only gets roughly 30 seconds after the response is sent (see Three-Second Ack). If this retry runs inside that background work and Retry-After is long enough to blow the budget, the runtime cancels the pending promise mid-wait. For a call where that matters, don't loop-and-sleep past a couple of retries in-Worker — persist "retry this later" state (KV, D1, a queue) and let the next cron tick or a dedicated retry path pick it up instead.
Gotchas
Form-encoding an array-shaped argument is the most common cause of a confusing
invalid_array_arg. If a method's arguments include arrays of objects (Block Kitblocks, Listscells, and similar), sendapplication/json— there's no reason to use form encoding for any Web API call from a Worker.A
200response does not mean success, and not every response is200. Most Web API responses are200with the real result in the JSON body'sokfield — checkjson.ok, not justres.ok— but429and transient HTTP-level errors (timeouts,5xx) do happen and need their own handling, as the retry helper above does for429.Retry-Afteris authoritative — don't guess your own backoff. Slack tells you exactly how long to wait; a shorter self-chosen delay just produces another429.Bulk operations should batch, not loop-and-call. Methods like
slackLists.items.updateaccept multiple entries in one call (up to a documented cap); a cron mirroring many rows should build one batched call per tick rather than one Web API call per row — see Cron Posting.