How receiving works

Receive Telegram updates, Slack events and any webhook in Nuxt with one listener. Webhooks, polling and streams underneath, and which of them run on serverless.

Sending looks the same everywhere. Receiving does not, and that is the interesting part of this module.

Telegram pushes to a url. Bluesky has no push at all and has to be asked. Slack pushes too, but wants an answer within three seconds. You write the same five lines for all of them.

Copy this

Turn receiving on

nuxt.config.ts
export default defineNuxtConfig({
  modules: ['nuxt-pigeon'],
  nuxtPigeon: {
    channels: {
      telegram: { receive: true },
    },
  },
})

Write a listener

A Nitro plugin, so it is registered once at startup rather than per request.

server/plugins/telegram.ts
export default defineNitroPlugin(() => {
  telegram.listen(async (update) => {
    const message = update.message
    if (!message?.text) return

    await telegram.send(`You said: ${message.text}`, { chatId: message.chat.id })
  })
})

Let Telegram reach you

terminal
pnpm dev --tunnel

Copy the public address it prints, then register it once:

server/api/register.get.ts
export default defineEventHandler(async () => {
  return telegram.setWebhook('https://your-tunnel.trycloudflare.com')
})

Open that route once in the browser.

Write to your bot

Your bot answers.

What just happened

  • listen returned a function. Calling it unregisters. That matters in development: without it a hot reload stacks a second copy of the same handler on top of the first.
  • The update arrived untouched. update is exactly what Telegram sent, every field, nothing renamed. What one service calls an event type and another calls an action stays where it was.
  • The signature was checked before your handler ran. Telegram signs with a secret token in a header, and a request without it never reaches you.

The three transports

You do not choose this. The channel does, and it is the reason listen can look the same everywhere.

ChannelHow it arrivesWorks on serverless
TelegramWebhook, a route the module registers
SlackEvents API, same idea, plus a signature and a deadline
Any webhookYour own route, for GitHub, Stripe, n8n, anything
MastodonPolling, there is no webhook for your own account
BlueskyPolling, there is no webhook at all
Polling needs a process that stays alive. On Cloudflare Workers, Vercel or Netlify there is no such process, so a poller never runs and you receive nothing. The build warns you when you configure one on a preset that cannot host it.

What will cost you an hour

  • A hot reload leaves the old listener running unless you unregister. The module keeps its pollers in a global symbol and stops them on close for exactly this reason. With Telegram, two pollers at once earn you a 409 Conflict.
  • Slack wants an acknowledgement within three seconds, or it retries the delivery. The module answers first and runs your handler afterwards, so a slow handler cannot cause a duplicate. You will still see retries if Slack itself times out, and they arrive with x-slack-retry-num set.
  • Slack sends you your own messages. Every message in the channel produces an event, including the one your app just posted. Answer it and you have built a loop that runs until Slack rate limits you. Yours carry bot_id:
    slack.listen((event) => {
      if (event.event?.bot_id) return // that was us
    })
    
  • Mastodon and Bluesky never notify you about your own doing. Testing needs a second account, or a friend.

Read more