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