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 +262 -37
- package/dist/cli.js +5632 -5338
- package/dist/index.d.ts +69 -11
- package/dist/index.js +60 -19
- package/dist/otel.d.ts +9 -5
- package/dist/otel.js +8 -5
- package/dist/signatures.d.ts +64 -0
- package/dist/signatures.js +171 -24
- package/package.json +2 -2
- package/src/cli-commands.ts +198 -169
- package/src/cli.ts +1 -3
- package/src/index.ts +141 -31
- package/src/otel.ts +17 -9
- package/src/signatures.ts +277 -33
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
|
|
36
|
-
body before anything is parsed, with ready-made verifiers and
|
|
37
|
-
Stripe, GitHub, Shopify
|
|
38
|
-
|
|
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-
|
|
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-
|
|
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
|
|
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 `
|
|
205
|
-
`401 { error: 'Invalid signature'
|
|
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`
|
|
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
|
|
222
|
-
|
|
223
|
-
matching `v1
|
|
224
|
-
- `
|
|
225
|
-
|
|
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,
|
|
266
|
+
time; `sharedSecret` compares through it too. Everything uses Web Crypto,
|
|
267
|
+
so it works on every runtime.
|
|
228
268
|
|
|
229
|
-
|
|
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
|
-
|
|
233
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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`)
|
|
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,
|
|
774
|
-
|
|
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
|