hono-webhooks 0.1.1 → 0.2.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/README.md CHANGED
@@ -32,16 +32,19 @@ app.route('/webhooks', createWebhooks([orderPaid]))
32
32
  value is checked against the `response` schema.
33
33
  - **Any schema library.** Zod, Valibot, ArkType, Effect Schema, or anything
34
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.
35
+ - **Signed webhooks built in.** `verify` checks the signature on the raw
36
+ body before anything is parsed, with ready-made verifiers and signers for
37
+ Stripe, GitHub, Shopify and any HMAC scheme. `rawBody` is always passed to
38
+ the handler too.
38
39
  - **Plain Hono.** `createWebhooks` returns a Hono app. Handlers get the Hono
39
40
  context `c`, Hono middleware works, `HTTPException` works, and it runs on
40
41
  every runtime Hono supports.
41
42
  - **One hook for observability.** `onEvent` fires once per request with the
42
43
  route, status, duration, validated input, response and error. Use it for
43
44
  logs, metrics, traces or an audit table.
44
- - **A CLI** to list webhooks, print their JSON Schemas and send test requests.
45
+ - **Tooling.** `describeWebhooks` gives JSON Schemas for docs and admin pages,
46
+ `testWebhook` runs one webhook in tests with typed input, and the CLI lists
47
+ webhooks and sends (signed) test requests.
45
48
 
46
49
  ## Contents
47
50
 
