├── .gitattributes
├── playground
├── app
│ └── app.vue
├── server
│ ├── tsconfig.json
│ └── api
│ │ └── webhooks
│ │ ├── kick.post.ts
│ │ ├── svix.post.ts
│ │ ├── brevo.post.ts
│ │ ├── github.post.ts
│ │ ├── gitlab.post.ts
│ │ ├── heroku.post.ts
│ │ ├── paddle.post.ts
│ │ ├── paypal.post.ts
│ │ ├── polar.post.ts
│ │ ├── resend.post.ts
│ │ ├── stripe.post.ts
│ │ ├── hygraph.post.ts
│ │ ├── nuxthub.post.ts
│ │ ├── shopify.post.ts
│ │ ├── fourthwall.post.ts
│ │ ├── mailchannels.post.ts
│ │ ├── slack.post.ts
│ │ ├── meta.ts
│ │ ├── dropbox.ts
│ │ ├── discord.post.ts
│ │ └── twitch.post.ts
├── tsconfig.json
├── package.json
├── .env.example
└── nuxt.config.ts
├── test
├── fixtures
│ └── basic
│ │ ├── app
│ │ ├── app.vue
│ │ └── pages
│ │ │ └── index.vue
│ │ ├── package.json
│ │ └── nuxt.config.ts
├── simulations
│ ├── resend.ts
│ ├── paypal.ts
│ ├── brevo.ts
│ ├── gitlab.ts
│ ├── nuxthub.ts
│ ├── dropbox.ts
│ ├── meta.ts
│ ├── github.ts
│ ├── heroku.ts
│ ├── fourthwall.ts
│ ├── shopify.ts
│ ├── paddle.ts
│ ├── stripe.ts
│ ├── discord.ts
│ ├── polar.ts
│ ├── slack.ts
│ ├── hygraph.ts
│ ├── twitch.ts
│ ├── kick.ts
│ ├── svix.ts
│ └── mailchannels.ts
├── genKeys.ts
├── events.ts
└── module.test.ts
├── tsconfig.json
├── pnpm-workspace.yaml
├── .editorconfig
├── src
├── runtime
│ └── server
│ │ └── lib
│ │ ├── validators
│ │ ├── resend.ts
│ │ ├── gitlab.ts
│ │ ├── dropbox.ts
│ │ ├── shopify.ts
│ │ ├── fourthwall.ts
│ │ ├── heroku.ts
│ │ ├── meta.ts
│ │ ├── nuxthub.ts
│ │ ├── discord.ts
│ │ ├── github.ts
│ │ ├── twitch.ts
│ │ ├── slack.ts
│ │ ├── brevo.ts
│ │ ├── polar.ts
│ │ ├── svix.ts
│ │ ├── paypal.ts
│ │ ├── stripe.ts
│ │ ├── paddle.ts
│ │ ├── kick.ts
│ │ ├── hygraph.ts
│ │ └── mailchannels.ts
│ │ └── helpers.ts
└── module.ts
├── .github
└── workflows
│ ├── autofix.yml
│ ├── release.yml
│ └── ci.yml
├── eslint.config.mjs
├── .gitignore
├── LICENSE
├── package.json
├── README.md
└── CHANGELOG.md
/.gitattributes:
--------------------------------------------------------------------------------
1 | # Auto detect text files and perform LF normalization
2 | * text=auto
3 |
--------------------------------------------------------------------------------
/playground/app/app.vue:
--------------------------------------------------------------------------------
1 |
2 | Waiting for webhooks
3 |
4 |
--------------------------------------------------------------------------------
/playground/server/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../.nuxt/tsconfig.server.json"
3 | }
4 |
--------------------------------------------------------------------------------
/test/fixtures/basic/app/app.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/test/fixtures/basic/app/pages/index.vue:
--------------------------------------------------------------------------------
1 |
2 | Nuxt Webhook Validators
3 |
4 |
--------------------------------------------------------------------------------
/test/fixtures/basic/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "name": "basic",
4 | "type": "module"
5 | }
6 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./.nuxt/tsconfig.json",
3 | "exclude": [
4 | "playground",
5 | "dist",
6 | ]
7 | }
8 |
--------------------------------------------------------------------------------
/pnpm-workspace.yaml:
--------------------------------------------------------------------------------
1 | shamefullyHoist: true
2 | strictPeerDependencies: false
3 |
4 | packages:
5 | - "playground"
6 |
7 | ignoredBuiltDependencies:
8 | - '@parcel/watcher'
9 | - esbuild
10 | - unrs-resolver
11 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | indent_size = 2
5 | indent_style = space
6 | end_of_line = lf
7 | charset = utf-8
8 | trim_trailing_whitespace = true
9 | insert_final_newline = true
10 |
11 | [*.md]
12 | trim_trailing_whitespace = false
13 |
--------------------------------------------------------------------------------
/test/simulations/resend.ts:
--------------------------------------------------------------------------------
1 | import nuxtConfig from '../fixtures/basic/nuxt.config'
2 | import { simulateSvixEvent } from './svix'
3 |
4 | const secretKey = nuxtConfig.runtimeConfig?.webhook?.resend?.secretKey
5 |
6 | export const simulateResendEvent = () => simulateSvixEvent(secretKey)
7 |
--------------------------------------------------------------------------------
/playground/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "files": [],
3 | "references": [
4 | { "path": "./.nuxt/tsconfig.app.json" },
5 | { "path": "./.nuxt/tsconfig.server.json" },
6 | { "path": "./.nuxt/tsconfig.shared.json" },
7 | { "path": "./.nuxt/tsconfig.node.json" }
8 | ]
9 | }
10 |
--------------------------------------------------------------------------------
/playground/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "name": "webhook-validators-playground",
4 | "type": "module",
5 | "scripts": {
6 | "dev": "nuxi dev",
7 | "build": "nuxi build",
8 | "generate": "nuxi generate"
9 | },
10 | "dependencies": {
11 | "nuxt": "^4.2.2"
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/playground/server/api/webhooks/kick.post.ts:
--------------------------------------------------------------------------------
1 | export default defineEventHandler(async (event) => {
2 | const isValidWebhook = await isValidKickWebhook(event)
3 |
4 | if (!isValidWebhook) throw createError({ statusCode: 401, message: 'Unauthorized: webhook is not valid' })
5 |
6 | return { isValidWebhook }
7 | })
8 |
--------------------------------------------------------------------------------
/playground/server/api/webhooks/svix.post.ts:
--------------------------------------------------------------------------------
1 | export default defineEventHandler(async (event) => {
2 | const isValidWebhook = await isValidSvixWebhook(event)
3 |
4 | if (!isValidWebhook) throw createError({ statusCode: 401, message: 'Unauthorized: webhook is not valid' })
5 |
6 | return { isValidWebhook }
7 | })
8 |
--------------------------------------------------------------------------------
/playground/server/api/webhooks/brevo.post.ts:
--------------------------------------------------------------------------------
1 | export default defineEventHandler(async (event) => {
2 | const isValidWebhook = await isValidBrevoWebhook(event)
3 |
4 | if (!isValidWebhook) throw createError({ statusCode: 401, message: 'Unauthorized: webhook is not valid' })
5 |
6 | return { isValidWebhook }
7 | })
8 |
--------------------------------------------------------------------------------
/playground/server/api/webhooks/github.post.ts:
--------------------------------------------------------------------------------
1 | export default defineEventHandler(async (event) => {
2 | const isValidWebhook = await isValidGitHubWebhook(event)
3 |
4 | if (!isValidWebhook) throw createError({ statusCode: 401, message: 'Unauthorized: webhook is not valid' })
5 |
6 | return { isValidWebhook }
7 | })
8 |
--------------------------------------------------------------------------------
/playground/server/api/webhooks/gitlab.post.ts:
--------------------------------------------------------------------------------
1 | export default defineEventHandler(async (event) => {
2 | const isValidWebhook = await isValidGitLabWebhook(event)
3 |
4 | if (!isValidWebhook) throw createError({ statusCode: 401, message: 'Unauthorized: webhook is not valid' })
5 |
6 | return { isValidWebhook }
7 | })
8 |
--------------------------------------------------------------------------------
/playground/server/api/webhooks/heroku.post.ts:
--------------------------------------------------------------------------------
1 | export default defineEventHandler(async (event) => {
2 | const isValidWebhook = await isValidHerokuWebhook(event)
3 |
4 | if (!isValidWebhook) throw createError({ statusCode: 401, message: 'Unauthorized: webhook is not valid' })
5 |
6 | return { isValidWebhook }
7 | })
8 |
--------------------------------------------------------------------------------
/playground/server/api/webhooks/paddle.post.ts:
--------------------------------------------------------------------------------
1 | export default defineEventHandler(async (event) => {
2 | const isValidWebhook = await isValidPaddleWebhook(event)
3 |
4 | if (!isValidWebhook) throw createError({ statusCode: 401, message: 'Unauthorized: webhook is not valid' })
5 |
6 | return { isValidWebhook }
7 | })
8 |
--------------------------------------------------------------------------------
/playground/server/api/webhooks/paypal.post.ts:
--------------------------------------------------------------------------------
1 | export default defineEventHandler(async (event) => {
2 | const isValidWebhook = await isValidPaypalWebhook(event)
3 |
4 | if (!isValidWebhook) throw createError({ statusCode: 401, message: 'Unauthorized: webhook is not valid' })
5 |
6 | return { isValidWebhook }
7 | })
8 |
--------------------------------------------------------------------------------
/playground/server/api/webhooks/polar.post.ts:
--------------------------------------------------------------------------------
1 | export default defineEventHandler(async (event) => {
2 | const isValidWebhook = await isValidPolarWebhook(event)
3 |
4 | if (!isValidWebhook) throw createError({ statusCode: 401, message: 'Unauthorized: webhook is not valid' })
5 |
6 | return { isValidWebhook }
7 | })
8 |
--------------------------------------------------------------------------------
/playground/server/api/webhooks/resend.post.ts:
--------------------------------------------------------------------------------
1 | export default defineEventHandler(async (event) => {
2 | const isValidWebhook = await isValidResendWebhook(event)
3 |
4 | if (!isValidWebhook) throw createError({ statusCode: 401, message: 'Unauthorized: webhook is not valid' })
5 |
6 | return { isValidWebhook }
7 | })
8 |
--------------------------------------------------------------------------------
/playground/server/api/webhooks/stripe.post.ts:
--------------------------------------------------------------------------------
1 | export default defineEventHandler(async (event) => {
2 | const isValidWebhook = await isValidStripeWebhook(event)
3 |
4 | if (!isValidWebhook) throw createError({ statusCode: 401, message: 'Unauthorized: webhook is not valid' })
5 |
6 | return { isValidWebhook }
7 | })
8 |
--------------------------------------------------------------------------------
/playground/server/api/webhooks/hygraph.post.ts:
--------------------------------------------------------------------------------
1 | export default defineEventHandler(async (event) => {
2 | const isValidWebhook = await isValidHygraphWebhook(event)
3 |
4 | if (!isValidWebhook) throw createError({ statusCode: 401, message: 'Unauthorized: webhook is not valid' })
5 |
6 | return { isValidWebhook }
7 | })
8 |
--------------------------------------------------------------------------------
/playground/server/api/webhooks/nuxthub.post.ts:
--------------------------------------------------------------------------------
1 | export default defineEventHandler(async (event) => {
2 | const isValidWebhook = await isValidNuxtHubWebhook(event)
3 |
4 | if (!isValidWebhook) throw createError({ statusCode: 401, message: 'Unauthorized: webhook is not valid' })
5 |
6 | return { isValidWebhook }
7 | })
8 |
--------------------------------------------------------------------------------
/playground/server/api/webhooks/shopify.post.ts:
--------------------------------------------------------------------------------
1 | export default defineEventHandler(async (event) => {
2 | const isValidWebhook = await isValidShopifyWebhook(event)
3 |
4 | if (!isValidWebhook) throw createError({ statusCode: 401, message: 'Unauthorized: webhook is not valid' })
5 |
6 | return { isValidWebhook }
7 | })
8 |
--------------------------------------------------------------------------------
/playground/server/api/webhooks/fourthwall.post.ts:
--------------------------------------------------------------------------------
1 | export default defineEventHandler(async (event) => {
2 | const isValidWebhook = await isValidFourthwallWebhook(event)
3 |
4 | if (!isValidWebhook) throw createError({ statusCode: 401, message: 'Unauthorized: webhook is not valid' })
5 |
6 | return { isValidWebhook }
7 | })
8 |
--------------------------------------------------------------------------------
/playground/server/api/webhooks/mailchannels.post.ts:
--------------------------------------------------------------------------------
1 | export default defineEventHandler(async (event) => {
2 | const isValidWebhook = await isValidMailChannelsWebhook(event)
3 |
4 | if (!isValidWebhook) throw createError({ statusCode: 401, message: 'Unauthorized: webhook is not valid' })
5 |
6 | return { isValidWebhook }
7 | })
8 |
--------------------------------------------------------------------------------
/test/simulations/paypal.ts:
--------------------------------------------------------------------------------
1 | import { vi } from 'vitest'
2 |
3 | export const simulatePaypalEvent = async () => {
4 | vi.stubGlobal('$fetch', () => new Promise(resolve => resolve({ isValidWebhook: true })))
5 | // Stubbing $fetch to return a valid webhook response since not testing actual PayPal API here
6 | const response = await $fetch('/api/webhooks/paypal', {
7 | method: 'POST',
8 | })
9 | return response
10 | }
11 |
--------------------------------------------------------------------------------
/playground/server/api/webhooks/slack.post.ts:
--------------------------------------------------------------------------------
1 | export default defineEventHandler(async (event) => {
2 | const body = await readBody(event)
3 |
4 | if (body.challenge) {
5 | return body.challenge
6 | }
7 |
8 | const isValidWebhook = await isValidSlackWebhook(event)
9 |
10 | if (!isValidWebhook) throw createError({ statusCode: 401, message: 'Unauthorized: webhook is not valid' })
11 |
12 | return { isValidWebhook }
13 | })
14 |
--------------------------------------------------------------------------------
/test/simulations/brevo.ts:
--------------------------------------------------------------------------------
1 | import { $fetch } from '@nuxt/test-utils/e2e'
2 |
3 | const body = { test: 'testData' }
4 |
5 | export const simulateBrevoEvent = async () => {
6 | const headers = {
7 | 'authorization': 'Bearer testToken',
8 | 'x-forwarded-for': '1.179.112.0',
9 | }
10 |
11 | return $fetch<{ isValidWebhook: boolean }>('/api/webhooks/brevo', {
12 | method: 'POST',
13 | headers,
14 | body,
15 | })
16 | }
17 |
--------------------------------------------------------------------------------
/test/simulations/gitlab.ts:
--------------------------------------------------------------------------------
1 | import { $fetch } from '@nuxt/test-utils/e2e'
2 | import nuxtConfig from '../fixtures/basic/nuxt.config'
3 |
4 | const body = 'testBody'
5 | const secretToken = nuxtConfig.runtimeConfig?.webhook?.gitlab?.secretToken
6 |
7 | export const simulateGitLabEvent = async () => {
8 | const headers = { 'X-Gitlab-Token': secretToken! }
9 |
10 | return $fetch<{ isValidWebhook: boolean }>('/api/webhooks/gitlab', {
11 | method: 'POST',
12 | headers,
13 | body,
14 | })
15 | }
16 |
--------------------------------------------------------------------------------
/playground/server/api/webhooks/meta.ts:
--------------------------------------------------------------------------------
1 | export default defineEventHandler(async (event) => {
2 | if (event.method === 'GET') {
3 | const query = getQuery(event)
4 | return query['hub.challenge']
5 | }
6 |
7 | if (event.method !== 'POST') throw createError({ statusCode: 405, message: 'Method Not Allowed' })
8 |
9 | const isValidWebhook = await isValidMetaWebhook(event)
10 |
11 | if (!isValidWebhook) throw createError({ statusCode: 401, message: 'Unauthorized: webhook is not valid' })
12 |
13 | return { isValidWebhook }
14 | })
15 |
--------------------------------------------------------------------------------
/playground/server/api/webhooks/dropbox.ts:
--------------------------------------------------------------------------------
1 | export default defineEventHandler(async (event) => {
2 | if (event.method === 'GET') {
3 | const query = getQuery(event)
4 | return query['challenge']
5 | }
6 |
7 | if (event.method !== 'POST') throw createError({ statusCode: 405, message: 'Method Not Allowed' })
8 |
9 | const isValidWebhook = await isValidDropboxWebhook(event)
10 |
11 | if (!isValidWebhook) throw createError({ statusCode: 401, message: 'Unauthorized: webhook is not valid' })
12 |
13 | return { isValidWebhook }
14 | })
15 |
--------------------------------------------------------------------------------
/src/runtime/server/lib/validators/resend.ts:
--------------------------------------------------------------------------------
1 | import type { H3Event } from 'h3'
2 | import { configContext } from '../helpers'
3 | import { isValidSvixWebhook } from './svix'
4 |
5 | /**
6 | * Validates Resend webhooks on the Edge
7 | * @see {@link https://resend.com/docs/dashboard/webhooks/verify-webhooks-requests}
8 | * @param event H3Event
9 | * @returns {boolean} `true` if the webhook is valid, `false` otherwise
10 | */
11 | export const isValidResendWebhook = async (event: H3Event): Promise => {
12 | configContext.provider = 'resend'
13 | return isValidSvixWebhook(event)
14 | }
15 |
--------------------------------------------------------------------------------
/.github/workflows/autofix.yml:
--------------------------------------------------------------------------------
1 | name: autofix.ci # needed to securely identify the workflow
2 |
3 | on:
4 | pull_request:
5 | branches:
6 | - main
7 |
8 | permissions:
9 | contents: read
10 |
11 | jobs:
12 | lint:
13 | runs-on: ubuntu-latest
14 |
15 | steps:
16 | - uses: actions/checkout@v5
17 | - run: corepack enable
18 | - uses: actions/setup-node@v5
19 | with:
20 | node-version: lts/*
21 | cache: pnpm
22 |
23 | - name: 📦 Install dependencies
24 | run: pnpm install
25 |
26 | - name: 🔎 Lint (code)
27 | run: pnpm lint:fix
28 |
29 | - name: ⚙️ Auto-fix
30 | uses: autofix-ci/action@635ffb0c9798bd160680f18fd73371e355b85f27
31 |
--------------------------------------------------------------------------------
/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | // @ts-check
2 | import { createConfigForNuxt } from '@nuxt/eslint-config/flat'
3 |
4 | // Run `npx @eslint/config-inspector` to inspect the resolved config interactively
5 | export default createConfigForNuxt({
6 | features: {
7 | // Rules for module authors
8 | tooling: true,
9 | // Rules for formatting
10 | stylistic: true,
11 | },
12 | dirs: {
13 | src: [
14 | './playground',
15 | ],
16 | },
17 | }).append({
18 | rules: {
19 | 'vue/multi-word-component-names': 'off',
20 | 'vue/max-attributes-per-line': 'off',
21 | 'vue/singleline-html-element-content-newline': 'off',
22 | 'unicorn/escape-case': 'off',
23 | 'no-misleading-character-class': 'off',
24 | },
25 | })
26 |
--------------------------------------------------------------------------------
/test/simulations/nuxthub.ts:
--------------------------------------------------------------------------------
1 | import { $fetch } from '@nuxt/test-utils/e2e'
2 | import { sha256 } from '../../src/runtime/server/lib/helpers'
3 | import nuxtConfig from '../fixtures/basic/nuxt.config'
4 |
5 | const body = { message: 'NuxtHub Sample Webhook, this is a test message from NuxtHub Admin' }
6 | const secretKey = nuxtConfig.runtimeConfig?.webhook?.nuxthub?.secretKey
7 |
8 | export const simulateNuxthubEvent = async () => {
9 | const payload = JSON.stringify(body) + secretKey
10 | const validSignature = await sha256(payload)
11 |
12 | const headers = { 'x-nuxthub-signature': validSignature }
13 |
14 | return $fetch<{ isValidWebhook: boolean }>('/api/webhooks/nuxthub', {
15 | method: 'POST',
16 | headers,
17 | body,
18 | })
19 | }
20 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: release
2 |
3 | on:
4 | push:
5 | tags:
6 | - 'v*'
7 |
8 | permissions:
9 | contents: write
10 | id-token: write
11 |
12 | jobs:
13 | release:
14 | runs-on: ubuntu-latest
15 | steps:
16 | - uses: actions/checkout@v5
17 | with:
18 | fetch-depth: 0
19 | - run: corepack enable
20 | - uses: actions/setup-node@v5
21 | with:
22 | node-version: latest
23 | cache: pnpm
24 | registry-url: https://registry.npmjs.org
25 |
26 | - run: pnpm config set registry https://registry.npmjs.org
27 |
28 | - name: 📦 Install dependencies
29 | run: pnpm i
30 |
31 | - name: 🚀 Publish package
32 | run: pnpm publish --access public --no-git-checks
33 |
--------------------------------------------------------------------------------
/playground/server/api/webhooks/discord.post.ts:
--------------------------------------------------------------------------------
1 | export default defineEventHandler(async (event) => {
2 | const isValidWebhook = await isValidDiscordWebhook(event)
3 | if (!isValidWebhook) throw createError({ statusCode: 401, message: 'Unauthorized: webhook is not valid' })
4 |
5 | const body = await readBody(event)
6 |
7 | /**
8 | * The PING message is used during the initial webhook handshake, and is
9 | required to configure the webhook in the developer portal.
10 | @see https://discord.com/developers/docs/interactions/overview#setting-up-an-endpoint-acknowledging-ping-requests
11 | @see https://discord.com/developers/docs/resources/webhook
12 | */
13 | if (body.type === 1) return { type: body.type, isValidWebhook } // PING
14 |
15 | return { isValidWebhook }
16 | })
17 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Dependencies
2 | node_modules
3 |
4 | # Logs
5 | *.log*
6 |
7 | # Temp directories
8 | .temp
9 | .tmp
10 | .cache
11 |
12 | # Yarn
13 | **/.yarn/cache
14 | **/.yarn/*state*
15 |
16 | # Generated dirs
17 | dist
18 |
19 | # Nuxt
20 | .nuxt
21 | .output
22 | .data
23 | .vercel_build_output
24 | .build-*
25 | .netlify
26 |
27 | # Env
28 | .env
29 |
30 | # Testing
31 | reports
32 | coverage
33 | *.lcov
34 | .nyc_output
35 |
36 | # VSCode
37 | .vscode/*
38 | !.vscode/settings.json
39 | !.vscode/tasks.json
40 | !.vscode/launch.json
41 | !.vscode/extensions.json
42 | !.vscode/*.code-snippets
43 |
44 | # Intellij idea
45 | *.iml
46 | .idea
47 |
48 | # OSX
49 | .DS_Store
50 | .AppleDouble
51 | .LSOverride
52 | .AppleDB
53 | .AppleDesktop
54 | Network Trash Folder
55 | Temporary Items
56 | .apdisk
57 |
--------------------------------------------------------------------------------
/test/genKeys.ts:
--------------------------------------------------------------------------------
1 | import { generateKeyPairSync } from 'node:crypto'
2 | import { writeFile } from 'node:fs/promises'
3 | import { fileURLToPath } from 'node:url'
4 |
5 | const keysDir = fileURLToPath(new URL('./fixtures/basic/test-keys.json', import.meta.url))
6 |
7 | export const generateTestingKeys = async () => {
8 | const rsaKeys = generateKeyPairSync('rsa', {
9 | modulusLength: 512,
10 | publicKeyEncoding: { type: 'spki', format: 'pem' },
11 | privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
12 | })
13 |
14 | const ed25519Keys = generateKeyPairSync('ed25519', {
15 | publicKeyEncoding: { type: 'spki', format: 'pem' },
16 | privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
17 | })
18 |
19 | await writeFile(keysDir, JSON.stringify({
20 | rsaKeys,
21 | ed25519Keys,
22 | }))
23 | }
24 |
--------------------------------------------------------------------------------
/playground/server/api/webhooks/twitch.post.ts:
--------------------------------------------------------------------------------
1 | export default defineEventHandler(async (event) => {
2 | const isValidWebhook = await isValidTwitchWebhook(event)
3 |
4 | if (!isValidWebhook) throw createError({ statusCode: 401, message: 'Unauthorized: webhook is not valid' })
5 |
6 | const headers = getHeaders(event)
7 | const body = await readBody(event)
8 |
9 | const MESSAGE_TYPE = 'Twitch-Eventsub-Message-Type'.toLowerCase()
10 | const MESSAGE_TYPE_VERIFICATION = 'webhook_callback_verification'
11 |
12 | /**
13 | * Verify the webhook callback subscription by responding to a challenge request.
14 | @see https://dev.twitch.tv/docs/eventsub/handling-webhook-events#responding-to-a-challenge-request
15 | */
16 | if (headers[MESSAGE_TYPE] === MESSAGE_TYPE_VERIFICATION) return body.challenge
17 |
18 | return { isValidWebhook }
19 | })
20 |
--------------------------------------------------------------------------------
/src/runtime/server/lib/validators/gitlab.ts:
--------------------------------------------------------------------------------
1 | import { type H3Event, getRequestHeaders, readRawBody } from 'h3'
2 | import { ensureConfiguration } from '../helpers'
3 |
4 | const GITLAB_TOKEN = 'X-Gitlab-Token'.toLowerCase()
5 |
6 | /**
7 | * Validates GitLab webhooks on the Edge
8 | * @see {@link https://docs.gitlab.com/ee/user/project/integrations/webhooks.html}
9 | * @param event H3Event
10 | * @returns {boolean} `true` if the webhook is valid, `false` otherwise
11 | */
12 | export const isValidGitLabWebhook = async (event: H3Event): Promise => {
13 | const config = ensureConfiguration('gitlab', event)
14 |
15 | const headers = getRequestHeaders(event)
16 | const body = await readRawBody(event)
17 |
18 | const header = headers[GITLAB_TOKEN]
19 |
20 | if (!body || !header) return false
21 |
22 | return header === config.secretToken
23 | }
24 |
--------------------------------------------------------------------------------
/test/simulations/dropbox.ts:
--------------------------------------------------------------------------------
1 | import { subtle } from 'node:crypto'
2 | import { Buffer } from 'node:buffer'
3 | import { $fetch } from '@nuxt/test-utils/e2e'
4 | import { encoder, HMAC_SHA256 } from '../../src/runtime/server/lib/helpers'
5 | import nuxtConfig from '../fixtures/basic/nuxt.config'
6 |
7 | const body = 'testBody'
8 | const appSecret = nuxtConfig.runtimeConfig?.webhook?.dropbox?.appSecret
9 |
10 | export const simulateDropboxEvent = async () => {
11 | const signature = await subtle.importKey('raw', encoder.encode(appSecret), HMAC_SHA256, false, ['sign'])
12 | const hmac = await subtle.sign(HMAC_SHA256.name, signature, encoder.encode(body))
13 | const computedHash = Buffer.from(hmac).toString('hex')
14 | const validSignature = computedHash
15 |
16 | const headers = { 'X-Dropbox-Signature': validSignature }
17 |
18 | return $fetch<{ isValidWebhook: boolean }>('/api/webhooks/dropbox', {
19 | method: 'POST',
20 | headers,
21 | body,
22 | })
23 | }
24 |
--------------------------------------------------------------------------------
/test/simulations/meta.ts:
--------------------------------------------------------------------------------
1 | import { subtle } from 'node:crypto'
2 | import { Buffer } from 'node:buffer'
3 | import { $fetch } from '@nuxt/test-utils/e2e'
4 | import { encoder, HMAC_SHA256 } from '../../src/runtime/server/lib/helpers'
5 | import nuxtConfig from '../fixtures/basic/nuxt.config'
6 |
7 | const body = 'testBody'
8 | const appSecret = nuxtConfig.runtimeConfig?.webhook?.meta?.appSecret
9 |
10 | export const simulateMetaEvent = async () => {
11 | const signature = await subtle.importKey('raw', encoder.encode(appSecret), HMAC_SHA256, false, ['sign'])
12 | const hmac = await subtle.sign(HMAC_SHA256.name, signature, encoder.encode(body))
13 | const computedHash = Buffer.from(hmac).toString('hex')
14 | const validSignature = `sha256=${computedHash}`
15 |
16 | const headers = { 'X-Hub-Signature-256': validSignature }
17 |
18 | return $fetch<{ isValidWebhook: boolean }>('/api/webhooks/meta', {
19 | method: 'POST',
20 | headers,
21 | body,
22 | })
23 | }
24 |
--------------------------------------------------------------------------------
/test/simulations/github.ts:
--------------------------------------------------------------------------------
1 | import { subtle } from 'node:crypto'
2 | import { Buffer } from 'node:buffer'
3 | import { $fetch } from '@nuxt/test-utils/e2e'
4 | import { encoder, HMAC_SHA256 } from '../../src/runtime/server/lib/helpers'
5 | import nuxtConfig from '../fixtures/basic/nuxt.config'
6 |
7 | const body = 'testBody'
8 | const secretKey = nuxtConfig.runtimeConfig?.webhook?.github?.secretKey
9 |
10 | export const simulateGitHubEvent = async () => {
11 | const signature = await subtle.importKey('raw', encoder.encode(secretKey), HMAC_SHA256, false, ['sign'])
12 | const hmac = await subtle.sign(HMAC_SHA256.name, signature, encoder.encode(body))
13 | const computedHash = Buffer.from(hmac).toString('hex')
14 | const validSignature = `sha256=${computedHash}`
15 |
16 | const headers = { 'X-Hub-Signature-256': validSignature }
17 |
18 | return $fetch<{ isValidWebhook: boolean }>('/api/webhooks/github', {
19 | method: 'POST',
20 | headers,
21 | body,
22 | })
23 | }
24 |
--------------------------------------------------------------------------------
/test/simulations/heroku.ts:
--------------------------------------------------------------------------------
1 | import { subtle } from 'node:crypto'
2 | import { Buffer } from 'node:buffer'
3 | import { $fetch } from '@nuxt/test-utils/e2e'
4 | import { encoder, HMAC_SHA256 } from '../../src/runtime/server/lib/helpers'
5 | import nuxtConfig from '../fixtures/basic/nuxt.config'
6 |
7 | const body = 'testBody'
8 | const secretKey = nuxtConfig.runtimeConfig?.webhook?.heroku?.secretKey
9 |
10 | export const simulateHerokuEvent = async () => {
11 | const signature = await subtle.importKey('raw', encoder.encode(secretKey), HMAC_SHA256, false, ['sign'])
12 | const hmac = await subtle.sign(HMAC_SHA256.name, signature, encoder.encode(body))
13 | const computedBase64 = Buffer.from(hmac).toString('base64')
14 | const validSignature = computedBase64
15 |
16 | const headers = { 'Heroku-Webhook-Hmac-SHA256': validSignature }
17 |
18 | return $fetch<{ isValidWebhook: boolean }>('/api/webhooks/heroku', {
19 | method: 'POST',
20 | headers,
21 | body,
22 | })
23 | }
24 |
--------------------------------------------------------------------------------
/test/simulations/fourthwall.ts:
--------------------------------------------------------------------------------
1 | import { subtle } from 'node:crypto'
2 | import { Buffer } from 'node:buffer'
3 | import { $fetch } from '@nuxt/test-utils/e2e'
4 | import { encoder, HMAC_SHA256 } from '../../src/runtime/server/lib/helpers'
5 | import nuxtConfig from '../fixtures/basic/nuxt.config'
6 |
7 | const body = 'testBody'
8 | const secretKey = nuxtConfig.runtimeConfig?.webhook?.fourthwall?.secretKey
9 |
10 | export const simulateFourthwallEvent = async () => {
11 | const signature = await subtle.importKey('raw', encoder.encode(secretKey), HMAC_SHA256, false, ['sign'])
12 | const hmac = await subtle.sign(HMAC_SHA256.name, signature, encoder.encode(body))
13 | const computedHash = Buffer.from(hmac).toString('base64')
14 | const validSignature = computedHash
15 |
16 | const headers = { 'X-Fourthwall-Hmac-SHA256': validSignature }
17 |
18 | return $fetch<{ isValidWebhook: boolean }>('/api/webhooks/fourthwall', {
19 | method: 'POST',
20 | headers,
21 | body,
22 | })
23 | }
24 |
--------------------------------------------------------------------------------
/src/runtime/server/lib/validators/dropbox.ts:
--------------------------------------------------------------------------------
1 | import { type H3Event, getRequestHeaders, readRawBody } from 'h3'
2 | import { computeSignature, HMAC_SHA256, ensureConfiguration } from '../helpers'
3 |
4 | const DROPBOX_SIGNATURE = 'X-Dropbox-Signature'.toLowerCase()
5 |
6 | /**
7 | * Validates Dropbox webhooks on the Edge
8 | * @see {@link https://www.dropbox.com/developers/reference/webhooks}
9 | * @param event H3Event
10 | * @returns {boolean} `true` if the webhook is valid, `false` otherwise
11 | */
12 | export const isValidDropboxWebhook = async (event: H3Event): Promise => {
13 | const config = ensureConfiguration('dropbox', event)
14 |
15 | const headers = getRequestHeaders(event)
16 | const body = await readRawBody(event)
17 |
18 | const webhookSignature = headers[DROPBOX_SIGNATURE]
19 |
20 | if (!body || !webhookSignature) return false
21 |
22 | const computedHash = await computeSignature(config.appSecret, HMAC_SHA256, body)
23 | return computedHash === webhookSignature
24 | }
25 |
--------------------------------------------------------------------------------
/test/simulations/shopify.ts:
--------------------------------------------------------------------------------
1 | import { subtle } from 'node:crypto'
2 | import { Buffer } from 'node:buffer'
3 | import { $fetch } from '@nuxt/test-utils/e2e'
4 | import { encoder, HMAC_SHA256 } from '../../src/runtime/server/lib/helpers'
5 | import nuxtConfig from '../fixtures/basic/nuxt.config'
6 |
7 | const body = 'testBody'
8 | const secretKey = nuxtConfig.runtimeConfig?.webhook?.shopify?.secretKey
9 |
10 | export const simulateShopifyEvent = async () => {
11 | const signature = await subtle.importKey('raw', encoder.encode(secretKey), HMAC_SHA256, false, ['sign'])
12 | const hmac = await subtle.sign(HMAC_SHA256.name, signature, encoder.encode(body))
13 | const computedHashBase64 = Buffer.from(hmac).toString('base64')
14 | const validSignature = computedHashBase64
15 |
16 | const headers = {
17 | 'X-Shopify-Hmac-Sha256': validSignature,
18 | }
19 |
20 | return $fetch<{ isValidWebhook: boolean }>('/api/webhooks/shopify', {
21 | method: 'POST',
22 | headers,
23 | body,
24 | })
25 | }
26 |
--------------------------------------------------------------------------------
/src/runtime/server/lib/validators/shopify.ts:
--------------------------------------------------------------------------------
1 | import { type H3Event, getRequestHeaders, readRawBody } from 'h3'
2 | import { computeSignature, HMAC_SHA256, ensureConfiguration } from '../helpers'
3 |
4 | const SHOPIFY_SIGNATURE = 'X-Shopify-Hmac-Sha256'.toLowerCase()
5 |
6 | /**
7 | * Validates Shopify webhooks on the Edge
8 | * @see {@link https://shopify.dev/docs/apps/build/webhooks}
9 | * @param event H3Event
10 | * @returns {boolean} `true` if the webhook is valid, `false` otherwise
11 | */
12 | export const isValidShopifyWebhook = async (event: H3Event): Promise => {
13 | const config = ensureConfiguration('shopify', event)
14 |
15 | const headers = getRequestHeaders(event)
16 | const body = await readRawBody(event)
17 |
18 | const webhookSignature = headers[SHOPIFY_SIGNATURE]
19 |
20 | if (!body || !webhookSignature) return false
21 |
22 | const computedBase64 = await computeSignature(config.secretKey, HMAC_SHA256, body, { encoding: 'base64' })
23 | return computedBase64 === webhookSignature
24 | }
25 |
--------------------------------------------------------------------------------
/src/runtime/server/lib/validators/fourthwall.ts:
--------------------------------------------------------------------------------
1 | import { type H3Event, getRequestHeaders, readRawBody } from 'h3'
2 | import { computeSignature, HMAC_SHA256, ensureConfiguration } from '../helpers'
3 |
4 | const FOURTHWALL_SIGNATURE = 'X-Fourthwall-Hmac-SHA256'.toLowerCase()
5 |
6 | /**
7 | * Validates Fourthwall webhooks on the Edge
8 | * @see {@link https://docs.fourthwall.com/webhooks/signature-verification/}
9 | * @param event H3Event
10 | * @returns {boolean} `true` if the webhook is valid, `false` otherwise
11 | */
12 | export const isValidFourthwallWebhook = async (event: H3Event): Promise => {
13 | const config = ensureConfiguration('fourthwall', event)
14 |
15 | const headers = getRequestHeaders(event)
16 | const body = await readRawBody(event)
17 |
18 | const webhookSignature = headers[FOURTHWALL_SIGNATURE]
19 |
20 | if (!body || !webhookSignature) return false
21 |
22 | const computedHash = await computeSignature(config.secretKey, HMAC_SHA256, body, { encoding: 'base64' })
23 | return computedHash === webhookSignature
24 | }
25 |
--------------------------------------------------------------------------------
/src/runtime/server/lib/validators/heroku.ts:
--------------------------------------------------------------------------------
1 | import { type H3Event, getRequestHeaders, readRawBody } from 'h3'
2 | import { computeSignature, HMAC_SHA256, ensureConfiguration } from '../helpers'
3 |
4 | const HEROKU_HMAC = 'Heroku-Webhook-Hmac-SHA256'.toLowerCase()
5 |
6 | /**
7 | * Validates Heroku webhooks on the Edge
8 | * @see {@link https://devcenter.heroku.com/articles/app-webhooks#securing-webhook-requests}
9 | * @param event H3Event
10 | * @returns {boolean} `true` if the webhook is valid, `false` otherwise
11 | */
12 | export const isValidHerokuWebhook = async (event: H3Event): Promise => {
13 | const config = ensureConfiguration('heroku', event)
14 |
15 | const headers = getRequestHeaders(event)
16 | const body = await readRawBody(event)
17 |
18 | const header = headers[HEROKU_HMAC]
19 |
20 | if (!body || !header) return false
21 |
22 | const webhookSignature = header
23 |
24 | const computedHash = await computeSignature(config.secretKey, HMAC_SHA256, body, { encoding: 'base64' })
25 | return computedHash === webhookSignature
26 | }
27 |
--------------------------------------------------------------------------------
/test/simulations/paddle.ts:
--------------------------------------------------------------------------------
1 | import { subtle } from 'node:crypto'
2 | import { Buffer } from 'node:buffer'
3 | import { $fetch } from '@nuxt/test-utils/e2e'
4 | import { encoder, HMAC_SHA256 } from '../../src/runtime/server/lib/helpers'
5 | import nuxtConfig from '../fixtures/basic/nuxt.config'
6 |
7 | const body = 'testBody'
8 | const webhookId = nuxtConfig.runtimeConfig?.webhook?.paddle?.webhookId
9 |
10 | export const simulatePaddleEvent = async () => {
11 | const timestamp = Math.floor(Date.now() / 1000)
12 | const signature = await subtle.importKey('raw', encoder.encode(webhookId), HMAC_SHA256, false, ['sign'])
13 | const hmac = await subtle.sign(HMAC_SHA256.name, signature, encoder.encode(`${timestamp}:${body}`))
14 | const computedHash = Buffer.from(hmac).toString('hex')
15 |
16 | const validSignature = `h1=${computedHash};ts=${timestamp}`
17 |
18 | const headers = { 'paddle-signature': validSignature }
19 |
20 | return $fetch<{ isValidWebhook: boolean }>('/api/webhooks/paddle', {
21 | method: 'POST',
22 | headers,
23 | body,
24 | })
25 | }
26 |
--------------------------------------------------------------------------------
/test/simulations/stripe.ts:
--------------------------------------------------------------------------------
1 | import { subtle } from 'node:crypto'
2 | import { Buffer } from 'node:buffer'
3 | import { $fetch } from '@nuxt/test-utils/e2e'
4 | import { encoder, HMAC_SHA256 } from '../../src/runtime/server/lib/helpers'
5 | import nuxtConfig from '../fixtures/basic/nuxt.config'
6 |
7 | const body = 'testBody'
8 | const secretKey = nuxtConfig.runtimeConfig?.webhook?.stripe?.secretKey
9 |
10 | export const simulateStripeEvent = async () => {
11 | const timestamp = Math.floor(Date.now() / 1000)
12 | const signature = await subtle.importKey('raw', encoder.encode(secretKey), HMAC_SHA256, false, ['sign'])
13 | const hmac = await subtle.sign(HMAC_SHA256.name, signature, encoder.encode(`${timestamp}.${body}`))
14 | const computedHash = Buffer.from(hmac).toString('hex')
15 |
16 | const validSignature = `t=${timestamp},v1=${computedHash}`
17 |
18 | const headers = { 'stripe-signature': validSignature }
19 |
20 | return $fetch<{ isValidWebhook: boolean }>('/api/webhooks/stripe', {
21 | method: 'POST',
22 | headers,
23 | body,
24 | })
25 | }
26 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Yizack Rangel
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/src/runtime/server/lib/validators/meta.ts:
--------------------------------------------------------------------------------
1 | import { type H3Event, getRequestHeaders, readRawBody } from 'h3'
2 | import { computeSignature, HMAC_SHA256, ensureConfiguration } from '../helpers'
3 |
4 | const META_SIGNATURE = 'X-Hub-Signature-256'.toLowerCase()
5 |
6 | /**
7 | * Validates Meta webhooks on the Edge
8 | * @see {@link https://developers.facebook.com/docs/messenger-platform/webhooks#validate-payloads}
9 | * @param event H3Event
10 | * @returns {boolean} `true` if the webhook is valid, `false` otherwise
11 | */
12 | export const isValidMetaWebhook = async (event: H3Event): Promise => {
13 | const config = ensureConfiguration('meta', event)
14 |
15 | const headers = getRequestHeaders(event)
16 | const body = await readRawBody(event)
17 |
18 | const signatureHeader = headers[META_SIGNATURE]
19 |
20 | if (!signatureHeader) return false
21 | const [prefix, webhookSignature] = signatureHeader.split('=')
22 |
23 | if (!body || prefix !== 'sha256' || !webhookSignature) return false
24 |
25 | const computedHash = await computeSignature(config.appSecret, HMAC_SHA256, body)
26 | return computedHash === webhookSignature
27 | }
28 |
--------------------------------------------------------------------------------
/src/runtime/server/lib/validators/nuxthub.ts:
--------------------------------------------------------------------------------
1 | import { type H3Event, getRequestHeaders, readRawBody } from 'h3'
2 | import { ensureConfiguration, sha256 } from '../helpers'
3 |
4 | const NUXTHUB_SIGNATURE = 'x-nuxthub-signature'
5 |
6 | /**
7 | * Validates NuxtHub webhooks on the Edge
8 | * @see {@link https://hub.nuxt.com/changelog/team-webhooks-env}
9 | * @param event H3Event
10 | * @returns {boolean} `true` if the webhook is valid, `false` otherwise
11 | */
12 | export const isValidNuxtHubWebhook = async (event: H3Event): Promise => {
13 | const config = ensureConfiguration('nuxthub', event)
14 |
15 | const headers = getRequestHeaders(event)
16 | const body = await readRawBody(event)
17 |
18 | const webhookSignature = headers[NUXTHUB_SIGNATURE]
19 |
20 | if (!body || !webhookSignature) return false
21 |
22 | const payload = body + config.secretKey
23 | const signature = await sha256(payload)
24 |
25 | return signature === webhookSignature
26 | }
27 |
28 | /**
29 | * Alias for backwards compatibility
30 | * @deprecated Use `isValidNuxtHubWebhook` instead
31 | */
32 | export const isValidNuxthubWebhook = isValidNuxtHubWebhook
33 |
--------------------------------------------------------------------------------
/test/simulations/discord.ts:
--------------------------------------------------------------------------------
1 | import { subtle } from 'node:crypto'
2 | import { Buffer } from 'node:buffer'
3 | import { $fetch } from '@nuxt/test-utils/e2e'
4 | import { encoder, ED25519 } from '../../src/runtime/server/lib/helpers'
5 |
6 | const body = { type: 0, data: 'testBody' }
7 | const privateKeyJwk: JsonWebKey = { key_ops: ['sign'], ext: true, crv: ED25519.name, d: 'YwlnnFf8D1xYAywudxu0velBWEmRSMdo6KJ-5b9sQbU', x: '_PRZT_VaWJin585UG5PcjOYYx6T6lqt-_RrCiQVxNFw', kty: 'OKP' }
8 |
9 | export const simulateDiscordEvent = async () => {
10 | const privateKey = await subtle.importKey('jwk', privateKeyJwk, ED25519, true, ['sign'])
11 |
12 | const timestamp = Math.floor(Date.now() / 1000).toString()
13 | const payloadWithTime = timestamp + JSON.stringify(body)
14 | const signatureBuffer = await subtle.sign(ED25519.name, privateKey, encoder.encode(payloadWithTime))
15 | const signature = Buffer.from(signatureBuffer).toString('hex')
16 |
17 | const headers = {
18 | 'x-signature-ed25519': signature,
19 | 'x-signature-timestamp': timestamp,
20 | }
21 |
22 | return $fetch<{ isValidWebhook: boolean }>('/api/webhooks/discord', {
23 | method: 'POST',
24 | headers,
25 | body,
26 | })
27 | }
28 |
--------------------------------------------------------------------------------
/test/simulations/polar.ts:
--------------------------------------------------------------------------------
1 | import { subtle } from 'node:crypto'
2 | import { Buffer } from 'node:buffer'
3 | import { $fetch } from '@nuxt/test-utils/e2e'
4 | import { encoder, HMAC_SHA256 } from '../../src/runtime/server/lib/helpers'
5 | import nuxtConfig from '../fixtures/basic/nuxt.config'
6 |
7 | const body = 'testBody'
8 | const webhookId = 'test-id'
9 | const secretKey = nuxtConfig.runtimeConfig?.webhook?.polar?.secretKey
10 |
11 | export const simulatePolarEvent = async () => {
12 | const timestamp = Math.floor(Date.now() / 1000).toString()
13 | const payload = `${webhookId}.${timestamp}.${body}`
14 | const signature = await subtle.importKey('raw', encoder.encode(secretKey), HMAC_SHA256, false, ['sign'])
15 | const hmac = await subtle.sign(HMAC_SHA256.name, signature, encoder.encode(payload))
16 | const computedHashBase64 = Buffer.from(hmac).toString('base64')
17 | const validSignature = `v1,${computedHashBase64}`
18 |
19 | const headers = {
20 | 'webhook-id': webhookId,
21 | 'webhook-timestamp': timestamp,
22 | 'webhook-signature': validSignature,
23 | }
24 |
25 | return $fetch<{ isValidWebhook: boolean }>('/api/webhooks/polar', {
26 | method: 'POST',
27 | headers,
28 | body,
29 | })
30 | }
31 |
--------------------------------------------------------------------------------
/test/simulations/slack.ts:
--------------------------------------------------------------------------------
1 | import { subtle } from 'node:crypto'
2 | import { Buffer } from 'node:buffer'
3 | import { $fetch } from '@nuxt/test-utils/e2e'
4 | import { encoder, HMAC_SHA256 } from '../../src/runtime/server/lib/helpers'
5 | import nuxtConfig from '../fixtures/basic/nuxt.config'
6 |
7 | const body = { data: 'testBody' }
8 | const secretKey = nuxtConfig.runtimeConfig?.webhook?.slack?.secretKey
9 | const signatureVersion = 'v0'
10 |
11 | export const simulateSlackEvent = async () => {
12 | const timestamp = Math.floor(Date.now() / 1000)
13 | const signingPayload = `${signatureVersion}:${timestamp}:${JSON.stringify(body)}`
14 | const signature = await subtle.importKey('raw', encoder.encode(secretKey), HMAC_SHA256, false, ['sign'])
15 | const hmac = await subtle.sign(HMAC_SHA256.name, signature, encoder.encode(signingPayload))
16 | const computedHash = Buffer.from(hmac).toString('hex')
17 | const validSignature = `${signatureVersion}=${computedHash}`
18 |
19 | const headers = {
20 | 'X-Slack-Signature': validSignature,
21 | 'X-Slack-Request-Timestamp': timestamp.toString(),
22 | }
23 |
24 | return $fetch<{ isValidWebhook: boolean }>('/api/webhooks/slack', {
25 | method: 'POST',
26 | headers,
27 | body,
28 | })
29 | }
30 |
--------------------------------------------------------------------------------
/test/simulations/hygraph.ts:
--------------------------------------------------------------------------------
1 | import { subtle } from 'node:crypto'
2 | import { Buffer } from 'node:buffer'
3 | import { $fetch } from '@nuxt/test-utils/e2e'
4 | import { encoder, HMAC_SHA256 } from '../../src/runtime/server/lib/helpers'
5 | import nuxtConfig from '../fixtures/basic/nuxt.config'
6 |
7 | const body = 'testBody'
8 | const secretKey = nuxtConfig.runtimeConfig?.webhook?.hygraph?.secretKey
9 |
10 | export const simulateHygraphEvent = async () => {
11 | const environmentName = 'master'
12 | const timestamp = Date.now()
13 |
14 | const payload = JSON.stringify({
15 | Body: body,
16 | EnvironmentName: environmentName,
17 | TimeStamp: timestamp,
18 | })
19 |
20 | const signature = await subtle.importKey('raw', encoder.encode(secretKey), HMAC_SHA256, false, ['sign'])
21 | const hmac = await subtle.sign(HMAC_SHA256.name, signature, encoder.encode(payload))
22 | const computedHashBase64 = Buffer.from(hmac).toString('base64')
23 | const validSignature = `sign=${computedHashBase64}, env=${environmentName}, t=${timestamp}`
24 |
25 | const headers = { 'gcms-signature': validSignature }
26 |
27 | return $fetch<{ isValidWebhook: boolean }>('/api/webhooks/hygraph', {
28 | method: 'POST',
29 | headers,
30 | body,
31 | })
32 | }
33 |
--------------------------------------------------------------------------------
/src/runtime/server/lib/validators/discord.ts:
--------------------------------------------------------------------------------
1 | import { type H3Event, getRequestHeaders, readRawBody } from 'h3'
2 | import { verifyPublicSignature, ED25519, ensureConfiguration } from '../helpers'
3 |
4 | const DISCORD_SIGNATURE = 'x-signature-ed25519'
5 | const DISCORD_SIGNATURE_TIMESTAMP = 'x-signature-timestamp'
6 |
7 | /**
8 | * Validates Discord webhooks on the Edge
9 | * @see {@link https://discord.com/developers/docs/interactions/overview#setting-up-an-endpoint-validating-security-request-headers}
10 | * @param event H3Event
11 | * @returns {boolean} `true` if the webhook is valid, `false` otherwise
12 | */
13 | export const isValidDiscordWebhook = async (event: H3Event): Promise => {
14 | const config = ensureConfiguration('discord', event)
15 |
16 | const headers = getRequestHeaders(event)
17 | const body = await readRawBody(event)
18 |
19 | const webhookSignature = headers[DISCORD_SIGNATURE]
20 | const webhookTimestamp = headers[DISCORD_SIGNATURE_TIMESTAMP]
21 |
22 | if (!body || !webhookSignature || !webhookTimestamp) return false
23 |
24 | const payloadWithTime = webhookTimestamp + body
25 |
26 | const isValid = await verifyPublicSignature(config.publicKey, ED25519, payloadWithTime, webhookSignature)
27 | return isValid
28 | }
29 |
--------------------------------------------------------------------------------
/src/runtime/server/lib/validators/github.ts:
--------------------------------------------------------------------------------
1 | import { type H3Event, getRequestHeaders, readRawBody } from 'h3'
2 | import { computeSignature, HMAC_SHA256, ensureConfiguration } from '../helpers'
3 |
4 | const GITHUB_SIGNATURE = 'X-Hub-Signature-256'.toLowerCase()
5 |
6 | /**
7 | * Validates GitHub webhooks on the Edge
8 | * @see {@link https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries#javascript-example}
9 | * @param event H3Event
10 | * @returns {boolean} `true` if the webhook is valid, `false` otherwise
11 | */
12 | export const isValidGitHubWebhook = async (event: H3Event): Promise => {
13 | const config = ensureConfiguration('github', event)
14 |
15 | const headers = getRequestHeaders(event)
16 | const body = await readRawBody(event)
17 |
18 | const header = headers[GITHUB_SIGNATURE]
19 |
20 | if (!body || !header) return false
21 |
22 | const parts = header.split('=')
23 | const webhookSignature = parts[1]
24 |
25 | const computedHash = await computeSignature(config.secretKey, HMAC_SHA256, body)
26 | return computedHash === webhookSignature
27 | }
28 |
29 | /**
30 | * Alias for backwards compatibility
31 | * @deprecated Use `isValidGitHubWebhook` instead
32 | */
33 | export const isValidGithubWebhook = isValidGitHubWebhook
34 |
--------------------------------------------------------------------------------
/test/simulations/twitch.ts:
--------------------------------------------------------------------------------
1 | import { subtle } from 'node:crypto'
2 | import { Buffer } from 'node:buffer'
3 | import { $fetch } from '@nuxt/test-utils/e2e'
4 | import { encoder, HMAC_SHA256 } from '../../src/runtime/server/lib/helpers'
5 | import nuxtConfig from '../fixtures/basic/nuxt.config'
6 |
7 | const body = { data: 'testBody' }
8 | const messageId = 'testMessageId'
9 | const secretKey = nuxtConfig.runtimeConfig?.webhook?.twitch?.secretKey
10 |
11 | export const simulateTwitchEvent = async () => {
12 | const timestamp = Math.floor(Date.now() / 1000)
13 | const message = messageId + timestamp + JSON.stringify(body)
14 | const signature = await subtle.importKey('raw', encoder.encode(secretKey), HMAC_SHA256, false, ['sign'])
15 | const hmac = await subtle.sign(HMAC_SHA256.name, signature, encoder.encode(message))
16 | const computedHash = Buffer.from(hmac).toString('hex')
17 | const validSignature = `sha256=${computedHash}`
18 |
19 | const headers = {
20 | 'Twitch-Eventsub-Message-Id': messageId,
21 | 'Twitch-Eventsub-Message-Timestamp': timestamp.toString(),
22 | 'Twitch-Eventsub-Message-Signature': validSignature,
23 | }
24 |
25 | return $fetch<{ isValidWebhook: boolean }>('/api/webhooks/twitch', {
26 | method: 'POST',
27 | headers,
28 | body,
29 | })
30 | }
31 |
--------------------------------------------------------------------------------
/test/events.ts:
--------------------------------------------------------------------------------
1 | export { simulateBrevoEvent } from './simulations/brevo'
2 | export { simulateDiscordEvent } from './simulations/discord'
3 | export { simulateDropboxEvent } from './simulations/dropbox'
4 | export { simulateFourthwallEvent } from './simulations/fourthwall'
5 | export { simulateGitHubEvent } from './simulations/github'
6 | export { simulateGitLabEvent } from './simulations/gitlab'
7 | export { simulateHerokuEvent } from './simulations/heroku'
8 | export { simulateKickEvent } from './simulations/kick'
9 | export { simulatePaddleEvent } from './simulations/paddle'
10 | export { simulateTwitchEvent } from './simulations/twitch'
11 | export { simulatePaypalEvent } from './simulations/paypal'
12 | export { simulateResendEvent } from './simulations/resend'
13 | export { simulateShopifyEvent } from './simulations/shopify'
14 | export { simulateSlackEvent } from './simulations/slack'
15 | export { simulateStripeEvent } from './simulations/stripe'
16 | export { simulateSvixEvent } from './simulations/svix'
17 | export { simulateNuxthubEvent } from './simulations/nuxthub'
18 | export { simulateMailChannelsEvent } from './simulations/mailchannels'
19 | export { simulateMetaEvent } from './simulations/meta'
20 | export { simulateHygraphEvent } from './simulations/hygraph'
21 | export { simulatePolarEvent } from './simulations/polar'
22 |
--------------------------------------------------------------------------------
/test/simulations/kick.ts:
--------------------------------------------------------------------------------
1 | import { subtle } from 'node:crypto'
2 | import { Buffer } from 'node:buffer'
3 | import { $fetch } from '@nuxt/test-utils/e2e'
4 | import { RSASSA_PKCS1_v1_5_SHA256, encoder, stripPemHeaders } from '../../src/runtime/server/lib/helpers'
5 | // @ts-expect-error generated on test command
6 | import { rsaKeys } from '../fixtures/basic/test-keys.json'
7 |
8 | const body = 'testBody'
9 | const messageId = 'testMessageId'
10 |
11 | export const simulateKickEvent = async () => {
12 | const privateKeyBuffer = Buffer.from(stripPemHeaders(rsaKeys.privateKey), 'base64')
13 | const privateKey = await subtle.importKey('pkcs8', privateKeyBuffer, RSASSA_PKCS1_v1_5_SHA256, false, ['sign'])
14 |
15 | const timestamp = Math.floor(Date.now() / 1000)
16 | const payload = `${messageId}.${timestamp}.${body}`
17 |
18 | const signatureBuffer = await subtle.sign(RSASSA_PKCS1_v1_5_SHA256.name, privateKey, encoder.encode(payload))
19 | const signature = Buffer.from(signatureBuffer).toString('base64')
20 |
21 | const headers = {
22 | 'Kick-Event-Message-Id': messageId,
23 | 'Kick-Event-Message-Timestamp': timestamp.toString(),
24 | 'Kick-Event-Signature': signature,
25 | }
26 |
27 | return $fetch<{ isValidWebhook: boolean }>('/api/webhooks/kick', {
28 | method: 'POST',
29 | headers,
30 | body,
31 | })
32 | }
33 |
--------------------------------------------------------------------------------
/test/simulations/svix.ts:
--------------------------------------------------------------------------------
1 | import { subtle } from 'node:crypto'
2 | import { Buffer } from 'node:buffer'
3 | import { $fetch } from '@nuxt/test-utils/e2e'
4 | import { encoder, HMAC_SHA256 } from '../../src/runtime/server/lib/helpers'
5 | import nuxtConfig from '../fixtures/basic/nuxt.config'
6 |
7 | const body = 'testBody'
8 | const webhookId = 'testSvixMessageId'
9 | const secretKey = nuxtConfig.runtimeConfig?.webhook?.svix?.secretKey
10 |
11 | export const simulateSvixEvent = async (key: string = secretKey!) => {
12 | const timestamp = Math.floor(Date.now() / 1000).toString()
13 | const payload = `${webhookId}.${timestamp}.${body}`
14 | const secretKeyBase64 = key.split('_')[1]!
15 | const signatureBuffer = Buffer.from(secretKeyBase64, 'base64')
16 | const signature = await subtle.importKey('raw', signatureBuffer, HMAC_SHA256, false, ['sign'])
17 | const hmac = await subtle.sign(HMAC_SHA256.name, signature, encoder.encode(payload))
18 | const computedHash = Buffer.from(hmac).toString('base64')
19 | const validSignature = `v1,${computedHash}`
20 |
21 | const headers = {
22 | 'svix-id': webhookId,
23 | 'svix-signature': validSignature,
24 | 'svix-timestamp': timestamp,
25 | }
26 |
27 | return $fetch<{ isValidWebhook: boolean }>('/api/webhooks/svix', {
28 | method: 'POST',
29 | headers,
30 | body,
31 | })
32 | }
33 |
--------------------------------------------------------------------------------
/src/runtime/server/lib/validators/twitch.ts:
--------------------------------------------------------------------------------
1 | import { type H3Event, getRequestHeaders, readRawBody } from 'h3'
2 | import { computeSignature, HMAC_SHA256, ensureConfiguration } from '../helpers'
3 |
4 | const TWITCH_MESSAGE_ID = 'Twitch-Eventsub-Message-Id'.toLowerCase()
5 | const TWITCH_MESSAGE_TIMESTAMP = 'Twitch-Eventsub-Message-Timestamp'.toLowerCase()
6 | const TWITCH_MESSAGE_SIGNATURE = 'Twitch-Eventsub-Message-Signature'.toLowerCase()
7 | const HMAC_PREFIX = 'sha256='
8 |
9 | /**
10 | * Validates Twitch webhooks on the Edge
11 | * @see {@link https://dev.twitch.tv/docs/eventsub/handling-webhook-events/#verifying-the-event-message}
12 | * @param event H3Event
13 | * @returns {boolean} `true` if the webhook is valid, `false` otherwise
14 | */
15 | export const isValidTwitchWebhook = async (event: H3Event): Promise => {
16 | const config = ensureConfiguration('twitch', event)
17 |
18 | const headers = getRequestHeaders(event)
19 | const body = await readRawBody(event)
20 |
21 | const message_id = headers[TWITCH_MESSAGE_ID]
22 | const message_timestamp = headers[TWITCH_MESSAGE_TIMESTAMP]
23 | const message_signature = headers[TWITCH_MESSAGE_SIGNATURE]
24 |
25 | if (!message_id || !message_timestamp || !message_signature) return false
26 |
27 | const message = message_id + message_timestamp + body
28 |
29 | const computedHash = await computeSignature(config.secretKey, HMAC_SHA256, message)
30 | return HMAC_PREFIX + computedHash === message_signature
31 | }
32 |
--------------------------------------------------------------------------------
/src/runtime/server/lib/validators/slack.ts:
--------------------------------------------------------------------------------
1 | import { type H3Event, getRequestHeaders, readRawBody } from 'h3'
2 | import { computeSignature, HMAC_SHA256, ensureConfiguration } from '../helpers'
3 |
4 | const SLACK_SIGNATURE = 'X-Slack-Signature'.toLowerCase()
5 | const SLACK_TIMESTAMP = 'X-Slack-Request-Timestamp'.toLowerCase()
6 | const DEFAULT_TOLERANCE = 300 // 5 minutes
7 |
8 | /**
9 | * Validates Slack webhooks on the Edge
10 | * @see {@link https://docs.slack.dev/authentication/verifying-requests-from-slack/}
11 | * @param event H3Event
12 | * @returns {boolean} `true` if the webhook is valid, `false` otherwise
13 | */
14 | export const isValidSlackWebhook = async (event: H3Event): Promise => {
15 | const config = ensureConfiguration('slack', event)
16 |
17 | const headers = getRequestHeaders(event)
18 | const body = await readRawBody(event)
19 |
20 | const fullSignature = headers[SLACK_SIGNATURE]
21 | const timestamp = headers[SLACK_TIMESTAMP]
22 |
23 | if (!body || !fullSignature || !timestamp) return false
24 |
25 | // Validate the timestamp to avoid replay attacks
26 | const now = Math.floor(Date.now() / 1000)
27 | if (now - Number.parseInt(timestamp) > DEFAULT_TOLERANCE) return false
28 |
29 | const [signatureVersion, webhookSignature] = fullSignature.split('=')
30 | const payload = `${signatureVersion}:${timestamp}:${body}`
31 |
32 | const computedHash = await computeSignature(config.secretKey, HMAC_SHA256, payload)
33 | return computedHash === webhookSignature
34 | }
35 |
--------------------------------------------------------------------------------
/playground/.env.example:
--------------------------------------------------------------------------------
1 | # Brevo Validator
2 | NUXT_WEBHOOK_BREVO_TOKEN=
3 |
4 | # Discord Validator
5 | NUXT_WEBHOOK_DISCORD_PUBLIC_KEY=
6 |
7 | # Dropbox Validator
8 | NUXT_WEBHOOK_DROPBOX_APP_SECRET=
9 |
10 | # Fourthwall Validator
11 | NUXT_WEBHOOK_FOURTHWALL_SECRET_KEY=
12 |
13 | # GitHub Validator
14 | NUXT_WEBHOOK_GITHUB_SECRET_KEY=
15 |
16 | # GitLab Validator
17 | NUXT_WEBHOOK_GITLAB_SECRET_TOKEN=
18 |
19 | # Heroku Validator
20 | NUXT_WEBHOOK_HEROKU_SECRET_KEY=
21 |
22 | # Kick Validator (Set in case Kick changes their public key)
23 | NUXT_WEBHOOK_KICK_PUBLIC_KEY=
24 |
25 | # MailChannels Validator (Optional unless a specific case)
26 | NUXT_WEBHOOK_MAILCHANNELS_PUBLIC_KEY=
27 |
28 | # Meta Validator
29 | NUXT_WEBHOOK_META_APP_SECRET=
30 |
31 | # NuxtHub Validator
32 | NUXT_WEBHOOK_NUXTHUB_SECRET_KEY=
33 |
34 | # Paddle Validator
35 | NUXT_WEBHOOK_PADDLE_WEBHOOK_ID=
36 |
37 | # PayPal Validator
38 | NUXT_WEBHOOK_PAYPAL_CLIENT_ID=
39 | NUXT_WEBHOOK_PAYPAL_SECRET_KEY=
40 | NUXT_WEBHOOK_PAYPAL_WEBHOOK_ID=
41 |
42 | # Resend Validator
43 | NUXT_WEBHOOK_RESEND_SECRET_KEY=
44 |
45 | # Shopify Validator
46 | NUXT_WEBHOOK_SHOPIFY_SECRET_KEY=
47 |
48 | # Slack Validator
49 | NUXT_WEBHOOK_SLACK_SECRET_KEY=
50 |
51 | # Stripe Validator
52 | NUXT_WEBHOOK_STRIPE_SECRET_KEY=
53 |
54 | # Svix Validator
55 | NUXT_WEBHOOK_SVIX_SECRET_KEY=
56 |
57 | # Twitch Validator
58 | NUXT_WEBHOOK_TWITCH_SECRET_KEY=
59 |
60 | # Hygraph Validator
61 | NUXT_WEBHOOK_HYGRAPH_SECRET_KEY=
62 |
63 | # Polar Validator
64 | NUXT_WEBHOOK_POLAR_SECRET_KEY=
65 |
--------------------------------------------------------------------------------
/src/runtime/server/lib/validators/brevo.ts:
--------------------------------------------------------------------------------
1 | import { type H3Event, getRequestHeader, getRequestIP } from 'h3'
2 | import { useRuntimeConfig } from '#imports'
3 |
4 | const ALLOWED_IP_RANGES = ['1.179.112.0/20', '172.246.240.0/20']
5 |
6 | const ipToInt = (ip: string) => {
7 | return ip.split('.').reduce((acc, octet) => (acc << 8) + Number(octet), 0)
8 | }
9 |
10 | const isIpInRange = (ip: string, cidr: string) => {
11 | const [range, bits] = cidr.split('/')
12 | if (!range || !bits) return false
13 |
14 | const mask = ~(2 ** (32 - Number(bits)) - 1)
15 | return (ipToInt(ip) & mask) === (ipToInt(range) & mask)
16 | }
17 |
18 | /**
19 | * Validates Brevo webhooks on the Edge
20 | * @see {@link https://help.brevo.com/hc/en-us/articles/27824932835474}
21 | * @param event H3Event
22 | * @returns {boolean} `true` if the webhook is valid, `false` otherwise
23 | */
24 | export const isValidBrevoWebhook = async (event: H3Event): Promise => {
25 | const ip = getRequestIP(event, { xForwardedFor: true })
26 |
27 | if (!ip || !ALLOWED_IP_RANGES.some(cidr => isIpInRange(ip, cidr))) return false
28 |
29 | // const config = ensureConfiguration('brevo', event) // No need to ensure brevo configuration
30 | const config = useRuntimeConfig(event).webhook.brevo
31 |
32 | if (config.token) {
33 | const authorization = getRequestHeader(event, 'authorization') || ''
34 | if (!authorization.toLowerCase().startsWith('bearer ')) return false
35 | const token = authorization.slice(7)
36 | if (token !== config.token) return false
37 | }
38 |
39 | return true
40 | }
41 |
--------------------------------------------------------------------------------
/src/runtime/server/lib/validators/polar.ts:
--------------------------------------------------------------------------------
1 | import { type H3Event, getRequestHeaders, readRawBody } from 'h3'
2 | import { computeSignature, HMAC_SHA256, ensureConfiguration } from '../helpers'
3 |
4 | const POLAR_SIGNATURE_ID = 'webhook-id'
5 | const POLAR_SIGNATURE = 'webhook-signature'
6 | const POLAR_SIGNATURE_TIMESTAMP = 'webhook-timestamp'
7 | const DEFAULT_TOLERANCE = 300 // 5 minutes
8 |
9 | /**
10 | * Validates Polar.sh webhooks on the Edge
11 | * @see {@link https://docs.polar.sh/api/webhooks#verify-signature}
12 | * @param event H3Event
13 | * @returns {boolean} `true` if the webhook is valid, `false` otherwise
14 | */
15 | export const isValidPolarWebhook = async (event: H3Event): Promise => {
16 | const config = ensureConfiguration('polar', event)
17 |
18 | const headers = getRequestHeaders(event)
19 | const body = await readRawBody(event)
20 |
21 | const webhookId = headers[POLAR_SIGNATURE_ID]
22 | const webhookSignature = headers[POLAR_SIGNATURE]
23 | const webhookTimestamp = headers[POLAR_SIGNATURE_TIMESTAMP]
24 |
25 | if (!body || !webhookId || !webhookSignature || !webhookTimestamp) return false
26 |
27 | // Validate the timestamp to ensure the request isn't too old
28 | const now = Math.floor(Date.now() / 1000)
29 | if (now - Number(webhookTimestamp) > DEFAULT_TOLERANCE) return false
30 |
31 | const payload = `${webhookId}.${webhookTimestamp}.${body}`
32 |
33 | const computedSignature = await computeSignature(config.secretKey, HMAC_SHA256, payload, { encoding: 'base64' })
34 | return computedSignature === webhookSignature.split(',')[1]
35 | }
36 |
--------------------------------------------------------------------------------
/test/simulations/mailchannels.ts:
--------------------------------------------------------------------------------
1 | import { subtle } from 'node:crypto'
2 | import { Buffer } from 'node:buffer'
3 | import { $fetch } from '@nuxt/test-utils/e2e'
4 | import { ED25519, encoder, stripPemHeaders, sha256 } from '../../src/runtime/server/lib/helpers'
5 | // @ts-expect-error generated on test command
6 | import { ed25519Keys } from '../fixtures/basic/test-keys.json'
7 |
8 | const body = [{ test: 'testBody' }]
9 |
10 | export const simulateMailChannelsEvent = async () => {
11 | const privateKeyBuffer = Buffer.from(stripPemHeaders(ed25519Keys.privateKey), 'base64')
12 | const privateKey = await subtle.importKey('pkcs8', privateKeyBuffer, ED25519, false, ['sign'])
13 |
14 | const timestamp = Math.floor(Date.now() / 1000)
15 | const bodyHash = await sha256(body, 'base64')
16 | const contentDigest = `sha-256=:${bodyHash}:`
17 |
18 | const signatureInputValues = `("content-digest");created=${timestamp};alg="ed25519";keyid="mckey"`
19 | const signatureInput = `sig_123456=${signatureInputValues}`
20 | const signingString = `"content-digest": ${contentDigest}
21 | "@signature-params": ${signatureInputValues}`
22 |
23 | const signatureBuffer = await subtle.sign(ED25519.name, privateKey, encoder.encode(signingString))
24 | const signature = `sig_123456=:${Buffer.from(signatureBuffer).toString('base64')}:`
25 |
26 | const headers = {
27 | 'content-digest': contentDigest,
28 | 'signature': signature,
29 | 'signature-input': signatureInput,
30 | }
31 |
32 | return $fetch<{ isValidWebhook: boolean }>('/api/webhooks/mailchannels', {
33 | method: 'POST',
34 | headers,
35 | body,
36 | })
37 | }
38 |
--------------------------------------------------------------------------------
/src/runtime/server/lib/validators/svix.ts:
--------------------------------------------------------------------------------
1 | import { type H3Event, getRequestHeaders, readRawBody } from 'h3'
2 | import { computeSignature, HMAC_SHA256, ensureConfiguration } from '../helpers'
3 |
4 | const SVIX_SIGNATURE_ID = 'svix-id'
5 | const SVIX_SIGNATURE = 'svix-signature'
6 | const SVIX_SIGNATURE_TIMESTAMP = 'svix-timestamp'
7 |
8 | /**
9 | * Validates Svix webhooks on the Edge
10 | * @see {@link https://docs.svix.com/receiving/verifying-payloads/how-manual}
11 | * @param event H3Event
12 | * @returns {boolean} `true` if the webhook is valid, `false` otherwise
13 | */
14 | export const isValidSvixWebhook = async (event: H3Event): Promise => {
15 | const config = ensureConfiguration('svix', event)
16 |
17 | const headers = getRequestHeaders(event)
18 | const body = await readRawBody(event)
19 |
20 | const webhookId = headers[SVIX_SIGNATURE_ID]
21 | const webhookSignature = headers[SVIX_SIGNATURE]
22 | const webhookTimestamp = headers[SVIX_SIGNATURE_TIMESTAMP]
23 |
24 | if (!body || !webhookId || !webhookSignature || !webhookTimestamp) return false
25 |
26 | const payload = `${webhookId}.${webhookTimestamp}.${body}`
27 | const secretKey = config.secretKey.split('_')[1]
28 |
29 | if (!secretKey) return false
30 | const signatureEntries = webhookSignature.split(' ')
31 |
32 | for (const signatureEntry of signatureEntries) {
33 | const signature = signatureEntry.split(',')[1]
34 | const computedBase64 = await computeSignature(secretKey, HMAC_SHA256, payload, { encoding: 'base64', decodeKey: true })
35 | if (computedBase64 === signature) return true
36 | }
37 |
38 | return false
39 | }
40 |
--------------------------------------------------------------------------------
/src/runtime/server/lib/validators/paypal.ts:
--------------------------------------------------------------------------------
1 | import { type H3Event, getRequestHeaders, readBody } from 'h3'
2 | import { ensureConfiguration } from '../helpers'
3 |
4 | const baseAPI = import.meta.dev ? 'https://api-m.sandbox.paypal.com/v1' : 'https://api-m.paypal.com/v1'
5 |
6 | /**
7 | * Validates PayPal webhooks on the Edge
8 | * @see {@link https://developer.paypal.com/docs/api/webhooks/v1/#verify-webhook-signature_post}
9 | * @param event H3Event
10 | * @returns {boolean} `true` if the webhook is valid, `false` otherwise
11 | */
12 | export const isValidPaypalWebhook = async (event: H3Event): Promise => {
13 | const config = ensureConfiguration('paypal', event)
14 |
15 | const headers = getRequestHeaders(event)
16 | const body = await readBody(event)
17 |
18 | if (!body || !headers) return false
19 |
20 | const basicAuth = btoa(`${config.clientId}:${config.secretKey}`)
21 | const endpoint = `${baseAPI}/notifications/verify-webhook-signature`
22 |
23 | const response = await $fetch<{ verification_status: string }>(endpoint, {
24 | method: 'POST',
25 | headers: {
26 | Authorization: `Basic ${basicAuth}`,
27 | },
28 | body: {
29 | auth_algo: headers['paypal-auth-algo'],
30 | cert_url: headers['paypal-cert-url'],
31 | transmission_id: headers['paypal-transmission-id'],
32 | transmission_sig: headers['paypal-transmission-sig'],
33 | transmission_time: headers['paypal-transmission-time'],
34 | webhook_id: config.webhookId,
35 | webhook_event: body,
36 | },
37 | }).catch(() => null)
38 |
39 | if (!response) return false
40 |
41 | return response.verification_status === 'SUCCESS'
42 | }
43 |
--------------------------------------------------------------------------------
/playground/nuxt.config.ts:
--------------------------------------------------------------------------------
1 | export default defineNuxtConfig({
2 | modules: [
3 | '../src/module',
4 | ],
5 | imports: {
6 | autoImport: true,
7 | },
8 | devtools: { enabled: true },
9 | runtimeConfig: {
10 | webhook: {
11 | brevo: {
12 | token: '',
13 | },
14 | discord: {
15 | publicKey: '',
16 | },
17 | dropbox: {
18 | appSecret: '',
19 | },
20 | fourthwall: {
21 | secretKey: '',
22 | },
23 | github: {
24 | secretKey: '',
25 | },
26 | gitlab: {
27 | secretToken: '',
28 | },
29 | heroku: {
30 | secretKey: '',
31 | },
32 | kick: {
33 | // (Set in case Kick changes their public key)
34 | publicKey: '',
35 | },
36 | mailchannels: {
37 | // (Optional unless a specific case)
38 | publicKey: '',
39 | },
40 | meta: {
41 | appSecret: '',
42 | },
43 | nuxthub: {
44 | secretKey: '',
45 | },
46 | paddle: {
47 | webhookId: '',
48 | },
49 | paypal: {
50 | clientId: '',
51 | secretKey: '',
52 | webhookId: '',
53 | },
54 | resend: {
55 | secretKey: '',
56 | },
57 | shopify: {
58 | secretKey: '',
59 | },
60 | slack: {
61 | secretKey: '',
62 | },
63 | stripe: {
64 | secretKey: '',
65 | },
66 | svix: {
67 | secretKey: '',
68 | },
69 | twitch: {
70 | secretKey: '',
71 | },
72 | hygraph: {
73 | secretKey: '',
74 | },
75 | polar: {
76 | secretKey: '',
77 | },
78 | },
79 | },
80 | compatibilityDate: '2025-08-06',
81 | })
82 |
--------------------------------------------------------------------------------
/src/runtime/server/lib/validators/stripe.ts:
--------------------------------------------------------------------------------
1 | import { type H3Event, getRequestHeaders, readRawBody } from 'h3'
2 | import { computeSignature, HMAC_SHA256, ensureConfiguration } from '../helpers'
3 |
4 | const DEFAULT_TOLERANCE = 300
5 | const STRIPE_SIGNATURE = 'Stripe-Signature'.toLowerCase()
6 |
7 | const extractHeaders = (header: string) => {
8 | const parts = header.split(',')
9 | let t = ''
10 | let v1 = ''
11 | for (const part of parts) {
12 | const [key, value] = part.split('=')
13 | if (value) {
14 | if (key === 't') t = value
15 | else if (key === 'v1') v1 = value
16 | }
17 | }
18 | if (!(t && v1)) return null
19 | return { t: Number.parseInt(t), v1 }
20 | }
21 |
22 | /**
23 | * Validates Stripe webhooks on the Edge
24 | * @see {@link https://docs.stripe.com/webhooks?verify=verify-manually}
25 | * @param event H3Event
26 | * @returns {boolean} `true` if the webhook is valid, `false` otherwise
27 | */
28 | export const isValidStripeWebhook = async (event: H3Event): Promise => {
29 | const config = ensureConfiguration('stripe', event)
30 |
31 | const headers = getRequestHeaders(event)
32 | const body = await readRawBody(event)
33 |
34 | const stripeSignature = headers[STRIPE_SIGNATURE]
35 |
36 | if (!body || !stripeSignature) return false
37 |
38 | const signatureHeaders = extractHeaders(stripeSignature)
39 | if (!signatureHeaders) return false
40 | const { t: webhookTimestamp, v1: webhookSignature } = signatureHeaders
41 |
42 | if ((new Date().getTime() / 1000) - webhookTimestamp > DEFAULT_TOLERANCE) return false
43 |
44 | const payloadWithTime = `${webhookTimestamp}.${body}`
45 |
46 | const computedHash = await computeSignature(config.secretKey, HMAC_SHA256, payloadWithTime)
47 | return computedHash === webhookSignature
48 | }
49 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: ci
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | pull_request:
8 | branches:
9 | - main
10 |
11 | # https://github.com/vitejs/vite/blob/main/.github/workflows/ci.yml
12 | env:
13 | # 7 GiB by default on GitHub, setting to 6 GiB
14 | # https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners#supported-runners-and-hardware-resources
15 | NODE_OPTIONS: --max-old-space-size=6144
16 |
17 | # Remove default permissions of GITHUB_TOKEN for security
18 | # https://docs.github.com/en/actions/using-jobs/assigning-permissions-to-jobs
19 | permissions: {}
20 |
21 | concurrency:
22 | group: ${{ github.workflow }}-${{ github.event.number || github.sha }}
23 | cancel-in-progress: ${{ github.event_name != 'push' }}
24 |
25 | jobs:
26 | lint:
27 | # autofix workflow will be triggered instead for PRs
28 | if: github.event_name == 'push'
29 | runs-on: ubuntu-latest
30 |
31 | steps:
32 | - uses: actions/checkout@v5
33 | - run: corepack enable
34 | - uses: actions/setup-node@v5
35 | with:
36 | node-version: lts/*
37 | cache: pnpm
38 |
39 | - name: 📦 Install dependencies
40 | run: pnpm install
41 |
42 | - name: 🔎 Lint
43 | run: pnpm lint
44 |
45 | test:
46 | runs-on: ubuntu-latest
47 |
48 | steps:
49 | - uses: actions/checkout@v5
50 | - run: corepack enable
51 | - uses: actions/setup-node@v5
52 | with:
53 | node-version: lts/*
54 | cache: pnpm
55 |
56 | - name: 📦 Install dependencies
57 | run: pnpm install
58 |
59 | - name: 🚧 Prepare module environment
60 | run: pnpm dev:prepare
61 |
62 | - name: 🧪 Run test suite
63 | run: pnpm test
64 |
65 | - name: 🧪 Test types
66 | run: pnpm test:types
67 |
--------------------------------------------------------------------------------
/src/runtime/server/lib/validators/paddle.ts:
--------------------------------------------------------------------------------
1 | import { type H3Event, getRequestHeaders, readRawBody } from 'h3'
2 | import { computeSignature, HMAC_SHA256, ensureConfiguration } from '../helpers'
3 |
4 | const MAX_VALID_TIME_DIFFERENCE = 5
5 | const PADDLE_SIGNATURE = 'paddle-signature'
6 |
7 | const extractHeaders = (header: string) => {
8 | const parts = header.split(';')
9 | let ts = ''
10 | let h1 = ''
11 | for (const part of parts) {
12 | const [key, value] = part.split('=')
13 | if (value) {
14 | if (key === 'ts') ts = value
15 | else if (key === 'h1') h1 = value
16 | }
17 | }
18 | if (!(ts && h1)) return null
19 | return { ts: Number.parseInt(ts), h1 }
20 | }
21 |
22 | /**
23 | * Validates Paddle webhooks on the Edge
24 | * @see {@link https://github.com/PaddleHQ/paddle-node-sdk/blob/main/src/notifications/helpers/webhooks-validator.ts}
25 | * @param event H3Event
26 | * @returns {boolean} `true` if the webhook is valid, `false` otherwise
27 | */
28 | export const isValidPaddleWebhook = async (event: H3Event): Promise => {
29 | const config = ensureConfiguration('paddle', event)
30 |
31 | const headers = getRequestHeaders(event)
32 | const body = await readRawBody(event)
33 |
34 | const paddleSignature = headers[PADDLE_SIGNATURE]
35 |
36 | if (!body || !paddleSignature) return false
37 |
38 | const signatureHeaders = extractHeaders(paddleSignature)
39 | if (!signatureHeaders) return false
40 | const { ts: webhookTimestamp, h1: webhookSignature } = signatureHeaders
41 |
42 | if (new Date().getTime() > new Date((webhookTimestamp + MAX_VALID_TIME_DIFFERENCE) * 1000).getTime()) return false
43 |
44 | const payloadWithTime = `${webhookTimestamp}:${body}`
45 |
46 | const computedHash = await computeSignature(config.webhookId, HMAC_SHA256, payloadWithTime)
47 | return computedHash === webhookSignature
48 | }
49 |
--------------------------------------------------------------------------------
/test/module.test.ts:
--------------------------------------------------------------------------------
1 | import { fileURLToPath } from 'node:url'
2 | import { rm } from 'node:fs/promises'
3 | import { describe, it, expect, vi, afterAll } from 'vitest'
4 | import { $fetch, setup } from '@nuxt/test-utils/e2e'
5 | import type { RuntimeConfig } from '@nuxt/schema'
6 | import { ensureConfiguration } from '../src/runtime/server/lib/helpers'
7 | import { generateTestingKeys } from './genKeys'
8 |
9 | const validWebhook = { isValidWebhook: true }
10 |
11 | // Generate test keys
12 | await generateTestingKeys()
13 | const nuxtConfig = (await import('./fixtures/basic/nuxt.config')).default
14 |
15 | // Nuxt setup
16 | await setup({ rootDir: fileURLToPath(new URL('./fixtures/basic', import.meta.url)) })
17 |
18 | // Start tests
19 | describe('ssr', () => {
20 | it('renders the index page', async () => {
21 | const html = await $fetch('/')
22 | expect(html).toContain('Nuxt Webhook Validators
')
23 | })
24 | })
25 |
26 | describe('ensureConfiguration method', () => {
27 | it('returns the configuration object', () => {
28 | if (!nuxtConfig.runtimeConfig?.webhook) return
29 | for (const [key, config] of Object.entries(nuxtConfig.runtimeConfig.webhook)) {
30 | const provider = key as keyof RuntimeConfig['webhook']
31 | const resultConfig = ensureConfiguration(provider)
32 | expect(resultConfig).toStrictEqual(config)
33 | }
34 | })
35 | })
36 |
37 | describe('webhooks', async () => {
38 | vi.mock('#imports', () => ({
39 | useRuntimeConfig: vi.fn(() => nuxtConfig.runtimeConfig),
40 | }))
41 |
42 | afterAll(() => {
43 | rm(fileURLToPath(new URL('./fixtures/basic/test-keys.json', import.meta.url)))
44 | })
45 |
46 | // Iterate over the `events` object dynamically
47 | const events = await import('./events')
48 | for (const [methodName, simulation] of Object.entries(events)) {
49 | const [, webhookName] = methodName.match(/^simulate(.*)Event$/) || []
50 | if (!webhookName) continue
51 | it(`valid ${webhookName} webhook`, async () => {
52 | const response = await simulation()
53 | expect(response).toStrictEqual(validWebhook)
54 | })
55 | }
56 | })
57 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "nuxt-webhook-validators",
3 | "version": "0.2.6",
4 | "description": "A simple nuxt module that works on the edge to easily validate incoming webhooks from different services.",
5 | "keywords": [
6 | "nuxt",
7 | "webhook",
8 | "validator"
9 | ],
10 | "repository": {
11 | "type": "git",
12 | "url": "git+https://github.com/Yizack/nuxt-webhook-validators.git"
13 | },
14 | "homepage": "https://github.com/Yizack/nuxt-webhook-validators",
15 | "author": {
16 | "name": "Yizack Rangel",
17 | "email": "yizackr@gmail.com",
18 | "url": "https://yizack.com"
19 | },
20 | "license": "MIT",
21 | "type": "module",
22 | "exports": {
23 | ".": {
24 | "types": "./dist/types.d.mts",
25 | "import": "./dist/module.mjs"
26 | }
27 | },
28 | "main": "./dist/module.mjs",
29 | "files": [
30 | "dist"
31 | ],
32 | "scripts": {
33 | "build": "nuxt-module-build build --stub && nuxt-module-build prepare && nuxt-module-build build",
34 | "prepack": "pnpm build",
35 | "dev": "nuxt dev playground",
36 | "dev:build": "nuxt build playground",
37 | "dev:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare && nuxt prepare playground",
38 | "release": "pnpm lint && pnpm build && pnpm test && changelogen --release && git push --follow-tags",
39 | "lint": "eslint .",
40 | "lint:fix": "eslint . --fix",
41 | "test": "vitest run --reporter=verbose",
42 | "test:types": "vue-tsc --noEmit && cd playground && vue-tsc -b --noEmit",
43 | "test:watch": "vitest watch"
44 | },
45 | "dependencies": {
46 | "@nuxt/kit": "^4.2.2",
47 | "defu": "^6.1.4",
48 | "scule": "^1.3.0"
49 | },
50 | "devDependencies": {
51 | "@nuxt/devtools": "^3.1.1",
52 | "@nuxt/eslint-config": "^1.11.0",
53 | "@nuxt/module-builder": "^1.0.2",
54 | "@nuxt/schema": "^4.2.2",
55 | "@nuxt/test-utils": "^3.21.0",
56 | "@types/node": "^24.10.2",
57 | "changelogen": "^0.6.2",
58 | "eslint": "^9.39.1",
59 | "nuxt": "^4.2.2",
60 | "typescript": "^5.9.3",
61 | "vitest": "^4.0.15",
62 | "vue-tsc": "^3.1.8"
63 | },
64 | "packageManager": "pnpm@10.25.0"
65 | }
66 |
--------------------------------------------------------------------------------
/src/runtime/server/lib/validators/kick.ts:
--------------------------------------------------------------------------------
1 | import { type H3Event, getRequestHeaders, readRawBody } from 'h3'
2 | import { verifyPublicSignature, RSASSA_PKCS1_v1_5_SHA256, stripPemHeaders } from '../helpers'
3 | import { useRuntimeConfig } from '#imports'
4 |
5 | const KICK_MESSAGE_ID = 'Kick-Event-Message-Id'.toLowerCase()
6 | const KICK_MESSAGE_TIMESTAMP = 'Kick-Event-Message-Timestamp'.toLowerCase()
7 | const KICK_MESSAGE_SIGNATURE = 'Kick-Event-Signature'.toLowerCase()
8 |
9 | const KICK_PUBLIC_KEY_PEM = `-----BEGIN PUBLIC KEY-----
10 | MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAq/+l1WnlRrGSolDMA+A8
11 | 6rAhMbQGmQ2SapVcGM3zq8ANXjnhDWocMqfWcTd95btDydITa10kDvHzw9WQOqp2
12 | MZI7ZyrfzJuz5nhTPCiJwTwnEtWft7nV14BYRDHvlfqPUaZ+1KR4OCaO/wWIk/rQ
13 | L/TjY0M70gse8rlBkbo2a8rKhu69RQTRsoaf4DVhDPEeSeI5jVrRDGAMGL3cGuyY
14 | 6CLKGdjVEM78g3JfYOvDU/RvfqD7L89TZ3iN94jrmWdGz34JNlEI5hqK8dd7C5EF
15 | BEbZ5jgB8s8ReQV8H+MkuffjdAj3ajDDX3DOJMIut1lBrUVD1AaSrGCKHooWoL2e
16 | twIDAQAB
17 | -----END PUBLIC KEY-----`
18 |
19 | /**
20 | * Validates Kick webhooks on the Edge
21 | * @see {@link https://docs.kick.com/events/webhook-security#webhook-sender-validation}
22 | * @param event H3Event
23 | * @returns {boolean} `true` if the webhook is valid, `false` otherwise
24 | */
25 | export const isValidKickWebhook = async (event: H3Event): Promise => {
26 | // const config = ensureConfiguration('kick', event) // No need to ensure kick configuration
27 | const config = useRuntimeConfig(event).webhook.kick
28 |
29 | const headers = getRequestHeaders(event)
30 | const body = await readRawBody(event)
31 |
32 | const messageId = headers[KICK_MESSAGE_ID]
33 | const messageTimestamp = headers[KICK_MESSAGE_TIMESTAMP]
34 | const messageSignature = headers[KICK_MESSAGE_SIGNATURE]
35 |
36 | if (!body || !messageId || !messageTimestamp || !messageSignature) return false
37 |
38 | const payload = `${messageId}.${messageTimestamp}.${body}`
39 | const publicKey = stripPemHeaders(config.publicKey || KICK_PUBLIC_KEY_PEM)
40 |
41 | const isValid = await verifyPublicSignature(publicKey, RSASSA_PKCS1_v1_5_SHA256, payload, messageSignature, {
42 | encoding: 'base64',
43 | format: 'spki',
44 | })
45 | return isValid
46 | }
47 |
--------------------------------------------------------------------------------
/test/fixtures/basic/nuxt.config.ts:
--------------------------------------------------------------------------------
1 | import { defineNuxtConfig } from 'nuxt/config'
2 | import myModule from '../../../src/module'
3 | // @ts-expect-error generated on test command
4 | import { rsaKeys, ed25519Keys } from './test-keys.json'
5 |
6 | export default defineNuxtConfig({
7 | modules: [myModule],
8 | runtimeConfig: {
9 | webhook: {
10 | brevo: {
11 | token: 'testToken',
12 | },
13 | discord: {
14 | publicKey: 'fcf4594ff55a5898a7e7ce541b93dc8ce618c7a4fa96ab7efd1ac2890571345c',
15 | },
16 | dropbox: {
17 | appSecret: 'testDropboxAppSecret',
18 | },
19 | fourthwall: {
20 | secretKey: 'testFourthwallSecretKey',
21 | },
22 | github: {
23 | secretKey: 'testGitHubSecretKey',
24 | },
25 | gitlab: {
26 | secretToken: 'testGitLabSecretToken',
27 | },
28 | heroku: {
29 | secretKey: 'testHerokuSecretKey',
30 | },
31 | kick: {
32 | // Generated on test setup
33 | publicKey: rsaKeys.publicKey,
34 | },
35 | mailchannels: {
36 | // Generated on test setup
37 | publicKey: ed25519Keys.publicKey,
38 | },
39 | meta: {
40 | appSecret: 'testMetaAppSecret',
41 | },
42 | nuxthub: {
43 | secretKey: 'testNuxtHubSecretKey',
44 | },
45 | paddle: {
46 | webhookId: 'testPaddleWebhookId',
47 | },
48 | paypal: {
49 | clientId: 'testPayPalClientId',
50 | secretKey: 'testPayPalSecretKey',
51 | webhookId: 'testPayPalWebhookId',
52 | },
53 | resend: {
54 | secretKey: 'test_c3ZpeFNlY3JldEtleQ==',
55 | },
56 | shopify: {
57 | secretKey: 'testShopifySecretKey',
58 | },
59 | slack: {
60 | secretKey: 'testSlackSecretKey',
61 | },
62 | stripe: {
63 | secretKey: 'testStripeSecretKey',
64 | },
65 | svix: {
66 | secretKey: 'test_c3ZpeFNlY3JldEtleQ==',
67 | },
68 | twitch: {
69 | secretKey: 'testTwitchSecretKey',
70 | },
71 | hygraph: {
72 | secretKey: 'testHygraphSecretKey',
73 | },
74 | polar: {
75 | secretKey: 'testPolarSecretKey',
76 | },
77 | },
78 | },
79 | serverDir: '../../../playground/server',
80 | })
81 |
--------------------------------------------------------------------------------
/src/runtime/server/lib/validators/hygraph.ts:
--------------------------------------------------------------------------------
1 | import { type H3Event, getRequestHeaders, readRawBody } from 'h3'
2 | import { computeSignature, HMAC_SHA256, ensureConfiguration } from '../helpers'
3 |
4 | const DEFAULT_TOLERANCE = 300 // 5 minutes tolerance
5 | const HYGRAPH_SIGNATURE = 'gcms-signature'
6 |
7 | interface ParsedSignature {
8 | sign: string
9 | env: string
10 | timestamp: number
11 | }
12 |
13 | function parseSignature(signature: string): ParsedSignature | null {
14 | const parts = signature.split(', ')
15 | if (parts.length !== 3) return null
16 |
17 | const parsed: Partial = {}
18 | for (const part of parts) {
19 | const [key, ...valueParts] = part.split('=')
20 | const value = valueParts.join('=') // Rejoin in case there are multiple '=' in the value
21 |
22 | if (key === 'sign') parsed.sign = value
23 | else if (key === 'env') parsed.env = value
24 | else if (key === 't') parsed.timestamp = Number.parseInt(value, 10)
25 | }
26 |
27 | if (!parsed.sign || !parsed.env || !parsed.timestamp) return null
28 | return parsed as ParsedSignature
29 | }
30 |
31 | /**
32 | * Validates Hygraph webhooks on the Edge
33 | * @see {@link https://hygraph.com/docs/api-reference/basics/webhooks#manual-verification}
34 | * @param event H3Event
35 | * @returns {boolean} `true` if the webhook is valid, `false` otherwise
36 | */
37 | export const isValidHygraphWebhook = async (event: H3Event): Promise => {
38 | const config = ensureConfiguration('hygraph', event)
39 |
40 | const headers = getRequestHeaders(event)
41 | const body = await readRawBody(event)
42 |
43 | const hygraphSignature = headers[HYGRAPH_SIGNATURE]
44 |
45 | if (!body || !hygraphSignature) return false
46 |
47 | const parsedSignature = parseSignature(hygraphSignature)
48 | if (!parsedSignature) return false
49 |
50 | const { sign: webhookSignature, env: webhookEnvironmentName, timestamp: webhookTimestamp } = parsedSignature
51 |
52 | // Validate the timestamp to ensure the request isn't too old
53 | const now = Math.floor(Date.now() / 1000)
54 | if (now - webhookTimestamp > DEFAULT_TOLERANCE) return false
55 |
56 | const payload = JSON.stringify({
57 | Body: body,
58 | EnvironmentName: webhookEnvironmentName,
59 | TimeStamp: webhookTimestamp,
60 | })
61 |
62 | const computedHash = await computeSignature(config.secretKey, HMAC_SHA256, payload, { encoding: 'base64' })
63 |
64 | return computedHash === webhookSignature
65 | }
66 |
--------------------------------------------------------------------------------
/src/runtime/server/lib/validators/mailchannels.ts:
--------------------------------------------------------------------------------
1 | import { type H3Event, getRequestHeaders, readRawBody } from 'h3'
2 | import { verifyPublicSignature, ED25519, validateSha256, stripPemHeaders } from '../helpers'
3 | import { useRuntimeConfig } from '#imports'
4 |
5 | const MAILCHANNELS_CONTENT_DIGEST = 'content-digest'
6 | const MAILCHANNELS_SIGNATURE = 'signature'
7 | const MAILCHANNELS_SIGNATURE_INPUT = 'signature-input'
8 | const DEFAULT_TOLERANCE = 300 // 5 minutes
9 |
10 | const validateContentDigest = async (header: string, body: string) => {
11 | const match = header.match(/^(.*?)=:(.*?):$/)
12 | if (!match) return false
13 |
14 | const [, algorithm, hash] = match
15 | if (!algorithm || !hash) return false
16 |
17 | const normalizedAlgorithm = algorithm.replace('-', '').toLowerCase()
18 |
19 | if (!['sha256'].includes(normalizedAlgorithm)) return false
20 | return validateSha256(hash, body, { encoding: 'base64' })
21 | }
22 |
23 | const extractSignature = (signatureHeader: string): string | null => {
24 | const signatureMatch = signatureHeader.match(/sig_\d+=:([^:]+):/)
25 | return signatureMatch && signatureMatch[1] ? signatureMatch[1] : null
26 | }
27 |
28 | const extractInputValues = (header: string) => {
29 | const regex = /^(\w+)=\(([^)]+)\);created=(\d+);alg="([^"]+)";keyid="([^"]+)"$/
30 | const match = header.match(regex)
31 |
32 | if (!match) return null
33 |
34 | return {
35 | name: match[1],
36 | timestamp: Number.parseInt(match[3] || '', 10),
37 | algorithm: match[4],
38 | keyId: match[5],
39 | }
40 | }
41 |
42 | /**
43 | * Validates MailChannels webhooks on the Edge
44 | * @see {@link https://docs.mailchannels.net/email-api/advanced/delivery-events/#verifying-message-signatures}
45 | * @param event H3Event
46 | * @returns {boolean} `true` if the webhook is valid, `false` otherwise
47 | */
48 | export const isValidMailChannelsWebhook = async (event: H3Event): Promise => {
49 | // const config = ensureConfiguration('mailchannels', event) // No need to ensure mailchannels configuration
50 | const config = useRuntimeConfig(event).webhook.mailchannels
51 |
52 | const headers = getRequestHeaders(event)
53 | const body = await readRawBody(event)
54 |
55 | const contentDigest = headers[MAILCHANNELS_CONTENT_DIGEST]
56 | const messageSignature = headers[MAILCHANNELS_SIGNATURE]
57 | const signatureInput = headers[MAILCHANNELS_SIGNATURE_INPUT]
58 |
59 | if (!body || !contentDigest || !messageSignature || !signatureInput) return false
60 |
61 | if (!(await validateContentDigest(contentDigest, body))) return false
62 |
63 | const signature = extractSignature(messageSignature)
64 | if (!signature) return false
65 |
66 | const values = extractInputValues(signatureInput)
67 | if (!values) return false
68 |
69 | // Validate the timestamp to ensure the request isn't too old
70 | const now = Math.floor(Date.now() / 1000)
71 | if (now - values.timestamp > DEFAULT_TOLERANCE) return false
72 |
73 | const signingString = `"content-digest": ${contentDigest}
74 | "@signature-params": ("content-digest");created=${values.timestamp};alg="${values.algorithm}";keyid="${values.keyId}"`
75 |
76 | let publicKey = config.publicKey
77 | if (!publicKey) {
78 | const publicKeyResponse = await $fetch<{ id: string, key: string }>('/tx/v1/webhook/public-key', {
79 | baseURL: 'https://api.mailchannels.net',
80 | query: { id: values.keyId },
81 | }).catch(() => null)
82 | if (!publicKeyResponse) return false
83 | publicKey = publicKeyResponse.key
84 | }
85 | publicKey = stripPemHeaders(publicKey)
86 |
87 | const isValid = await verifyPublicSignature(publicKey, ED25519, signingString, signature, {
88 | encoding: 'base64',
89 | format: 'spki',
90 | })
91 |
92 | return isValid
93 | }
94 |
--------------------------------------------------------------------------------
/src/runtime/server/lib/helpers.ts:
--------------------------------------------------------------------------------
1 | import { subtle, type webcrypto } from 'node:crypto'
2 | import { Buffer } from 'node:buffer'
3 | import { snakeCase } from 'scule'
4 | import { type H3Event, createError } from 'h3'
5 | import type { RuntimeConfig } from '@nuxt/schema'
6 | import { useRuntimeConfig } from '#imports'
7 |
8 | /* Algorithms */
9 | export const HMAC_SHA256 = { name: 'HMAC', hash: 'SHA-256' }
10 | export const ED25519 = { name: 'Ed25519', namedCurve: 'Ed25519' }
11 | export const RSASSA_PKCS1_v1_5_SHA256 = { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }
12 |
13 | export const encoder = new TextEncoder()
14 |
15 | export const computeSignature = async (
16 | secretKey: string,
17 | algorithm: webcrypto.Algorithm,
18 | payload: string,
19 | options?: Partial<{
20 | extractable: boolean
21 | encoding: BufferEncoding
22 | decodeKey: boolean
23 | }>,
24 | ) => {
25 | const encoding = options?.encoding ?? 'hex'
26 | const secretKeyBuffer = options?.decodeKey ? Buffer.from(secretKey, encoding) : encoder.encode(secretKey)
27 |
28 | const key = await subtle.importKey('raw', secretKeyBuffer, algorithm, Boolean(options?.extractable), ['sign'])
29 | const signature = await subtle.sign(algorithm.name, key, encoder.encode(payload))
30 | return Buffer.from(signature).toString(encoding)
31 | }
32 |
33 | export const verifyPublicSignature = async (
34 | publicKey: string,
35 | algorithm: webcrypto.Algorithm,
36 | payload: string,
37 | signature: string,
38 | options?: Partial<{
39 | extractable: boolean
40 | encoding: BufferEncoding
41 | format: Exclude
42 | }>,
43 | ) => {
44 | const encoding = options?.encoding ?? 'hex'
45 | const format = options?.format ?? 'raw'
46 |
47 | const publicKeyBuffer = Buffer.from(publicKey, encoding)
48 | const webhookSignatureBuffer = Buffer.from(signature, encoding)
49 |
50 | const key = await subtle.importKey(format, publicKeyBuffer, algorithm, Boolean(options?.extractable), ['verify'])
51 | const result = await subtle.verify(algorithm.name, key, webhookSignatureBuffer, encoder.encode(payload))
52 | return result
53 | }
54 |
55 | export const configContext: { provider: keyof RuntimeConfig['webhook'] | null } = {
56 | provider: null,
57 | }
58 |
59 | export const ensureConfiguration = (provider: T, event?: H3Event) => {
60 | if (configContext.provider) provider = configContext.provider as T
61 | const runtimeConfig = useRuntimeConfig(event).webhook[provider]
62 | if (configContext.provider) configContext.provider = null
63 |
64 | const missingKeys = Object.entries(runtimeConfig).filter(([_, value]) => !value).map(([key]) => key)
65 | if (!missingKeys.length) return runtimeConfig
66 |
67 | const environmentVariables = missingKeys.map(key => `NUXT_WEBHOOK_${provider.toUpperCase()}_${snakeCase(key).toUpperCase()}`)
68 | const errorMessage = `Missing ${environmentVariables.join(' or ')} env ${missingKeys.length > 1 ? 'variables' : 'variable'}.`
69 | console.error(errorMessage)
70 | throw createError({
71 | statusCode: 500,
72 | message: errorMessage,
73 | })
74 | }
75 |
76 | export const stripPemHeaders = (pem: string) => pem.replace(/-----[^-]+-----|\s/g, '')
77 |
78 | export const sha256 = async (payload: string | object, encoding?: BufferEncoding) => {
79 | const buffer = typeof payload === 'object' ? Buffer.from(JSON.stringify(payload)) : encoder.encode(payload)
80 | const signatureBuffer = await subtle.digest(HMAC_SHA256.hash, buffer)
81 | return Buffer.from(signatureBuffer).toString(encoding ?? 'hex')
82 | }
83 |
84 | export const validateSha256 = async (
85 | hash: string,
86 | payload: string,
87 | options?: Partial<{
88 | encoding: BufferEncoding
89 | }>,
90 | ) => {
91 | return hash === await sha256(payload, options?.encoding)
92 | }
93 |
--------------------------------------------------------------------------------
/src/module.ts:
--------------------------------------------------------------------------------
1 | import { defineNuxtModule, createResolver, addServerImportsDir } from '@nuxt/kit'
2 | import { defu } from 'defu'
3 |
4 | // eslint-disable-next-line @typescript-eslint/no-empty-object-type
5 | export interface ModuleOptions {}
6 |
7 | export default defineNuxtModule({
8 | meta: {
9 | name: 'webhook-validators',
10 | configKey: 'webhook',
11 | compatibility: {
12 | nuxt: '>=3.0.0',
13 | },
14 | },
15 | defaults: {},
16 | setup(options, nuxt) {
17 | const { resolve } = createResolver(import.meta.url)
18 | addServerImportsDir(resolve('./runtime/server/lib/validators'))
19 |
20 | const runtimeConfig = nuxt.options.runtimeConfig
21 | // Webhook settings
22 | runtimeConfig.webhook = defu(runtimeConfig.webhook, {})
23 | // Brevo Webhook
24 | runtimeConfig.webhook.brevo = defu(runtimeConfig.webhook.brevo, {
25 | token: '',
26 | })
27 | // Discord Webhook
28 | runtimeConfig.webhook.discord = defu(runtimeConfig.webhook.discord, {
29 | publicKey: '',
30 | })
31 | // Dropbox Webhook
32 | runtimeConfig.webhook.dropbox = defu(runtimeConfig.webhook.dropbox, {
33 | appSecret: '',
34 | })
35 | // Fourthwall Webhook
36 | runtimeConfig.webhook.fourthwall = defu(runtimeConfig.webhook.fourthwall, {
37 | secretKey: '',
38 | })
39 | // GitHub Webhook
40 | runtimeConfig.webhook.github = defu(runtimeConfig.webhook.github, {
41 | secretKey: '',
42 | })
43 | // GitLab Webhook
44 | runtimeConfig.webhook.gitlab = defu(runtimeConfig.webhook.gitlab, {
45 | secretToken: '',
46 | })
47 | // Heroku Webhook
48 | runtimeConfig.webhook.heroku = defu(runtimeConfig.webhook.heroku, {
49 | secretKey: '',
50 | })
51 | // Kick Webhook
52 | runtimeConfig.webhook.kick = defu(runtimeConfig.webhook.kick, {
53 | publicKey: '',
54 | })
55 | // MailChannels Webhook
56 | runtimeConfig.webhook.mailchannels = defu(runtimeConfig.webhook.mailchannels, {
57 | publicKey: '',
58 | })
59 | // Meta Webhook
60 | runtimeConfig.webhook.meta = defu(runtimeConfig.webhook.meta, {
61 | appSecret: '',
62 | })
63 | // NuxtHub Webhook
64 | runtimeConfig.webhook.nuxthub = defu(runtimeConfig.webhook.nuxthub, {
65 | secretKey: '',
66 | })
67 | // Paddle Webhook
68 | runtimeConfig.webhook.paddle = defu(runtimeConfig.webhook.paddle, {
69 | webhookId: '',
70 | })
71 | // PayPal Webhook
72 | runtimeConfig.webhook.paypal = defu(runtimeConfig.webhook.paypal, {
73 | clientId: '',
74 | secretKey: '',
75 | webhookId: '',
76 | })
77 | // Resend Webhook
78 | runtimeConfig.webhook.resend = defu(runtimeConfig.webhook.resend, {
79 | secretKey: '',
80 | })
81 | // Shopify Webhook
82 | runtimeConfig.webhook.shopify = defu(runtimeConfig.webhook.shopify, {
83 | secretKey: '',
84 | })
85 | // Slack Webhook
86 | runtimeConfig.webhook.slack = defu(runtimeConfig.webhook.slack, {
87 | secretKey: '',
88 | })
89 | // Stripe Webhook
90 | runtimeConfig.webhook.stripe = defu(runtimeConfig.webhook.stripe, {
91 | secretKey: '',
92 | })
93 | // Svix Webhook
94 | runtimeConfig.webhook.svix = defu(runtimeConfig.webhook.svix, {
95 | secretKey: '',
96 | })
97 | // Twitch Webhook
98 | runtimeConfig.webhook.twitch = defu(runtimeConfig.webhook.twitch, {
99 | secretKey: '',
100 | })
101 | // Hygraph Webhook
102 | runtimeConfig.webhook.hygraph = defu(runtimeConfig.webhook.hygraph, {
103 | secretKey: '',
104 | })
105 | // Polar Webhook
106 | runtimeConfig.webhook.polar = defu(runtimeConfig.webhook.polar, {
107 | secretKey: '',
108 | })
109 | },
110 | })
111 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
2 |
3 | # Nuxt Webhook Validators
4 |
5 | [![npm version][npm-version-src]][npm-version-href]
6 | [![npm downloads][npm-downloads-src]][npm-downloads-href]
7 | [![License][license-src]][license-href]
8 | [![Nuxt][nuxt-src]][nuxt-href]
9 | [![Modules][modules-src]][modules-href]
10 |
11 | A simple nuxt module that works on the edge to easily validate incoming webhooks from different services.
12 |
13 | - [✨ Release Notes](CHANGELOG.md)
14 |
15 | ## Features
16 |
17 | - 21 [Webhook validators](#supported-webhook-validators)
18 | - Works on the edge
19 | - Exposed [Server utils](#server-utils)
20 |
21 | ## Requirements
22 |
23 | This module only works with a Nuxt server running as it uses server API routes (`nuxt build`).
24 |
25 | This means that you cannot use this module with `nuxt generate`.
26 |
27 | ## Quick Setup
28 |
29 | 1. Add nuxt-webhook-validators in your Nuxt project
30 |
31 | ```
32 | npx nuxi@latest module add webhook-validators
33 | ```
34 |
35 | 2. Add the module in your `nuxt.config.ts`
36 |
37 | ```js
38 | export default defineNuxtConfig({
39 | modules: [
40 | 'nuxt-webhook-validators'
41 | ],
42 | })
43 | ```
44 |
45 |
46 | ## Server utils
47 |
48 | The validator helpers are auto-imported in your `server/` directory.
49 |
50 | ### Webhook Validators
51 |
52 | All validator helpers are exposed globally and can be used in your server API routes.
53 |
54 | The helpers return a boolean value indicating if the webhook request is valid or not.
55 |
56 | The config can be defined directly from the `runtimeConfig` in your `nuxt.config.ts`:
57 |
58 | ```js
59 | export default defineNuxtConfig({
60 | runtimeConfig: {
61 | webhook: {
62 | : {
63 | : '',
64 | }
65 | }
66 | }
67 | })
68 | ```
69 |
70 | It can also be set using environment variables:
71 |
72 | ```sh
73 | NUXT_WEBHOOK__ = ""
74 | ```
75 |
76 | Go to [playground/.env.example](./playground/.env.example) or [playground/nuxt.config.ts](./playground/nuxt.config.ts) to see a list of all the available properties needed for each provider.
77 |
78 |
79 | #### Supported webhook validators:
80 |
81 | - Brevo
82 | - Discord
83 | - Dropbox
84 | - Fourthwall
85 | - GitHub
86 | - GitLab
87 | - Heroku
88 | - Hygraph
89 | - Kick
90 | - MailChannels
91 | - Meta
92 | - NuxtHub
93 | - Paddle
94 | - PayPal
95 | - Polar
96 | - Resend
97 | - Shopify
98 | - Slack
99 | - Stripe
100 | - Svix
101 | - Twitch
102 |
103 | You can add your favorite webhook validator by creating a new file in [src/runtime/server/lib/validators/](./src/runtime/server/lib/validators/)
104 |
105 | ## Example
106 |
107 | Validate a GitHub webhook in a server API route.
108 |
109 | `~/server/api/webhooks/github.post.ts`
110 |
111 | ```js
112 | export default defineEventHandler(async (event) => {
113 | const isValidWebhook = await isValidGitHubWebhook(event)
114 |
115 | if (!isValidWebhook) {
116 | throw createError({ statusCode: 401, message: 'Unauthorized: webhook is not valid' })
117 | }
118 |
119 | // Some logic...
120 |
121 | return { isValidWebhook }
122 | })
123 | ```
124 |
125 | # Development
126 |
127 | ```sh
128 | # Install dependencies
129 | npm install
130 |
131 | # Generate type stubs
132 | npm run dev:prepare
133 |
134 | # Develop with the playground
135 | npm run dev
136 |
137 | # Build the playground
138 | npm run dev:build
139 |
140 | # Run ESLint
141 | npm run lint
142 |
143 | # Run Vitest
144 | npm run test
145 | npm run test:watch
146 |
147 | # Run typecheck
148 | npm run test:types
149 |
150 | # Release new version
151 | npm run release
152 | ```
153 |
154 |
155 | [npm-version-src]: https://img.shields.io/npm/v/nuxt-webhook-validators/latest.svg?style=flat&colorA=020420&colorB=00DC82
156 | [npm-version-href]: https://npmjs.com/package/nuxt-webhook-validators
157 |
158 | [npm-downloads-src]: https://img.shields.io/npm/dm/nuxt-webhook-validators.svg?style=flat&colorA=020420&colorB=00DC82
159 | [npm-downloads-href]: https://npmjs.com/package/nuxt-webhook-validators
160 |
161 | [license-src]: https://img.shields.io/npm/l/nuxt-webhook-validators.svg?style=flat&colorA=020420&colorB=00DC82
162 | [license-href]: LICENSE
163 |
164 | [nuxt-src]: https://img.shields.io/badge/Nuxt-020420?logo=nuxt
165 | [nuxt-href]: https://nuxt.com
166 |
167 | [modules-src]: https://img.shields.io/badge/Modules-020420?logo=nuxt
168 | [modules-href]: https://nuxt.com/modules/webhook-validators
169 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 |
4 | ## v0.2.6
5 |
6 | [compare changes](https://github.com/Yizack/nuxt-webhook-validators/compare/v0.2.5...v0.2.6)
7 |
8 | ### 🚀 Enhancements
9 |
10 | - Add Slack webhook validator ([5e78215](https://github.com/Yizack/nuxt-webhook-validators/commit/5e78215))
11 |
12 | ### 🏡 Chore
13 |
14 | - Update nuxt to version `4.2.2` ([8b4fb48](https://github.com/Yizack/nuxt-webhook-validators/commit/8b4fb48))
15 |
16 | ### ❤️ Contributors
17 |
18 | - Yizack Rangel ([@Yizack](https://github.com/Yizack))
19 |
20 | ## v0.2.5
21 |
22 | [compare changes](https://github.com/Yizack/nuxt-webhook-validators/compare/v0.2.4...v0.2.5)
23 |
24 | ### 🚀 Enhancements
25 |
26 | - Add brevo webhook validator ([6fca667](https://github.com/Yizack/nuxt-webhook-validators/commit/6fca667))
27 |
28 | ### ✅ Tests
29 |
30 | - **paypal:** Add fake credentials ([ac4e624](https://github.com/Yizack/nuxt-webhook-validators/commit/ac4e624))
31 |
32 | ### ❤️ Contributors
33 |
34 | - Yizack Rangel ([@Yizack](https://github.com/Yizack))
35 |
36 | ## v0.2.4
37 |
38 | [compare changes](https://github.com/Yizack/nuxt-webhook-validators/compare/v0.2.3...v0.2.4)
39 |
40 | ### 🏡 Chore
41 |
42 | - **playground:** Add twitch webhook callback verification ([#13](https://github.com/Yizack/nuxt-webhook-validators/pull/13))
43 | - Adjust package scripts ([698200f](https://github.com/Yizack/nuxt-webhook-validators/commit/698200f))
44 |
45 | ### 🤖 CI
46 |
47 | - Update to latest checkout and node actions ([bab0dde](https://github.com/Yizack/nuxt-webhook-validators/commit/bab0dde))
48 | - Use npm trusted publishing ([eaf6f0b](https://github.com/Yizack/nuxt-webhook-validators/commit/eaf6f0b))
49 |
50 | ### ❤️ Contributors
51 |
52 | - Yizack Rangel ([@Yizack](https://github.com/Yizack))
53 | - Ahmed Rangel ([@ahmedrangel](https://github.com/ahmedrangel))
54 |
55 | ## v0.2.3
56 |
57 | [compare changes](https://github.com/Yizack/nuxt-webhook-validators/compare/v0.2.2...v0.2.3)
58 |
59 | ### 🩹 Fixes
60 |
61 | - **types:** Handle undefined cases ([b9359ed](https://github.com/Yizack/nuxt-webhook-validators/commit/b9359ed))
62 |
63 | ### 🏡 Chore
64 |
65 | - Move pnpm .npmrc settings to pnpm-workspace.yaml ([d95adfc](https://github.com/Yizack/nuxt-webhook-validators/commit/d95adfc))
66 | - Update to nuxt v4 ([a27f487](https://github.com/Yizack/nuxt-webhook-validators/commit/a27f487))
67 |
68 | ### 🤖 CI
69 |
70 | - Update autofix-ci ([aea8a63](https://github.com/Yizack/nuxt-webhook-validators/commit/aea8a63))
71 |
72 | ### ❤️ Contributors
73 |
74 | - Yizack Rangel ([@Yizack](https://github.com/Yizack))
75 |
76 | ## v0.2.2
77 |
78 | [compare changes](https://github.com/Yizack/nuxt-webhook-validators/compare/v0.2.1...v0.2.2)
79 |
80 | ### 🏡 Chore
81 |
82 | - Update nuxt and deps ([e096b8c](https://github.com/Yizack/nuxt-webhook-validators/commit/e096b8c))
83 | - **lint:** Disable consistent-type-specifier-style rule ([7c96f76](https://github.com/Yizack/nuxt-webhook-validators/commit/7c96f76))
84 |
85 | ### ❤️ Contributors
86 |
87 | - Yizack Rangel ([@Yizack](https://github.com/Yizack))
88 |
89 | ## v0.2.1
90 |
91 | [compare changes](https://github.com/Yizack/nuxt-webhook-validators/compare/v0.2.0...v0.2.1)
92 |
93 | ### 🚀 Enhancements
94 |
95 | - Add fourthwall webhook validator ([#12](https://github.com/Yizack/nuxt-webhook-validators/pull/12))
96 |
97 | ### 📖 Documentation
98 |
99 | - Fix nuxt logo in badges ([a380ddb](https://github.com/Yizack/nuxt-webhook-validators/commit/a380ddb))
100 |
101 | ### 🤖 CI
102 |
103 | - Replace pnpm action with corepack ([ed8cd7b](https://github.com/Yizack/nuxt-webhook-validators/commit/ed8cd7b))
104 |
105 | ### ❤️ Contributors
106 |
107 | - Yizack Rangel ([@Yizack](https://github.com/Yizack))
108 |
109 | ## v0.2.0
110 |
111 | [compare changes](https://github.com/Yizack/nuxt-webhook-validators/compare/v0.1.11...v0.2.0)
112 |
113 | ### 📦 Build
114 |
115 | - ⚠️ Remove `.cjs` and update module builder + deps ([1ab6932](https://github.com/Yizack/nuxt-webhook-validators/commit/1ab6932))
116 |
117 | #### ⚠️ Breaking Changes
118 |
119 | - ⚠️ Remove `.cjs` and update module builder + deps ([1ab6932](https://github.com/Yizack/nuxt-webhook-validators/commit/1ab6932))
120 |
121 | ### ❤️ Contributors
122 |
123 | - Yizack Rangel ([@Yizack](https://github.com/Yizack))
124 |
125 | ## v0.1.11
126 |
127 | [compare changes](https://github.com/Yizack/nuxt-webhook-validators/compare/v0.1.10...v0.1.11)
128 |
129 | ### 🚀 Enhancements
130 |
131 | - Add mailchannels validator ([#11](https://github.com/Yizack/nuxt-webhook-validators/pull/11))
132 |
133 | ### 🏡 Chore
134 |
135 | - **nuxt:** Use sha256 utils helper ([1298799](https://github.com/Yizack/nuxt-webhook-validators/commit/1298799))
136 | - **test:** Add missing await ([c97ef20](https://github.com/Yizack/nuxt-webhook-validators/commit/c97ef20))
137 |
138 | ### ✅ Tests
139 |
140 | - Iterate simulation events + generate key pairs before testing ([7373ea9](https://github.com/Yizack/nuxt-webhook-validators/commit/7373ea9))
141 |
142 | ### ❤️ Contributors
143 |
144 | - Yizack Rangel ([@Yizack](https://github.com/Yizack))
145 |
146 | ## v0.1.10
147 |
148 | [compare changes](https://github.com/Yizack/nuxt-webhook-validators/compare/v0.1.9...v0.1.10)
149 |
150 | ### 🚀 Enhancements
151 |
152 | - Add shopify webhook validator ([#10](https://github.com/Yizack/nuxt-webhook-validators/pull/10))
153 |
154 | ### ❤️ Contributors
155 |
156 | - Yizack Rangel ([@Yizack](https://github.com/Yizack))
157 |
158 | ## v0.1.9
159 |
160 | [compare changes](https://github.com/Yizack/nuxt-webhook-validators/compare/v0.1.8...v0.1.9)
161 |
162 | ### 🩹 Fixes
163 |
164 | - **resend:** Provide context to ensure configuration ([679b927](https://github.com/Yizack/nuxt-webhook-validators/commit/679b927))
165 |
166 | ### 🏡 Chore
167 |
168 | - Throw config errors using `createError` ([5b7c224](https://github.com/Yizack/nuxt-webhook-validators/commit/5b7c224))
169 |
170 | ### ❤️ Contributors
171 |
172 | - Yizack Rangel ([@Yizack](http://github.com/Yizack))
173 |
174 | ## v0.1.8
175 |
176 | [compare changes](https://github.com/Yizack/nuxt-webhook-validators/compare/v0.1.7...v0.1.8)
177 |
178 | ### 🚀 Enhancements
179 |
180 | - Add svix webhook validator ([#8](https://github.com/Yizack/nuxt-webhook-validators/pull/8))
181 | - Add resend webhook validator ([#9](https://github.com/Yizack/nuxt-webhook-validators/pull/9))
182 |
183 | ### 💅 Refactors
184 |
185 | - Rename `isValidNuxthubWebhook` to `isValidNuxtHubWebhook` ([fa7243b](https://github.com/Yizack/nuxt-webhook-validators/commit/fa7243b))
186 | - Simplify function reference for deprecated exports ([fd4de48](https://github.com/Yizack/nuxt-webhook-validators/commit/fd4de48))
187 |
188 | ### 🏡 Chore
189 |
190 | - Support strip all pem headers just in case ([bc79c7f](https://github.com/Yizack/nuxt-webhook-validators/commit/bc79c7f))
191 |
192 | ### ✅ Tests
193 |
194 | - **kick:** Add missing buffer import ([c7ed444](https://github.com/Yizack/nuxt-webhook-validators/commit/c7ed444))
195 |
196 | ### ❤️ Contributors
197 |
198 | - Yizack Rangel ([@Yizack](https://github.com/Yizack))
199 |
200 | ## v0.1.7
201 |
202 | [compare changes](https://github.com/Yizack/nuxt-webhook-validators/compare/v0.1.6...v0.1.7)
203 |
204 | ### 🚀 Enhancements
205 |
206 | - Add kick webhook validator ([#7](https://github.com/Yizack/nuxt-webhook-validators/pull/7))
207 |
208 | ### ❤️ Contributors
209 |
210 | - Yizack Rangel ([@Yizack](http://github.com/Yizack))
211 |
212 | ## v0.1.6
213 |
214 | [compare changes](https://github.com/Yizack/nuxt-webhook-validators/compare/v0.1.5...v0.1.6)
215 |
216 | ### 🚀 Enhancements
217 |
218 | - Add gitlab webhook validator ([#6](https://github.com/Yizack/nuxt-webhook-validators/pull/6))
219 |
220 | ### 🤖 CI
221 |
222 | - Temporal fix corepack keyid ([13e28c8](https://github.com/Yizack/nuxt-webhook-validators/commit/13e28c8))
223 |
224 | ### ❤️ Contributors
225 |
226 | - Emmanuel Salomon ([@ManUtopiK](http://github.com/ManUtopiK))
227 | - Yizack Rangel ([@Yizack](http://github.com/Yizack))
228 |
229 | ## v0.1.5
230 |
231 | [compare changes](https://github.com/Yizack/nuxt-webhook-validators/compare/v0.1.4...v0.1.5)
232 |
233 | ### 💅 Refactors
234 |
235 | - Rename `isValidGithubWebhook` to `isValidGitHubWebhook` ([#5](https://github.com/Yizack/nuxt-webhook-validators/pull/5))
236 |
237 | ### 📖 Documentation
238 |
239 | - Fix typo in composable name ([#4](https://github.com/Yizack/nuxt-webhook-validators/pull/4))
240 |
241 | ### 🏡 Chore
242 |
243 | - Update dependencies ([c14fa0b](https://github.com/Yizack/nuxt-webhook-validators/commit/c14fa0b))
244 |
245 | ### ❤️ Contributors
246 |
247 | - Yizack Rangel ([@Yizack](http://github.com/Yizack))
248 | - Daniel Roe ([@danielroe](http://github.com/danielroe))
249 |
250 | ## v0.1.4
251 |
252 | [compare changes](https://github.com/Yizack/nuxt-webhook-validators/compare/v0.1.3...v0.1.4)
253 |
254 | ### 💅 Refactors
255 |
256 | - Return provider config after ensuring it ([a0a6e4c](https://github.com/Yizack/nuxt-webhook-validators/commit/a0a6e4c))
257 |
258 | ### 🏡 Chore
259 |
260 | - Update deps ([8710d2b](https://github.com/Yizack/nuxt-webhook-validators/commit/8710d2b))
261 | - Update deps ([6e30670](https://github.com/Yizack/nuxt-webhook-validators/commit/6e30670))
262 |
263 | ### ❤️ Contributors
264 |
265 | - Yizack Rangel ([@Yizack](http://github.com/Yizack))
266 |
267 | ## v0.1.3
268 |
269 | [compare changes](https://github.com/Yizack/nuxt-webhook-validators/compare/v0.1.2...v0.1.3)
270 |
271 | ### 🚀 Enhancements
272 |
273 | - Add polar.sh webhook validator ([#3](https://github.com/Yizack/nuxt-webhook-validators/pull/3))
274 |
275 | ### 💅 Refactors
276 |
277 | - Ensure missing configuration error ([c2c15f1](https://github.com/Yizack/nuxt-webhook-validators/commit/c2c15f1))
278 |
279 | ### ❤️ Contributors
280 |
281 | - Ahmed Rangel ([@ahmedrangel](http://github.com/ahmedrangel))
282 | - Yizack Rangel ([@Yizack](http://github.com/Yizack))
283 |
284 | ## v0.1.2
285 |
286 | [compare changes](https://github.com/Yizack/nuxt-webhook-validators/compare/v0.1.1...v0.1.2)
287 |
288 | ### 🚀 Enhancements
289 |
290 | - Add Hygraph webhook validator ([#2](https://github.com/Yizack/nuxt-webhook-validators/pull/2))
291 |
292 | ### 📖 Documentation
293 |
294 | - Use `GitHub` as example ([61b1e4a](https://github.com/Yizack/nuxt-webhook-validators/commit/61b1e4a))
295 |
296 | ### 🏡 Chore
297 |
298 | - Update dependencies ([8f4f788](https://github.com/Yizack/nuxt-webhook-validators/commit/8f4f788))
299 |
300 | ### ❤️ Contributors
301 |
302 | - Wayne Gibson ([@waynegibson](http://github.com/waynegibson))
303 | - Yizack Rangel ([@Yizack](http://github.com/Yizack))
304 |
305 | ## v0.1.1
306 |
307 | [compare changes](https://github.com/Yizack/nuxt-webhook-validators/compare/v0.1.0...v0.1.1)
308 |
309 | ### 🚀 Enhancements
310 |
311 | - Add `Dropbox` webhook validator ([46629f3](https://github.com/Yizack/nuxt-webhook-validators/commit/46629f3))
312 |
313 | ### 💅 Refactors
314 |
315 | - Rename algorithm constants ([a9b3cbc](https://github.com/Yizack/nuxt-webhook-validators/commit/a9b3cbc))
316 | - Remove `ohash` dependency and use `webcrypto` for `NuxtHub` validation ([8180f1c](https://github.com/Yizack/nuxt-webhook-validators/commit/8180f1c))
317 |
318 | ### 📖 Documentation
319 |
320 | - Add missing Meta validator ([1f109b5](https://github.com/Yizack/nuxt-webhook-validators/commit/1f109b5))
321 | - Use new `nuxi module add` command in installation ([62020b5](https://github.com/Yizack/nuxt-webhook-validators/commit/62020b5))
322 |
323 | ### 🏡 Chore
324 |
325 | - Direct import validators ([dd0a4da](https://github.com/Yizack/nuxt-webhook-validators/commit/dd0a4da))
326 | - Update dependencies ([ad8f227](https://github.com/Yizack/nuxt-webhook-validators/commit/ad8f227))
327 | - Update devtools ([a939eb4](https://github.com/Yizack/nuxt-webhook-validators/commit/a939eb4))
328 | - **types:** Reference `webcrypto.Algorithm` type ([e20f6c8](https://github.com/Yizack/nuxt-webhook-validators/commit/e20f6c8))
329 | - Add missing buffer import ([9b909e3](https://github.com/Yizack/nuxt-webhook-validators/commit/9b909e3))
330 |
331 | ### ❤️ Contributors
332 |
333 | - Yizack Rangel ([@Yizack](http://github.com/Yizack))
334 |
335 | ## v0.1.0
336 |
337 | [compare changes](https://github.com/Yizack/nuxt-webhook-validators/compare/v0.0.6...v0.1.0)
338 |
339 | ### 🚀 Enhancements
340 |
341 | - Add `Meta` webhook validator ([84b2a91](https://github.com/Yizack/nuxt-webhook-validators/commit/84b2a91))
342 |
343 | ### 💅 Refactors
344 |
345 | - Simplify signature compute and verify ([1d4d3f3](https://github.com/Yizack/nuxt-webhook-validators/commit/1d4d3f3))
346 |
347 | ### 🏡 Chore
348 |
349 | - Update dependencies ([2092724](https://github.com/Yizack/nuxt-webhook-validators/commit/2092724))
350 | - Lint ignore empty module options interface ([f89c0bc](https://github.com/Yizack/nuxt-webhook-validators/commit/f89c0bc))
351 | - Update eslint ([074fe4d](https://github.com/Yizack/nuxt-webhook-validators/commit/074fe4d))
352 | - Fix npm repo property ([c57e978](https://github.com/Yizack/nuxt-webhook-validators/commit/c57e978))
353 | - Update dependencies ([4ea7961](https://github.com/Yizack/nuxt-webhook-validators/commit/4ea7961))
354 |
355 | ### ✅ Tests
356 |
357 | - Stub paypal response ([d332e04](https://github.com/Yizack/nuxt-webhook-validators/commit/d332e04))
358 | - Import from e2e, nuxt 4 compat ([3a252d4](https://github.com/Yizack/nuxt-webhook-validators/commit/3a252d4))
359 |
360 | ### ❤️ Contributors
361 |
362 | - Yizack Rangel ([@Yizack](http://github.com/Yizack))
363 |
364 | ## v0.0.6
365 |
366 | [compare changes](https://github.com/Yizack/nuxt-webhook-validators/compare/v0.0.5...v0.0.6)
367 |
368 | ### 🚀 Enhancements
369 |
370 | - Add `Heroku` webhook validator ([ccad636](https://github.com/Yizack/nuxt-webhook-validators/commit/ccad636))
371 |
372 | ### 📖 Documentation
373 |
374 | - Add jsdoc `@see` and remove `@async` tag ([c940cf4](https://github.com/Yizack/nuxt-webhook-validators/commit/c940cf4))
375 |
376 | ### 🏡 Chore
377 |
378 | - Updates ([47e5dbf](https://github.com/Yizack/nuxt-webhook-validators/commit/47e5dbf))
379 |
380 | ### ✅ Tests
381 |
382 | - Import nuxt config webhook secrets from fixture ([474a8be](https://github.com/Yizack/nuxt-webhook-validators/commit/474a8be))
383 |
384 | ### ❤️ Contributors
385 |
386 | - Yizack Rangel ([@Yizack](http://github.com/Yizack))
387 |
388 | ## v0.0.5
389 |
390 | [compare changes](https://github.com/Yizack/nuxt-webhook-validators/compare/v0.0.4...v0.0.5)
391 |
392 | ### 🚀 Enhancements
393 |
394 | - Add `Discord` webhook validator ([#1](https://github.com/Yizack/nuxt-webhook-validators/pull/1))
395 |
396 | ### 🏡 Chore
397 |
398 | - Update dependencies ([e52e404](https://github.com/Yizack/nuxt-webhook-validators/commit/e52e404))
399 |
400 | ### ❤️ Contributors
401 |
402 | - Yizack Rangel ([@Yizack](http://github.com/Yizack))
403 | - Ahmed Rangel ([@ahmedrangel](http://github.com/ahmedrangel))
404 |
405 | ## v0.0.4
406 |
407 | [compare changes](https://github.com/Yizack/nuxt-webhook-validators/compare/v0.0.3...v0.0.4)
408 |
409 | ### 🚀 Enhancements
410 |
411 | - Add `nuxthub` webhook validator ([fbe5945](https://github.com/Yizack/nuxt-webhook-validators/commit/fbe5945))
412 |
413 | ### 📖 Documentation
414 |
415 | - Updates ([4a51084](https://github.com/Yizack/nuxt-webhook-validators/commit/4a51084))
416 | - Add nuxt modules badge ([6f39479](https://github.com/Yizack/nuxt-webhook-validators/commit/6f39479))
417 |
418 | ### 🏡 Chore
419 |
420 | - Add helpers ([406cecd](https://github.com/Yizack/nuxt-webhook-validators/commit/406cecd))
421 | - Import helpers ([01e4ff5](https://github.com/Yizack/nuxt-webhook-validators/commit/01e4ff5))
422 | - Update ci code ([cb1d472](https://github.com/Yizack/nuxt-webhook-validators/commit/cb1d472))
423 | - Remove api routes from fixtures, use playground as server dir ([5b60e59](https://github.com/Yizack/nuxt-webhook-validators/commit/5b60e59))
424 | - Capitalize some names ([d9e1c1a](https://github.com/Yizack/nuxt-webhook-validators/commit/d9e1c1a))
425 |
426 | ### ✅ Tests
427 |
428 | - Simulate webhook events in one server start ([2c93261](https://github.com/Yizack/nuxt-webhook-validators/commit/2c93261))
429 |
430 | ### ❤️ Contributors
431 |
432 | - Yizack Rangel ([@Yizack](http://github.com/Yizack))
433 |
434 | ## v0.0.3
435 |
436 | [compare changes](https://github.com/Yizack/nuxt-webhook-validators/compare/v0.0.2...v0.0.3)
437 |
438 | ### 🚀 Enhancements
439 |
440 | - Add Stripe webhook validator ([6a0b494](https://github.com/Yizack/nuxt-webhook-validators/commit/6a0b494))
441 |
442 | ### 📖 Documentation
443 |
444 | - Add more readme info ([e1be01b](https://github.com/Yizack/nuxt-webhook-validators/commit/e1be01b))
445 | - Consistent `createError` ([c0bcac1](https://github.com/Yizack/nuxt-webhook-validators/commit/c0bcac1))
446 |
447 | ### 🏡 Chore
448 |
449 | - Ci actions ([8bcdb41](https://github.com/Yizack/nuxt-webhook-validators/commit/8bcdb41))
450 | - Add update configs ([3da2b98](https://github.com/Yizack/nuxt-webhook-validators/commit/3da2b98))
451 | - Move playground into workspace ([2d0c343](https://github.com/Yizack/nuxt-webhook-validators/commit/2d0c343))
452 | - Regenerate lock ([d083fdc](https://github.com/Yizack/nuxt-webhook-validators/commit/d083fdc))
453 | - Update deps ([4a37a4e](https://github.com/Yizack/nuxt-webhook-validators/commit/4a37a4e))
454 | - Use recommended `statusCode` and `statusMessage` on playground error ([a49152a](https://github.com/Yizack/nuxt-webhook-validators/commit/a49152a))
455 | - Fix wrong test name ([0140183](https://github.com/Yizack/nuxt-webhook-validators/commit/0140183))
456 | - Update vue-tsc ([08ea51b](https://github.com/Yizack/nuxt-webhook-validators/commit/08ea51b))
457 | - Use getRequestHeaders instead of alias ([d3c670c](https://github.com/Yizack/nuxt-webhook-validators/commit/d3c670c))
458 | - Move constants outside function ([b018b78](https://github.com/Yizack/nuxt-webhook-validators/commit/b018b78))
459 |
460 | ### ✅ Tests
461 |
462 | - Add basic test ([3febbec](https://github.com/Yizack/nuxt-webhook-validators/commit/3febbec))
463 | - Fix basic test ([687a364](https://github.com/Yizack/nuxt-webhook-validators/commit/687a364))
464 | - Add some tests ([fff9828](https://github.com/Yizack/nuxt-webhook-validators/commit/fff9828))
465 |
466 | ### ❤️ Contributors
467 |
468 | - Yizack Rangel ([@Yizack](http://github.com/Yizack))
469 |
470 | ## v0.0.2
471 |
472 | [compare changes](https://github.com/Yizack/nuxt-webhook-validators/compare/v0.0.1...v0.0.2)
473 |
474 | ### 🚀 Enhancements
475 |
476 | - Add webhook validator for GitHub ([bee0536](https://github.com/Yizack/nuxt-webhook-validators/commit/bee0536))
477 |
478 | ### 📖 Documentation
479 |
480 | - Add GitHub validator info ([51a7dbc](https://github.com/Yizack/nuxt-webhook-validators/commit/51a7dbc))
481 | - Update readme ([4140e32](https://github.com/Yizack/nuxt-webhook-validators/commit/4140e32))
482 |
483 | ### 🏡 Chore
484 |
485 | - Add banner ([d1745c6](https://github.com/Yizack/nuxt-webhook-validators/commit/d1745c6))
486 | - Add webhook validator for GitHub ([13a868d](https://github.com/Yizack/nuxt-webhook-validators/commit/13a868d))
487 | - Set status as unauthorized ([ca19a30](https://github.com/Yizack/nuxt-webhook-validators/commit/ca19a30))
488 |
489 | ### ❤️ Contributors
490 |
491 | - Yizack Rangel ([@Yizack](http://github.com/Yizack))
492 |
493 | ## v0.0.1
494 |
495 |
496 | ### 💅 Refactors
497 |
498 | - Lib name approach ([249734e](https://github.com/Yizack/nuxt-webhook-validators/commit/249734e))
499 |
500 | ### 📖 Documentation
501 |
502 | - Add module info ([4f1243f](https://github.com/Yizack/nuxt-webhook-validators/commit/4f1243f))
503 |
504 | ### 🏡 Chore
505 |
506 | - Init ([687e60c](https://github.com/Yizack/nuxt-webhook-validators/commit/687e60c))
507 | - Add supported webhook validators head ([672be15](https://github.com/Yizack/nuxt-webhook-validators/commit/672be15))
508 | - Remove unused dep ([7c2d2d8](https://github.com/Yizack/nuxt-webhook-validators/commit/7c2d2d8))
509 | - **release:** V1.0.1 ([980371e](https://github.com/Yizack/nuxt-webhook-validators/commit/980371e))
510 | - Import node:crypto and node:buffer twitch ([d93ec0f](https://github.com/Yizack/nuxt-webhook-validators/commit/d93ec0f))
511 | - **release:** V1.0.2 ([42a1ed8](https://github.com/Yizack/nuxt-webhook-validators/commit/42a1ed8))
512 | - Delete workflow for now ([991e96b](https://github.com/Yizack/nuxt-webhook-validators/commit/991e96b))
513 |
514 | ### ❤️ Contributors
515 |
516 | - Yizack Rangel ([@Yizack](http://github.com/Yizack))
517 |
518 |
--------------------------------------------------------------------------------