Slack

Send Slack messages from Nuxt with an incoming webhook or a bot token: threads, file uploads, editing, deleting, and receiving through the Events API.

Slack is the only channel here with two credentials that do different things. Which one you configure decides what the channel can do, and the module tells you which mode it is in rather than guessing.

Copy this

Create an app and get a bot token

At api.slack.com/apps: Create New App → From scratch. Then OAuth & Permissions → Bot Token Scopes and add:

  • chat:write - send, edit, delete
  • chat:write.public - post to public channels without being invited
  • files:write - upload images

Install to Workspace, then copy the Bot User OAuth Token, starting with xoxb-.

Get a channel id

Open the channel, scroll to the bottom of its details. It looks like C01ABC2DEF, and it is not #deploys.

Configure it

.env
PIGEON_SLACK_BOT_TOKEN=xoxb-...
PIGEON_SLACK_CHANNEL=C01ABC2DEF
nuxt.config.ts
export default defineNuxtConfig({
  modules: ['nuxt-pigeon'],
  nuxtPigeon: {
    channels: {
      slack: true,
    },
  },
})

Send

server/api/hello.get.ts
export default defineEventHandler(async () => {
  const message = await slack.send('*Deploy failed* on `main`')

  await slack.send('Fixed it', { threadTs: message.id })

  return { ts: message.id }
})

A message appears, and a reply hangs under it in a thread.

The two modes

Incoming webhookBot token
Send
Choose the channel per message❌ baked into the url
Reply in a thread
Message id back❌ answers ok✅ the ts
Edit, delete
Upload a file

If both are configured the bot token wins, because it can do strictly more. A token without a channel is an error, not a quiet fallback to the webhook: you set a token to get an id back, and the webhook would hand you a message you cannot touch again.

Every option

OptionWhat it does
channelIdA channel id. Bot token only
threadTsReply in a thread. The id of the message to answer
replyBroadcastA thread reply that also shows in the channel
blocksBlock Kit, passed through untouched. text stays the notification preview
mrkdwnOff sends the text literally
mediaFiles. Uploaded in three steps, see below
unfurlLinks, unfurlMediaTurn link previews off
username, iconEmoji, iconUrlOverride name and picture for one message
attachmentsThe older, pre Block Kit way of adding structure
metadataYour own event metadata

Receiving

This is the part that costs people an evening, so it is spelled out in full. Slack has four switches that each silently disable delivery on their own, and none of them produces an error anywhere.

Get the signing secret

Basic Information → App Credentials → Signing Secret. It is not the bot token and it is not under OAuth, which is the first place everyone looks.

.env
PIGEON_SLACK_SIGNING_SECRET=...

Turn the route on

nuxt.config.ts
nuxtPigeon: {
  channels: {
    slack: { receive: true },
  },
}

That registers POST /api/_pigeon/slack.

Start the server first

terminal
pnpm dev --tunnel

This order matters. The next step fails if your server is not already answering.

Paste the url into Event Subscriptions

Event Subscriptions → Enable Events → Request URL:

https://your-tunnel.trycloudflare.com/api/_pigeon/slack

Slack calls it immediately with a url_verification challenge and will not let you save until it gets the right answer back. The module answers it for you, so if the field turns green, your route works. If it does not, nothing after this will work either, and that is useful to know now rather than in an hour.

Subscribe to events

Subscribe to bot events, then add what you actually want:

EventYou getNeeds
app_mentionOnly messages that mention the botapp_mentions:read
message.channelsEvery message in public channels it is inchannels:history
message.imDirect messages to the botim:history

Start with app_mention. message.channels is a firehose.

Reinstall the app

Adding an event adds a scope, and the token you already have does not have it. Slack shows a yellow banner for this. Click reinstall your app, or go to OAuth & Permissions → Reinstall to Workspace.

Skipping this is the single most common reason nothing arrives.

Invite the bot

/invite @your-bot

In the channel you want to hear from. A bot that is not in a channel receives nothing from it, and there is no error to tell you.

Write in the channel

It arrives.

Nothing arrives? In this order

Every one of these fails silently. Work down the list, it is sorted by how often it is the answer.

  1. Is Socket Mode off? Settings → Socket Mode. With it on, Slack delivers everything over a WebSocket and nothing over HTTP. Your Request URL is ignored, your route is never called, and no error appears anywhere. This is the one that costs the evening.
  2. Did you reinstall after changing scopes? A yellow banner at the top of the app page means your token is older than your permissions.
  3. Is the bot in the channel? /invite @your-bot.
  4. Did the tunnel address change? Every restart of --tunnel gives you a new one, and Slack still has the old one. Paste the new one into Event Subscriptions again.
  5. Is the event actually subscribed? app_mention fires only when the bot is mentioned. Writing a normal message in the channel needs message.channels.
  6. Is the signing secret right? A wrong one means every delivery is rejected before your handler, which looks exactly like nothing arriving.

The listener

server/plugins/slack.ts
export default defineNitroPlugin(() => {
  slack.listen(async (envelope) => {
    // Yours come back too. Without this you have built a loop.
    if (envelope.event?.bot_id) return

    if (envelope.event?.type === 'app_mention') {
      await slack.send('You rang?', { threadTs: envelope.event.ts })
    }
  })
})

The signature is checked, the url_verification challenge is answered, and the acknowledgement goes out inside Slack's three second deadline before your handler runs. A slow handler therefore cannot cause a duplicate delivery.

What will cost you an hour

  • Bold is one asterisk. *bold*, not **bold**. Two are literal.
  • @here is written <!here>. A @here copied from Discord sits there as dead text and notifies nobody, without an error.
  • Slack answers 200 even when it failed. The reason is in the body as { ok: false, error: 'missing_scope' }. The module throws on that, so a missing scope cannot pass for success.
  • An edit drops the blocks when only text is sent, but keeps the attachments. Two opposite rules in one method. The module sends the blocks again for you.
  • An upload has no message timestamp. The file becomes the message, so it can be deleted but never edited, and delete uses files.delete rather than chat.delete.
  • Uploading needs the bot in the channel. chat:write.public covers messages but not files, so /invite it once.
  • Slack sends you your own messages. Every message in the channel produces an event, including the one your app just posted. Yours carry bot_id, and answering without checking builds a loop.

Read more