Connecting your app
Send real Slack interaction payloads from the browser to an existing Bolt app, signed the way Slack signs them.
Everything so far handles interactions locally with onAction/onSubmit/onOptions. To instead
drive a real Slack app (the same Bolt or raw HTTP app you’d deploy), point the provider at your
app’s request URL. Two pieces make this safe: httpTransport on the client, and a relay on your
server that signs the request the way Slack does.
The signing secret never reaches the browser: only your server has it. The browser only ever talks to its own origin.
Client: httpTransport
@nkootstra/block-kit/transport is browser-safe (no Node built-ins), so it can ship in your page
bundle. httpTransport returns onPayload/onSubmit/onClose/onOptions ready to spread onto the
provider:
import { BlockKitProvider, Message } from "@nkootstra/block-kit";
import { httpTransport } from "@nkootstra/block-kit/transport";
export function App() {
return (
<BlockKitProvider
{...httpTransport({
url: "/api/slack/relay",
onResponse: (payload, result) => console.log("delivered", result.status),
onError: (payload, error) => console.error("failed to deliver", error),
})}
>
<Message
blocks={[
{
type: "actions",
block_id: "actions",
elements: [
{
type: "button",
action_id: "approve",
text: { type: "plain_text", text: "Approve" },
},
],
},
]}
/>
</BlockKitProvider>
);
}
urlstring
Where payloads are POSTed: your relay route, or an app with signature verification disabled.
stringheaders?HeadersInit
Extra headers, e.g. an internal auth token for your own relay route.
HeadersInitfetch?typeof fetch
Injectable for tests; defaults to the global fetch.
typeof fetchonResponse?(payload, result) => void
Called with every payload sent and the app's reply.
(payload, result) => voidonError?(payload, error) => void
Called when delivery fails or the app answers with a non-2xx status.
(payload, error) => voidblock_actions and view_closed are sent fire-and-forget. A view_submission’s response_action
reply is parsed and applied to the modal automatically, and a block_suggestion’s reply fills the
external select, the same as if you’d wired up onSubmit/onOptions by hand.
Server: createInteractionRelay
@nkootstra/block-kit/server runs on your server (it imports node:crypto) and exports a plain
(Request) => Response handler that signs the payload httpTransport sent and forwards it to your
app’s real request URL, then relays the app’s reply back:
import { createInteractionRelay } from "@nkootstra/block-kit/server";
export const relay = createInteractionRelay({
requestUrl: "https://internal.example.com/slack/events",
signingSecret: process.env.SLACK_SIGNING_SECRET!,
});
requestUrlstring
Your app's Slack request URL (an Events API / interactivity endpoint).
stringsigningSecretstring
The same signing secret your app is configured with.
stringfetch?typeof fetch
Injectable for tests; defaults to the global fetch.
typeof fetchNext.js route handler
import { createInteractionRelay } from "@nkootstra/block-kit/server";
export const POST = createInteractionRelay({
requestUrl: process.env.SLACK_REQUEST_URL!,
signingSecret: process.env.SLACK_SIGNING_SECRET!,
});
Point httpTransport({ url: "/api/slack/relay" }) at that route and you’re done.
Bun.serve
import { createInteractionRelay } from "@nkootstra/block-kit/server";
const relay = createInteractionRelay({
requestUrl: process.env.SLACK_REQUEST_URL!,
signingSecret: process.env.SLACK_SIGNING_SECRET!,
});
Bun.serve({
port: 3000,
fetch(request) {
const url = new URL(request.url);
if (url.pathname === "/api/slack/relay") return relay(request);
return new Response("Not found", { status: 404 });
},
});
The relay works with any framework that speaks the Fetch API Request/Response pair: Hono, Vite
middleware, Cloudflare Workers, and so on.
Why the signing secret must stay server-side
Slack’s apps verify every incoming request with an HMAC computed from the signing secret, the request
timestamp and the raw body (X-Slack-Signature / X-Slack-Request-Timestamp); an unsigned request is
rejected. If the secret shipped in your page’s JavaScript, anyone could read it out of the bundle and
forge requests to your app as if they came from Slack. Keeping signingSecret only on the server, and
having the browser talk only to your own relay route, over your own origin’s auth, is the same trust
boundary a real Slack app relies on.
Signing without the relay
If you want to send one interaction yourself rather than wiring up the full transport/relay pair
(a background job, a test, a CLI), use the lower-level signing helpers directly, also from
@nkootstra/block-kit/server:
import { sendInteraction, signSlackRequest, postSigned } from "@nkootstra/block-kit/server";
import { buildBlockActionsPayload } from "@nkootstra/block-kit";
const result = await sendInteraction({
requestUrl: process.env.SLACK_REQUEST_URL!,
signingSecret: process.env.SLACK_SIGNING_SECRET!,
payload: buildBlockActionsPayload({
action: {
type: "button",
action_id: "approve",
block_id: "actions",
action_ts: "1719000000.000100",
},
state: {},
container: { type: "message", messageTs: "1719000000.000100" },
}),
});
console.log(result.status, result.responseAction);
signSlackRequest?(signingSecret, timestamp, body) => string
Slack's v0 signing scheme: "v0=" + HMAC-SHA256(secret, `v0:{ts}:{body}`), hex-encoded.
(signingSecret, timestamp, body) => stringpostSigned?(opts) => Promise<Response>
POSTs an already form-encoded body with the signature headers set, unparsed.
(opts) => Promise<Response>sendInteraction?(opts) => Promise<SendInteractionResult>
postSigned, plus JSON-encoding the payload and parsing the app's response_action reply.
(opts) => Promise<SendInteractionResult>