Slack
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, deletechat:write.public- post to public channels without being invitedfiles: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
PIGEON_SLACK_BOT_TOKEN=xoxb-...
PIGEON_SLACK_CHANNEL=C01ABC2DEF
export default defineNuxtConfig({
modules: ['nuxt-pigeon'],
nuxtPigeon: {
channels: {
slack: true,
},
},
})
Send
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 webhook | Bot 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
| Option | What it does |
|---|---|
channelId | A channel id. Bot token only |
threadTs | Reply in a thread. The id of the message to answer |
replyBroadcast | A thread reply that also shows in the channel |
blocks | Block Kit, passed through untouched. text stays the notification preview |
mrkdwn | Off sends the text literally |
media | Files. Uploaded in three steps, see below |
unfurlLinks, unfurlMedia | Turn link previews off |
username, iconEmoji, iconUrl | Override name and picture for one message |
attachments | The older, pre Block Kit way of adding structure |
metadata | Your 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.
PIGEON_SLACK_SIGNING_SECRET=...
Turn the route on
nuxtPigeon: {
channels: {
slack: { receive: true },
},
}
That registers POST /api/_pigeon/slack.
Start the server first
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:
| Event | You get | Needs |
|---|---|---|
app_mention | Only messages that mention the bot | app_mentions:read |
message.channels | Every message in public channels it is in | channels:history |
message.im | Direct messages to the bot | im: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.
- 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.
- Did you reinstall after changing scopes? A yellow banner at the top of the app page means your token is older than your permissions.
- Is the bot in the channel?
/invite @your-bot. - Did the tunnel address change? Every restart of
--tunnelgives you a new one, and Slack still has the old one. Paste the new one into Event Subscriptions again. - Is the event actually subscribed?
app_mentionfires only when the bot is mentioned. Writing a normal message in the channel needsmessage.channels. - Is the signing secret right? A wrong one means every delivery is rejected before your handler, which looks exactly like nothing arriving.
The listener
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. @hereis written<!here>. A@herecopied from Discord sits there as dead text and notifies nobody, without an error.- Slack answers
200even 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
deleteusesfiles.deleterather thanchat.delete. - Uploading needs the bot in the channel.
chat:write.publiccovers messages but not files, so/inviteit 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
Discord
Send Discord messages from Nuxt with a webhook url and no bot: embeds, file attachments, spoilers, threads, polls, and editing or deleting them afterwards.
ntfy
Push notifications to your phone from Nuxt with ntfy: no account and no keys, priorities, tags and one notification that replaces itself instead of repeating.