---
title: Connecting your app
description: 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.

```mermaid
sequenceDiagram
  participant P as BlockKitProvider (browser)
  participant R as Your server (the relay)
  participant A as Bolt app (unchanged)
  P->>R: Unsigned POST via httpTransport
  Note over R: Signs with signingSecret
  R->>A: Signed POST to the request URL
  A-->>R: Response
  R-->>P: Relayed response
```

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:

```tsx
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>
  );
}
```

| Prop | Type | Default | Description |
| - | - | - | - |
| `url` | `string` | - | Where payloads are POSTed: your relay route, or an app with signature verification disabled. |
| `headers?` | `HeadersInit` | - | Extra headers, e.g. an internal auth token for your own relay route. |
| `fetch?` | `typeof fetch` | - | Injectable for tests; defaults to the global fetch. |
| `onResponse?` | `(payload, result) => void` | - | Called with every payload sent and the app's reply. |
| `onError?` | `(payload, error) => void` | - | Called when delivery fails or the app answers with a non-2xx status. |

`block_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:

```ts
import { createInteractionRelay } from "@nkootstra/block-kit/server";

export const relay = createInteractionRelay({
  requestUrl: "https://internal.example.com/slack/events",
  signingSecret: process.env.SLACK_SIGNING_SECRET!,
});
```

| Prop | Type | Default | Description |
| - | - | - | - |
| `requestUrl` | `string` | - | Your app's Slack request URL (an Events API / interactivity endpoint). |
| `signingSecret` | `string` | - | The same signing secret your app is configured with. |
| `fetch?` | `typeof fetch` | - | Injectable for tests; defaults to the global fetch. |

### Next.js route handler

```ts app/api/slack/relay/route.ts
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

```ts server.ts
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`:

```ts
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);
```

| Prop | Type | Default | Description |
| - | - | - | - |
| `signSlackRequest?` | `(signingSecret, timestamp, body) => string` | - | Slack's v0 signing scheme: "v0=" + HMAC-SHA256(secret, `v0:{ts}:{body}`), hex-encoded. |
| `postSigned?` | `(opts) => Promise<Response>` | - | POSTs an already form-encoded body with the signature headers set, unparsed. |
| `sendInteraction?` | `(opts) => Promise<SendInteractionResult>` | - | postSigned, plus JSON-encoding the payload and parsing the app's response_action reply. |

## Related

**[Handling actions](/guides/interactivity)**

The local onAction/onPayload API this replaces.

**[Modals](/guides/modals)**

response_action still applies the same way when it comes back through the relay.

**[External data](/guides/external-data)**

block_suggestion requests flow through the same transport.
