hono-webhooks 0.5.0 → 0.6.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
@@ -9,12 +9,14 @@ and others.
9
9
  ```ts
10
10
  import { Hono } from 'hono'
11
11
  import { createWebhooks, defineWebhook } from 'hono-webhooks'
12
+ import { sharedSecret } from 'hono-webhooks/signatures'
12
13
  import { z } from 'zod'
13
14
 
14
15
  const orderPaid = defineWebhook(
15
16
  {
16
17
  method: 'POST',
17
18
  path: '/shop/orders/:orderId/paid',
19
+ verify: sharedSecret({ header: 'x-shop-token' }).verify((c) => c.env.SHOP_TOKEN),
18
20
  body: z.object({ amount: z.number(), currency: z.string() })
19
21
  },
20
22
  async ({ params, body }) => {
@@ -32,10 +34,13 @@ app.route('/webhooks', createWebhooks([orderPaid]))
32
34
  value is checked against the `response` schema.
33
35
  - **Any schema library.** Zod, Valibot, ArkType, Effect Schema, or anything
34
36
  else that implements Standard Schema. Async schemas work too.
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.
37
+ - **Signed webhooks built in, and required.** `verify` checks the signature
38
+ on the raw body before anything is parsed, with ready-made verifiers and
39
+ signers for Stripe, GitHub, Meta, Shopify, Svix, Twilio, HubSpot, Infisical,
40
+ shared secrets and any HMAC scheme. A webhook that takes a body must have a
41
+ `verify`, or say `verify: false`. `rawBody` is always passed to the handler
42
+ too.
43
+ - **Bounded bodies.** Bodies over 1 MiB answer 413 before they are read.
39
44
  - **Plain Hono.** `createWebhooks` returns a Hono app. Handlers get the Hono
40
45
  context `c`, Hono middleware works, `HTTPException` works, and it runs on
41
46
  every runtime Hono supports.
@@ -63,6 +68,7 @@ app.route('/webhooks', createWebhooks([orderPaid]))
63
68
  - [Testing](#testing)
64
69
  - [CLI](#cli)
65
70
  - [API reference](#api-reference)
71
+ - [Upgrading to 0.6](#upgrading-to-06)
66
72
  - [Upgrading from 0.1](#upgrading-from-01)
67
73
  - [Migrating from `createHttpRoute`](#migrating-from-createhttproute)
68
74
  - [Releasing](#releasing)
@@ -93,14 +99,15 @@ export const updateItem = defineWebhook(
93
99
  method: 'PUT',
94
100
  path: '/items/:id',
95
101
  description: 'Update an item from the ERP',
102
+ verify: sharedSecret({ header: 'x-api-key' }).verify((c) => c.env.ERP_KEY),
96
103
  params: v.object({ id: v.pipe(v.string(), v.transform(Number)) }),
97
104
  query: v.object({ dryRun: v.optional(v.picklist(['true', 'false'])) }),
98
- headers: v.object({ 'x-api-key': v.string() }),
105
+ headers: v.object({ 'x-erp-user': v.string() }),
99
106
  body: v.object({ name: v.string(), price: v.number() }),
100
107
  response: v.object({ id: v.number(), updated: v.boolean() })
101
108
  },
102
109
  async ({ params, query, headers, body }) => {
103
- // params.id: number, body.price: number, headers['x-api-key']: string
110
+ // params.id: number, body.price: number, headers['x-erp-user']: string
104
111
  return { id: params.id, updated: query.dryRun !== 'true' }
105
112
  }
106
113
  )
@@ -114,7 +121,8 @@ export const updateItem = defineWebhook(
114
121
  | `query` | Standard Schema | Validates the query string as `Record<string, string>`. |
115
122
  | `headers` | Standard Schema | Validates headers as `Record<string, string>` with **lower-case** names. |
116
123
  | `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)). |
