├── .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 | 4 | -------------------------------------------------------------------------------- /playground/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../.nuxt/tsconfig.server.json" 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/basic/app/app.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /test/fixtures/basic/app/pages/index.vue: -------------------------------------------------------------------------------- 1 | 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 | ![webhook-validators](https://github.com/Yizack/nuxt-webhook-validators/assets/16264115/56cded71-46b2-4895-8732-484ab6df5181) 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 | --------------------------------------------------------------------------------