Slack Low-Level APIs

Use Slack request verification, formatting, Web API, and Block Kit helpers without the full Chat runtime.

The Slack adapter is the right default for most bots. It verifies requests, resolves tokens, parses Slack payloads, stores thread state, and routes events through Chat.

Use the low-level Slack subpaths when your app already owns routing, state, sessions, or workflow execution and only needs the Slack-specific primitives.

SubpathUse for
@chat-adapter/slack/webhookRequest verification, body parsing, Events API payloads, slash commands, interactions, and continuation data
@chat-adapter/slack/formatSlack mrkdwn tokens, text objects, dates, links, mentions, and simple mrkdwn to Markdown conversion
@chat-adapter/slack/apiFetch-based Slack Web API calls, thread replies, views, and files without @slack/web-api
@chat-adapter/slack/blocksRuntime-free conversion from simple card objects and input requests to Slack Block Kit

These subpaths are for custom runtimes. If you want Chat SDK to handle webhook routing, state, subscriptions, and platform normalization, use createSlackAdapter from @chat-adapter/slack.

Webhooks

Slack signs incoming HTTP requests with x-slack-signature and x-slack-request-timestamp. verifySlackRequest reads the request body, verifies the signature with your signing secret, and returns the raw body so you can parse it once.

app/api/slack/route.ts
import {
  parseSlackWebhookBody,
  verifySlackRequest,
} from "@chat-adapter/slack/webhook";
import { postSlackMessage } from "@chat-adapter/slack/api";

export async function POST(request: Request) {
  const body = await verifySlackRequest(request, {
    signingSecret: process.env.SLACK_SIGNING_SECRET!,
  });

  const payload = parseSlackWebhookBody(body, {
    contentType: request.headers.get("content-type"),
    headers: request.headers,
  });

  if (payload.kind === "url_verification") {
    return Response.json({ challenge: payload.challenge });
  }

  if (payload.kind === "app_mention") {
    await postSlackMessage({
      channel: payload.continuation.channelId,
      markdownText: `received: ${payload.text}`,
      threadTs: payload.continuation.threadTs,
      token: process.env.SLACK_BOT_TOKEN!,
    });
  }

  return new Response(null, { status: 200 });
}

Slack slash commands and interactions should be acknowledged quickly. Slack documents a 3000 ms acknowledgement window for slash commands, so do slow work in your queue or workflow runtime after returning a 2xx response.

If you do not need direct access to the verified raw body, readSlackWebhook combines verification and parsing:

import { readSlackWebhook } from "@chat-adapter/slack/webhook";

const payload = await readSlackWebhook(request, {
  signingSecret: process.env.SLACK_SIGNING_SECRET!,
});

If your framework already buffered the request body, use verifySlackSignature with the raw body and headers, then pass that same body to parseSlackWebhookBody.

Payloads

parseSlackWebhookBody returns typed payloads:

KindSlack surface
url_verificationEvents API URL verification
app_mentionApp mention events
direct_messageDirect message events
slash_commandSlash command form posts
block_actionsButton, select, and Block Kit action payloads
block_suggestionExternal select suggestion payloads
view_submissionModal submissions
view_closedModal close events
unsupportedValid Slack payloads not normalized by this helper yet

Message-like payloads include continuation, which contains provider-native reply context:

type SlackContinuation = {
  channelId: string;
  enterpriseId?: string;
  teamId?: string;
  threadTs: string;
};

This is not a Chat SDK Thread. It is the durable Slack data you need to reply later with @chat-adapter/slack/api.

App mention and direct message payloads also include typed files parsed from Slack file objects. Each file keeps the raw Slack object plus common fields like id, name, mimeType, size, url, and downloadUrl.

Interaction payloads expose convenience fields from Slack's raw payload:

  • block_actions includes actions, messageBlocks, messagePromptBlock, messagePromptText, messageTs, triggerId, responseUrl, user, and continuation
  • view_submission includes callbackId, privateMetadata, values, responseUrls, and user

Formatting

Slack uses mrkdwn and special tokens for mentions, channels, dates, and links. The format subpath gives you small helpers for those strings.

The helper surface includes escapeSlackText, unescapeSlackText, createSlackPlainText, createSlackMrkdwn, formatSlackUser, formatSlackChannel, formatSlackUserGroup, formatSlackSpecialMention, formatSlackLink, formatSlackDate, and simple mrkdwn to Markdown normalization.

format.ts
import {
  createSlackMrkdwn,
  formatSlackDate,
  formatSlackLink,
  formatSlackUser,
  slackMrkdwnToMarkdown,
} from "@chat-adapter/slack/format";

const text = createSlackMrkdwn(
  `${formatSlackUser("U123")} approved ${formatSlackLink("https://example.com", "the deploy")}`
);

const when = formatSlackDate(
  new Date("2026-05-27T12:00:00Z"),
  "{date_short_pretty} at {time}",
  "May 27 at 12:00"
);

const markdown = slackMrkdwnToMarkdown("hello <@U123|jane>, see <https://example.com|this>");

linkBareSlackMentions only links Slack user IDs like @U123. It does not resolve display names, because Slack mentions are ID-based.

Web API

The API subpath calls Slack Web API methods with fetch. It does not import @slack/web-api.

