Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/petite-phones-drop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@hono/stripe-webhook': minor
---

Add Stripe webhook verification middleware
87 changes: 87 additions & 0 deletions packages/stripe-webhook/README.md
Original file line number Diff line number Diff line change
@@ -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: '<Your signing 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 - <https://github.com/solasamuel>

## License

MIT
15 changes: 15 additions & 0 deletions packages/stripe-webhook/deno.json
Original file line number Diff line number Diff line change
@@ -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"]
}
}
1 change: 1 addition & 0 deletions packages/stripe-webhook/eslint-suppressions.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
55 changes: 55 additions & 0 deletions packages/stripe-webhook/package.json
Original file line number Diff line number Diff line change
@@ -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 <samuel@driv.ly> (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"
}
}
114 changes: 114 additions & 0 deletions packages/stripe-webhook/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -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 })
})
})
42 changes: 42 additions & 0 deletions packages/stripe-webhook/src/index.ts
Original file line number Diff line number Diff line change
@@ -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()
}
}
5 changes: 5 additions & 0 deletions packages/stripe-webhook/tsconfig.build.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.build.json",
"compilerOptions": {},
"references": []
}
12 changes: 12 additions & 0 deletions packages/stripe-webhook/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.build.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}
8 changes: 8 additions & 0 deletions packages/stripe-webhook/tsconfig.spec.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "../../dist/packages/sentry",
"types": ["vitest/globals"]
},
"references": []
}
11 changes: 11 additions & 0 deletions packages/stripe-webhook/tsdown.config.ts
Original file line number Diff line number Diff line change
@@ -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',
})
10 changes: 10 additions & 0 deletions packages/stripe-webhook/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { defineProject } from 'vitest/config'

export default defineProject({
test: {
globals: true,
include: ['src/**/*.test.ts'],
restoreMocks: true,
unstubEnvs: true,
},
})
Loading