hono-webhooks 0.1.1 → 0.3.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,62 @@ 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 on the raw body **right after routing, before anything is
183
+ validated or parsed**. A request with a bad signature gets a 401 and never
184
+ reaches the params, query, headers or body schemas, so unauthenticated
185
+ callers never see a description of your schemas. Signed webhooks can use
186
+ `headers` and `body` schemas like any other webhook:
187
+
188
+ ```ts
189
+ import { shopify } from 'hono-webhooks/signatures'
190
+
191
+ export const orderCreated = defineWebhook(
192
+ {
193
+ method: 'POST',
194
+ path: '/shopify/orders',
195
+ verify: shopify.verify((c) => c.env.SHOPIFY_WEBHOOK_SECRET),
196
+ body: z.object({ id: z.number(), total_price: z.string() })
197
+ },
198
+ ({ body }) => {
199
+ // only runs for correctly signed requests
200
+ }
201
+ )
202
+ ```
203
+
204
+ When `verify` returns `false`, the answer is
205
+ `401 { error: 'Invalid signature', issues: [...] }` and `onEvent` gets
206
+ `validation.target: 'signature'`.
207
+
208
+ `hono-webhooks/signatures` has a verifier and a matching signer per provider:
209
+
210
+ | Scheme | Header | Signed content |
211
+ | --------------------------------------- | --------------------------------------- | ------------------- |
212
+ | `github` | `x-hub-signature-256: sha256=<hex>` | body |
213
+ | `shopify` | `x-shopify-hmac-sha256: <base64>` | body |
214
+ | `stripe` | `stripe-signature: t=<unix>,v1=<hex>` | `<t>.<body>` |
215
+ | `svix` (Resend, Clerk, Standard Webhooks) | `svix-id`, `svix-timestamp`, `svix-signature: v1,<base64> ...` (or `webhook-*`) | `<id>.<timestamp>.<body>`, key from `whsec_<base64>` |
216
+ | `hmac({ header, encoding, prefix?, hash? })` | any header, hex or base64, SHA-1/256/512 | body |
217
+
218
+ - `scheme.verify(secret)` gives a `verify` function. `secret` is a string or
219
+ `(c) => string | Promise<string>`, so it can come from `c.env`. An empty
220
+ secret throws (a 500), instead of silently accepting or rejecting everything.
221
+ - `stripe.verify(secret, { toleranceSeconds })` and `svix.verify(...)` also
222
+ reject timestamps more than 300 seconds away by default, and accept any
223
+ matching `v1` so secret rotation works.
224
+ - `scheme.sign(rawBody, secret)` returns the headers the provider would send,
225
+ for tests and the CLI.
226
+ - Signatures are compared with `crypto.subtle.verify`, which runs in constant
227
+ time. Everything uses Web Crypto, so it works on every runtime.
228
+
229
+ For another provider, write the function yourself:
230
+
231
+ ```ts
232
+ verify: async ({ c, rawBody, headers }) =>
233
+ headers['x-token'] === c.env.WEBHOOK_TOKEN
234
+ ```
235
+
173
236
  ## Mounting
174
237
 
175
238
  `createWebhooks(webhooks, options?)` returns a Hono app. Mount it under a
