hono-webhooks 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -179,10 +179,11 @@ Malformed JSON is a `400` with `issues: [{ message: 'Malformed JSON' }]`.
179
179
 
180
180
  ## Signature verification
181
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:
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:
186
187
 
187
188
  ```ts
188
189
  import { shopify } from 'hono-webhooks/signatures'
@@ -211,14 +212,15 @@ When `verify` returns `false`, the answer is
211
212
  | `github` | `x-hub-signature-256: sha256=<hex>` | body |
212
213
  | `shopify` | `x-shopify-hmac-sha256: <base64>` | body |
213
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>` |
214
216
  | `hmac({ header, encoding, prefix?, hash? })` | any header, hex or base64, SHA-1/256/512 | body |
215
217
 
216
218
  - `scheme.verify(secret)` gives a `verify` function. `secret` is a string or
217
219
  `(c) => string | Promise<string>`, so it can come from `c.env`. An empty
218
220
  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.
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.
222
224
  - `scheme.sign(rawBody, secret)` returns the headers the provider would send,
223
225
  for tests and the CLI.
224
226
  - Signatures are compared with `crypto.subtle.verify`, which runs in constant
@@ -386,7 +388,7 @@ summarizeEvent(event)
386
388
  // ip: '203.0.113.9', // x-forwarded-for, x-real-ip or cf-connecting-ip
387
389
  // userAgent: 'Shopify-Captain-Hook',
388
390
  // params: { id: '42' }, query: {}, body: null, response: null,
389
- // error: null, // or { name, message, stack }
391
+ // error: null, // or { name, message, stack }; null when validation is set
390
392
  // validation: {
391
393
  // target: 'body',
392
394
  // message: 'total: Expected number',
@@ -486,6 +488,27 @@ export const githubPush = defineWebhook(
486
488
  )
487
489
  ```
488
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() })
504
+ })
505
+ },
506
+ ({ body }) => {
507
+ if (body.type === 'email.bounced') markBounced(body.data.email_id)
508
+ }
509
+ )
510
+ ```
511
+
489
512
  ### Twilio form posts
490
513
 
491
514
  ```ts
@@ -691,7 +714,7 @@ Options for test:
691
714
  -b, --body <text> Request body (JSON sets content-type: application/json)
692
715
  -q, --query <params> Query string, e.g. "a=1&b=2"
693
716
  -H, --header <k: v> Request header, repeatable
694
- -s, --sign <scheme> Sign the body: github, shopify, stripe
717
+ -s, --sign <scheme> Sign the body: github, shopify, stripe, svix
695
718
  -e, --secret-env <name> Environment variable holding the signing secret
696
719
  ```
697
720
 
