Any webhook

Send and receive webhooks in Nuxt: any url, any payload, Standard Webhooks signing, and one route for GitHub, Stripe, n8n or anything else that speaks HTTP.

The other six channels each know one service. This one knows none, and that is the point: GitHub, Stripe, n8n, Home Assistant, a colleague's endpoint. Whatever speaks HTTP.

Copy this

Configure a target

.env
PIGEON_WEBHOOK_N8N_URL=https://n8n.example.com/webhook/abc
PIGEON_WEBHOOK_N8N_SECRET=whsec_...
nuxt.config.ts
export default defineNuxtConfig({
  modules: ['nuxt-pigeon'],
  nuxtPigeon: {
    webhook: {
      receive: true,
      endpoints: {
        n8n: { headers: { 'X-Source': 'nuxt' } },
      },
    },
  },
})

Send

server/api/hello.get.ts
export default defineEventHandler(async () => {
  await webhook.send({ event: 'deploy', ok: false, branch: 'main' }, { to: 'n8n' })

  return { ok: true }
})

It arrives exactly as written, plus the signature headers, and nothing else.

Nothing is added

The body is yours. No envelope, no data wrapper, no metadata. The only header set on its own is Content-Type, and it is decided rather than guessed:

What you passWhat goes out
an object or arrayapplication/json
a stringtext/plain;charset=UTF-8
URLSearchParamsform encoded
FormData, Blob, bytesuntouched
nothingno body and no content type

A string labelled as json would be a lie, so it is not.

Three ways to say where

await webhook.send(body) // the configured default url
await webhook.send(body, { to: 'n8n' }) // a named endpoint
await webhook.send(body, { url: 'https://…' }) // straight at a url

The raw url beats the name, the name beats the default. Naming is a convenience, never a gate: a target that comes out of your database at runtime cannot be in a config, and that has to stay possible.

A name that does not exist is an error that lists the ones that do.

Signing

Set a secret and the request is signed to Standard Webhooks, so the other side can verify with an existing library instead of reading our docs:

webhook-id: 550e8400-e29b-41d4-a716-446655440000
webhook-timestamp: 1735689600
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=

webhook-id doubles as an idempotency key for the receiver: the same id twice is the same delivery, not two.

Receiving

nuxt.config.ts
nuxtPigeon: {
  webhook: { receive: true },
}

That registers POST /api/_pigeon/webhook. Paste it into GitHub, Stripe or anything else, with a tunnel in development.

server/plugins/webhook.ts
export default defineNitroPlugin(() => {
  webhook.listen((message) => {
    // `type` is the event name in the sender's own words: GitHub `push`, Stripe
    // `payment_intent.succeeded`. Not interpreted, only passed on.
    console.log(message.type, message.body)
  })
})
There is no format to agree on here, and that is deliberate. The body arrives untouched together with every header. What one service calls an event type and another calls an action stays where it was, and you decide what it means.

What will cost you an hour

  • The signature covers the raw bytes, not a re-serialised body. That is why raw is kept: verifying against JSON.stringify(body) fails for anyone whose sender formats differently.
  • Each side has its own secret. The one you send with and the one you verify with are not the same secret unless you make them so. Two directions, two settings.
  • GitHub signs differently. It uses X-Hub-Signature-256, not Standard Webhooks. The raw body and all headers reach you, so verifying it is a few lines you write, and the module does not pretend to know every scheme in the world.
  • edit and delete do not exist here. You decide what the receiver is, so only you know how it is changed. send carries method and url, so a PATCH or a DELETE is one call.

Read more