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;
}A class-based client and the workerd receiver pitfall
Wrapping the call above in a small client class — often to make fetch injectable for tests — is a natural next step:
class SlackClient {
constructor(
private readonly botToken: string,
private readonly fetchImpl: typeof fetch = fetch,
) {}
async call<T>(method: string, body: Record<string, unknown>): Promise<T> {
const res = await this.fetchImpl(`${SLACK_API_BASE_URL}/${method}`, {
method: "POST",
headers: {
authorization: `Bearer ${this.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;
}
}This breaks in production. this.fetchImpl(url, init) invokes the stored function with the class instance as its receiver, and workerd rejects that: the native fetch implementation requires being called without a receiver (or with undefined/globalThis), so calling it as a method throws an illegal-invocation-style error before any request goes out.
// Fix: read fetchImpl into a local binding first, so the call site has
// no receiver at all.
async call<T>(method: string, body: Record<string, unknown>): Promise<T> {
const fetchImpl = this.fetchImpl;
const res = await fetchImpl(`${SLACK_API_BASE_URL}/${method}`, {
method: "POST",
headers: {
authorization: `Bearer ${this.botToken}`,
"content-type": "application/json; charset=utf-8",
},
body: JSON.stringify(body),
});
// ...
}This only fails on Workers — not in Node-based tests
Node's fetch doesn't enforce the same receiver check, so a test suite running under a plain Node test runner calls this.fetchImpl(...) without complaint and the bug ships. It only surfaces at runtime on Workers (workerd) — pin the fix with a dedicated unit test that exercises the client under a Workers-accurate runtime, such as Cloudflare'sWorkers Vitest integration, not a generic Node test runner, or the regression has no test to catch it.
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);Read methods are conventionally GET + query string
The rule above is about how to encode a POST body. Read methods likeconversations.history and users.info are conventionally called as GETwith their arguments in the query string, not sent as a JSON body at all — there's no encoding choice to make for them, because there's no body.
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. A non-Marketplace app without the higher-throughput approval also runs into a much coarser per-workspace throttle on top of that — as low as roughly 1 request/minute on some methods — which is why a legitimate Retry-After can be as long as ~60 seconds; a retry strategy sized for "a few seconds" will clamp or abandon a wait Slack actually meant. When a call is rate-limited, Slack responds 429 Too Many Requests, ideally with a Retry-After header giving the number of seconds to wait before retrying that specific method against that specific workspace.
Retry-After isn't guaranteed present or parseable, though: it never appears on 5xx responses at all, and even on 429 it can be missing or non-numeric depending on what sits between the Worker and Slack. Number(header ?? "1") silently produces NaN on a malformed header and schedules a wait for NaN milliseconds — fall back to exponential backoff (1 second base, doubling each attempt) whenever the header is missing or doesn't parse, and clamp every wait, server-supplied or backed-off, to a per-path ceiling. Nothing stops a misbehaving intermediary or an unanticipated Slack response from sending a huge Retry-After, and an unclamped wait can blow the isolate's own time or background-work budget (see the ctx.waitUntil() warning below).
The right ceiling depends on what's being retried, so split the budget by call class instead of using one number everywhere:
interface RetryBudget {
maxRetries: number;
ceilingMs: number;
retryOn5xx: boolean;
}
// Fail-fast: a single item write inside a request handler. If it's still
// failing after a couple of quick retries, persist "retry this" state and
// let the next cron tick pick it up instead of blocking the handler.
const WRITE_BUDGET: RetryBudget = { maxRetries: 2, ceilingMs: 2_000, retryOn5xx: false };
// Patient: a once-per-tick cron read that can afford to wait out the
// ~1 req/min non-Marketplace throttle rather than skip the tick entirely.
const READ_BUDGET: RetryBudget = { maxRetries: 5, ceilingMs: 60_000, retryOn5xx: true };
async function callSlackApiWithRetry<T>(
method: string,
body: Record<string, unknown>,
botToken: string,
budget: RetryBudget,
): Promise<T> {
let backoffMs = 1_000;
for (let attempt = 0; attempt <= budget.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),
});
const isRateLimited = res.status === 429;
const isRetryable5xx = budget.retryOn5xx && res.status >= 500;
if (isRateLimited || isRetryable5xx) {
if (attempt === budget.maxRetries) {
throw new Error(`${method} failed after ${budget.maxRetries} retries (status ${res.status})`);
}
const header = isRateLimited ? Number(res.headers.get("retry-after")) : NaN;
const waitMs = Number.isFinite(header) && header > 0 ? header * 1000 : backoffMs;
await new Promise((resolve) => setTimeout(resolve, Math.min(waitMs, budget.ceilingMs)));
backoffMs *= 2;
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`);
}Retrying on 5xx is a per-path opt-in (retryOn5xx), not a default: a transient Slack-side error is often worth one retry on a patient read, but a write's fail-fast budget usually shouldn't spend its two retries on a 5xx it can't distinguish from a real outage — better to fail fast and let the persisted-retry fallback handle it.
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 wait can outlive ctx.waitUntil()'s budget
Sleeping in-isolate is fine for the short delays a write's tight ceiling allows, but background work registered with ctx.waitUntil() only gets roughly 30 seconds after the response is sent (see Three-Second Ack). If a retry loop runs inside that background work and its ceiling is close to or past that budget, the runtime cancels the pending promise mid-wait. This is the real argument for keeping the write budget's ceiling small (2 seconds, above) rather than reusing the read budget's 60-second ceiling everywhere: past a couple of quick retries, persist "retry this later" state (KV, D1, a queue) and let the next cron tick or a dedicated retry path pick it up instead of looping and sleeping in-Worker.
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.Retry-Afteris the right signal, but never trust it uncapped. Slack tells you how long to wait, and that's usually correct — but clamp it to a per-path ceiling before sleeping on it; a missing, malformed, or unexpectedly huge value shouldn't be able to blow the isolate's budget.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.