@@ -50,14 +53,17 @@ app.route('/webhooks', createWebhooks([orderPaid]))
50
53
  - [Handler input](#handler-input)
51
54
  - [Return values](#return-values)
52
55
  - [Body parsing](#body-parsing)
56
+ - [Signature verification](#signature-verification)
53
57
  - [Mounting](#mounting)
54
58
  - [Typed env and middleware](#typed-env-and-middleware)
55
59
  - [Errors](#errors)
56
60
  - [Observability with `onEvent`](#observability-with-onevent)
57
61
  - [Recipes](#recipes)
62
+ - [Describing webhooks](#describing-webhooks)
58
63
  - [Testing](#testing)
59
64
  - [CLI](#cli)
60
65
  - [API reference](#api-reference)
66
+ - [Upgrading from 0.1](#upgrading-from-01)
61
67
  - [Migrating from `createHttpRoute`](#migrating-from-createhttproute)
62
68
  - [Releasing](#releasing)
63
69
 
@@ -69,7 +75,7 @@ bun add hono-webhooks hono
69
75
  bun add zod
70
76
  ```
71
77
 
72
- `hono` (4.6 or newer) is a peer dependency. The package is ESM only. Bun
78
+ `hono` (4.8 or newer) is a peer dependency. The package is ESM only. Bun
73
79
  loads the TypeScript source directly; Node and bundlers load the built files
74
80
  from `dist`.
75
81
 
@@ -108,6 +114,7 @@ export const updateItem = defineWebhook(
108
114
  | `query` | Standard Schema | Validates the query string as `Record<string, string>`. |
109
115
  | `headers` | Standard Schema | Validates headers as `Record<string, string>` with **lower-case** names. |
110
116
  | `body` | Standard Schema | Validates the parsed body (see [Body parsing](#body-parsing)). |
117
+ | `verify` | `({ c, rawBody, headers }) => boolean \| Promise<boolean>` | Checks the signature before the body is parsed (see [Signature verification](#signature-verification)). |
111
118
  | `response` | Standard Schema | Types the handler's return value and checks it at runtime (see [Return values](#return-values)). |
112
119
  | `bodyType` | `'auto' \| 'json' \| 'form' \| 'text'` | How to parse the body. Default `'auto'`. |
113
120
  | `description` | `string` | Shown by `hono-webhooks list`. |
@@ -170,6 +177,60 @@ The body is read once as text (`rawBody`), then parsed according to
170
177
  An empty body is `undefined` (the schema decides whether that's allowed).
171
178
  Malformed JSON is a `400` with `issues: [{ message: 'Malformed JSON' }]`.
172
179
 
180
+ ## Signature verification
181
+
182
+ `verify` runs after the params, query and headers are validated but **before
183
+ the body is parsed**, on the raw body. A request with a bad signature never
184
+ reaches JSON parsing or the body schema, so signed webhooks can use `body`
185
+ like any other webhook:
186
+
187
+ ```ts
188
+ import { shopify } from 'hono-webhooks/signatures'
189
+
190
+ export const orderCreated = defineWebhook(
191
+ {
192
+ method: 'POST',
193
+ path: '/shopify/orders',
194
+ verify: shopify.verify((c) => c.env.SHOPIFY_WEBHOOK_SECRET),
195
+ body: z.object({ id: z.number(), total_price: z.string() })
196
+ },
197
+ ({ body }) => {
198
+ // only runs for correctly signed requests
199
+ }
200
+ )
201
+ ```
202
+
203
+ When `verify` returns `false`, the answer is
204
+ `401 { error: 'Invalid signature', issues: [...] }` and `onEvent` gets
205
+ `validation.target: 'signature'`.
206
+
207
+ `hono-webhooks/signatures` has a verifier and a matching signer per provider:
208
+
209
+ | Scheme | Header | Signed content |
210
+ | --------------------------------------- | --------------------------------------- | ------------------- |
211
+ | `github` | `x-hub-signature-256: sha256=<hex>` | body |
212
+ | `shopify` | `x-shopify-hmac-sha256: <base64>` | body |
213
+ | `stripe` | `stripe-signature: t=<unix>,v1=<hex>` | `<t>.<body>` |
214
+ | `hmac({ header, encoding, prefix?, hash? })` | any header, hex or base64, SHA-1/256/512 | body |
215
+
216
+ - `scheme.verify(secret)` gives a `verify` function. `secret` is a string or
217
+ `(c) => string | Promise<string>`, so it can come from `c.env`. An empty
218
+ secret throws (a 500), instead of silently accepting or rejecting everything.
219
+ - `stripe.verify(secret, { toleranceSeconds })` also rejects timestamps more
220
+ than 300 seconds away by default, and accepts any matching `v1` so secret
221
+ rotation works.
222
+ - `scheme.sign(rawBody, secret)` returns the headers the provider would send,
223
+ for tests and the CLI.
224
+ - Signatures are compared with `crypto.subtle.verify`, which runs in constant
225
+ time. Everything uses Web Crypto, so it works on every runtime.
226
+
227
+ For another provider, write the function yourself:
228
+
229
+ ```ts
230
+ verify: async ({ c, rawBody, headers }) =>
231
+ headers['x-token'] === c.env.WEBHOOK_TOKEN
232
+ ```
233
+
173
234
  ## Mounting
174
235
 
175
236
  `createWebhooks(webhooks, options?)` returns a Hono app. Mount it under a
@@ -255,13 +316,17 @@ defineWebhook({ method: 'POST', path: '/orders/:id' }, async ({ params }) => {
255
316
  ```
256
317
 
257
318
  `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`.
319
+ `Response`, or nothing to fall back to the default.
320
+
321
+ The default 500 response logs the error with `console.error`. Pass
322
+ `logErrors: false` when you log errors yourself, in `onError` or `onEvent`,
323
+ so they aren't logged twice.
260
324
 
261
325
  ```ts
262
326
  import { WebhookValidationError } from 'hono-webhooks'
263
327
 
264
328
  createWebhooks(webhooks, {
329
+ logErrors: false,
265
330
  onError(error, c) {
266
331
  if (error instanceof WebhookValidationError) {
267
332
  return c.json({ type: 'validation', target: error.target, issues: error.issues }, 422)
@@ -283,7 +348,7 @@ createWebhooks(webhooks, {
283
348
  logger.info('webhook', {
284
349
  route: event.route, // '/orders/:id', undefined for a 404
285
350
  method: event.method,
286
- path: event.path, // '/webhooks/orders/42'
351
+ path: event.path, // '/orders/42', relative to the mount point
287
352
  status: event.status,
288
353
  durationMs: event.durationMs
289
354
  })
@@ -295,86 +360,128 @@ createWebhooks(webhooks, {
295
360
  | ------------------------------------------ | ---------------------------------------------------------------------------------------------------- |
296
361
  | `c` | The Hono context, e.g. `c.req.header('user-agent')` |
297
362
  | `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` |
363
+ | `method`, `path` | Request method, and path relative to the mount point (`/orders/42`). The full path is `c.req.path` |
364
+ | `status`, `durationMs` | Response status, and time spent in the webhooks app in fractional milliseconds |
365
+ | `params`, `query`, `headers` | `Record<string, unknown>`: the schema output, or the raw strings without a schema |
366
+ | `body`, `rawBody` | Parsed body and raw body |
301
367
  | `response` | What the handler returned, unless it returned a `Response` |
302
368
  | `error` | Whatever was thrown |
303
- | `validation` | `{ target, issues }` when a request part or the response failed its schema |
369
+ | `validation` | `{ target, issues }` when a request part, the signature or the response failed |
370
+
371
+ Each request part is set once it has passed validation, so a request that
372
+ fails on `body` still reports `params`.
373
+
374
+ ### Summaries and issue messages
375
+
376
+ `summarizeEvent(event)` flattens an event into a JSON-safe record for logs or
377
+ a database table:
378
+
379
+ ```ts
380
+ import { formatIssues, summarizeEvent } from 'hono-webhooks'
381
+
382
+ summarizeEvent(event)
383
+ // {
384
+ // route: '/orders/:id', method: 'POST', path: '/orders/42', status: 400,
385
+ // durationMs: 1.37, // rounded to two decimals
386
+ // ip: '203.0.113.9', // x-forwarded-for, x-real-ip or cf-connecting-ip
387
+ // userAgent: 'Shopify-Captain-Hook',
388
+ // params: { id: '42' }, query: {}, body: null, response: null,
389
+ // error: null, // or { name, message, stack }
390
+ // validation: {
391
+ // target: 'body',
392
+ // message: 'total: Expected number',
393
+ // issues: [{ path: 'total', message: 'Expected number' }]
394
+ // }
395
+ // }
396
+ ```
397
+
398
+ - Missing values are `null`, never `undefined`.
399
+ - Request headers are left out on purpose, because they carry signatures and
400
+ credentials.
401
+ - `ip` comes from headers the client can set, so only trust it behind a
402
+ proxy that overwrites them.
403
+
404
+ `formatIssues(issues)` turns Standard Schema issues into one line, like
405
+ `total: Expected number; items.0.id: Required`.
304
406
 
305
407
  `onEvent` isn't awaited, so a slow database write doesn't delay the response.
306
408
  When it returns a promise, the promise is passed to
307
409
  `c.executionCtx.waitUntil()` on runtimes that have one (Cloudflare Workers,
308
410
  Vercel Edge), and a rejection is logged instead of crashing the request.
309
411
 
310
- ## Recipes
412
+ `onEvent` is called inside the request's async context, before the response
413
+ is returned. The active OpenTelemetry span, `AsyncLocalStorage` values and
414
+ anything set up in [`around`](#wrapping-requests-with-around) are available
415
+ there, so log lines written in `onEvent` attach to the request.
311
416
 
312
- ### Stripe signature verification
417
+ ### Wrapping requests with `around`
313
418
 
314
- `rawBody` is the exact payload Stripe signed.
419
+ `around` wraps every request, including 404s and 405s, and already knows the
420
+ matched route. Use it for anything that has to surround the whole request:
421
+ a tracing span, an `AsyncLocalStorage` scope, a timer. Call `next()` to
422
+ handle the request; it returns the response and runs `onEvent` inside your
423
+ wrapper.
315
424
 
316
425
  ```ts
317
- import Stripe from 'stripe'
318
- import { HTTPException } from 'hono/http-exception'
426
+ createWebhooks(webhooks, {
427
+ around: async ({ c, route, method }, next) => {
428
+ const res = await next()
429
+ console.log(method, route ?? '(no route)', res.status)
430
+ return res
431
+ }
432
+ })
433
+ ```
434
+
435
+ `route` is the pattern relative to the mount point (`/orders/:id`), or
436
+ `undefined` for a 404. If `around` returns a response without calling
437
+ `next()`, the request isn't handled and `onEvent` doesn't run.
438
+
439
+ ## Recipes
440
+
441
+ ### Stripe events
319
442
 
320
- const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
443
+ The body schema only runs on verified requests. `constructEventAsync` from
444
+ the Stripe SDK works too, with `rawBody`, if you'd rather use their event
445
+ types.
446
+
447
+ ```ts
448
+ import { stripe } from 'hono-webhooks/signatures'
321
449
 
322
450
  export const stripeEvents = defineWebhook(
323
451
  {
324
452
  method: 'POST',
325
453
  path: '/stripe',
326
- headers: z.object({ 'stripe-signature': z.string() })
454
+ verify: stripe.verify(process.env.STRIPE_WEBHOOK_SECRET!),
455
+ body: z.object({
456
+ id: z.string(),
457
+ type: z.string(),
458
+ data: z.object({ object: z.record(z.string(), z.unknown()) })
459
+ })
327
460
  },
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)
461
+ async ({ body }) => {
462
+ if (body.type === 'checkout.session.completed') {
463
+ await fulfil(body.data.object)
341
464
  }
342
465
  return { received: true }
343
466
  }
344
467
  )
345
468
  ```
346
469
 
347
- ### GitHub signature verification (Web Crypto, any runtime)
470
+ ### GitHub events
348
471
 
349
472
  ```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
- }
473
+ import { github } from 'hono-webhooks/signatures'
361
474
 
362
475
  export const githubPush = defineWebhook(
363
476
  {
364
477
  method: 'POST',
365
478
  path: '/github',
366
- headers: z.object({
367
- 'x-github-event': z.string(),
368
- 'x-hub-signature-256': z.string()
369
- })
479
+ verify: github.verify((c) => c.env.GITHUB_WEBHOOK_SECRET),
480
+ headers: z.object({ 'x-github-event': z.string() }),
481
+ body: z.object({ ref: z.string().optional() })
370
482
  },
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
483
+ ({ headers, body }) => {
484
+ if (headers['x-github-event'] === 'push') deploy(body.ref)
378
485
  }
379
486
  )
380
487
  ```
@@ -398,21 +505,20 @@ defineWebhook(
398
505
  ### Storing every call
399
506
 
400
507
  ```ts
508
+ import { summarizeEvent } from 'hono-webhooks'
509
+
401
510
  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
511
+ logErrors: false, // errors are stored below instead
512
+ onEvent: async (e) => {
513
+ if (e.route === '/health') return
514
+ const call = summarizeEvent(e)
515
+ await db.insert(webhookCalls).values({
516
+ ...call,
517
+ errorMessage: call.error?.message ?? null,
518
+ errorStack: call.error?.stack ?? null,
519
+ validationError: call.validation?.message ?? null
415
520
  })
521
+ }
416
522
  })
417
523
  ```
418
524
 
@@ -433,9 +539,45 @@ createWebhooks(webhooks, {
433
539
  })
434
540
  ```
435
541
 
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', ...)`.
542
+ ### OpenTelemetry traces
543
+
544
+ One span per request, named by the route pattern (low cardinality), with
545
+ `onEvent` logs attached to it:
546
+
547
+ ```ts
548
+ import { SpanKind, SpanStatusCode, trace } from '@opentelemetry/api'
549
+
550
+ const tracer = trace.getTracer('webhooks')
551
+
552
+ createWebhooks(webhooks, {
553
+ around: ({ c, route, method }, next) =>
554
+ tracer.startActiveSpan(
555
+ `webhook ${method} ${route ?? 'unmatched'}`,
556
+ {
557
+ kind: SpanKind.SERVER,
558
+ attributes: {
559
+ 'http.request.method': method,
560
+ 'http.route': route ?? 'unmatched',
561
+ 'url.path': c.req.path
562
+ }
563
+ },
564
+ async (span) => {
565
+ try {
566
+ const res = await next()
567
+ span.setAttribute('http.response.status_code', res.status)
568
+ if (res.status >= 500) span.setStatus({ code: SpanStatusCode.ERROR })
569
+ return res
570
+ } finally {
571
+ span.end()
572
+ }
573
+ }
574
+ ),
575
+ onEvent: (e) => {
576
+ // trace.getActiveSpan() is the span above
577
+ if (e.error instanceof Error) trace.getActiveSpan()?.recordException(e.error)
578
+ }
579
+ })
580
+ ```
439
581
 
440
582
  ### Skipping noise
441
583
 
@@ -468,26 +610,69 @@ app.route('/webhooks', webhooks)
468
610
 
469
611
  Hono's `showRoutes(app)` from `hono/dev` prints every route at startup.
470
612
 
613
+ ## Describing webhooks
614
+
615
+ `describeWebhooks(appOrArray)` returns one entry per webhook, with JSON
616
+ Schemas from any library that implements
617
+ [Standard JSON Schema](https://standardschema.dev) (Zod 4.2+ does). Use it for
618
+ an admin page, docs or an OpenAPI document:
619
+
620
+ ```ts
621
+ import { describeWebhooks } from 'hono-webhooks'
622
+
623
+ describeWebhooks(webhooks)
624
+ // [{
625
+ // method: 'POST',
626
+ // path: '/orders/:id',
627
+ // description: 'Order paid',
628
+ // verified: true, // has a verify function
629
+ // bodyType: 'auto',
630
+ // jsonSchema: { params: null, query: null, headers: null, body: {...}, response: {...} }
631
+ // }]
632
+
633
+ describeWebhooks(webhooks, { target: 'openapi-3.0' }) // or 'draft-07'
634
+ ```
635
+
636
+ Request parts use the schema's input side (what the sender sends), `response`
637
+ its output side. A part is `null` when it has no schema, or when the library
638
+ can't produce JSON Schema.
639
+
471
640
  ## Testing
472
641
 
473
- The webhooks app is a Hono app, so `request()` runs the full pipeline:
474
- routing, validation, handler and response.
642
+ `testWebhook(webhook, input)` sends one request through a single webhook and
643
+ returns the `Response`. It runs everything a real request does: signature
644
+ check, validation, handler and response handling. Its input is typed from the
645
+ webhook's schemas:
475
646
 
476
647
  ```ts
477
648
  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({})
649
+ import { testWebhook } from 'hono-webhooks'
650
+ import { shopify } from 'hono-webhooks/signatures'
651
+ import { orderPaid } from './webhooks/order-paid'
652
+
653
+ test('marks the order paid', async () => {
654
+ const res = await testWebhook(orderPaid, {
655
+ params: { orderId: '42' }, // filled into the path
656
+ query: { dryRun: 'true' },
657
+ body: { amount: 10, currency: 'DKK' }, // typed from the body schema
658
+ sign: (rawBody) => shopify.sign(rawBody, 'test-secret'),
659
+ env: { SHOPIFY_WEBHOOK_SECRET: 'test-secret' }
485
660
  })
486
- expect(res.status).toBe(400)
661
+ expect(res.status).toBe(200)
487
662
  })
488
663
  ```
489
664
 
490
- Pass bindings as the third argument: `webhooks.request(path, init, env)`.
665
+ | Input | Description |
666
+ | --------- | ------------------------------------------------------------------------------------------------------------ |
667
+ | `params` | Filled into the path pattern. Use `path` instead for wildcard patterns. |
668
+ | `query`, `headers` | String records. |
669
+ | `body` | A string is sent as is. Anything else is sent as JSON, or url-encoded when `bodyType` is `'form'`. |
670
+ | `sign` | Gets the exact raw body and returns headers to add, e.g. `(raw) => github.sign(raw, secret)`. |
671
+ | `env` | Bindings, available as `c.env`. |
672
+ | `options` | `createWebhooks` options, e.g. `{ onEvent }`. |
673
+
674
+ To test a whole webhooks app with its routing, use Hono's `request()`:
675
+ `webhooks.request('/orders/42', init, env)`.
491
676
 
492
677
  ## CLI
493
678
 
@@ -499,28 +684,42 @@ Commands:
499
684
  schema <path> Print the JSON Schemas of the webhooks at <path>
500
685
  test <method> <path> Send a request through the webhooks app
501
686
 
687
+ Options:
688
+ -r, --preload <module> Import a module first (e.g. to load secrets), repeatable
689
+
502
690
  Options for test:
503
691
  -b, --body <text> Request body (JSON sets content-type: application/json)
504
692
  -q, --query <params> Query string, e.g. "a=1&b=2"
505
693
  -H, --header <k: v> Request header, repeatable
694
+ -s, --sign <scheme> Sign the body: github, shopify, stripe
695
+ -e, --secret-env <name> Environment variable holding the signing secret
506
696
  ```
507
697
 
508
- `<module>` is a file that exports the `createWebhooks()` result, as default or
509
- as any named export.
698
+ `<module>` exports either a `createWebhooks()` app or an array of webhooks, as
699
+ default or as any named export. With an array, the CLI builds the app itself,
700
+ so it can point straight at the file that lists your webhooks.
510
701
 
511
702
  ```bash
512
703
  bunx hono-webhooks ./src/webhooks/index.ts list
513
704
  bunx hono-webhooks ./src/webhooks/index.ts schema /orders/:id
514
705
  bunx hono-webhooks ./src/webhooks/index.ts test POST /orders/42 -b '{"total":10}' -H 'x-api-key: dev'
706
+
707
+ # Load secrets first, then sign the body like Shopify would
708
+ bunx hono-webhooks ./src/webhooks/index.ts test POST /shopify/orders -b '{"id":1}' \
709
+ --preload ./src/secrets.ts --sign shopify --secret-env SHOPIFY_WEBHOOK_SECRET
515
710
  ```
516
711
 
517
712
  - `test` sends a real request through the webhooks app (without the parent
518
713
  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).
714
+ - `--preload` imports modules in order, before the webhooks module. It takes
715
+ a file path or a package name, and works the same under Bun and Node.
716
+ - `--sign` signs the exact `--body` with the secret read from the environment
717
+ variable named by `--secret-env`, after preloading, so a preloaded module
718
+ can set it.
719
+ - `schema` prints the JSON Schemas from
720
+ [`describeWebhooks`](#describing-webhooks).
521
721
  - 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 ...`.
722
+ 22.18+ which strips types natively.
524
723
 
525
724
  ## API reference
526
725
 
@@ -529,17 +728,34 @@ import {
529
728
  defineWebhook, // (config, handler) => Webhook
530
729
  createWebhooks, // (webhooks, options?) => WebhooksApp (a Hono app + .webhooks)
531
730
  createWebhookFactory, // <E extends Env>() => { defineWebhook, createWebhooks }
532
- WebhookValidationError // extends HTTPException; .target, .issues
731
+ WebhookValidationError, // extends HTTPException; .target, .issues
732
+ describeWebhooks, // (appOrArray, { target? }) => WebhookDescription[]
733
+ testWebhook, // (webhook, input?) => Promise<Response>
734
+ summarizeEvent, // (event) => WebhookEventSummary, flat and JSON-safe
735
+ formatIssues // (issues) => 'total: Expected number; ...'
533
736
  } from 'hono-webhooks'
534
737
 
738
+ import {
739
+ github,
740
+ shopify,
741
+ stripe, // each: .verify(secret, options?) and .sign(rawBody, secret, options?)
742
+ hmac // (options) => a scheme like the ones above
743
+ } from 'hono-webhooks/signatures'
744
+
535
745
  import type {
536
746
  Webhook,
537
747
  WebhookConfig,
538
748
  WebhookInput,
539
749
  WebhookResult,
540
750
  WebhookEvent,
751
+ WebhookEventSummary,
752
+ WebhookDescription,
753
+ TestWebhookInput,
541
754
  WebhooksOptions,
755
+ WebhookRequestInfo,
542
756
  WebhooksApp,
757
+ Verify,
758
+ VerifyInput,
543
759
  WebhookMethod,
544
760
  BodyType,
545
761
  ValidationTarget,
@@ -550,6 +766,14 @@ import type {
550
766
 
551
767
  Use `Webhook` to type a list: `const all: Webhook<AppEnv>[] = [a, b]`.
552
768
 
769
+ ## Upgrading from 0.1
770
+
771
+ - `event.path` is relative to the mount point (`/orders/42`), like
772
+ `event.route`. Use `event.c.req.path` for the full path.
773
+ - Passing `onError` no longer turns off `console.error` for the default 500
774
+ response. Pass `logErrors: false` for that.
775
+ - `hono` 4.8 or newer is required.
776
+
553
777
  ## Migrating from `createHttpRoute`
554
778
 
555
779
  For code that used the in-app `lib/webhook` this package came from:
@@ -560,10 +784,10 @@ For code that used the in-app `lib/webhook` this package came from:
560
784
  | `reqBodySchema`, `reqQuerySchema`, ... | `body`, `query`, `headers`, `params`, `response` |
561
785
  | `z.string()` body switches to text parsing | Parsing follows `Content-Type`; set `bodyType: 'text'` to force it |
562
786
  | `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 |
787
+ | `rawBody: true` + checking the signature by hand | `verify: shopify.verify(secret)` (or your own function); `rawBody` is always passed too |
564
788
  | `request` in the handler | `c.req.raw` |
565
789
  | `useHonoWebhooks(app)` | `app.route('/webhooks', createWebhooks(webhooks, { onEvent }))` |
566
- | Built-in OTel span, logger, `recordWebhookCall` | `onEvent` (see [Recipes](#recipes)) and tracing middleware |
790
+ | Built-in OTel span, logger, `recordWebhookCall` | `around` for the span, `onEvent` for logs and storage (see [Recipes](#recipes)) |
567
791
  | `HEAD` / custom methods in `method` | `GET` routes answer `HEAD` automatically |
568
792
  | Response schema mismatch recorded as 500 | Recorded as `event.validation` with the real status |
569
793
  | Malformed JSON without a schema → `body: null` | `400 Invalid body`. Use `bodyType: 'text'` to accept anything |