From 4b5fdfc1ac37b5de2abe0856419b73b6db50ffbe Mon Sep 17 00:00:00 2001 From: Sola Samuel Date: Mon, 4 May 2026 23:25:11 +0100 Subject: [PATCH 01/11] feat(stripe-webhook): initialise package scaffold --- packages/stripe-webhook/README.md | 88 +++++++++++++++++++ packages/stripe-webhook/deno.json | 15 ++++ .../stripe-webhook/eslint-suppressions.json | 30 +++++++ packages/stripe-webhook/package.json | 56 ++++++++++++ packages/stripe-webhook/src/index.test.ts | 65 ++++++++++++++ packages/stripe-webhook/src/index.ts | 1 + packages/stripe-webhook/tsconfig.build.json | 5 ++ packages/stripe-webhook/tsconfig.json | 12 +++ packages/stripe-webhook/tsconfig.spec.json | 8 ++ packages/stripe-webhook/tsdown.config.ts | 11 +++ packages/stripe-webhook/vitest.config.ts | 10 +++ 11 files changed, 301 insertions(+) create mode 100644 packages/stripe-webhook/README.md create mode 100644 packages/stripe-webhook/deno.json create mode 100644 packages/stripe-webhook/eslint-suppressions.json create mode 100644 packages/stripe-webhook/package.json create mode 100644 packages/stripe-webhook/src/index.test.ts create mode 100644 packages/stripe-webhook/src/index.ts create mode 100644 packages/stripe-webhook/tsconfig.build.json create mode 100644 packages/stripe-webhook/tsconfig.json create mode 100644 packages/stripe-webhook/tsconfig.spec.json create mode 100644 packages/stripe-webhook/tsdown.config.ts create mode 100644 packages/stripe-webhook/vitest.config.ts diff --git a/packages/stripe-webhook/README.md b/packages/stripe-webhook/README.md new file mode 100644 index 000000000..ed115e917 --- /dev/null +++ b/packages/stripe-webhook/README.md @@ -0,0 +1,88 @@ +# Sentry Middleware for Hono + +[![codecov](https://codecov.io/github/honojs/middleware/graph/badge.svg?flag=sentry)](https://codecov.io/github/honojs/middleware) + +This middleware integrates [Hono](https://github.com/honojs/hono) with Sentry. It captures exceptions and sends them to the specified Sentry data source name (DSN) using [toucan-js](https://github.com/robertcepa/toucan-js). + +## Installation + +```plain +npm i hono @hono/sentry +``` + +## Configuration + +If you're running your application on Cloudflare Workers, set a binding value named `SENTRY_DSN`, which will be used as the DSN. For instance, during development, you can specify this in `.dev.vars`: + +```plain +SENTRY_DSN= +``` + +On other platforms, you can directly provide the DSN by passing it as an option: + +```ts +sentry({ + dsn: ``, +}) +``` + +## How to Use + +```ts +import { Hono } from 'hono' +import { sentry } from '@hono/sentry' + +const app = new Hono() + +app.use('*', sentry()) +app.get('/', (c) => c.text('foo')) + +export default app +``` + +Options: + +```ts +import type { Options as ToucanOptions } from 'toucan-js' +type Options = Omit +``` + +### For Deno Users + +```ts +import { serve } from 'https://deno.land/std/http/server.ts' +import { sentry } from 'npm:@hono/sentry' +import { Hono } from 'https://deno.land/x/hono/mod.ts' + +const app = new Hono() + +app.use('*', sentry({ dsn: 'https://xxxxxx@xxx.ingest.sentry.io/xxxxxx' })) +app.get('/', (c) => c.text('foo')) + +serve(app.fetch) +``` + +### Accessing an instance of `Sentry` + +You can retrieve an instance of `Sentry` using `c.get('sentry')`. + +```ts +app.onError((e, c) => { + c.get('sentry').setContext('character', { + name: 'Mighty Fighter', + age: 19, + attack_type: 'melee', + }) + c.get('sentry').captureException(e) + return c.text('Internal Server Error', 500) +}) +``` + +## Authors + +- Samuel Lippert - +- Yusuke Wada - + +## License + +MIT diff --git a/packages/stripe-webhook/deno.json b/packages/stripe-webhook/deno.json new file mode 100644 index 000000000..25b52df0a --- /dev/null +++ b/packages/stripe-webhook/deno.json @@ -0,0 +1,15 @@ +{ + "name": "@hono/sentry", + "version": "1.2.2", + "license": "MIT", + "exports": { + ".": "./src/index.ts" + }, + "imports": { + "hono": "jsr:@hono/hono@^4.8.3" + }, + "publish": { + "include": ["deno.json", "README.md", "src/**/*.ts"], + "exclude": ["src/**/*.test.ts"] + } +} diff --git a/packages/stripe-webhook/eslint-suppressions.json b/packages/stripe-webhook/eslint-suppressions.json new file mode 100644 index 000000000..68ac7c61e --- /dev/null +++ b/packages/stripe-webhook/eslint-suppressions.json @@ -0,0 +1,30 @@ +{ + "src/index.test.ts": { + "@typescript-eslint/no-misused-promises": { + "count": 1 + }, + "@typescript-eslint/no-unsafe-call": { + "count": 1 + }, + "@typescript-eslint/no-unsafe-return": { + "count": 1 + }, + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 1 + }, + "@typescript-eslint/unbound-method": { + "count": 1 + } + }, + "src/index.ts": { + "@typescript-eslint/no-misused-promises": { + "count": 1 + }, + "@typescript-eslint/no-unsafe-assignment": { + "count": 1 + }, + "@typescript-eslint/no-unsafe-member-access": { + "count": 2 + } + } +} diff --git a/packages/stripe-webhook/package.json b/packages/stripe-webhook/package.json new file mode 100644 index 000000000..e01d2d19a --- /dev/null +++ b/packages/stripe-webhook/package.json @@ -0,0 +1,56 @@ +{ + "name": "@hono/stripe-webhook", + "version": "0.0.1", + "description": "Stripe Webhook Middleware for Hono", + "type": "module", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsdown", + "format": "prettier --check . --ignore-path ../../.gitignore", + "lint": "eslint", + "typecheck": "tsc -b tsconfig.json", + "test": "vitest", + "version:jsr": "yarn version:set $npm_package_version" + }, + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + } + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/honojs/middleware.git", + "directory": "packages/stripe-webhook" + }, + "homepage": "https://github.com/honojs/middleware", + "author": "Samuel Lippert (https://github.com/sam-lippert)", + "publishConfig": { + "registry": "https://registry.npmjs.org", + "access": "public", + "provenance": true + }, + "peerDependencies": { + "hono": ">=3.0.0" + }, + "dependencies": { + "stripe": "^17.0.0" + }, + "devDependencies": { + "hono": "^4.11.5", + "tsdown": "^0.15.9", + "typescript": "^5.9.3", + "vitest": "^4.1.0-beta.1" + } +} diff --git a/packages/stripe-webhook/src/index.test.ts b/packages/stripe-webhook/src/index.test.ts new file mode 100644 index 000000000..f0e82eeb6 --- /dev/null +++ b/packages/stripe-webhook/src/index.test.ts @@ -0,0 +1,65 @@ +import { Hono } from 'hono' +import { Toucan } from 'toucan-js' +import { getSentry, sentry } from '.' + +// Mock +class Context implements ExecutionContext { + passThroughOnException(): void { + throw new Error('Method not implemented.') + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async waitUntil(promise: Promise): Promise { + await promise + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + props: any +} + +vi.mock(import('toucan-js'), async (importOriginal) => { + const original = await importOriginal() + + Object.assign(original.Toucan.prototype, { captureException: vi.fn(), log: vi.fn() }) + + return original +}) + +const callback = vi.fn() + +describe('Sentry middleware', () => { + const app = new Hono() + + app.use('/sentry/*', sentry(undefined, callback)) + app.get('/sentry/foo', (c) => c.text('foo')) + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + app.get('/sentry/bar', (c) => getSentry(c).log('bar') || c.text('bar')) + app.get('/sentry/error', () => { + throw new Error('a catastrophic error') + }) + + it('Should initialize Toucan', async () => { + const req = new Request('http://localhost/sentry/foo') + const res = await app.fetch(req, {}, new Context()) + expect(res).not.toBeNull() + expect(res.status).toBe(200) + expect(callback).toHaveBeenCalled() + }) + + it('Should make Sentry available via context', async () => { + const req = new Request('http://localhost/sentry/bar') + const res = await app.fetch(req, {}, new Context()) + expect(res).not.toBeNull() + expect(res.status).toBe(200) + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + expect(Toucan.prototype.log).toHaveBeenCalled() + }) + + it('Should report errors', async () => { + const req = new Request('http://localhost/sentry/error') + const res = await app.fetch(req, {}, new Context()) + expect(res).not.toBeNull() + expect(res.status).toBe(500) + expect(Toucan.prototype.captureException).toHaveBeenCalled() + }) +}) diff --git a/packages/stripe-webhook/src/index.ts b/packages/stripe-webhook/src/index.ts new file mode 100644 index 000000000..719c28b74 --- /dev/null +++ b/packages/stripe-webhook/src/index.ts @@ -0,0 +1 @@ +export const _placeholder = undefined diff --git a/packages/stripe-webhook/tsconfig.build.json b/packages/stripe-webhook/tsconfig.build.json new file mode 100644 index 000000000..4a1f19acc --- /dev/null +++ b/packages/stripe-webhook/tsconfig.build.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.build.json", + "compilerOptions": {}, + "references": [] +} diff --git a/packages/stripe-webhook/tsconfig.json b/packages/stripe-webhook/tsconfig.json new file mode 100644 index 000000000..d4ad6cfa3 --- /dev/null +++ b/packages/stripe-webhook/tsconfig.json @@ -0,0 +1,12 @@ +{ + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.build.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/packages/stripe-webhook/tsconfig.spec.json b/packages/stripe-webhook/tsconfig.spec.json new file mode 100644 index 000000000..c5dbbd15d --- /dev/null +++ b/packages/stripe-webhook/tsconfig.spec.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "../../dist/packages/sentry", + "types": ["vitest/globals"] + }, + "references": [] +} diff --git a/packages/stripe-webhook/tsdown.config.ts b/packages/stripe-webhook/tsdown.config.ts new file mode 100644 index 000000000..4baf13a49 --- /dev/null +++ b/packages/stripe-webhook/tsdown.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'tsdown' + +export default defineConfig({ + attw: true, + clean: true, + dts: true, + entry: 'src/index.ts', + format: ['cjs', 'esm'], + publint: true, + tsconfig: 'tsconfig.build.json', +}) diff --git a/packages/stripe-webhook/vitest.config.ts b/packages/stripe-webhook/vitest.config.ts new file mode 100644 index 000000000..094d5fd56 --- /dev/null +++ b/packages/stripe-webhook/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineProject } from 'vitest/config' + +export default defineProject({ + test: { + globals: true, + include: ['src/**/*.test.ts'], + restoreMocks: true, + unstubEnvs: true, + }, +}) From 53a4ef9faab1d298701bced24f6ec981a0adab03 Mon Sep 17 00:00:00 2001 From: Sola Samuel Date: Sat, 9 May 2026 19:45:19 +0100 Subject: [PATCH 02/11] feat(stripe-webhook): implement webhook signature verification --- packages/stripe-webhook/src/index.test.ts | 118 +++++++++++++--------- packages/stripe-webhook/src/index.ts | 47 ++++++++- yarn.lock | 49 +++++++++ 3 files changed, 163 insertions(+), 51 deletions(-) diff --git a/packages/stripe-webhook/src/index.test.ts b/packages/stripe-webhook/src/index.test.ts index f0e82eeb6..a0984013a 100644 --- a/packages/stripe-webhook/src/index.test.ts +++ b/packages/stripe-webhook/src/index.test.ts @@ -1,65 +1,83 @@ import { Hono } from 'hono' -import { Toucan } from 'toucan-js' -import { getSentry, sentry } from '.' +import { stripeWebhook } from '.' -// Mock -class Context implements ExecutionContext { - passThroughOnException(): void { - throw new Error('Method not implemented.') - } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - async waitUntil(promise: Promise): Promise { - await promise - } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - props: any -} - -vi.mock(import('toucan-js'), async (importOriginal) => { - const original = await importOriginal() +const constructEventAsync = vi.fn() - Object.assign(original.Toucan.prototype, { captureException: vi.fn(), log: vi.fn() }) - - return original +vi.mock('stripe', () => { + class StripeMock { + webhooks = { constructEventAsync } + } + return { default: StripeMock } }) -const callback = vi.fn() - -describe('Sentry middleware', () => { - const app = new Hono() +describe('Stripe webhook middleware', () => { + const secret = 'whsec_test' + const buildApp = () => { + const app = new Hono() + app.post('/webhook', stripeWebhook({ secret }), (c) => { + const event = c.get('stripeEvent') + return c.json({ type: event.type }) + }) + return app + } - app.use('/sentry/*', sentry(undefined, callback)) - app.get('/sentry/foo', (c) => c.text('foo')) - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error - app.get('/sentry/bar', (c) => getSentry(c).log('bar') || c.text('bar')) - app.get('/sentry/error', () => { - throw new Error('a catastrophic error') + it('Should reject requests without a stripe-signature header', async () => { + const app = buildApp() + const res = await app.request('/webhook', { + method: 'POST', + body: '{}', + }) + expect(res.status).toBe(400) + expect(await res.json()).toEqual({ error: 'Invalid webhook signature' }) + expect(constructEventAsync).not.toHaveBeenCalled() }) - it('Should initialize Toucan', async () => { - const req = new Request('http://localhost/sentry/foo') - const res = await app.fetch(req, {}, new Context()) - expect(res).not.toBeNull() - expect(res.status).toBe(200) - expect(callback).toHaveBeenCalled() + it('Should return 400 when signature verification fails', async () => { + constructEventAsync.mockRejectedValueOnce(new Error('bad sig')) + const app = buildApp() + const res = await app.request('/webhook', { + method: 'POST', + headers: { 'stripe-signature': 't=1,v1=deadbeef' }, + body: '{}', + }) + expect(res.status).toBe(400) + expect(await res.json()).toEqual({ error: 'Invalid webhook signature' }) }) - it('Should make Sentry available via context', async () => { - const req = new Request('http://localhost/sentry/bar') - const res = await app.fetch(req, {}, new Context()) - expect(res).not.toBeNull() + it('Should expose the verified event on the context and continue', async () => { + const event = { id: 'evt_1', type: 'payment_intent.succeeded' } + constructEventAsync.mockResolvedValueOnce(event) + const app = buildApp() + const res = await app.request('/webhook', { + method: 'POST', + headers: { 'stripe-signature': 't=1,v1=deadbeef' }, + body: '{"id":"evt_1"}', + }) expect(res.status).toBe(200) - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error - expect(Toucan.prototype.log).toHaveBeenCalled() + expect(await res.json()).toEqual({ type: 'payment_intent.succeeded' }) + expect(constructEventAsync).toHaveBeenCalledWith( + '{"id":"evt_1"}', + 't=1,v1=deadbeef', + secret, + 300 + ) }) - it('Should report errors', async () => { - const req = new Request('http://localhost/sentry/error') - const res = await app.fetch(req, {}, new Context()) - expect(res).not.toBeNull() - expect(res.status).toBe(500) - expect(Toucan.prototype.captureException).toHaveBeenCalled() + it('Should forward a custom tolerance to constructEventAsync', async () => { + constructEventAsync.mockResolvedValueOnce({ id: 'evt_2', type: 'charge.refunded' }) + const app = new Hono() + app.post('/webhook', stripeWebhook({ secret, tolerance: 60 }), (c) => c.text('ok')) + const res = await app.request('/webhook', { + method: 'POST', + headers: { 'stripe-signature': 't=1,v1=deadbeef' }, + body: 'payload', + }) + expect(res.status).toBe(200) + expect(constructEventAsync).toHaveBeenLastCalledWith( + 'payload', + 't=1,v1=deadbeef', + secret, + 60 + ) }) }) diff --git a/packages/stripe-webhook/src/index.ts b/packages/stripe-webhook/src/index.ts index 719c28b74..0f837c375 100644 --- a/packages/stripe-webhook/src/index.ts +++ b/packages/stripe-webhook/src/index.ts @@ -1 +1,46 @@ -export const _placeholder = undefined +import type { MiddlewareHandler } from 'hono' +import Stripe from 'stripe' + +export type StripeWebhookVariables = { + stripeEvent: Stripe.Event +} + +declare module 'hono' { + interface ContextVariableMap { + stripeEvent: Stripe.Event + } +} + +type Options = { + secret: string + tolerance?: number +} + +export const stripeWebhook = (options: Options): MiddlewareHandler => { + const { secret, tolerance = 300 } = options + const stripe = new Stripe(secret, { apiVersion: '2025-02-24.acacia' }) + + return async (c, next) => { + const rawBody = await c.req.raw.clone().text() + const signature = c.req.header('stripe-signature') + + if (!signature) { + return c.json({ error: 'Invalid webhook signature' }, 400) + } + + let event: Stripe.Event + try { + event = await stripe.webhooks.constructEventAsync( + rawBody, + signature, + secret, + tolerance + ) + } catch { + return c.json({ error: 'Invalid webhook signature' }, 400) + } + + c.set('stripeEvent', event) + return next() + } +} diff --git a/yarn.lock b/yarn.lock index 31525ac78..d3ce0b139 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2360,6 +2360,20 @@ __metadata: languageName: unknown linkType: soft +"@hono/stripe-webhook@workspace:packages/stripe-webhook": + version: 0.0.0-use.local + resolution: "@hono/stripe-webhook@workspace:packages/stripe-webhook" + dependencies: + hono: "npm:^4.11.5" + stripe: "npm:^17.0.0" + tsdown: "npm:^0.15.9" + typescript: "npm:^5.9.3" + vitest: "npm:^4.1.0-beta.1" + peerDependencies: + hono: ">=3.0.0" + languageName: unknown + linkType: soft + "@hono/structured-logger@workspace:packages/structured-logger": version: 0.0.0-use.local resolution: "@hono/structured-logger@workspace:packages/structured-logger" @@ -5126,6 +5140,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:>=8.1.0": + version: 25.6.0 + resolution: "@types/node@npm:25.6.0" + dependencies: + undici-types: "npm:~7.19.0" + checksum: 10c0/d2d2015630ff098a201407f55f5077a20270ae4f465c739b40865cd9933b91b9c5d2b85568eadaf3db0801b91e267333ca7eb39f007428b173d1cdab4b339ac5 + languageName: node + linkType: hard + "@types/node@npm:^12.7.1": version: 12.20.55 resolution: "@types/node@npm:12.20.55" @@ -13560,6 +13583,15 @@ __metadata: languageName: node linkType: hard +"qs@npm:^6.11.0": + version: 6.15.1 + resolution: "qs@npm:6.15.1" + dependencies: + side-channel: "npm:^1.1.0" + checksum: 10c0/19ee504f0ebff72598503e38cd6d9bd7b52a8ab62ae18b1e6bee3d4db58469bd65871ef1893a881bafb0f80ef2f9ab586e1f255cf25cc8d816c0f5a704721d97 + languageName: node + linkType: hard + "qs@npm:^6.14.0, qs@npm:^6.6.0": version: 6.14.0 resolution: "qs@npm:6.14.0" @@ -15250,6 +15282,16 @@ __metadata: languageName: node linkType: hard +"stripe@npm:^17.0.0": + version: 17.7.0 + resolution: "stripe@npm:17.7.0" + dependencies: + "@types/node": "npm:>=8.1.0" + qs: "npm:^6.11.0" + checksum: 10c0/df67c6d455bd0dd87140640924c220fa9581fc00c3267d171f407c8d088f946f61e3ae7e88a89e7dd705b10fd5254630fc943222eb6f003390ebafbd391f81b2 + languageName: node + linkType: hard + "stubs@npm:^3.0.0": version: 3.0.0 resolution: "stubs@npm:3.0.0" @@ -16107,6 +16149,13 @@ __metadata: languageName: node linkType: hard +"undici-types@npm:~7.19.0": + version: 7.19.2 + resolution: "undici-types@npm:7.19.2" + checksum: 10c0/7159f10546f9f6c47d36776bb1bbf8671e87c1e587a6fee84ae1f111ae8de4f914efa8ca0dfcd224f4f4a9dfc3f6028f627ccb5ddaccf82d7fd54671b89fac3e + languageName: node + linkType: hard + "undici@npm:*": version: 7.5.0 resolution: "undici@npm:7.5.0" From af3999e903ce23400b6e925f5412563f745186f6 Mon Sep 17 00:00:00 2001 From: Sola Samuel Date: Sat, 9 May 2026 19:47:35 +0100 Subject: [PATCH 03/11] test(stripe-webhook): add unit tests --- packages/stripe-webhook/src/index.test.ts | 31 +++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/packages/stripe-webhook/src/index.test.ts b/packages/stripe-webhook/src/index.test.ts index a0984013a..8b24cec89 100644 --- a/packages/stripe-webhook/src/index.test.ts +++ b/packages/stripe-webhook/src/index.test.ts @@ -80,4 +80,35 @@ describe('Stripe webhook middleware', () => { 60 ) }) + + it('Should return 400 when the timestamp is outside the tolerance window', async () => { + constructEventAsync.mockRejectedValueOnce( + new Error('Timestamp outside the tolerance zone') + ) + const app = buildApp() + const res = await app.request('/webhook', { + method: 'POST', + headers: { 'stripe-signature': 't=1,v1=deadbeef' }, + body: '{}', + }) + expect(res.status).toBe(400) + expect(await res.json()).toEqual({ error: 'Invalid webhook signature' }) + }) + + it('Should leave the original request body readable by downstream handlers', async () => { + const payload = '{"id":"evt_3","type":"customer.created"}' + constructEventAsync.mockResolvedValueOnce({ id: 'evt_3', type: 'customer.created' }) + const app = new Hono() + app.post('/webhook', stripeWebhook({ secret }), async (c) => { + const body = await c.req.text() + return c.json({ body }) + }) + const res = await app.request('/webhook', { + method: 'POST', + headers: { 'stripe-signature': 't=1,v1=deadbeef' }, + body: payload, + }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ body: payload }) + }) }) From 8b3c5288ba1a0e8246efebf945c4fc52d9ce3e5d Mon Sep 17 00:00:00 2001 From: Sola Samuel Date: Sat, 9 May 2026 20:02:59 +0100 Subject: [PATCH 04/11] docs(stripe-webhook): README and usage example --- packages/stripe-webhook/README.md | 82 +++++++++++++++---------------- 1 file changed, 39 insertions(+), 43 deletions(-) diff --git a/packages/stripe-webhook/README.md b/packages/stripe-webhook/README.md index ed115e917..f02af6ddf 100644 --- a/packages/stripe-webhook/README.md +++ b/packages/stripe-webhook/README.md @@ -1,28 +1,28 @@ -# Sentry Middleware for Hono +# Stripe Webhook Middleware for Hono -[![codecov](https://codecov.io/github/honojs/middleware/graph/badge.svg?flag=sentry)](https://codecov.io/github/honojs/middleware) +[![codecov](https://codecov.io/github/honojs/middleware/graph/badge.svg?flag=stripe-webhook)](https://codecov.io/github/honojs/middleware) -This middleware integrates [Hono](https://github.com/honojs/hono) with Sentry. It captures exceptions and sends them to the specified Sentry data source name (DSN) using [toucan-js](https://github.com/robertcepa/toucan-js). +This middleware integrates [Hono](https://github.com/honojs/hono) with [Stripe](https://stripe.com) webhook signature verification. It validates the `stripe-signature` header against the raw request body using [stripe-node](https://github.com/stripe/stripe-node) and exposes the verified `Stripe.Event` on the request context. ## Installation ```plain -npm i hono @hono/sentry +npm i hono stripe @hono/stripe-webhook ``` ## Configuration -If you're running your application on Cloudflare Workers, set a binding value named `SENTRY_DSN`, which will be used as the DSN. For instance, during development, you can specify this in `.dev.vars`: +Provide your endpoint signing secret (e.g. `whsec_...`) to the middleware. On Cloudflare Workers, set a binding named `STRIPE_WEBHOOK_SECRET` and read it from `c.env`. For instance, during development, you can specify this in `.dev.vars`: ```plain -SENTRY_DSN= +STRIPE_WEBHOOK_SECRET=whsec_... ``` -On other platforms, you can directly provide the DSN by passing it as an option: +On other platforms, you can directly provide the secret by passing it as an option: ```ts -sentry({ - dsn: ``, +stripeWebhook({ + secret: '', }) ``` @@ -30,58 +30,54 @@ sentry({ ```ts import { Hono } from 'hono' -import { sentry } from '@hono/sentry' +import { stripeWebhook } from '@hono/stripe-webhook' const app = new Hono() -app.use('*', sentry()) -app.get('/', (c) => c.text('foo')) +app.post('/webhook', stripeWebhook({ secret: process.env.STRIPE_WEBHOOK_SECRET! }), (c) => { + const event = c.get('stripeEvent') + if (event.type === 'payment_intent.succeeded') { + // handle the event + } + return c.json({ received: true }) +}) export default app ``` Options: -```ts -import type { Options as ToucanOptions } from 'toucan-js' -type Options = Omit -``` - -### For Deno Users +| Option | Type | Default | Description | +| ----------- | -------- | ------- | --------------------------------------------------------------------------------- | +| `secret` | `string` | — | Required. Your Stripe webhook endpoint signing secret (`whsec_...`). | +| `tolerance` | `number` | `300` | Maximum age (in seconds) of the signed timestamp before the request is rejected. | -```ts -import { serve } from 'https://deno.land/std/http/server.ts' -import { sentry } from 'npm:@hono/sentry' -import { Hono } from 'https://deno.land/x/hono/mod.ts' - -const app = new Hono() +### Accessing the verified event -app.use('*', sentry({ dsn: 'https://xxxxxx@xxx.ingest.sentry.io/xxxxxx' })) -app.get('/', (c) => c.text('foo')) - -serve(app.fetch) -``` - -### Accessing an instance of `Sentry` - -You can retrieve an instance of `Sentry` using `c.get('sentry')`. +You can retrieve the verified `Stripe.Event` using `c.get('stripeEvent')`. ```ts -app.onError((e, c) => { - c.get('sentry').setContext('character', { - name: 'Mighty Fighter', - age: 19, - attack_type: 'melee', - }) - c.get('sentry').captureException(e) - return c.text('Internal Server Error', 500) +app.post('/webhook', stripeWebhook({ secret }), async (c) => { + const event = c.get('stripeEvent') + switch (event.type) { + case 'checkout.session.completed': + // ... + break + case 'invoice.payment_failed': + // ... + break + } + return c.json({ received: true }) }) ``` +### Why `clone()` is used to read the body + +Stripe signature verification must run against the **raw, byte-for-byte** request body. The middleware reads the body with `c.req.raw.clone().text()` so the original `Request` stream stays untouched and downstream handlers can still call `c.req.text()`, `c.req.json()`, or read `c.req.raw.body` themselves. Without `clone()`, the body stream would be consumed by the middleware and any subsequent read would throw. + ## Authors -- Samuel Lippert - -- Yusuke Wada - +- Sola Samuel - ## License From adf5771e6587b06ee7589c0606f65bc1b1abc1bc Mon Sep 17 00:00:00 2001 From: Sola Samuel Date: Sat, 9 May 2026 20:05:55 +0100 Subject: [PATCH 05/11] chore: changeset for stripe-webhook --- .changeset/petite-phones-drop.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/petite-phones-drop.md diff --git a/.changeset/petite-phones-drop.md b/.changeset/petite-phones-drop.md new file mode 100644 index 000000000..5a59f14f5 --- /dev/null +++ b/.changeset/petite-phones-drop.md @@ -0,0 +1,5 @@ +--- +'@hono/stripe-webhook': minor +--- + +Add Stripe webhook verification middleware From 651f946149ef732bc2a288d65f8b8efa3618cbf4 Mon Sep 17 00:00:00 2001 From: Sola Samuel Date: Sat, 9 May 2026 20:16:14 +0100 Subject: [PATCH 06/11] fix(stripe-webhook): move stripe to peerDependencies Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/stripe-webhook/package.json | 5 ++--- yarn.lock | 7 ++++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/stripe-webhook/package.json b/packages/stripe-webhook/package.json index e01d2d19a..25810cbf4 100644 --- a/packages/stripe-webhook/package.json +++ b/packages/stripe-webhook/package.json @@ -42,13 +42,12 @@ "provenance": true }, "peerDependencies": { - "hono": ">=3.0.0" - }, - "dependencies": { + "hono": ">=3.0.0", "stripe": "^17.0.0" }, "devDependencies": { "hono": "^4.11.5", + "stripe": "^17.0.0", "tsdown": "^0.15.9", "typescript": "^5.9.3", "vitest": "^4.1.0-beta.1" diff --git a/yarn.lock b/yarn.lock index d3ce0b139..bd2cce4a8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2371,6 +2371,7 @@ __metadata: vitest: "npm:^4.1.0-beta.1" peerDependencies: hono: ">=3.0.0" + stripe: ^17.0.0 languageName: unknown linkType: soft @@ -5141,11 +5142,11 @@ __metadata: linkType: hard "@types/node@npm:>=8.1.0": - version: 25.6.0 - resolution: "@types/node@npm:25.6.0" + version: 25.6.2 + resolution: "@types/node@npm:25.6.2" dependencies: undici-types: "npm:~7.19.0" - checksum: 10c0/d2d2015630ff098a201407f55f5077a20270ae4f465c739b40865cd9933b91b9c5d2b85568eadaf3db0801b91e267333ca7eb39f007428b173d1cdab4b339ac5 + checksum: 10c0/7f540331aa3ab88c285aeaf2eb43e3992f54f0cdb7f3593d156af67b199d4eaf56590fa1c310a00aa58ff69dba668cb3915a157fe83cd6b40a73bb338a12f09a languageName: node linkType: hard From f07e23114b16734af9c0a5887bc2a2dcfb0a51c2 Mon Sep 17 00:00:00 2001 From: Sola Samuel Date: Sat, 9 May 2026 21:04:48 +0100 Subject: [PATCH 07/11] chore(stripe-webhook): prune stale eslint suppressions Co-Authored-By: Claude Opus 4.7 (1M context) --- .../stripe-webhook/eslint-suppressions.json | 31 +------------------ 1 file changed, 1 insertion(+), 30 deletions(-) diff --git a/packages/stripe-webhook/eslint-suppressions.json b/packages/stripe-webhook/eslint-suppressions.json index 68ac7c61e..0967ef424 100644 --- a/packages/stripe-webhook/eslint-suppressions.json +++ b/packages/stripe-webhook/eslint-suppressions.json @@ -1,30 +1 @@ -{ - "src/index.test.ts": { - "@typescript-eslint/no-misused-promises": { - "count": 1 - }, - "@typescript-eslint/no-unsafe-call": { - "count": 1 - }, - "@typescript-eslint/no-unsafe-return": { - "count": 1 - }, - "@typescript-eslint/prefer-nullish-coalescing": { - "count": 1 - }, - "@typescript-eslint/unbound-method": { - "count": 1 - } - }, - "src/index.ts": { - "@typescript-eslint/no-misused-promises": { - "count": 1 - }, - "@typescript-eslint/no-unsafe-assignment": { - "count": 1 - }, - "@typescript-eslint/no-unsafe-member-access": { - "count": 2 - } - } -} +{} From 913c373fe48f576136e866b330a391d7a8fa01a6 Mon Sep 17 00:00:00 2001 From: Sola Samuel Date: Sun, 10 May 2026 12:23:30 +0100 Subject: [PATCH 08/11] fix(stripe-webhook): correct package name and version in deno.json Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/stripe-webhook/deno.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/stripe-webhook/deno.json b/packages/stripe-webhook/deno.json index 25b52df0a..a83ddd6b0 100644 --- a/packages/stripe-webhook/deno.json +++ b/packages/stripe-webhook/deno.json @@ -1,6 +1,6 @@ { - "name": "@hono/sentry", - "version": "1.2.2", + "name": "@hono/stripe-webhook", + "version": "0.0.1", "license": "MIT", "exports": { ".": "./src/index.ts" From c1620d3f1611cf3fffca9cf5932dae6f34409c59 Mon Sep 17 00:00:00 2001 From: Sola Samuel Date: Sun, 10 May 2026 12:23:41 +0100 Subject: [PATCH 09/11] fix(stripe-webhook): read body only after signature header is present Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/stripe-webhook/src/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/stripe-webhook/src/index.ts b/packages/stripe-webhook/src/index.ts index 0f837c375..135d4fc63 100644 --- a/packages/stripe-webhook/src/index.ts +++ b/packages/stripe-webhook/src/index.ts @@ -21,13 +21,14 @@ export const stripeWebhook = (options: Options): MiddlewareHandler => { const stripe = new Stripe(secret, { apiVersion: '2025-02-24.acacia' }) return async (c, next) => { - const rawBody = await c.req.raw.clone().text() const signature = c.req.header('stripe-signature') if (!signature) { return c.json({ error: 'Invalid webhook signature' }, 400) } + const rawBody = await c.req.raw.clone().text() + let event: Stripe.Event try { event = await stripe.webhooks.constructEventAsync( From dd59efbde696abb64f9119a63eafc5d42a18e183 Mon Sep 17 00:00:00 2001 From: Sola Samuel Date: Sun, 10 May 2026 12:24:02 +0100 Subject: [PATCH 10/11] feat(stripe-webhook): expose apiVersion as an option Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/stripe-webhook/README.md | 9 +++++---- packages/stripe-webhook/src/index.ts | 5 +++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/stripe-webhook/README.md b/packages/stripe-webhook/README.md index f02af6ddf..980785244 100644 --- a/packages/stripe-webhook/README.md +++ b/packages/stripe-webhook/README.md @@ -47,10 +47,11 @@ export default app Options: -| Option | Type | Default | Description | -| ----------- | -------- | ------- | --------------------------------------------------------------------------------- | -| `secret` | `string` | — | Required. Your Stripe webhook endpoint signing secret (`whsec_...`). | -| `tolerance` | `number` | `300` | Maximum age (in seconds) of the signed timestamp before the request is rejected. | +| Option | Type | Default | Description | +| ------------ | -------------------------- | ---------------------- | --------------------------------------------------------------------------------- | +| `secret` | `string` | — | Required. Your Stripe webhook endpoint signing secret (`whsec_...`). | +| `tolerance` | `number` | `300` | Maximum age (in seconds) of the signed timestamp before the request is rejected. | +| `apiVersion` | `Stripe.LatestApiVersion` | `'2025-02-24.acacia'` | Stripe API version pinned on the internal `Stripe` client. | ### Accessing the verified event diff --git a/packages/stripe-webhook/src/index.ts b/packages/stripe-webhook/src/index.ts index 135d4fc63..4caff0c0a 100644 --- a/packages/stripe-webhook/src/index.ts +++ b/packages/stripe-webhook/src/index.ts @@ -14,11 +14,12 @@ declare module 'hono' { type Options = { secret: string tolerance?: number + apiVersion?: Stripe.LatestApiVersion } export const stripeWebhook = (options: Options): MiddlewareHandler => { - const { secret, tolerance = 300 } = options - const stripe = new Stripe(secret, { apiVersion: '2025-02-24.acacia' }) + const { secret, tolerance = 300, apiVersion = '2025-02-24.acacia' } = options + const stripe = new Stripe(secret, { apiVersion }) return async (c, next) => { const signature = c.req.header('stripe-signature') From 45ccb3a3e50fb74f3628c74fce43f89a2fe87bdc Mon Sep 17 00:00:00 2001 From: Sola Samuel Date: Sun, 10 May 2026 12:24:57 +0100 Subject: [PATCH 11/11] refactor(stripe-webhook): drop ContextVariableMap augmentation Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/stripe-webhook/README.md | 6 ++++-- packages/stripe-webhook/src/index.test.ts | 6 +++--- packages/stripe-webhook/src/index.ts | 6 ------ 3 files changed, 7 insertions(+), 11 deletions(-) diff --git a/packages/stripe-webhook/README.md b/packages/stripe-webhook/README.md index 980785244..724a76bd7 100644 --- a/packages/stripe-webhook/README.md +++ b/packages/stripe-webhook/README.md @@ -30,9 +30,9 @@ stripeWebhook({ ```ts import { Hono } from 'hono' -import { stripeWebhook } from '@hono/stripe-webhook' +import { stripeWebhook, type StripeWebhookVariables } from '@hono/stripe-webhook' -const app = new Hono() +const app = new Hono<{ Variables: StripeWebhookVariables }>() app.post('/webhook', stripeWebhook({ secret: process.env.STRIPE_WEBHOOK_SECRET! }), (c) => { const event = c.get('stripeEvent') @@ -45,6 +45,8 @@ app.post('/webhook', stripeWebhook({ secret: process.env.STRIPE_WEBHOOK_SECRET! export default app ``` +Pass `StripeWebhookVariables` as the `Variables` generic on `Hono<...>` so `c.get('stripeEvent')` is typed. + Options: | Option | Type | Default | Description | diff --git a/packages/stripe-webhook/src/index.test.ts b/packages/stripe-webhook/src/index.test.ts index 8b24cec89..40c1af5b9 100644 --- a/packages/stripe-webhook/src/index.test.ts +++ b/packages/stripe-webhook/src/index.test.ts @@ -1,5 +1,5 @@ import { Hono } from 'hono' -import { stripeWebhook } from '.' +import { stripeWebhook, type StripeWebhookVariables } from '.' const constructEventAsync = vi.fn() @@ -13,7 +13,7 @@ vi.mock('stripe', () => { describe('Stripe webhook middleware', () => { const secret = 'whsec_test' const buildApp = () => { - const app = new Hono() + const app = new Hono<{ Variables: StripeWebhookVariables }>() app.post('/webhook', stripeWebhook({ secret }), (c) => { const event = c.get('stripeEvent') return c.json({ type: event.type }) @@ -98,7 +98,7 @@ describe('Stripe webhook middleware', () => { it('Should leave the original request body readable by downstream handlers', async () => { const payload = '{"id":"evt_3","type":"customer.created"}' constructEventAsync.mockResolvedValueOnce({ id: 'evt_3', type: 'customer.created' }) - const app = new Hono() + const app = new Hono<{ Variables: StripeWebhookVariables }>() app.post('/webhook', stripeWebhook({ secret }), async (c) => { const body = await c.req.text() return c.json({ body }) diff --git a/packages/stripe-webhook/src/index.ts b/packages/stripe-webhook/src/index.ts index 4caff0c0a..ab9590551 100644 --- a/packages/stripe-webhook/src/index.ts +++ b/packages/stripe-webhook/src/index.ts @@ -5,12 +5,6 @@ export type StripeWebhookVariables = { stripeEvent: Stripe.Event } -declare module 'hono' { - interface ContextVariableMap { - stripeEvent: Stripe.Event - } -} - type Options = { secret: string tolerance?: number