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.

Discord needs no bot and no application. A webhook url from a channel's settings is the whole credential, and it can do far more than most people expect.

Copy this

Create a webhook

In Discord: Channel settings → Integrations → Webhooks → New Webhook, then Copy Webhook URL.

Configure it

.env
PIGEON_DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/123.../abc...
nuxt.config.ts
export default defineNuxtConfig({
  modules: ['nuxt-pigeon'],
  nuxtPigeon: {
    channels: {
      discord: true,
    },
  },
})

Send

server/api/hello.get.ts
export default defineEventHandler(async () => {
  const message = await discord.send('Deploy failed', {
    embeds: [
      {
        title: 'main',
        description: 'The build step failed after 40s.',
        color: 0x00dc82,
      },
    ],
  })

  return { id: message.id }
})

A green embed appears in your channel.

That url is the credential. Anyone who has it can post in your channel. It belongs in .env, never in nuxt.config, and errors from this channel deliberately name Discord instead of the address so it never lands in a log.

Every option

OptionWhat it does
embedsUp to 10, the rich boxes. Colour is decimal, 0x00dc82
mediaUp to 10 files. Fetched and attached as multipart
username, avatarUrlOverride the webhook's name and picture per message
allowedMentionsPassed through untouched, so @everyone can be defused
threadIdPost into an existing thread
threadNameCreate a thread, in a forum or media channel
appliedTagsForum tags
componentsButtons and selects. Application owned webhooks only
pollA poll, passed through untouched
ttsRead aloud for whoever has that on
flagsFor example 4 to suppress link previews
waitDefaults to true, so you get the message back and can edit it
webhookUrlAnother webhook for this call. A url is a channel, so this is how a second server or channel is reached. edit and delete need it again, it is never kept in the handle

Images inside an embed

Attach the file, then point the embed at it by name:

await discord.send('The failing step', {
  media: [{ url: 'https://example.com/shot.png', filename: 'shot.png' }],
  embeds: [{ title: 'Build log', image: { url: 'attachment://shot.png' } }],
})

Spoilers

Only on a real attachment, through the filename:

await discord.send('Careful', {
  media: [{ url: '…/shot.png', spoiler: true }],
})

The module renames it to SPOILER_shot.png, which is the only thing Discord looks at. On an embed.image.url a spoiler is ignored, with no error.

What will cost you an hour

  • An edit drops every attachment it does not name. Discord's rule, from API v10. Turned around here: edit keeps them unless you pass media: [].
  • wait: false means no id. Discord then answers 204 and says nothing, so the message cannot be edited or deleted. The default is true for that reason.
  • A message link needs the guild id, which the webhook answer does not carry. So url stays empty on this channel, rather than being guessed.
  • Deleting twice is a 404, unlike Bluesky where it is idempotent.
  • Receiving is not possible with a webhook. Reading messages needs a Gateway connection and the privileged MESSAGE_CONTENT intent, which means a process that stays alive. Not offered here.

Read more