slack.ts
import {
  postSlackMessage,
  sendSlackResponseUrl,
  updateSlackMessage,
} from "@chat-adapter/slack/api";

const posted = await postSlackMessage({
  channel: "C123",
  markdownText: "**hello**",
  token: process.env.SLACK_BOT_TOKEN!,
});

await updateSlackMessage({
  channel: "C123",
  text: "updated",
  token: process.env.SLACK_BOT_TOKEN!,
  ts: posted.id,
});

await sendSlackResponseUrl("https://hooks.slack.com/actions/T/1/abc", {
  replaceOriginal: true,
  text: "done",
});

Use callSlackApi when you need a Slack method that does not have a helper yet:

import { callSlackApi } from "@chat-adapter/slack/api";

const result = await callSlackApi(
  "reactions.add",
  { channel: "C123", name: "white_check_mark", timestamp: "1710000000.000001" },
  { token: process.env.SLACK_BOT_TOKEN! }
);

markdownText maps to the markdown_text field on chat.postMessage and cannot be combined with text or blocks. Use text with blocks when you need fallback text.

The subpath also includes postSlackEphemeral, deleteSlackMessage, resolveSlackBotToken, encodeSlackApiBody, and assertSlackOk.

Use fetchSlackThreadReplies when a custom runtime needs to refresh a thread with conversations.replies:

import { fetchSlackThreadReplies } from "@chat-adapter/slack/api";

const replies = await fetchSlackThreadReplies({
  channel: payload.continuation.channelId,
  limit: 50,
  token: process.env.SLACK_BOT_TOKEN!,
  ts: payload.continuation.threadTs,
});

Use openSlackView to open a modal from an interaction trigger_id:

import { openSlackView } from "@chat-adapter/slack/api";

await openSlackView({
  token: process.env.SLACK_BOT_TOKEN!,
  triggerId: payload.triggerId,
  view: {
    type: "modal",
    title: { type: "plain_text", text: "Answer" },
    blocks: [],
  },
});

Files

Slack's current external upload flow uses files.getUploadURLExternal, then uploads bytes to the returned URL, then calls files.completeUploadExternal.

import { uploadSlackFiles } from "@chat-adapter/slack/api";

await uploadSlackFiles(
  [{ data: new Uint8Array([1, 2, 3]), filename: "report.txt" }],
  {
    channelId: "C123",
    initialComment: "report attached",
    token: process.env.SLACK_BOT_TOKEN!,
  }
);

Use fetchSlackFile for private Slack file URLs that require bearer token authorization.

Blocks

The blocks subpath converts simple card objects into Slack Block Kit without importing the full chat JSX runtime.

It exports cardToSlackBlocks, cardToBlockKit, cardToSlackFallbackText, cardToFallbackText, and convertSlackEmojiPlaceholders.

blocks.ts
import {
  cardToSlackBlocks,
  cardToSlackFallbackText,
} from "@chat-adapter/slack/blocks";
import { postSlackMessage } from "@chat-adapter/slack/api";

const card = {
  children: [
    { content: "deploy v2.4.1?", type: "text" },
    {
      children: [
        { id: "approve", label: "Approve", style: "primary", type: "button" },
        { id: "deny", label: "Deny", style: "danger", type: "button" },
      ],
      type: "actions",
    },
  ],
  title: "Deployment",
  type: "card",
} as const;

await postSlackMessage({
  blocks: cardToSlackBlocks(card),
  channel: "C123",
  text: cardToSlackFallbackText(card),
  token: process.env.SLACK_BOT_TOKEN!,
});

Use the full Chat SDK card JSX when you want cross-platform rendering. Use @chat-adapter/slack/blocks when you are building a Slack-only runtime and want Block Kit output directly.

The blocks subpath also includes small input request helpers for Slack-only runtimes:

import {
  inputRequestToSlackBlocks,
  parseSlackInputResponse,
} from "@chat-adapter/slack/blocks";
import { postSlackMessage } from "@chat-adapter/slack/api";

await postSlackMessage({
  blocks: inputRequestToSlackBlocks({
    options: [
      { id: "approve", label: "Approve", style: "primary" },
      { id: "deny", label: "Deny", style: "danger" },
    ],
    prompt: "Approve deploy?",
    requestId: "deploy-1",
  }),
  channel: "C123",
  text: "Approve deploy?",
  token: process.env.SLACK_BOT_TOKEN!,
});

if (payload.kind === "block_actions") {
  const action = payload.actions[0];
  const response = action ? parseSlackInputResponse(action) : null;
}

Set display: "radio" for radio buttons, or display: "select" for a static select menu. Set allowFreeform: true to add a "Type your answer" button next to the provided options.

For freeform answers, use buildSlackFreeformView with openSlackView, then read the submitted value from payload.values with parseSlackFreeformValue.

Import boundaries

The low-level Slack subpaths are designed to avoid the full runtime import graph:

  • no chat import
  • no @chat-adapter/shared import
  • no @slack/web-api import
  • no @slack/socket-mode import

The package still installs the full Slack adapter dependencies. The subpaths keep your source and bundle imports clean, but they are not a package-size split.

On this page

GitHubEdit this page on GitHub