@@ -255,13 +318,17 @@ defineWebhook({ method: 'POST', path: '/orders/:id' }, async ({ params }) => {
255
318
  ```
256
319
 
257
320
  `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`.
321
+ `Response`, or nothing to fall back to the default.
322
+
323
+ The default 500 response logs the error with `console.error`. Pass
324
+ `logErrors: false` when you log errors yourself, in `onError` or `onEvent`,
325
+ so they aren't logged twice.
260
326
 
261
327
  ```ts
262
328
  import { WebhookValidationError } from 'hono-webhooks'
263
329
 
264
330
  createWebhooks(webhooks, {
331
+ logErrors: false,
265
332
  onError(error, c) {
266
333
  if (error instanceof WebhookValidationError) {
267
334
  return c.json({ type: 'validation', target: error.target, issues: error.issues }, 422)
@@ -283,7 +350,7 @@ createWebhooks(webhooks, {
283
350
  logger.info('webhook', {
284
351
  route: event.route, // '/orders/:id', undefined for a 404
285
352
  method: event.method,
286
- path: event.path, // '/webhooks/orders/42'
353
+ path: event.path, // '/orders/42', relative to the mount point
287
354
  status: event.status,
288
355
  durationMs: event.durationMs
289
356
  })
@@ -295,86 +362,149 @@ createWebhooks(webhooks, {
295
362
  | ------------------------------------------ | ---------------------------------------------------------------------------------------------------- |
296
363
  | `c` | The Hono context, e.g. `c.req.header('user-agent')` |
297
364
  | `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` |
365
+ | `method`, `path` | Request method, and path relative to the mount point (`/orders/42`). The full path is `c.req.path` |
366
+ | `status`, `durationMs` | Response status, and time spent in the webhooks app in fractional milliseconds |
367
+ | `params`, `query`, `headers` | `Record<string, unknown>`: the schema output, or the raw strings without a schema |
368
+ | `body`, `rawBody` | Parsed body and raw body |
301
369
  | `response` | What the handler returned, unless it returned a `Response` |
302
370
  | `error` | Whatever was thrown |
303
- | `validation` | `{ target, issues }` when a request part or the response failed its schema |
371
+ | `validation` | `{ target, issues }` when a request part, the signature or the response failed |
372
+
373
+ Each request part is set once it has passed validation, so a request that
374
+ fails on `body` still reports `params`.
375
+
376
+ ### Summaries and issue messages
377
+
378
+ `summarizeEvent(event)` flattens an event into a JSON-safe record for logs or
379
+ a database table:
380
+
381
+ ```ts
382
+ import { formatIssues, summarizeEvent } from 'hono-webhooks'
383
+
384
+ summarizeEvent(event)
385
+ // {
386
+ // route: '/orders/:id', method: 'POST', path: '/orders/42', status: 400,
387
+ // durationMs: 1.37, // rounded to two decimals
388
+ // ip: '203.0.113.9', // x-forwarded-for, x-real-ip or cf-connecting-ip
389
+ // userAgent: 'Shopify-Captain-Hook',
390
+ // params: { id: '42' }, query: {}, body: null, response: null,
391
+ // error: null, // or { name, message, stack }; null when validation is set
392
+ // validation: {
393
+ // target: 'body',
394
+ // message: 'total: Expected number',
395
+ // issues: [{ path: 'total', message: 'Expected number' }]
396
+ // }
397
+ // }
398
+ ```
399
+
400
+ - Missing values are `null`, never `undefined`.
401
+ - Request headers are left out on purpose, because they carry signatures and
402
+ credentials.
403
+ - `ip` comes from headers the client can set, so only trust it behind a
404
+ proxy that overwrites them.
405
+
406
+ `formatIssues(issues)` turns Standard Schema issues into one line, like
407
+ `total: Expected number; items.0.id: Required`.
304
408
 
305
409
  `onEvent` isn't awaited, so a slow database write doesn't delay the response.
306
410
  When it returns a promise, the promise is passed to
307
411
  `c.executionCtx.waitUntil()` on runtimes that have one (Cloudflare Workers,
308
412
  Vercel Edge), and a rejection is logged instead of crashing the request.
309
413
 
310
- ## Recipes
414
+ `onEvent` is called inside the request's async context, before the response
415
+ is returned. The active OpenTelemetry span, `AsyncLocalStorage` values and
416
+ anything set up in [`around`](#wrapping-requests-with-around) are available
417
+ there, so log lines written in `onEvent` attach to the request.
311
418
 
312
- ### Stripe signature verification
419
+ ### Wrapping requests with `around`
313
420
 
314
- `rawBody` is the exact payload Stripe signed.
421
+ `around` wraps every request, including 404s and 405s, and already knows the
422
+ matched route. Use it for anything that has to surround the whole request:
423
+ a tracing span, an `AsyncLocalStorage` scope, a timer. Call `next()` to
424
+ handle the request; it returns the response and runs `onEvent` inside your
425
+ wrapper.
315
426
 
316
427
  ```ts
317
- import Stripe from 'stripe'
318
- import { HTTPException } from 'hono/http-exception'
428
+ createWebhooks(webhooks, {
429
+ around: async ({ c, route, method }, next) => {
430
+ const res = await next()
431
+ console.log(method, route ?? '(no route)', res.status)
432
+ return res
433
+ }
434
+ })
435
+ ```
319
436
 
320
- const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
437
+ `route` is the pattern relative to the mount point (`/orders/:id`), or
438
+ `undefined` for a 404. If `around` returns a response without calling
439
+ `next()`, the request isn't handled and `onEvent` doesn't run.
440
+
441
+ ## Recipes
442
+
443
+ ### Stripe events
444
+
445
+ The body schema only runs on verified requests. `constructEventAsync` from
446
+ the Stripe SDK works too, with `rawBody`, if you'd rather use their event
447
+ types.
448
+
449
+ ```ts
450
+ import { stripe } from 'hono-webhooks/signatures'
321
451
 
322
452
  export const stripeEvents = defineWebhook(
323
453
  {
324
454
  method: 'POST',
325
455
  path: '/stripe',
326
- headers: z.object({ 'stripe-signature': z.string() })
456
+ verify: stripe.verify(process.env.STRIPE_WEBHOOK_SECRET!),
457
+ body: z.object({
458
+ id: z.string(),
459
+ type: z.string(),
460
+ data: z.object({ object: z.record(z.string(), z.unknown()) })
461
+ })
327
462
  },
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)
463
+ async ({ body }) => {
464
+ if (body.type === 'checkout.session.completed') {
465
+ await fulfil(body.data.object)
341
466
  }
342
467
  return { received: true }
343
468
  }
344
469
  )
345
470
  ```
346
471
 
347
- ### GitHub signature verification (Web Crypto, any runtime)
472
+ ### GitHub events
348
473
 
349
474
  ```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
- }
475
+ import { github } from 'hono-webhooks/signatures'
361
476
 
362
477
  export const githubPush = defineWebhook(
363
478
  {
364
479
  method: 'POST',
365
480
  path: '/github',
366
- headers: z.object({
367
- 'x-github-event': z.string(),
368
- 'x-hub-signature-256': z.string()
481
+ verify: github.verify((c) => c.env.GITHUB_WEBHOOK_SECRET),
482
+ headers: z.object({ 'x-github-event': z.string() }),
483
+ body: z.object({ ref: z.string().optional() })
484
+ },
485
+ ({ headers, body }) => {
486
+ if (headers['x-github-event'] === 'push') deploy(body.ref)
487
+ }
488
+ )
489
+ ```
490
+
491
+ ### Resend (Svix) events
492
+
493
+ ```ts
494
+ import { svix } from 'hono-webhooks/signatures'
495
+
496
+ export const resendEvents = defineWebhook(
497
+ {
498
+ method: 'POST',
499
+ path: '/resend',
500
+ verify: svix.verify((c) => c.env.RESEND_WEBHOOK_SECRET), // whsec_...
501
+ body: z.object({
502
+ type: z.string(),
503
+ data: z.object({ email_id: z.string() })
369
504
  })
370
505
  },
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
506
+ ({ body }) => {
507
+ if (body.type === 'email.bounced') markBounced(body.data.email_id)
378
508
  }
379
509
  )
380
510
  ```
@@ -398,21 +528,20 @@ defineWebhook(
398
528
  ### Storing every call
399
529
 
400
530
  ```ts
531
+ import { summarizeEvent } from 'hono-webhooks'
532
+
401
533
  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
534
+ logErrors: false, // errors are stored below instead
535
+ onEvent: async (e) => {
536
+ if (e.route === '/health') return
537
+ const call = summarizeEvent(e)
538
+ await db.insert(webhookCalls).values({
539
+ ...call,
540
+ errorMessage: call.error?.message ?? null,
541
+ errorStack: call.error?.stack ?? null,
542
+ validationError: call.validation?.message ?? null
415
543
  })
544
+ }
416
545
  })
417
546
  ```
418
547
 
@@ -433,9 +562,45 @@ createWebhooks(webhooks, {
433
562
  })
434
563
  ```
435
564
 
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', ...)`.
565
+ ### OpenTelemetry traces
566
+
567
+ One span per request, named by the route pattern (low cardinality), with
568
+ `onEvent` logs attached to it:
569
+
570
+ ```ts
571
+ import { SpanKind, SpanStatusCode, trace } from '@opentelemetry/api'
572
+
573
+ const tracer = trace.getTracer('webhooks')
574
+
575
+ createWebhooks(webhooks, {
576
+ around: ({ c, route, method }, next) =>
577
+ tracer.startActiveSpan(
578
+ `webhook ${method} ${route ?? 'unmatched'}`,
579
+ {
580
+ kind: SpanKind.SERVER,
581
+ attributes: {
582
+ 'http.request.method': method,
583
+ 'http.route': route ?? 'unmatched',
584
+ 'url.path': c.req.path
585
+ }
586
+ },
587
+ async (span) => {
588
+ try {
589
+ const res = await next()
590
+ span.setAttribute('http.response.status_code', res.status)
591
+ if (res.status >= 500) span.setStatus({ code: SpanStatusCode.ERROR })
592
+ return res
593
+ } finally {
594
+ span.end()
595
+ }
596
+ }
597
+ ),
598
+ onEvent: (e) => {
599
+ // trace.getActiveSpan() is the span above
600
+ if (e.error instanceof Error) trace.getActiveSpan()?.recordException(e.error)
601
+ }
602
+ })
603
+ ```
439
604
 
440
605
  ### Skipping noise
441
606
 
@@ -468,26 +633,69 @@ app.route('/webhooks', webhooks)
468
633
 
469
634
  Hono's `showRoutes(app)` from `hono/dev` prints every route at startup.
470
635
 
636
+ ## Describing webhooks
637
+
638
+ `describeWebhooks(appOrArray)` returns one entry per webhook, with JSON
639
+ Schemas from any library that implements
640
+ [Standard JSON Schema](https://standardschema.dev) (Zod 4.2+ does). Use it for
641
+ an admin page, docs or an OpenAPI document:
642
+
643
+ ```ts
644
+ import { describeWebhooks } from 'hono-webhooks'
645
+
646
+ describeWebhooks(webhooks)
647
+ // [{
648
+ // method: 'POST',
649
+ // path: '/orders/:id',
650
+ // description: 'Order paid',
651
+ // verified: true, // has a verify function
652
+ // bodyType: 'auto',
653
+ // jsonSchema: { params: null, query: null, headers: null, body: {...}, response: {...} }
654
+ // }]
655
+
656
+ describeWebhooks(webhooks, { target: 'openapi-3.0' }) // or 'draft-07'
657
+ ```
658
+
659
+ Request parts use the schema's input side (what the sender sends), `response`
660
+ its output side. A part is `null` when it has no schema, or when the library
661
+ can't produce JSON Schema.
662
+
471
663
  ## Testing
472
664
 
473
- The webhooks app is a Hono app, so `request()` runs the full pipeline:
474
- routing, validation, handler and response.
665
+ `testWebhook(webhook, input)` sends one request through a single webhook and
666
+ returns the `Response`. It runs everything a real request does: signature
667
+ check, validation, handler and response handling. Its input is typed from the
668
+ webhook's schemas:
475
669
 
476
670
  ```ts
477
671
  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({})
672
+ import { testWebhook } from 'hono-webhooks'
673
+ import { shopify } from 'hono-webhooks/signatures'
674
+ import { orderPaid } from './webhooks/order-paid'
675
+
676
+ test('marks the order paid', async () => {
677
+ const res = await testWebhook(orderPaid, {
678
+ params: { orderId: '42' }, // filled into the path
679
+ query: { dryRun: 'true' },
680
+ body: { amount: 10, currency: 'DKK' }, // typed from the body schema
681
+ sign: (rawBody) => shopify.sign(rawBody, 'test-secret'),
682
+ env: { SHOPIFY_WEBHOOK_SECRET: 'test-secret' }
485
683
  })
486
- expect(res.status).toBe(400)
684
+ expect(res.status).toBe(200)
487
685
  })
488
686
  ```
489
687
 
490
- Pass bindings as the third argument: `webhooks.request(path, init, env)`.
688
+ | Input | Description |
689
+ | --------- | ------------------------------------------------------------------------------------------------------------ |
690
+ | `params` | Filled into the path pattern. Use `path` instead for wildcard patterns. |
691
+ | `query`, `headers` | String records. |
692
+ | `body` | A string is sent as is. Anything else is sent as JSON, or url-encoded when `bodyType` is `'form'`. |
693
+ | `sign` | Gets the exact raw body and returns headers to add, e.g. `(raw) => github.sign(raw, secret)`. |
694
+ | `env` | Bindings, available as `c.env`. |
695
+ | `options` | `createWebhooks` options, e.g. `{ onEvent }`. |
696
+
697
+ To test a whole webhooks app with its routing, use Hono's `request()`:
698
+ `webhooks.request('/orders/42', init, env)`.
491
699
 
492
700
  ## CLI
493
701
 
@@ -499,28 +707,47 @@ Commands:
499
707
  schema <path> Print the JSON Schemas of the webhooks at <path>
500
708
  test <method> <path> Send a request through the webhooks app
501
709
 
710
+ Options:
711
+ -r, --preload <module> Import a module first (e.g. to load secrets), repeatable
712
+
502
713
  Options for test:
503
714
  -b, --body <text> Request body (JSON sets content-type: application/json)
504
715
  -q, --query <params> Query string, e.g. "a=1&b=2"
505
716
  -H, --header <k: v> Request header, repeatable
717
+ -s, --sign <scheme> Sign the body: github, shopify, stripe, svix
718
+ -e, --secret-env <name> Environment variable holding the signing secret
506
719
  ```
507
720
 
508
- `<module>` is a file that exports the `createWebhooks()` result, as default or
509
- as any named export.
721
+ `<module>` exports either a `createWebhooks()` app or an array of webhooks, as
722
+ default or as any named export. With an array, the CLI builds the app itself,
723
+ so it can point straight at the file that lists your webhooks.
510
724
 
511
725
  ```bash
512
726
  bunx hono-webhooks ./src/webhooks/index.ts list
513
727
  bunx hono-webhooks ./src/webhooks/index.ts schema /orders/:id
514
728
  bunx hono-webhooks ./src/webhooks/index.ts test POST /orders/42 -b '{"total":10}' -H 'x-api-key: dev'
729
+
730
+ # Load secrets first, then sign the body like Shopify would
731
+ bunx hono-webhooks ./src/webhooks/index.ts test POST /shopify/orders -b '{"id":1}' \
732
+ --preload ./src/secrets.ts --sign shopify --secret-env SHOPIFY_WEBHOOK_SECRET
515
733
  ```
516
734
 
517
735
  - `test` sends a real request through the webhooks app (without the parent
518
736
  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 ...`.
737
+ - `--preload` imports modules in order, before the webhooks module. It takes
738
+ a file path or a package name.
739
+ - `--sign` signs the exact `--body` with the secret read from the environment
740
+ variable named by `--secret-env`, after preloading, so a preloaded module
741
+ can set it.
742
+ - `schema` prints the JSON Schemas from
743
+ [`describeWebhooks`](#describing-webhooks).
744
+ - The CLI imports your TypeScript file. `bunx hono-webhooks` starts the CLI
745
+ under Node (its shebang is `node`), and Node 22.18+ can load plain
746
+ TypeScript. It can't load everything Bun can, such as extensionless
747
+ imports (`import { x } from './x'`). When Node fails to load your module
748
+ and Bun is installed, the CLI reruns the same command under Bun
749
+ automatically. To skip the failed Node attempt in a Bun project, run
750
+ `bunx --bun hono-webhooks ...`.
524
751
 
525
752
  ## API reference
526
753
 
@@ -529,17 +756,35 @@ import {
529
756
  defineWebhook, // (config, handler) => Webhook
530
757
  createWebhooks, // (webhooks, options?) => WebhooksApp (a Hono app + .webhooks)
531
758
  createWebhookFactory, // <E extends Env>() => { defineWebhook, createWebhooks }
532
- WebhookValidationError // extends HTTPException; .target, .issues
759
+ WebhookValidationError, // extends HTTPException; .target, .issues
760
+ describeWebhooks, // (appOrArray, { target? }) => WebhookDescription[]
761
+ testWebhook, // (webhook, input?) => Promise<Response>
762
+ summarizeEvent, // (event) => WebhookEventSummary, flat and JSON-safe
763
+ formatIssues // (issues) => 'total: Expected number; ...'
533
764
  } from 'hono-webhooks'
534
765
 
766
+ import {
767
+ github,
768
+ shopify,
769
+ stripe,
770
+ svix, // each: .verify(secret, options?) and .sign(rawBody, secret, options?)
771
+ hmac // (options) => a scheme like the ones above
772
+ } from 'hono-webhooks/signatures'
773
+
535
774
  import type {
536
775
  Webhook,
537
776
  WebhookConfig,
538
777
  WebhookInput,
539
778
  WebhookResult,
540
779
  WebhookEvent,
780
+ WebhookEventSummary,
781
+ WebhookDescription,
782
+ TestWebhookInput,
541
783
  WebhooksOptions,
784
+ WebhookRequestInfo,
542
785
  WebhooksApp,
786
+ Verify,
787
+ VerifyInput,
543
788
  WebhookMethod,
544
789
  BodyType,
545
790
  ValidationTarget,
@@ -550,6 +795,14 @@ import type {
550
795
 
551
796
  Use `Webhook` to type a list: `const all: Webhook<AppEnv>[] = [a, b]`.
552
797
 
798
+ ## Upgrading from 0.1
799
+
800
+ - `event.path` is relative to the mount point (`/orders/42`), like
801
+ `event.route`. Use `event.c.req.path` for the full path.
802
+ - Passing `onError` no longer turns off `console.error` for the default 500
803
+ response. Pass `logErrors: false` for that.
804
+ - `hono` 4.8 or newer is required.
805
+
553
806
  ## Migrating from `createHttpRoute`
554
807
 
555
808
  For code that used the in-app `lib/webhook` this package came from:
@@ -560,10 +813,10 @@ For code that used the in-app `lib/webhook` this package came from:
560
813
  | `reqBodySchema`, `reqQuerySchema`, ... | `body`, `query`, `headers`, `params`, `response` |
561
814
  | `z.string()` body switches to text parsing | Parsing follows `Content-Type`; set `bodyType: 'text'` to force it |
562
815
  | `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 |
816
+ | `rawBody: true` + checking the signature by hand | `verify: shopify.verify(secret)` (or your own function); `rawBody` is always passed too |
564
817
  | `request` in the handler | `c.req.raw` |
565
818
  | `useHonoWebhooks(app)` | `app.route('/webhooks', createWebhooks(webhooks, { onEvent }))` |
566
- | Built-in OTel span, logger, `recordWebhookCall` | `onEvent` (see [Recipes](#recipes)) and tracing middleware |
819
+ | Built-in OTel span, logger, `recordWebhookCall` | `around` for the span, `onEvent` for logs and storage (see [Recipes](#recipes)) |
567
820
  | `HEAD` / custom methods in `method` | `GET` routes answer `HEAD` automatically |
568
821
  | Response schema mismatch recorded as 500 | Recorded as `event.validation` with the real status |
569
822
  | Malformed JSON without a schema → `body: null` | `400 Invalid body`. Use `bodyType: 'text'` to accept anything |