Any webhook
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
PIGEON_WEBHOOK_N8N_URL=https://n8n.example.com/webhook/abc
PIGEON_WEBHOOK_N8N_SECRET=whsec_...
export default defineNuxtConfig({
modules: ['nuxt-pigeon'],
nuxtPigeon: {
webhook: {
receive: true,
endpoints: {
n8n: { headers: { 'X-Source': 'nuxt' } },
},
},
},
})
Send
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 pass | What goes out |
|---|---|
| an object or array | application/json |
| a string | text/plain;charset=UTF-8 |
URLSearchParams | form encoded |
FormData, Blob, bytes | untouched |
| nothing | no 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
nuxtPigeon: {
webhook: { receive: true },
}
That registers POST /api/_pigeon/webhook. Paste it into GitHub, Stripe or anything
else, with a tunnel in development.
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)
})
})
What will cost you an hour
- The signature covers the raw bytes, not a re-serialised body. That is why
rawis kept: verifying againstJSON.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. editanddeletedo not exist here. You decide what the receiver is, so only you know how it is changed.sendcarriesmethodandurl, so aPATCHor aDELETEis one call.
Read more
- Standard Webhooks - the specification
- GitHub webhook events
- In the browser - what arrives, live on a page
Bluesky
Post to Bluesky from Nuxt with an app password: images, link cards, and facets computed for you, so links and hashtags are clickable instead of dead text.
Retries and rate limits
Retries with growing delays, Retry-After honoured, and no retry after a network error, because a duplicate notification is worse than a missing one.