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 +344 -91
- package/dist/cli.js +491 -20
- package/dist/index.d.ts +172 -18
- package/dist/index.js +123 -7
- package/dist/signatures.d.ts +53 -0
- package/dist/signatures.js +132 -0
- package/package.json +9 -3
- package/src/cli.ts +129 -25
- package/src/index.ts +366 -24
- package/src/signatures.ts +240 -0
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
|
-
- **
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
- **
|
|
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.
|
|
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.
|
|
259
|
-
|
|
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, // '/
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
###
|
|
419
|
+
### Wrapping requests with `around`
|
|
313
420
|
|
|
314
|
-
`
|
|
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
|
-
|
|
318
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ({
|
|
329
|
-
|
|
330
|
-
|
|
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
|
|
472
|
+
### GitHub events
|
|
348
473
|
|
|
349
474
|
```ts
|
|
350
|
-
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
-
|
|
372
|
-
|
|
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
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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
|
-
|
|
474
|
-
|
|
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 {
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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(
|
|
684
|
+
expect(res.status).toBe(200)
|
|
487
685
|
})
|
|
488
686
|
```
|
|
489
687
|
|
|
490
|
-
|
|
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>`
|
|
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
|
-
- `
|
|
520
|
-
|
|
521
|
-
-
|
|
522
|
-
|
|
523
|
-
|
|
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`
|
|
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))
|
|
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 |
|