124
+ | `verify` | `({ c, rawBody, headers, fail }) => boolean \| Promise<boolean>`, or `false` | Checks the signature before the body is parsed. Required for every method but `GET`; `false` means public on purpose (see [Signature verification](#signature-verification)). |
125
+ | `maxBodyBytes` | `number` | Overrides the app's body limit for this webhook (see [Mounting](#mounting)). |
118
126
  | `response` | Standard Schema | Types the handler's return value and checks it at runtime (see [Return values](#return-values)). |
119
127
  | `bodyType` | `'auto' \| 'json' \| 'form' \| 'text'` | How to parse the body. Default `'auto'`. |
120
128
  | `description` | `string` | Shown by `hono-webhooks list`. |
@@ -201,36 +209,140 @@ export const orderCreated = defineWebhook(
201
209
  )
202
210
  ```
203
211
 
204
- When `verify` returns `false`, the answer is
205
- `401 { error: 'Invalid signature', issues: [...] }` and `onEvent` gets
206
- `validation.target: 'signature'`.
212
+ When `verify` returns anything but `true`, the answer is
213
+ `401 { error: 'Invalid signature' }` and `onEvent` gets
214
+ `validation.target: 'signature'` with the reason in
215
+ `validation.issues[0].message` (see [Failure reasons](#failure-reasons)).
216
+
217
+ ### Every webhook says how it is authenticated
218
+
219
+ `verify` is required for every method except `GET`. `createWebhooks` throws
220
+ at startup for a webhook that has none:
221
+
222
+ ```text
223
+ hono-webhooks: POST /update-secrets has no verify. Pass a verify function, or verify: false if it is public on purpose.
224
+ ```
225
+
226
+ Pass `verify: false` for a webhook that really is public, such as a device
227
+ that cannot sign or a beacon. `describeWebhooks` reports it as
228
+ `verified: false` and `hono-webhooks list` prints `public (verify: false)`.
229
+ `GET` webhooks (health checks, provider URL challenges, OAuth callbacks) may
230
+ leave `verify` out.
231
+
232
+ A check that is not a signature still belongs in `verify`: an API key, an
233
+ OAuth `state`, a channel token. That keeps unauthenticated callers at 401
234
+ before any schema runs.
235
+
236
+ ### Schemes
207
237
 
208
238
  `hono-webhooks/signatures` has a verifier and a matching signer per provider:
209
239
 
210
240
  | Scheme | Header | Signed content |
211
241
  | --------------------------------------- | --------------------------------------- | ------------------- |
212
- | `github` | `x-hub-signature-256: sha256=<hex>` | body |
242
+ | `github`, `meta` (WhatsApp, Messenger, Instagram) | `x-hub-signature-256: sha256=<hex>` | body |
213
243
  | `shopify` | `x-shopify-hmac-sha256: <base64>` | body |
214
244
  | `stripe` | `stripe-signature: t=<unix>,v1=<hex>` | `<t>.<body>` |
215
245
  | `svix` (Resend, Clerk, Standard Webhooks) | `svix-id`, `svix-timestamp`, `svix-signature: v1,<base64> ...` (or `webhook-*`) | `<id>.<timestamp>.<body>`, key from `whsec_<base64>` |
246
+ | `infisical` | `x-infisical-signature: t=<ms>;<hex>` | body (which carries the `timestamp`) |
247
+ | `twilio` | `x-twilio-signature: <base64>` (HMAC-SHA1) | URL + sorted form fields |
248
+ | `hubspot` (v3) | `x-hubspot-signature-v3: <base64>`, `x-hubspot-request-timestamp` | `<METHOD><url><body><timestamp>` |
249
+ | `sharedSecret({ header, prefix? })` | the secret itself, e.g. `authorization: Bearer <token>` | nothing; compared in constant time |
216
250
  | `hmac({ header, encoding, prefix?, hash? })` | any header, hex or base64, SHA-1/256/512 | body |
217
251
 
218
252
  - `scheme.verify(secret)` gives a `verify` function. `secret` is a string or
219
253
  `(c) => string | Promise<string>`, so it can come from `c.env`. An empty
220
254
  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.
255
+ - `stripe`, `svix`, `infisical` and `hubspot` reject timestamps more than 300
256
+ seconds away by default (`verify(secret, { toleranceSeconds })`). `stripe`
257
+ and `svix` accept any matching `v1`, so secret rotation works.
258
+ - `twilio` and `hubspot` sign the URL the provider called. Behind a proxy
259
+ `c.req.url` is the internal URL, so pass the public one:
260
+ `twilio.verify(secret, { url: (c) => publicUrl(c) })`. Return an array to
261
+ try several; any match passes.
262
+ - `scheme.sign(rawBody, secret, options?)` returns the headers the provider
263
+ would send, for tests and the CLI. `twilio.sign` and `hubspot.sign` need
264
+ `{ url }`.
226
265
  - Signatures are compared with `crypto.subtle.verify`, which runs in constant
227
- time. Everything uses Web Crypto, so it works on every runtime.
266
+ time; `sharedSecret` compares through it too. Everything uses Web Crypto,
267
+ so it works on every runtime.
228
268
 
229
- For another provider, write the function yourself:
269
+ ### Shared secrets
270
+
271
+ For a provider that sends a fixed token instead of a signature (GitLab's
272
+ `x-gitlab-token`, Telegram's `x-telegram-bot-api-secret-token`, a bearer
273
+ token), use `sharedSecret`. Do not compare with `===`: it is not constant
274
+ time, and an unset secret and a missing header compare equal
275
+ (`undefined === undefined`).
230
276
 
231
277
  ```ts
232
- verify: async ({ c, rawBody, headers }) =>
233
- headers['x-token'] === c.env.WEBHOOK_TOKEN
278
+ import { sharedSecret } from 'hono-webhooks/signatures'
279
+
280
+ verify: sharedSecret({ header: 'x-gitlab-token' }).verify((c) => c.env.GITLAB_WEBHOOK_TOKEN)
281
+ verify: sharedSecret({ header: 'authorization', prefix: 'Bearer ' }).verify(() => getSecret('API_TOKEN'))
282
+ ```
283
+
284
+ To accept either of two headers, try both:
285
+
286
+ ```ts
287
+ const header = sharedSecret({ header: 'x-once-secret' }).verify(secret)
288
+ const bearer = sharedSecret({ header: 'authorization', prefix: 'Bearer ' }).verify(secret)
289
+ verify: async (input) => (await header(input)) || bearer(input)
290
+ ```
291
+
292
+ ### Writing your own
293
+
294
+ A scheme that signs a timestamp in its own format (Slack's
295
+ `v0:<timestamp>:<body>`, for example) needs a hand-written `verify`. Check the
296
+ timestamp, and return `fail(reason)` so the operator sees why:
297
+
298
+ ```ts
299
+ import { createHmac, timingSafeEqual } from 'node:crypto'
300
+
301
+ verify: ({ c, rawBody, headers, fail }) => {
302
+ const timestamp = Number(headers['x-slack-request-timestamp'])
303
+ const given = headers['x-slack-signature'] ?? ''
304
+ if (!timestamp || !given) return fail('missing-header')
305
+ if (Math.abs(Date.now() / 1000 - timestamp) > 300) return fail('timestamp-out-of-tolerance')
306
+ const expected = `v0=${createHmac('sha256', c.env.SLACK_SIGNING_SECRET).update(`v0:${timestamp}:${rawBody}`).digest('hex')}`
307
+ return (
308
+ (given.length === expected.length && timingSafeEqual(Buffer.from(given), Buffer.from(expected))) ||
309
+ fail('mismatch')
310
+ )
311
+ }
312
+ ```
313
+
314
+ ### Failure reasons
315
+
316
+ `fail(reason)` records why a check failed and returns `false`. The reason
317
+ goes to `onEvent` as `validation.issues[0].message` (and so to
318
+ `summarizeEvent(e).validation.message`), never to the caller. The built-in
319
+ schemes use `missing-header`, `malformed`, `timestamp-out-of-tolerance` and
320
+ `mismatch`, so a log tells clock skew from a wrong secret. A plain `false`
321
+ gives `Signature does not match`.
322
+
323
+ ### Passing what `verify` learned to the handler
324
+
325
+ When the check finds out who is calling (an API key's organization, a
326
+ decoded JWT), set it on the context and read it in the handler. Type it with
327
+ [`createWebhookFactory`](#typed-env-and-middleware):
328
+
329
+ ```ts
330
+ type AppEnv = { Variables: { caller?: { organizationId: string } } }
331
+ const { defineWebhook } = createWebhookFactory<AppEnv>()
332
+
333
+ defineWebhook(
334
+ {
335
+ method: 'POST',
336
+ path: '/sessions',
337
+ verify: async ({ c, fail }) => {
338
+ const caller = await findApiKey(c.req.header('authorization'))
339
+ if (!caller) return fail('unknown api key')
340
+ c.set('caller', caller)
341
+ return true
342
+ }
343
+ },
344
+ ({ c }) => createSession(c.var.caller!.organizationId)
345
+ )
234
346
  ```
235
347
 
236
348
  ## Mounting
@@ -251,6 +363,15 @@ Below that prefix:
251
363
  - A static path wins over a dynamic one that also matches (`/users/me` beats
252
364
  `/users/:id`) no matter which order they were passed in.
253
365
  - Defining the same method and path twice throws when `createWebhooks` runs.
366
+ - A body over `maxBodyBytes` (default 1 MiB) answers
367
+ `413 { error: 'Payload too large' }` before it is read or verified, from
368
+ `content-length` or while streaming. Set it on `createWebhooks(webhooks, {
369
+ maxBodyBytes })` or per webhook; `Infinity` turns it off.
370
+
371
+ Anything mounted under the prefix gets the webhooks' behavior: the body
372
+ limit, `onEvent`, logging and metrics. A health check defined as a webhook
373
+ is fine; skip it in metrics and logs with `ignoreRoutes: ['/health']` (see
374
+ [OpenTelemetry](#opentelemetry)).
254
375
 
255
376
  Because the webhooks app answers 404 for anything under its prefix, mount it
256
377
  under its own prefix (`/webhooks`), not at `/`.
@@ -277,7 +398,7 @@ export const { defineWebhook, createWebhooks } = createWebhookFactory<AppEnv>()
277
398
  ```
278
399
 
279
400
  ```ts
280
- defineWebhook({ method: 'POST', path: '/ping' }, ({ c }) => {
401
+ defineWebhook({ method: 'POST', path: '/ping', verify: false }, ({ c }) => {
281
402
  c.env.DB // D1Database
282
403
  c.var.requestId // string
283
404
  })
@@ -300,6 +421,8 @@ Anything thrown while handling a request becomes a response:
300
421
  | Thrown | Default response |
301
422
  | ---------------------------------------------------- | ------------------------------------------------- |
302
423
  | `WebhookValidationError` (a request schema failed) | `400 { error: 'Invalid body', issues: [...] }` |
424
+ | `WebhookValidationError` (the signature check failed) | `401 { error: 'Invalid signature' }` |
425
+ | a body over `maxBodyBytes` | `413 { error: 'Payload too large' }` |
303
426
  | Hono's `HTTPException` | Its status and `{ error: message }`, or its `res` |
304
427
  | anything else | `500 { error: 'Internal Server Error' }`, and the error is logged with `console.error` |
305
428
 
@@ -310,7 +433,9 @@ Throw an `HTTPException` from a handler to answer with another status:
310
433
  ```ts
311
434
  import { HTTPException } from 'hono/http-exception'
312
435
 
313
- defineWebhook({ method: 'POST', path: '/orders/:id' }, async ({ params }) => {
436
+ const verify = shopify.verify((c) => c.env.SHOPIFY_SECRET)
437
+
438
+ defineWebhook({ method: 'POST', path: '/orders/:id', verify }, async ({ params }) => {
314
439
  const order = await findOrder(params.id)
315
440
  if (!order) throw new HTTPException(404, { message: 'Unknown order' })
316
441
  // ...
@@ -400,6 +525,21 @@ summarizeEvent(event)
400
525
  - Missing values are `null`, never `undefined`.
401
526
  - Request headers are left out on purpose, because they carry signatures and
402
527
  credentials.
528
+
529
+ The summary keeps the parsed body, the response and the query string.
530
+ Webhook bodies are often customer data and query strings can carry tokens,
531
+ so choose what to keep before storing it:
532
+
533
+ ```ts
534
+ summarizeEvent(event, {
535
+ body: false, // body and response become null
536
+ query: false, // query becomes null
537
+ headers: ['x-github-delivery', 'svix-id'] // kept as `headers`, lower-case
538
+ })
539
+ ```
540
+
541
+ A delivery id header lets a stored call be matched to the provider's
542
+ delivery log.
403
543
  - `ip` comes from headers the client can set, so only trust it behind a
404
544
  proxy that overwrites them.
405
545
 
@@ -511,11 +651,20 @@ export const resendEvents = defineWebhook(
511
651
 
512
652
  ### Twilio form posts
513
653
 
654
+ Twilio signs the public URL it called, so pass it when the app runs behind a
655
+ proxy:
656
+
514
657
  ```ts
515
- defineWebhook(
658
+ import { twilio } from 'hono-webhooks/signatures'
659
+
660
+ export const incomingSms = defineWebhook(
516
661
  {
517
662
  method: 'POST',
518
663
  path: '/twilio/sms',
664
+ bodyType: 'form',
665
+ verify: twilio.verify((c) => c.env.TWILIO_AUTH_TOKEN, {
666
+ url: (c) => `${c.env.PUBLIC_URL}${new URL(c.req.url).pathname}`
667
+ }),
519
668
  body: z.object({ From: z.string(), Body: z.string() })
520
669
  },
521
670
  ({ c, body }) =>
@@ -523,8 +672,31 @@ defineWebhook(
523
672
  'content-type': 'text/xml'
524
673
  })
525
674
  )
675
+
676
+ // In a test, sign the URL testWebhook sends to
677
+ await testWebhook(incomingSms, {
678
+ body: { From: '+4512345678', Body: 'hi' },
679
+ sign: (raw, { url }) => twilio.sign(raw, 'token', { url })
680
+ })
526
681
  ```
527
682
 
683
+ Twilio answers a failed call with a spoken error unless it gets TwiML. Mount
684
+ the Twilio webhooks with their own `createWebhooks` and an `onError` that
685
+ returns TwiML with status 200.
686
+
687
+ ### Duplicate deliveries and slow handlers
688
+
689
+ Providers retry: on a timeout, a 5xx, sometimes for no reason. Expect each
690
+ delivery more than once.
691
+
692
+ - Answer fast. Most providers give up after 5 to 30 seconds and send again.
693
+ Enqueue a job and return; do the work in the job.
694
+ - Use the provider's delivery id (`svix-id`, Stripe's `event.id`,
695
+ `x-github-delivery`) as the job key or a unique column, so a retry finds
696
+ the first one.
697
+ - To cap handler time, put `timeout()` from `hono/timeout` on the mount:
698
+ `app.use('/webhooks/*', timeout(10_000))`.
699
+
528
700
  ### Storing every call
529
701
 
530
702
  ```ts
@@ -534,7 +706,7 @@ createWebhooks(webhooks, {
534
706
  logErrors: false, // errors are stored below instead
535
707
  onEvent: async (e) => {
536
708
  if (e.route === '/health') return
537
- const call = summarizeEvent(e)
709
+ const call = summarizeEvent(e, { body: false, headers: ['x-github-delivery'] })
538
710
  await db.insert(webhookCalls).values({
539
711
  ...call,
540
712
  errorMessage: call.error?.message ?? null,
@@ -562,10 +734,14 @@ createWebhooks(webhooks, {
562
734
  ignoreRoutes: ['/health'],
563
735
  // one `webhook.completed` log per request, with the request's span
564
736
  logger: (span) => createLogger({ span }),
737
+ // what `call` keeps, as for summarizeEvent
738
+ summary: { body: false },
565
739
  onWebhookCall(call, event) {
566
- // `call` is summarizeEvent(event) plus `level` and `outcome`;
567
- // the request's span is active here
740
+ // `call` is summarizeEvent(event) plus `level`, `outcome` and
741
+ // `ignored`; the request's span is active here
742
+ if (call.ignored) return
568
743
  storeCall(call)
744
+ if (call.status >= 500) captureException(event.error)
569
745
  }
570
746
  }),
571
747
  // the completion log carries the error type
@@ -583,12 +759,15 @@ createWebhooks(webhooks, {
583
759
  `__unmatched__` for a 404) and `status` (`success` below 400, else `failed`).
584
760
  - **Log** `webhook.completed`, when `logger` is set, at `error` for 5xx,
585
761
  `warn` for 4xx and `info` otherwise, with `route`, `method`, `status_code`,
586
- `duration_ms`, `status` (`success`, `failed` or `validation_failed`) and
587
- `error_type`. `logger` gets the request's span and returns anything with
762
+ `duration_ms`, `status` (`success`, `failed` or `validation_failed`),
763
+ `error_type` and `error_message`. `logger` gets the request's span and returns anything with
588
764
  `info`, `warn` and `error` methods taking `(message, fields)`, so
589
765
  `logger: () => console` works. 404s and `ignoreRoutes` are not logged.
590
766
  - `ignoreRoutes` skips metrics and the log; the span and `onWebhookCall` still
591
- run.
767
+ run, with `call.ignored: true`.
768
+ - With `logErrors: false`, a handler error reaches the span and the log, not
769
+ the parent app's `onError`. Report it to an error tracker from
770
+ `onWebhookCall` (`event.error`).
592
771
  - `webhookMetrics` describes both metrics (`name`, `kind`, `unit`,
593
772
  `description` and `attributes`, where each attribute maps to its values or
594
773
  `'string'`), so a metric catalog can list them:
@@ -616,6 +795,9 @@ onEvent(e) {
616
795
  }
617
796
  ```
618
797
 
798
+ With `otelWebhooks`, use `ignoreRoutes` and check `call.ignored` in
799
+ `onWebhookCall`.
800
+
619
801
  ### Organising webhooks in a project
620
802
 
621
803
  Keep one file per webhook and a registry that lists them:
@@ -651,7 +833,7 @@ describeWebhooks(webhooks)
651
833
  // method: 'POST',
652
834
  // path: '/orders/:id',
653
835
  // description: 'Order paid',
654
- // verified: true, // has a verify function
836
+ // verified: true, // has a verify function (false for verify: false)
655
837
  // bodyType: 'auto',
656
838
  // jsonSchema: { params: null, query: null, headers: null, body: {...}, response: {...} }
657
839
  // }]
@@ -693,7 +875,8 @@ test('marks the order paid', async () => {
693
875
  | `params` | Filled into the path pattern. Use `path` instead for wildcard patterns. |
694
876
  | `query`, `headers` | String records. |
695
877
  | `body` | A string is sent as is. Anything else is sent as JSON, or url-encoded when `bodyType` is `'form'`. |
696
- | `sign` | Gets the exact raw body and returns headers to add, e.g. `(raw) => github.sign(raw, secret)`. |
878
+ | `sign` | Gets the exact raw body and `{ url, method }`, and returns headers to add, e.g. `(raw) => github.sign(raw, secret)` or `(raw, { url }) => twilio.sign(raw, token, { url })`. |
879
+ | `baseUrl` | Origin the request goes to, default `http://localhost`. For schemes that sign the URL. |
697
880
  | `env` | Bindings, available as `c.env`. |
698
881
  | `options` | `createWebhooks` options, e.g. `{ onEvent }`. |
699
882
 
@@ -717,10 +900,12 @@ Options for test:
717
900
  -b, --body <text> Request body (JSON sets content-type: application/json)
718
901
  -q, --query <params> Query string, e.g. "a=1&b=2"
719
902
  -H, --header <k: v> Request header, repeatable
720
- -s, --sign <scheme> Sign the body: github, shopify, stripe, svix
903
+ -s, --sign <scheme> Sign the body: github, hubspot, infisical, meta, shopify, stripe, svix, twilio
721
904
  -e, --secret-env <name> Environment variable holding the signing secret
722
905
  ```
723
906
 
907
+ `hono-webhooks <module> <command> --help` prints the options of one command.
908
+
724
909
  `<module>` exports either a `createWebhooks()` app or an array of webhooks, as
725
910
  default or as any named export. With an array, the CLI builds the app itself,
726
911
  so it can point straight at the file that lists your webhooks.
@@ -741,7 +926,9 @@ bunx hono-webhooks ./src/webhooks/index.ts test POST /shopify/orders -b '{"id":1
741
926
  a file path or a package name.
742
927
  - `--sign` signs the exact `--body` with the secret read from the environment
743
928
  variable named by `--secret-env`, after preloading, so a preloaded module
744
- can set it.
929
+ can set it. `twilio` and `hubspot` sign `http://localhost<path>`, the URL
930
+ the request is sent to. For `infisical`, put a current `timestamp`
931
+ (milliseconds) in the body. For a shared secret, send the header with `-H`.
745
932
  - `schema` prints the JSON Schemas from
746
933
  [`describeWebhooks`](#describing-webhooks).
747
934
  - The CLI imports your TypeScript file. `bunx hono-webhooks` starts the CLI
@@ -762,16 +949,31 @@ import {
762
949
  WebhookValidationError, // extends HTTPException; .target, .issues
763
950
  describeWebhooks, // (appOrArray, { target? }) => WebhookDescription[]
764
951
  testWebhook, // (webhook, input?) => Promise<Response>
765
- summarizeEvent, // (event) => WebhookEventSummary, flat and JSON-safe
766
- formatIssues // (issues) => 'total: Expected number; ...'
952
+ summarizeEvent, // (event, options?) => WebhookEventSummary, flat and JSON-safe
953
+ formatIssues, // (issues) => 'total: Expected number; ...'
954
+ webhookPath // (webhook, params?) => '/channels/gmail/callback'
767
955
  } from 'hono-webhooks'
768
956
 
769
957
  import {
770
958
  github,
959
+ meta,
771
960
  shopify,
772
961
  stripe,
773
- svix, // each: .verify(secret, options?) and .sign(rawBody, secret, options?)
774
- hmac // (options) => a scheme like the ones above
962
+ svix,
963
+ infisical,
964
+ twilio,
965
+ hubspot, // each: .verify(secret, options?) and .sign(rawBody, secret, options?)
966
+ hmac, // (options) => a scheme like the ones above
967
+ sharedSecret // ({ header, prefix? }) => a scheme comparing the secret itself
968
+ } from 'hono-webhooks/signatures'
969
+
970
+ import type {
971
+ Secret,
972
+ SignatureScheme,
973
+ HmacOptions,
974
+ SharedSecretOptions,
975
+ SignedUrl,
976
+ VerifyFailureReason
775
977
  } from 'hono-webhooks/signatures'
776
978
 
777
979
  import {
@@ -787,6 +989,7 @@ import type {
787
989
  WebhookResult,
788
990
  WebhookEvent,
789
991
  WebhookEventSummary,
992
+ SummaryOptions,
790
993
  WebhookDescription,
791
994
  TestWebhookInput,
792
995
  WebhooksOptions,
@@ -811,6 +1014,28 @@ import type {
811
1014
 
812
1015
  Use `Webhook` to type a list: `const all: Webhook<AppEnv>[] = [a, b]`.
813
1016
 
1017
+ `webhookPath(webhook, params)` fills a webhook's path pattern, typed from the
1018
+ path, so an OAuth redirect or a provider subscription URL is built from the
1019
+ definition instead of typed twice. It is relative to the mount point; add
1020
+ your origin and prefix.
1021
+
1022
+ ## Upgrading to 0.6
1023
+
1024
+ - **`verify` is required for every method but `GET`.** `createWebhooks`
1025
+ throws at startup for a webhook without one. Add a `verify` function, or
1026
+ `verify: false` to a webhook that is public on purpose. Move checks that
1027
+ live in the handler (API keys, tokens, OAuth state) into `verify`.
1028
+ - **Bodies over 1 MiB answer 413.** Raise it with `maxBodyBytes` on
1029
+ `createWebhooks` or on the webhook, or pass `Infinity` for the old
1030
+ behavior.
1031
+ - **The 401 body is `{ error: 'Invalid signature' }`**, without `issues`.
1032
+ The reason is on `event.validation`.
1033
+ - **`VerifyInput` has a `fail` function.** Code that calls a scheme's
1034
+ `verify` by hand should pass `fail: () => false` (the built-in schemes
1035
+ work without it).
1036
+ - **Only `true` passes.** A custom `verify` returning another truthy value
1037
+ now answers 401.
1038
+
814
1039
  ## Upgrading from 0.1
815
1040
 
816
1041
  - `event.path` is relative to the mount point (`/orders/42`), like