zudo-slack-wisdom
GitHub repository

Type to search...

to open search from anywhere

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.com/api/* 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.io/workers-slack), 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 Kit blocks, Lists cells, and similar), send application/json — there's no reason to use form encoding for any Web API call from a Worker.

  • A 200 response does not mean success, and not every response is 200. Most Web API responses are 200 with the real result in the JSON body's ok field — check json.ok, not just res.ok — but 429 and transient HTTP-level errors (timeouts, 5xx) do happen and need their own handling, as the retry helper above does.

  • Retry-After is 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.update accept 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.

Revision History

CreatedUpdated