@@ -712,14 +735,19 @@ bunx hono-webhooks ./src/webhooks/index.ts test POST /shopify/orders -b '{"id":1
712
735
  - `test` sends a real request through the webhooks app (without the parent
713
736
  app's middleware) and exits with code 1 on a 4xx or 5xx response.
714
737
  - `--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.
738
+ a file path or a package name.
716
739
  - `--sign` signs the exact `--body` with the secret read from the environment
717
740
  variable named by `--secret-env`, after preloading, so a preloaded module
718
741
  can set it.
719
742
  - `schema` prints the JSON Schemas from
720
743
  [`describeWebhooks`](#describing-webhooks).
721
- - The CLI imports your TypeScript file. That works under Bun, and under Node
722
- 22.18+ which strips types natively.
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 ...`.
723
751
 
724
752
  ## API reference
725
753
 
@@ -738,7 +766,8 @@ import {
738
766
  import {
739
767
  github,
740
768
  shopify,
741
- stripe, // each: .verify(secret, options?) and .sign(rawBody, secret, options?)
769
+ stripe,
770
+ svix, // each: .verify(secret, options?) and .sign(rawBody, secret, options?)
742
771
  hmac // (options) => a scheme like the ones above
743
772
  } from 'hono-webhooks/signatures'
744
773
 
package/dist/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
+ import { spawnSync } from "node:child_process";
4
5
  import { existsSync } from "node:fs";
5
6
  import { resolve } from "node:path";
6
7
  import { pathToFileURL } from "node:url";
@@ -126,9 +127,6 @@ function makeCreateWebhooks() {
126
127
  const ordered = [...webhooks].sort((a, b) => Number(isDynamic(a.config.path)) - Number(isDynamic(b.config.path)));
127
128
  for (const { config, handler } of ordered) {
128
129
  app.on(config.method, config.path, handle(config.path, async (c, event) => {
129
- event.params = await parse(config.params, c.req.param(), "params");
130
- event.query = await parse(config.query, c.req.query(), "query");
131
- event.headers = await parse(config.headers, c.req.header(), "headers");
132
130
  event.rawBody = await c.req.text();
133
131
  if (config.verify && !await config.verify({
134
132
  c,
@@ -139,6 +137,9 @@ function makeCreateWebhooks() {
139
137
  { message: "Signature does not match" }
140
138
  ]);
141
139
  }
140
+ event.params = await parse(config.params, c.req.param(), "params");
141
+ event.query = await parse(config.query, c.req.query(), "query");
142
+ event.headers = await parse(config.headers, c.req.header(), "headers");
142
143
  event.body = await parse(config.body, parseBody(event.rawBody, config.bodyType ?? "auto", c.req.header("content-type")), "body");
143
144
  const result = await handler({
144
145
  c,
@@ -213,7 +214,7 @@ function summarizeEvent(event) {
213
214
  query: event.query ?? null,
214
215
  body: event.body ?? null,
215
216
  response: event.response ?? null,
216
- error: error === undefined ? null : error instanceof Error ? {
217
+ error: error === undefined || error instanceof WebhookValidationError ? null : error instanceof Error ? {
217
218
  name: error.name,
218
219
  message: error.message,
219
220
  stack: error.stack ?? null
@@ -290,9 +291,10 @@ async function testWebhook(webhook, input = {}) {
290
291
  // src/signatures.ts
291
292
  var encoder = new TextEncoder;
292
293
  function hmacKey(secret, hash) {
293
- if (!secret)
294
+ if (!secret.length) {
294
295
  throw new Error("hono-webhooks: the signing secret is empty");
295
- return crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash }, false, ["sign", "verify"]);
296
+ }
297
+ return crypto.subtle.importKey("raw", typeof secret === "string" ? encoder.encode(secret) : secret, { name: "HMAC", hash }, false, ["sign", "verify"]);
296
298
  }
297
299
  async function hmacSign(secret, payload, hash = "SHA-256") {
298
300
  const key = await hmacKey(secret, hash);
@@ -372,12 +374,52 @@ var stripe = {
372
374
  return { "stripe-signature": `t=${t},v1=${toHex(signature)}` };
373
375
  }
374
376
  };
377
+ function svixKey(secret) {
378
+ const key = fromBase64(secret.replace(/^whsec_/, ""));
379
+ if (!key?.length) {
380
+ throw new Error("hono-webhooks: the Svix secret must be whsec_<base64>");
381
+ }
382
+ return key;
383
+ }
384
+ var svix = {
385
+ verify: (secret, { toleranceSeconds = 300 } = {}) => async ({ c, rawBody, headers }) => {
386
+ const prefix = headers["svix-id"] ? "svix" : "webhook";
387
+ const id = headers[`${prefix}-id`];
388
+ const timestamp = headers[`${prefix}-timestamp`] ?? "";
389
+ const signatures = headers[`${prefix}-signature`];
390
+ if (!id || !signatures || !/^\d+$/.test(timestamp))
391
+ return false;
392
+ if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) {
393
+ return false;
394
+ }
395
+ const key = svixKey(await resolveSecret(secret, c));
396
+ for (const entry of signatures.split(" ")) {
397
+ const [version, encoded] = entry.split(",");
398
+ const signature = version === "v1" && encoded ? fromBase64(encoded) : undefined;
399
+ if (signature && await hmacVerify(key, `${id}.${timestamp}.${rawBody}`, signature)) {
400
+ return true;
401
+ }
402
+ }
403
+ return false;
404
+ },
405
+ async sign(rawBody, secret, { id, timestamp } = {}) {
406
+ const msgId = id ?? `msg_${crypto.randomUUID().replaceAll("-", "")}`;
407
+ const t = timestamp ?? Math.floor(Date.now() / 1000);
408
+ const signature = await hmacSign(svixKey(secret), `${msgId}.${t}.${rawBody}`);
409
+ return {
410
+ "svix-id": msgId,
411
+ "svix-timestamp": String(t),
412
+ "svix-signature": `v1,${toBase64(signature)}`
413
+ };
414
+ }
415
+ };
375
416
 
376
417
  // src/cli.ts
377
418
  var schemes = {
378
419
  github,
379
420
  shopify,
380
- stripe
421
+ stripe,
422
+ svix
381
423
  };
382
424
  var HELP = `Usage: hono-webhooks <module> <command> [options]
383
425
 
@@ -441,6 +483,24 @@ async function loadApp(path) {
441
483
  return createWebhooks(webhooks);
442
484
  return fail(`${path} exports neither a createWebhooks() app nor an array of webhooks`);
443
485
  }
486
+ var BUN_ONLY_ERRORS = new Set([
487
+ "ERR_MODULE_NOT_FOUND",
488
+ "ERR_UNKNOWN_FILE_EXTENSION",
489
+ "ERR_UNSUPPORTED_DIR_IMPORT",
490
+ "ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX",
491
+ "ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING"
492
+ ]);
493
+ function rerunUnderBun(error) {
494
+ const code = error.code;
495
+ if (process.versions.bun || !code || !BUN_ONLY_ERRORS.has(code))
496
+ throw error;
497
+ console.error(`Node can't load the module (${code}), trying Bun`);
498
+ const run = spawnSync("bun", process.argv.slice(1), { stdio: "inherit" });
499
+ if (run.error) {
500
+ fail("Bun is not installed either. Run the CLI with a runtime that can load your code, e.g. bunx --bun hono-webhooks ...");
501
+ }
502
+ process.exit(run.status ?? 1);
503
+ }
444
504
  async function signatureHeaders(rawBody) {
445
505
  if (!values.sign)
446
506
  return {};
@@ -454,9 +514,14 @@ async function main() {
454
514
  console.log(HELP);
455
515
  return;
456
516
  }
457
- for (const preload of values.preload ?? [])
458
- await importModule(preload);
459
- const app = await loadApp(modulePath);
517
+ let app;
518
+ try {
519
+ for (const preload of values.preload ?? [])
520
+ await importModule(preload);
521
+ app = await loadApp(modulePath);
522
+ } catch (error) {
523
+ rerunUnderBun(error);
524
+ }
460
525
  if (command === "list") {
461
526
  for (const { config } of app.webhooks) {
462
527
  const validates = [
package/dist/index.d.ts CHANGED
@@ -48,8 +48,8 @@ export interface WebhookConfig<P extends string = string, TParams extends Schema
48
48
  headers?: THeaders;
49
49
  body?: TBody;
50
50
  /**
51
- * Checks the signature before the body is parsed or validated. Returning
52
- * `false` answers `401 { error: 'Invalid signature' }`.
51
+ * Checks the signature right after routing, before anything is validated
52
+ * or parsed. Returning `false` answers `401 { error: 'Invalid signature' }`.
53
53
  *
54
54
  * @example verify: shopify.verify((c) => c.env.SHOPIFY_SECRET)
55
55
  */
@@ -215,6 +215,7 @@ export interface WebhookEventSummary {
215
215
  query: Record<string, unknown> | null;
216
216
  body: unknown;
217
217
  response: unknown;
218
+ /** `null` for validation failures, which `validation` describes. */
218
219
  error: {
219
220
  name: string;
220
221
  message: string;
package/dist/index.js CHANGED
@@ -118,9 +118,6 @@ function makeCreateWebhooks() {
118
118
  const ordered = [...webhooks].sort((a, b) => Number(isDynamic(a.config.path)) - Number(isDynamic(b.config.path)));
119
119
  for (const { config, handler } of ordered) {
120
120
  app.on(config.method, config.path, handle(config.path, async (c, event) => {
121
- event.params = await parse(config.params, c.req.param(), "params");
122
- event.query = await parse(config.query, c.req.query(), "query");
123
- event.headers = await parse(config.headers, c.req.header(), "headers");
124
121
  event.rawBody = await c.req.text();
125
122
  if (config.verify && !await config.verify({
126
123
  c,
@@ -131,6 +128,9 @@ function makeCreateWebhooks() {
131
128
  { message: "Signature does not match" }
132
129
  ]);
133
130
  }
131
+ event.params = await parse(config.params, c.req.param(), "params");
132
+ event.query = await parse(config.query, c.req.query(), "query");
133
+ event.headers = await parse(config.headers, c.req.header(), "headers");
134
134
  event.body = await parse(config.body, parseBody(event.rawBody, config.bodyType ?? "auto", c.req.header("content-type")), "body");
135
135
  const result = await handler({
136
136
  c,
@@ -205,7 +205,7 @@ function summarizeEvent(event) {
205
205
  query: event.query ?? null,
206
206
  body: event.body ?? null,
207
207
  response: event.response ?? null,
208
- error: error === undefined ? null : error instanceof Error ? {
208
+ error: error === undefined || error instanceof WebhookValidationError ? null : error instanceof Error ? {
209
209
  name: error.name,
210
210
  message: error.message,
211
211
  stack: error.stack ?? null
@@ -38,3 +38,16 @@ export declare const stripe: SignatureScheme<{
38
38
  }, {
39
39
  timestamp?: number;
40
40
  }>;
41
+ /**
42
+ * Svix and Standard Webhooks (Resend, Clerk, ...): `svix-id`,
43
+ * `svix-timestamp` and `svix-signature: v1,<base64> v1,<base64>`, signed over
44
+ * `<id>.<timestamp>.<body>` with the base64 key in a `whsec_` secret. The
45
+ * `webhook-*` header names of Standard Webhooks work too. Rejects timestamps
46
+ * more than `toleranceSeconds` (default 300) away.
47
+ */
48
+ export declare const svix: SignatureScheme<{
49
+ toleranceSeconds?: number;
50
+ }, {
51
+ id?: string;
52
+ timestamp?: number;
53
+ }>;
@@ -1,9 +1,10 @@
1
1
  // src/signatures.ts
2
2
  var encoder = new TextEncoder;
3
3
  function hmacKey(secret, hash) {
4
- if (!secret)
4
+ if (!secret.length) {
5
5
  throw new Error("hono-webhooks: the signing secret is empty");
6
- return crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash }, false, ["sign", "verify"]);
6
+ }
7
+ return crypto.subtle.importKey("raw", typeof secret === "string" ? encoder.encode(secret) : secret, { name: "HMAC", hash }, false, ["sign", "verify"]);
7
8
  }
8
9
  async function hmacSign(secret, payload, hash = "SHA-256") {
9
10
  const key = await hmacKey(secret, hash);
@@ -83,9 +84,49 @@ var stripe = {
83
84
  return { "stripe-signature": `t=${t},v1=${toHex(signature)}` };
84
85
  }
85
86
  };
87
+ function svixKey(secret) {
88
+ const key = fromBase64(secret.replace(/^whsec_/, ""));
89
+ if (!key?.length) {
90
+ throw new Error("hono-webhooks: the Svix secret must be whsec_<base64>");
91
+ }
92
+ return key;
93
+ }
94
+ var svix = {
95
+ verify: (secret, { toleranceSeconds = 300 } = {}) => async ({ c, rawBody, headers }) => {
96
+ const prefix = headers["svix-id"] ? "svix" : "webhook";
97
+ const id = headers[`${prefix}-id`];
98
+ const timestamp = headers[`${prefix}-timestamp`] ?? "";
99
+ const signatures = headers[`${prefix}-signature`];
100
+ if (!id || !signatures || !/^\d+$/.test(timestamp))
101
+ return false;
102
+ if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) {
103
+ return false;
104
+ }
105
+ const key = svixKey(await resolveSecret(secret, c));
106
+ for (const entry of signatures.split(" ")) {
107
+ const [version, encoded] = entry.split(",");
108
+ const signature = version === "v1" && encoded ? fromBase64(encoded) : undefined;
109
+ if (signature && await hmacVerify(key, `${id}.${timestamp}.${rawBody}`, signature)) {
110
+ return true;
111
+ }
112
+ }
113
+ return false;
114
+ },
115
+ async sign(rawBody, secret, { id, timestamp } = {}) {
116
+ const msgId = id ?? `msg_${crypto.randomUUID().replaceAll("-", "")}`;
117
+ const t = timestamp ?? Math.floor(Date.now() / 1000);
118
+ const signature = await hmacSign(svixKey(secret), `${msgId}.${t}.${rawBody}`);
119
+ return {
120
+ "svix-id": msgId,
121
+ "svix-timestamp": String(t),
122
+ "svix-signature": `v1,${toBase64(signature)}`
123
+ };
124
+ }
125
+ };
86
126
  export {
87
127
  github,
88
128
  hmac,
89
129
  shopify,
90
- stripe
130
+ stripe,
131
+ svix
91
132
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hono-webhooks",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Type-safe webhook routes for Hono, validated with any Standard Schema library (Zod, Valibot, ArkType)",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/cli.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { spawnSync } from 'node:child_process'
2
3
  import { existsSync } from 'node:fs'
3
4
  import { resolve } from 'node:path'
4
5
  import { pathToFileURL } from 'node:url'
@@ -9,12 +10,19 @@ import {
9
10
  type Webhook,
10
11
  type WebhooksApp
11
12
  } from './index'
12
- import { github, type SignatureScheme, shopify, stripe } from './signatures'
13
+ import {
14
+ github,
15
+ type SignatureScheme,
16
+ shopify,
17
+ stripe,
18
+ svix
19
+ } from './signatures'
13
20
 
14
21
  const schemes: Record<string, SignatureScheme<any, any>> = {
15
22
  github,
16
23
  shopify,
17
- stripe
24
+ stripe,
25
+ svix
18
26
  }
19
27
 
20
28
  const HELP = `Usage: hono-webhooks <module> <command> [options]
@@ -106,6 +114,32 @@ async function loadApp(path: string): Promise<WebhooksApp> {
106
114
  )
107
115
  }
108
116
 
117
+ /** Errors Node gives for TypeScript it can't load but Bun can. */
118
+ const BUN_ONLY_ERRORS = new Set([
119
+ 'ERR_MODULE_NOT_FOUND',
120
+ 'ERR_UNKNOWN_FILE_EXTENSION',
121
+ 'ERR_UNSUPPORTED_DIR_IMPORT',
122
+ 'ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX',
123
+ 'ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING'
124
+ ])
125
+
126
+ /**
127
+ * Under Node, a Bun project's TypeScript (extensionless imports, for one)
128
+ * fails to load. Rerun the same command under Bun when it is installed.
129
+ */
130
+ function rerunUnderBun(error: unknown): never {
131
+ const code = (error as { code?: string }).code
132
+ if (process.versions.bun || !code || !BUN_ONLY_ERRORS.has(code)) throw error
133
+ console.error(`Node can't load the module (${code}), trying Bun`)
134
+ const run = spawnSync('bun', process.argv.slice(1), { stdio: 'inherit' })
135
+ if (run.error) {
136
+ fail(
137
+ 'Bun is not installed either. Run the CLI with a runtime that can load your code, e.g. bunx --bun hono-webhooks ...'
138
+ )
139
+ }
140
+ process.exit(run.status ?? 1)
141
+ }
142
+
109
143
  async function signatureHeaders(rawBody: string) {
110
144
  if (!values.sign) return {}
111
145
  const scheme =
@@ -123,8 +157,13 @@ async function main() {
123
157
  console.log(HELP)
124
158
  return
125
159
  }
126
- for (const preload of values.preload ?? []) await importModule(preload)
127
- const app = await loadApp(modulePath)
160
+ let app: WebhooksApp
161
+ try {
162
+ for (const preload of values.preload ?? []) await importModule(preload)
163
+ app = await loadApp(modulePath)
164
+ } catch (error) {
165
+ rerunUnderBun(error)
166
+ }
128
167
 
129
168
  if (command === 'list') {
130
169
  for (const { config } of app.webhooks) {
package/src/index.ts CHANGED
@@ -92,8 +92,8 @@ export interface WebhookConfig<
92
92
  headers?: THeaders
93
93
  body?: TBody
94
94
  /**
95
- * Checks the signature before the body is parsed or validated. Returning
96
- * `false` answers `401 { error: 'Invalid signature' }`.
95
+ * Checks the signature right after routing, before anything is validated
96
+ * or parsed. Returning `false` answers `401 { error: 'Invalid signature' }`.
97
97
  *
98
98
  * @example verify: shopify.verify((c) => c.env.SHOPIFY_SECRET)
99
99
  */
@@ -392,6 +392,21 @@ function makeCreateWebhooks<E extends Env>() {
392
392
  config.method,
393
393
  config.path,
394
394
  handle(config.path, async (c, event) => {
395
+ // The signature is checked first, so unauthenticated callers only
396
+ // ever see 401, never a description of the schemas.
397
+ event.rawBody = await c.req.text()
398
+ if (
399
+ config.verify &&
400
+ !(await config.verify({
401
+ c,
402
+ rawBody: event.rawBody,
403
+ headers: c.req.header()
404
+ }))
405
+ ) {
406
+ throw new WebhookValidationError('signature', [
407
+ { message: 'Signature does not match' }
408
+ ])
409
+ }
395
410
  type Parts = Record<string, unknown>
396
411
  event.params = (await parse(
397
412
  config.params,
@@ -408,19 +423,6 @@ function makeCreateWebhooks<E extends Env>() {
408
423
  c.req.header(),
409
424
  'headers'
410
425
  )) as Parts
411
- event.rawBody = await c.req.text()
412
- if (
413
- config.verify &&
414
- !(await config.verify({
415
- c,
416
- rawBody: event.rawBody,
417
- headers: c.req.header()
418
- }))
419
- ) {
420
- throw new WebhookValidationError('signature', [
421
- { message: 'Signature does not match' }
422
- ])
423
- }
424
426
  event.body = await parse(
425
427
  config.body,
426
428
  parseBody(
@@ -555,6 +557,7 @@ export interface WebhookEventSummary {
555
557
  query: Record<string, unknown> | null
556
558
  body: unknown
557
559
  response: unknown
560
+ /** `null` for validation failures, which `validation` describes. */
558
561
  error: { name: string; message: string; stack: string | null } | null
559
562
  validation: {
560
563
  target: ValidationTarget
@@ -589,8 +592,10 @@ export function summarizeEvent(event: WebhookEvent): WebhookEventSummary {
589
592
  query: event.query ?? null,
590
593
  body: event.body ?? null,
591
594
  response: event.response ?? null,
595
+ // A validation failure is described by `validation`; its error adds only
596
+ // a library stack trace.
592
597
  error:
593
- error === undefined
598
+ error === undefined || error instanceof WebhookValidationError
594
599
  ? null
595
600
  : error instanceof Error
596
601
  ? {
package/src/signatures.ts CHANGED
@@ -27,18 +27,27 @@ export interface HmacOptions {
27
27
 
28
28
  const encoder = new TextEncoder()
29
29
 
30
- function hmacKey(secret: string, hash: string) {
31
- if (!secret) throw new Error('hono-webhooks: the signing secret is empty')
30
+ /** A string secret is used as UTF-8; bytes are used as is. */
31
+ function hmacKey(secret: string | Uint8Array, hash: string) {
32
+ if (!secret.length) {
33
+ throw new Error('hono-webhooks: the signing secret is empty')
34
+ }
32
35
  return crypto.subtle.importKey(
33
36
  'raw',
34
- encoder.encode(secret),
37
+ (typeof secret === 'string'
38
+ ? encoder.encode(secret)
39
+ : secret) as Uint8Array<ArrayBuffer>,
35
40
  { name: 'HMAC', hash },
36
41
  false,
37
42
  ['sign', 'verify']
38
43
  )
39
44
  }
40
45
 
41
- async function hmacSign(secret: string, payload: string, hash = 'SHA-256') {
46
+ async function hmacSign(
47
+ secret: string | Uint8Array,
48
+ payload: string,
49
+ hash = 'SHA-256'
50
+ ) {
42
51
  const key = await hmacKey(secret, hash)
43
52
  return new Uint8Array(
44
53
  await crypto.subtle.sign('HMAC', key, encoder.encode(payload))
@@ -47,7 +56,7 @@ async function hmacSign(secret: string, payload: string, hash = 'SHA-256') {
47
56
 
48
57
  /** `crypto.subtle.verify` compares in constant time. */
49
58
  async function hmacVerify(
50
- secret: string,
59
+ secret: string | Uint8Array,
51
60
  payload: string,
52
61
  signature: Uint8Array,
53
62
  hash = 'SHA-256'
@@ -169,3 +178,63 @@ export const stripe: SignatureScheme<
169
178
  return { 'stripe-signature': `t=${t},v1=${toHex(signature)}` }
170
179
  }
171
180
  }
181
+
182
+ /** Svix secrets are `whsec_` followed by the base64 key. */
183
+ function svixKey(secret: string) {
184
+ const key = fromBase64(secret.replace(/^whsec_/, ''))
185
+ if (!key?.length) {
186
+ throw new Error('hono-webhooks: the Svix secret must be whsec_<base64>')
187
+ }
188
+ return key
189
+ }
190
+
191
+ /**
192
+ * Svix and Standard Webhooks (Resend, Clerk, ...): `svix-id`,
193
+ * `svix-timestamp` and `svix-signature: v1,<base64> v1,<base64>`, signed over
194
+ * `<id>.<timestamp>.<body>` with the base64 key in a `whsec_` secret. The
195
+ * `webhook-*` header names of Standard Webhooks work too. Rejects timestamps
196
+ * more than `toleranceSeconds` (default 300) away.
197
+ */
198
+ export const svix: SignatureScheme<
199
+ { toleranceSeconds?: number },
200
+ { id?: string; timestamp?: number }
201
+ > = {
202
+ verify:
203
+ (secret, { toleranceSeconds = 300 } = {}) =>
204
+ async ({ c, rawBody, headers }) => {
205
+ const prefix = headers['svix-id'] ? 'svix' : 'webhook'
206
+ const id = headers[`${prefix}-id`]
207
+ const timestamp = headers[`${prefix}-timestamp`] ?? ''
208
+ const signatures = headers[`${prefix}-signature`]
209
+ if (!id || !signatures || !/^\d+$/.test(timestamp)) return false
210
+ if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) {
211
+ return false
212
+ }
213
+ const key = svixKey(await resolveSecret(secret, c))
214
+ for (const entry of signatures.split(' ')) {
215
+ const [version, encoded] = entry.split(',')
216
+ const signature =
217
+ version === 'v1' && encoded ? fromBase64(encoded) : undefined
218
+ if (
219
+ signature &&
220
+ (await hmacVerify(key, `${id}.${timestamp}.${rawBody}`, signature))
221
+ ) {
222
+ return true
223
+ }
224
+ }
225
+ return false
226
+ },
227
+ async sign(rawBody, secret, { id, timestamp } = {}) {
228
+ const msgId = id ?? `msg_${crypto.randomUUID().replaceAll('-', '')}`
229
+ const t = timestamp ?? Math.floor(Date.now() / 1000)
230
+ const signature = await hmacSign(
231
+ svixKey(secret),
232
+ `${msgId}.${t}.${rawBody}`
233
+ )
234
+ return {
235
+ 'svix-id': msgId,
236
+ 'svix-timestamp': String(t),
237
+ 'svix-signature': `v1,${toBase64(signature)}`
238
+ }
239
+ }
240
+ }