hono-webhooks 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +587 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +137 -0
- package/dist/index.d.ts +143 -0
- package/dist/index.js +174 -0
- package/package.json +74 -0
- package/src/cli.ts +151 -0
- package/src/index.ts +418 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kristoffer Hansen
|
|
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.
|
package/README.md
ADDED
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
# hono-webhooks
|
|
2
|
+
|
|
3
|
+
Type-safe webhook endpoints for [Hono](https://hono.dev). Define each webhook
|
|
4
|
+
once with its schemas, get typed `params`, `query`, `headers` and `body` in the
|
|
5
|
+
handler, and mount them all as one Hono app. You can use any
|
|
6
|
+
[Standard Schema](https://standardschema.dev) library: Zod, Valibot, ArkType
|
|
7
|
+
and others.
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { Hono } from 'hono'
|
|
11
|
+
import { createWebhooks, defineWebhook } from 'hono-webhooks'
|
|
12
|
+
import { z } from 'zod'
|
|
13
|
+
|
|
14
|
+
const orderPaid = defineWebhook(
|
|
15
|
+
{
|
|
16
|
+
method: 'POST',
|
|
17
|
+
path: '/shop/orders/:orderId/paid',
|
|
18
|
+
body: z.object({ amount: z.number(), currency: z.string() })
|
|
19
|
+
},
|
|
20
|
+
async ({ params, body }) => {
|
|
21
|
+
await markPaid(params.orderId, body.amount, body.currency)
|
|
22
|
+
return { received: true }
|
|
23
|
+
}
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
const app = new Hono()
|
|
27
|
+
app.route('/webhooks', createWebhooks([orderPaid]))
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
- **Typed from the definition.** `params.orderId` is a `string` because it is
|
|
31
|
+
in the path, `body` has the schema's output type, and the handler's return
|
|
32
|
+
value is checked against the `response` schema.
|
|
33
|
+
- **Any schema library.** Zod, Valibot, ArkType, Effect Schema, or anything
|
|
34
|
+
else that implements Standard Schema. Async schemas work too.
|
|
35
|
+
- **Raw body included.** `rawBody` is always passed next to the parsed body,
|
|
36
|
+
so you can verify signatures (Stripe, GitHub, Shopify, ...) without extra
|
|
37
|
+
setup.
|
|
38
|
+
- **Plain Hono.** `createWebhooks` returns a Hono app. Handlers get the Hono
|
|
39
|
+
context `c`, Hono middleware works, `HTTPException` works, and it runs on
|
|
40
|
+
every runtime Hono supports.
|
|
41
|
+
- **One hook for observability.** `onEvent` fires once per request with the
|
|
42
|
+
route, status, duration, validated input, response and error. Use it for
|
|
43
|
+
logs, metrics, traces or an audit table.
|
|
44
|
+
- **A CLI** to list webhooks, print their JSON Schemas and send test requests.
|
|
45
|
+
|
|
46
|
+
## Contents
|
|
47
|
+
|
|
48
|
+
- [Install](#install)
|
|
49
|
+
- [Defining webhooks](#defining-webhooks)
|
|
50
|
+
- [Handler input](#handler-input)
|
|
51
|
+
- [Return values](#return-values)
|
|
52
|
+
- [Body parsing](#body-parsing)
|
|
53
|
+
- [Mounting](#mounting)
|
|
54
|
+
- [Typed env and middleware](#typed-env-and-middleware)
|
|
55
|
+
- [Errors](#errors)
|
|
56
|
+
- [Observability with `onEvent`](#observability-with-onevent)
|
|
57
|
+
- [Recipes](#recipes)
|
|
58
|
+
- [Testing](#testing)
|
|
59
|
+
- [CLI](#cli)
|
|
60
|
+
- [API reference](#api-reference)
|
|
61
|
+
- [Migrating from `createHttpRoute`](#migrating-from-createhttproute)
|
|
62
|
+
- [Releasing](#releasing)
|
|
63
|
+
|
|
64
|
+
## Install
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
bun add hono-webhooks hono
|
|
68
|
+
# plus the schema library you use
|
|
69
|
+
bun add zod
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`hono` (4.6 or newer) is a peer dependency. The package is ESM only. Bun
|
|
73
|
+
loads the TypeScript source directly; Node and bundlers load the built files
|
|
74
|
+
from `dist`.
|
|
75
|
+
|
|
76
|
+
## Defining webhooks
|
|
77
|
+
|
|
78
|
+
`defineWebhook(config, handler)` returns a webhook definition. It does not
|
|
79
|
+
register anything; pass the definitions to `createWebhooks`.
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
import { defineWebhook } from 'hono-webhooks'
|
|
83
|
+
import * as v from 'valibot'
|
|
84
|
+
|
|
85
|
+
export const updateItem = defineWebhook(
|
|
86
|
+
{
|
|
87
|
+
method: 'PUT',
|
|
88
|
+
path: '/items/:id',
|
|
89
|
+
description: 'Update an item from the ERP',
|
|
90
|
+
params: v.object({ id: v.pipe(v.string(), v.transform(Number)) }),
|
|
91
|
+
query: v.object({ dryRun: v.optional(v.picklist(['true', 'false'])) }),
|
|
92
|
+
headers: v.object({ 'x-api-key': v.string() }),
|
|
93
|
+
body: v.object({ name: v.string(), price: v.number() }),
|
|
94
|
+
response: v.object({ id: v.number(), updated: v.boolean() })
|
|
95
|
+
},
|
|
96
|
+
async ({ params, query, headers, body }) => {
|
|
97
|
+
// params.id: number, body.price: number, headers['x-api-key']: string
|
|
98
|
+
return { id: params.id, updated: query.dryRun !== 'true' }
|
|
99
|
+
}
|
|
100
|
+
)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
| Option | Type | Description |
|
|
104
|
+
| ------------- | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
|
|
105
|
+
| `method` | `'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE' \| 'OPTIONS'` | HTTP method. `HEAD` is answered by the `GET` route automatically. |
|
|
106
|
+
| `path` | `string` | A [Hono path](https://hono.dev/docs/api/routing): `/a/:id`, `/a/:id?`, `/a/:id{[0-9]+}`, `/a/*`. |
|
|
107
|
+
| `params` | Standard Schema | Validates path params. Without it, `params` is typed from `path`. |
|
|
108
|
+
| `query` | Standard Schema | Validates the query string as `Record<string, string>`. |
|
|
109
|
+
| `headers` | Standard Schema | Validates headers as `Record<string, string>` with **lower-case** names. |
|
|
110
|
+
| `body` | Standard Schema | Validates the parsed body (see [Body parsing](#body-parsing)). |
|
|
111
|
+
| `response` | Standard Schema | Types the handler's return value and checks it at runtime (see [Return values](#return-values)). |
|
|
112
|
+
| `bodyType` | `'auto' \| 'json' \| 'form' \| 'text'` | How to parse the body. Default `'auto'`. |
|
|
113
|
+
| `description` | `string` | Shown by `hono-webhooks list`. |
|
|
114
|
+
|
|
115
|
+
Schemas can transform: the handler receives the schema's **output**, so
|
|
116
|
+
`z.coerce.number()` on a param gives you a `number`.
|
|
117
|
+
|
|
118
|
+
### Params from the path
|
|
119
|
+
|
|
120
|
+
Without a `params` schema, the path gives the types:
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
defineWebhook({ method: 'GET', path: '/repos/:owner/:repo/:ref?' }, ({ params }) => {
|
|
124
|
+
params.owner // string
|
|
125
|
+
params.ref // string | undefined
|
|
126
|
+
params.nope // type error
|
|
127
|
+
})
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Handler input
|
|
131
|
+
|
|
132
|
+
The handler receives one object:
|
|
133
|
+
|
|
134
|
+
| Field | Type | Description |
|
|
135
|
+
| --------- | ------------------------------------------------------ | ------------------------------------------------------------------- |
|
|
136
|
+
| `c` | `Context<E>` | The Hono context: `c.env`, `c.var`, `c.header()`, `c.req.raw`, ... |
|
|
137
|
+
| `params` | schema output, or typed from `path` | Path params, URL-decoded. |
|
|
138
|
+
| `query` | schema output, or `Record<string, string>` | Query string. Repeated keys keep the first value. |
|
|
139
|
+
| `headers` | schema output, or `Record<string, string>` | Request headers, lower-case names. |
|
|
140
|
+
| `body` | schema output, or `unknown` | Parsed body. `undefined` when the body is empty. |
|
|
141
|
+
| `rawBody` | `string` | The body exactly as received. `''` when empty. |
|
|
142
|
+
|
|
143
|
+
## Return values
|
|
144
|
+
|
|
145
|
+
| Handler returns | Response |
|
|
146
|
+
| ----------------- | -------------------------------------------------------------------- |
|
|
147
|
+
| a `Response` | Sent as is. Use `c.json(data, 201)`, `c.redirect()`, `new Response()` |
|
|
148
|
+
| `undefined` | `204 No Content` |
|
|
149
|
+
| a `string` | `200`, `text/plain` |
|
|
150
|
+
| anything else | `200`, JSON |
|
|
151
|
+
|
|
152
|
+
With a `response` schema, the return type is checked at compile time. At
|
|
153
|
+
runtime the value is also validated. If it fails, the response is **still
|
|
154
|
+
sent** so the caller isn't affected, and the failure is reported on
|
|
155
|
+
[`onEvent`](#observability-with-onevent) as `event.validation` with
|
|
156
|
+
`target: 'response'`.
|
|
157
|
+
|
|
158
|
+
## Body parsing
|
|
159
|
+
|
|
160
|
+
The body is read once as text (`rawBody`), then parsed according to
|
|
161
|
+
`bodyType`:
|
|
162
|
+
|
|
163
|
+
| `bodyType` | Parsed as |
|
|
164
|
+
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
165
|
+
| `auto` (default) | Depends on `Content-Type`: `application/json` and any `+json` type → JSON; `application/x-www-form-urlencoded` → `Record<string, string>`; anything else → `string` |
|
|
166
|
+
| `json` | JSON, whatever the `Content-Type` says. Use it for providers that send JSON without the right header. |
|
|
167
|
+
| `form` | URL-encoded form (for example Twilio) |
|
|
168
|
+
| `text` | The raw string |
|
|
169
|
+
|
|
170
|
+
An empty body is `undefined` (the schema decides whether that's allowed).
|
|
171
|
+
Malformed JSON is a `400` with `issues: [{ message: 'Malformed JSON' }]`.
|
|
172
|
+
|
|
173
|
+
## Mounting
|
|
174
|
+
|
|
175
|
+
`createWebhooks(webhooks, options?)` returns a Hono app. Mount it under a
|
|
176
|
+
prefix with `app.route()`:
|
|
177
|
+
|
|
178
|
+
```ts
|
|
179
|
+
const app = new Hono()
|
|
180
|
+
app.route('/webhooks', createWebhooks([stripe, github, health], options))
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Below that prefix:
|
|
184
|
+
|
|
185
|
+
- An unknown path answers `404 { error: 'Route not found' }`.
|
|
186
|
+
- A known path with a method that isn't defined answers
|
|
187
|
+
`405 { error: 'Method DELETE not allowed' }` with an `Allow` header.
|
|
188
|
+
- A static path wins over a dynamic one that also matches (`/users/me` beats
|
|
189
|
+
`/users/:id`) no matter which order they were passed in.
|
|
190
|
+
- Defining the same method and path twice throws when `createWebhooks` runs.
|
|
191
|
+
|
|
192
|
+
Because the webhooks app answers 404 for anything under its prefix, mount it
|
|
193
|
+
under its own prefix (`/webhooks`), not at `/`.
|
|
194
|
+
|
|
195
|
+
The definitions are available as `webhooksApp.webhooks`, which is handy for
|
|
196
|
+
generating docs.
|
|
197
|
+
|
|
198
|
+
## Typed env and middleware
|
|
199
|
+
|
|
200
|
+
`defineWebhook` and `createWebhooks` type `c` as `Context<any>`. To type
|
|
201
|
+
`c.env` (Cloudflare bindings) and `c.var` (values set by middleware), make a
|
|
202
|
+
factory once and use it everywhere:
|
|
203
|
+
|
|
204
|
+
```ts
|
|
205
|
+
// src/webhooks/factory.ts
|
|
206
|
+
import { createWebhookFactory } from 'hono-webhooks'
|
|
207
|
+
|
|
208
|
+
export type AppEnv = {
|
|
209
|
+
Bindings: { STRIPE_WEBHOOK_SECRET: string; DB: D1Database }
|
|
210
|
+
Variables: { requestId: string }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export const { defineWebhook, createWebhooks } = createWebhookFactory<AppEnv>()
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
```ts
|
|
217
|
+
defineWebhook({ method: 'POST', path: '/ping' }, ({ c }) => {
|
|
218
|
+
c.env.DB // D1Database
|
|
219
|
+
c.var.requestId // string
|
|
220
|
+
})
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Middleware goes on the parent app, before `app.route()`, so it runs before
|
|
224
|
+
the webhooks (Hono runs handlers in the order they were registered):
|
|
225
|
+
|
|
226
|
+
```ts
|
|
227
|
+
const app = new Hono<AppEnv>()
|
|
228
|
+
app.use('/webhooks/*', requestId())
|
|
229
|
+
app.use('/webhooks/admin/*', bearerAuth({ token }))
|
|
230
|
+
app.route('/webhooks', createWebhooks(webhooks))
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
## Errors
|
|
234
|
+
|
|
235
|
+
Anything thrown while handling a request becomes a response:
|
|
236
|
+
|
|
237
|
+
| Thrown | Default response |
|
|
238
|
+
| ---------------------------------------------------- | ------------------------------------------------- |
|
|
239
|
+
| `WebhookValidationError` (a request schema failed) | `400 { error: 'Invalid body', issues: [...] }` |
|
|
240
|
+
| Hono's `HTTPException` | Its status and `{ error: message }`, or its `res` |
|
|
241
|
+
| anything else | `500 { error: 'Internal Server Error' }`, and the error is logged with `console.error` |
|
|
242
|
+
|
|
243
|
+
`issues` is the Standard Schema issue list: `{ message, path? }[]`.
|
|
244
|
+
|
|
245
|
+
Throw an `HTTPException` from a handler to answer with another status:
|
|
246
|
+
|
|
247
|
+
```ts
|
|
248
|
+
import { HTTPException } from 'hono/http-exception'
|
|
249
|
+
|
|
250
|
+
defineWebhook({ method: 'POST', path: '/orders/:id' }, async ({ params }) => {
|
|
251
|
+
const order = await findOrder(params.id)
|
|
252
|
+
if (!order) throw new HTTPException(404, { message: 'Unknown order' })
|
|
253
|
+
// ...
|
|
254
|
+
})
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
`onError` changes the response for any error, including 404 and 405. Return a
|
|
258
|
+
`Response`, or nothing to fall back to the default. `console.error` is only
|
|
259
|
+
called when you don't pass `onError`.
|
|
260
|
+
|
|
261
|
+
```ts
|
|
262
|
+
import { WebhookValidationError } from 'hono-webhooks'
|
|
263
|
+
|
|
264
|
+
createWebhooks(webhooks, {
|
|
265
|
+
onError(error, c) {
|
|
266
|
+
if (error instanceof WebhookValidationError) {
|
|
267
|
+
return c.json({ type: 'validation', target: error.target, issues: error.issues }, 422)
|
|
268
|
+
}
|
|
269
|
+
logger.error(error)
|
|
270
|
+
// return nothing: default response
|
|
271
|
+
}
|
|
272
|
+
})
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
## Observability with `onEvent`
|
|
276
|
+
|
|
277
|
+
`onEvent` is called once per request after the response is built, for
|
|
278
|
+
successes, validation failures, handler errors, 404s and 405s.
|
|
279
|
+
|
|
280
|
+
```ts
|
|
281
|
+
createWebhooks(webhooks, {
|
|
282
|
+
onEvent(event) {
|
|
283
|
+
logger.info('webhook', {
|
|
284
|
+
route: event.route, // '/orders/:id', undefined for a 404
|
|
285
|
+
method: event.method,
|
|
286
|
+
path: event.path, // '/webhooks/orders/42'
|
|
287
|
+
status: event.status,
|
|
288
|
+
durationMs: event.durationMs
|
|
289
|
+
})
|
|
290
|
+
}
|
|
291
|
+
})
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
| Field | Description |
|
|
295
|
+
| ------------------------------------------ | ---------------------------------------------------------------------------------------------------- |
|
|
296
|
+
| `c` | The Hono context, e.g. `c.req.header('user-agent')` |
|
|
297
|
+
| `route` | Matched path pattern, `undefined` for a 404 |
|
|
298
|
+
| `method`, `path` | Request method and path |
|
|
299
|
+
| `status`, `durationMs` | Response status and time spent in the webhooks app |
|
|
300
|
+
| `params`, `query`, `headers`, `body`, `rawBody` | Each is set once it passed validation, so a request that fails on `body` still reports `params` |
|
|
301
|
+
| `response` | What the handler returned, unless it returned a `Response` |
|
|
302
|
+
| `error` | Whatever was thrown |
|
|
303
|
+
| `validation` | `{ target, issues }` when a request part or the response failed its schema |
|
|
304
|
+
|
|
305
|
+
`onEvent` isn't awaited, so a slow database write doesn't delay the response.
|
|
306
|
+
When it returns a promise, the promise is passed to
|
|
307
|
+
`c.executionCtx.waitUntil()` on runtimes that have one (Cloudflare Workers,
|
|
308
|
+
Vercel Edge), and a rejection is logged instead of crashing the request.
|
|
309
|
+
|
|
310
|
+
## Recipes
|
|
311
|
+
|
|
312
|
+
### Stripe signature verification
|
|
313
|
+
|
|
314
|
+
`rawBody` is the exact payload Stripe signed.
|
|
315
|
+
|
|
316
|
+
```ts
|
|
317
|
+
import Stripe from 'stripe'
|
|
318
|
+
import { HTTPException } from 'hono/http-exception'
|
|
319
|
+
|
|
320
|
+
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
|
|
321
|
+
|
|
322
|
+
export const stripeEvents = defineWebhook(
|
|
323
|
+
{
|
|
324
|
+
method: 'POST',
|
|
325
|
+
path: '/stripe',
|
|
326
|
+
headers: z.object({ 'stripe-signature': z.string() })
|
|
327
|
+
},
|
|
328
|
+
async ({ headers, rawBody }) => {
|
|
329
|
+
let event: Stripe.Event
|
|
330
|
+
try {
|
|
331
|
+
event = await stripe.webhooks.constructEventAsync(
|
|
332
|
+
rawBody,
|
|
333
|
+
headers['stripe-signature'],
|
|
334
|
+
process.env.STRIPE_WEBHOOK_SECRET!
|
|
335
|
+
)
|
|
336
|
+
} catch {
|
|
337
|
+
throw new HTTPException(400, { message: 'Invalid signature' })
|
|
338
|
+
}
|
|
339
|
+
if (event.type === 'checkout.session.completed') {
|
|
340
|
+
await fulfil(event.data.object)
|
|
341
|
+
}
|
|
342
|
+
return { received: true }
|
|
343
|
+
}
|
|
344
|
+
)
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
### GitHub signature verification (Web Crypto, any runtime)
|
|
348
|
+
|
|
349
|
+
```ts
|
|
350
|
+
async function hmacSha256Hex(secret: string, payload: string) {
|
|
351
|
+
const key = await crypto.subtle.importKey(
|
|
352
|
+
'raw',
|
|
353
|
+
new TextEncoder().encode(secret),
|
|
354
|
+
{ name: 'HMAC', hash: 'SHA-256' },
|
|
355
|
+
false,
|
|
356
|
+
['sign']
|
|
357
|
+
)
|
|
358
|
+
const signature = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(payload))
|
|
359
|
+
return [...new Uint8Array(signature)].map((b) => b.toString(16).padStart(2, '0')).join('')
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export const githubPush = defineWebhook(
|
|
363
|
+
{
|
|
364
|
+
method: 'POST',
|
|
365
|
+
path: '/github',
|
|
366
|
+
headers: z.object({
|
|
367
|
+
'x-github-event': z.string(),
|
|
368
|
+
'x-hub-signature-256': z.string()
|
|
369
|
+
})
|
|
370
|
+
},
|
|
371
|
+
async ({ c, headers, rawBody, body }) => {
|
|
372
|
+
const expected = `sha256=${await hmacSha256Hex(c.env.GITHUB_WEBHOOK_SECRET, rawBody)}`
|
|
373
|
+
// Use a constant-time compare in production, e.g. crypto.timingSafeEqual on Node.
|
|
374
|
+
if (expected !== headers['x-hub-signature-256']) {
|
|
375
|
+
throw new HTTPException(401, { message: 'Invalid signature' })
|
|
376
|
+
}
|
|
377
|
+
// body is the parsed JSON
|
|
378
|
+
}
|
|
379
|
+
)
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
### Twilio form posts
|
|
383
|
+
|
|
384
|
+
```ts
|
|
385
|
+
defineWebhook(
|
|
386
|
+
{
|
|
387
|
+
method: 'POST',
|
|
388
|
+
path: '/twilio/sms',
|
|
389
|
+
body: z.object({ From: z.string(), Body: z.string() })
|
|
390
|
+
},
|
|
391
|
+
({ c, body }) =>
|
|
392
|
+
c.body(`<Response><Message>Got "${body.Body}"</Message></Response>`, 200, {
|
|
393
|
+
'content-type': 'text/xml'
|
|
394
|
+
})
|
|
395
|
+
)
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
### Storing every call
|
|
399
|
+
|
|
400
|
+
```ts
|
|
401
|
+
createWebhooks(webhooks, {
|
|
402
|
+
onEvent: (e) =>
|
|
403
|
+
db.insert(webhookCalls).values({
|
|
404
|
+
route: e.route ?? null,
|
|
405
|
+
path: e.path,
|
|
406
|
+
method: e.method,
|
|
407
|
+
statusCode: e.status,
|
|
408
|
+
durationMs: Math.round(e.durationMs),
|
|
409
|
+
body: e.body ?? null,
|
|
410
|
+
responseBody: e.response ?? null,
|
|
411
|
+
errorMessage: e.error instanceof Error ? e.error.message : null,
|
|
412
|
+
validationError: e.validation ?? null,
|
|
413
|
+
ipAddress: e.c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ?? null,
|
|
414
|
+
userAgent: e.c.req.header('user-agent') ?? null
|
|
415
|
+
})
|
|
416
|
+
})
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
### OpenTelemetry metrics
|
|
420
|
+
|
|
421
|
+
```ts
|
|
422
|
+
import { metrics } from '@opentelemetry/api'
|
|
423
|
+
|
|
424
|
+
const duration = metrics.getMeter('webhooks').createHistogram('webhook.duration', { unit: 'ms' })
|
|
425
|
+
|
|
426
|
+
createWebhooks(webhooks, {
|
|
427
|
+
onEvent: (e) =>
|
|
428
|
+
duration.record(e.durationMs, {
|
|
429
|
+
'http.route': e.route ?? 'unmatched',
|
|
430
|
+
'http.request.method': e.method,
|
|
431
|
+
'http.response.status_code': e.status
|
|
432
|
+
})
|
|
433
|
+
})
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
For traces, add a tracing middleware (for example
|
|
437
|
+
[`@hono/otel`](https://github.com/honojs/middleware/tree/main/packages/otel))
|
|
438
|
+
on the parent app before `app.route('/webhooks', ...)`.
|
|
439
|
+
|
|
440
|
+
### Skipping noise
|
|
441
|
+
|
|
442
|
+
`onEvent` fires for everything, so filter in the hook:
|
|
443
|
+
|
|
444
|
+
```ts
|
|
445
|
+
onEvent(e) {
|
|
446
|
+
if (e.route === '/health') return
|
|
447
|
+
log(e)
|
|
448
|
+
}
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
### Organising webhooks in a project
|
|
452
|
+
|
|
453
|
+
Keep one file per webhook and a registry that lists them:
|
|
454
|
+
|
|
455
|
+
```ts
|
|
456
|
+
// src/webhooks/index.ts
|
|
457
|
+
import { createWebhooks } from './factory'
|
|
458
|
+
import { health } from './health'
|
|
459
|
+
import { stripeEvents } from './stripe'
|
|
460
|
+
|
|
461
|
+
export const webhooks = createWebhooks([health, stripeEvents], { onEvent, onError })
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
```ts
|
|
465
|
+
// src/app.ts
|
|
466
|
+
app.route('/webhooks', webhooks)
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
Hono's `showRoutes(app)` from `hono/dev` prints every route at startup.
|
|
470
|
+
|
|
471
|
+
## Testing
|
|
472
|
+
|
|
473
|
+
The webhooks app is a Hono app, so `request()` runs the full pipeline:
|
|
474
|
+
routing, validation, handler and response.
|
|
475
|
+
|
|
476
|
+
```ts
|
|
477
|
+
import { expect, test } from 'bun:test'
|
|
478
|
+
import { webhooks } from './webhooks'
|
|
479
|
+
|
|
480
|
+
test('rejects an order without a total', async () => {
|
|
481
|
+
const res = await webhooks.request('/orders/42', {
|
|
482
|
+
method: 'POST',
|
|
483
|
+
headers: { 'content-type': 'application/json' },
|
|
484
|
+
body: JSON.stringify({})
|
|
485
|
+
})
|
|
486
|
+
expect(res.status).toBe(400)
|
|
487
|
+
})
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
Pass bindings as the third argument: `webhooks.request(path, init, env)`.
|
|
491
|
+
|
|
492
|
+
## CLI
|
|
493
|
+
|
|
494
|
+
```text
|
|
495
|
+
hono-webhooks <module> <command> [options]
|
|
496
|
+
|
|
497
|
+
Commands:
|
|
498
|
+
list List the webhooks
|
|
499
|
+
schema <path> Print the JSON Schemas of the webhooks at <path>
|
|
500
|
+
test <method> <path> Send a request through the webhooks app
|
|
501
|
+
|
|
502
|
+
Options for test:
|
|
503
|
+
-b, --body <text> Request body (JSON sets content-type: application/json)
|
|
504
|
+
-q, --query <params> Query string, e.g. "a=1&b=2"
|
|
505
|
+
-H, --header <k: v> Request header, repeatable
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
`<module>` is a file that exports the `createWebhooks()` result, as default or
|
|
509
|
+
as any named export.
|
|
510
|
+
|
|
511
|
+
```bash
|
|
512
|
+
bunx hono-webhooks ./src/webhooks/index.ts list
|
|
513
|
+
bunx hono-webhooks ./src/webhooks/index.ts schema /orders/:id
|
|
514
|
+
bunx hono-webhooks ./src/webhooks/index.ts test POST /orders/42 -b '{"total":10}' -H 'x-api-key: dev'
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
- `test` sends a real request through the webhooks app (without the parent
|
|
518
|
+
app's middleware) and exits with code 1 on a 4xx or 5xx response.
|
|
519
|
+
- `schema` prints JSON Schema for libraries that implement
|
|
520
|
+
[Standard JSON Schema](https://standardschema.dev) (Zod 4.2+ does).
|
|
521
|
+
- The CLI imports your TypeScript file. That works under Bun, and under Node
|
|
522
|
+
22.18+ which strips types natively. If the module needs setup first (loading
|
|
523
|
+
secrets, for example), preload it: `bun --preload ./src/secrets.ts x hono-webhooks ...`.
|
|
524
|
+
|
|
525
|
+
## API reference
|
|
526
|
+
|
|
527
|
+
```ts
|
|
528
|
+
import {
|
|
529
|
+
defineWebhook, // (config, handler) => Webhook
|
|
530
|
+
createWebhooks, // (webhooks, options?) => WebhooksApp (a Hono app + .webhooks)
|
|
531
|
+
createWebhookFactory, // <E extends Env>() => { defineWebhook, createWebhooks }
|
|
532
|
+
WebhookValidationError // extends HTTPException; .target, .issues
|
|
533
|
+
} from 'hono-webhooks'
|
|
534
|
+
|
|
535
|
+
import type {
|
|
536
|
+
Webhook,
|
|
537
|
+
WebhookConfig,
|
|
538
|
+
WebhookInput,
|
|
539
|
+
WebhookResult,
|
|
540
|
+
WebhookEvent,
|
|
541
|
+
WebhooksOptions,
|
|
542
|
+
WebhooksApp,
|
|
543
|
+
WebhookMethod,
|
|
544
|
+
BodyType,
|
|
545
|
+
ValidationTarget,
|
|
546
|
+
PathParams,
|
|
547
|
+
StandardSchemaV1
|
|
548
|
+
} from 'hono-webhooks'
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
Use `Webhook` to type a list: `const all: Webhook<AppEnv>[] = [a, b]`.
|
|
552
|
+
|
|
553
|
+
## Migrating from `createHttpRoute`
|
|
554
|
+
|
|
555
|
+
For code that used the in-app `lib/webhook` this package came from:
|
|
556
|
+
|
|
557
|
+
| Before | After |
|
|
558
|
+
| ---------------------------------------------- | ----------------------------------------------------------------------------- |
|
|
559
|
+
| `createHttpRoute(handlerData, handler)` | `defineWebhook(config, handler)` |
|
|
560
|
+
| `reqBodySchema`, `reqQuerySchema`, ... | `body`, `query`, `headers`, `params`, `response` |
|
|
561
|
+
| `z.string()` body switches to text parsing | Parsing follows `Content-Type`; set `bodyType: 'text'` to force it |
|
|
562
|
+
| `z.any()` body skips the content-type check | No content-type check anymore; leave `body` out and read `body` / `rawBody` |
|
|
563
|
+
| `rawBody: true` | Always available as `rawBody`; the body is also parsed |
|
|
564
|
+
| `request` in the handler | `c.req.raw` |
|
|
565
|
+
| `useHonoWebhooks(app)` | `app.route('/webhooks', createWebhooks(webhooks, { onEvent }))` |
|
|
566
|
+
| Built-in OTel span, logger, `recordWebhookCall` | `onEvent` (see [Recipes](#recipes)) and tracing middleware |
|
|
567
|
+
| `HEAD` / custom methods in `method` | `GET` routes answer `HEAD` automatically |
|
|
568
|
+
| Response schema mismatch recorded as 500 | Recorded as `event.validation` with the real status |
|
|
569
|
+
| Malformed JSON without a schema → `body: null` | `400 Invalid body`. Use `bodyType: 'text'` to accept anything |
|
|
570
|
+
|
|
571
|
+
## Releasing
|
|
572
|
+
|
|
573
|
+
Releases are automated with [Release Please](https://github.com/googleapis/release-please)
|
|
574
|
+
and npm trusted publishing:
|
|
575
|
+
|
|
576
|
+
1. PRs are squash-merged with a [conventional](https://www.conventionalcommits.org)
|
|
577
|
+
title. `fix:` releases a patch, `feat:` a minor, `feat!:` or a
|
|
578
|
+
`BREAKING CHANGE:` footer a major. `chore:`, `ci:`, `docs:`, `refactor:`
|
|
579
|
+
and `test:` don't release.
|
|
580
|
+
2. Each push to `main` opens or updates a release PR with the next version and
|
|
581
|
+
the changelog.
|
|
582
|
+
3. Merging the release PR tags the version, creates the GitHub release and
|
|
583
|
+
publishes to npm with provenance.
|
|
584
|
+
|
|
585
|
+
## License
|
|
586
|
+
|
|
587
|
+
MIT
|
package/dist/cli.d.ts
ADDED