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 diff --git a/packages/stripe-webhook/README.md b/packages/stripe-webhook/README.md new file mode 100644 index 000000000..724a76bd7 --- /dev/null +++ b/packages/stripe-webhook/README.md @@ -0,0 +1,87 @@ +# Stripe Webhook Middleware for Hono + +[![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 [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 stripe @hono/stripe-webhook +``` + +## Configuration + +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 +STRIPE_WEBHOOK_SECRET=whsec_... +``` + +On other platforms, you can directly provide the secret by passing it as an option: + +```ts +stripeWebhook({ + secret: '', +}) +``` + +## How to Use + +```ts +import { Hono } from 'hono' +import { stripeWebhook, type StripeWebhookVariables } from '@hono/stripe-webhook' + +const app = new Hono<{ Variables: StripeWebhookVariables }>() + +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 +``` + +Pass `StripeWebhookVariables` as the `Variables` generic on `Hono<...>` so `c.get('stripeEvent')` is typed. + +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. | +| `apiVersion` | `Stripe.LatestApiVersion` | `'2025-02-24.acacia'` | Stripe API version pinned on the internal `Stripe` client. | + +### Accessing the verified event + +You can retrieve the verified `Stripe.Event` using `c.get('stripeEvent')`. + +```ts +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 + +- Sola Samuel - + +## License + +MIT diff --git a/packages/stripe-webhook/deno.json b/packages/stripe-webhook/deno.json new file mode 100644 index 000000000..a83ddd6b0 --- /dev/null +++ b/packages/stripe-webhook/deno.json @@ -0,0 +1,15 @@ +{ + "name": "@hono/stripe-webhook", + "version": "0.0.1", + "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..0967ef424 --- /dev/null +++ b/packages/stripe-webhook/eslint-suppressions.json @@ -0,0 +1 @@ +{} diff --git a/packages/stripe-webhook/package.json b/packages/stripe-webhook/package.json new file mode 100644 index 000000000..25810cbf4 --- /dev/null +++ b/packages/stripe-webhook/package.json @@ -0,0 +1,55 @@ +{ + "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", + "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/packages/stripe-webhook/src/index.test.ts b/packages/stripe-webhook/src/index.test.ts new file mode 100644 index 000000000..40c1af5b9 --- /dev/null +++ b/packages/stripe-webhook/src/index.test.ts @@ -0,0 +1,114 @@ +import { Hono } from 'hono' +import { stripeWebhook, type StripeWebhookVariables } from '.' + +const constructEventAsync = vi.fn() + +vi.mock('stripe', () => { + class StripeMock { + webhooks = { constructEventAsync } + } + return { default: StripeMock } +}) + +describe('Stripe webhook middleware', () => { + const secret = 'whsec_test' + const buildApp = () => { + const app = new Hono<{ Variables: StripeWebhookVariables }>() + app.post('/webhook', stripeWebhook({ secret }), (c) => { + const event = c.get('stripeEvent') + return c.json({ type: event.type }) + }) + return app + } + + 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 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 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) + expect(await res.json()).toEqual({ type: 'payment_intent.succeeded' }) + expect(constructEventAsync).toHaveBeenCalledWith( + '{"id":"evt_1"}', + 't=1,v1=deadbeef', + secret, + 300 + ) + }) + + 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 + ) + }) + + 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<{ Variables: StripeWebhookVariables }>() + 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 }) + }) +}) diff --git a/packages/stripe-webhook/src/index.ts b/packages/stripe-webhook/src/index.ts new file mode 100644 index 000000000..ab9590551 --- /dev/null +++ b/packages/stripe-webhook/src/index.ts @@ -0,0 +1,42 @@ +import type { MiddlewareHandler } from 'hono' +import Stripe from 'stripe' + +export type StripeWebhookVariables = { + stripeEvent: Stripe.Event +} + +type Options = { + secret: string + tolerance?: number + apiVersion?: Stripe.LatestApiVersion +} + +export const stripeWebhook = (options: Options): MiddlewareHandler => { + 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') + + 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( + rawBody, + signature, + secret, + tolerance + ) + } catch { + return c.json({ error: 'Invalid webhook signature' }, 400) + } + + c.set('stripeEvent', event) + return next() + } +} 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, + }, +}) diff --git a/yarn.lock b/yarn.lock index 31525ac78..bd2cce4a8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2360,6 +2360,21 @@ __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" + stripe: ^17.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 +5141,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:>=8.1.0": + version: 25.6.2 + resolution: "@types/node@npm:25.6.2" + dependencies: + undici-types: "npm:~7.19.0" + checksum: 10c0/7f540331aa3ab88c285aeaf2eb43e3992f54f0cdb7f3593d156af67b199d4eaf56590fa1c310a00aa58ff69dba668cb3915a157fe83cd6b40a73bb338a12f09a + languageName: node + linkType: hard + "@types/node@npm:^12.7.1": version: 12.20.55 resolution: "@types/node@npm:12.20.55" @@ -13560,6 +13584,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 +15283,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 +16150,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"