@amirabaris/webhook-kit 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +659 -2
- package/bin/webhook-kit.mjs +136 -0
- package/dist/index.cjs +1774 -0
- package/dist/index.d.cts +923 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.mts +923 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.d.ts +923 -0
- package/dist/index.js +1774 -0
- package/dist/index.mjs +1738 -0
- package/dist/index.mjs.map +1 -0
- package/docs/agent-rules.md +151 -0
- package/package.json +83 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Amir Abaris
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,660 @@
|
|
|
1
|
-
#
|
|
1
|
+
# webhook-kit
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> **Zero-dependency, edge-compatible webhook signature verification toolkit powered by the Web Cryptography API (`crypto.subtle`).**
|
|
4
|
+
|
|
5
|
+
[](https://opensource.org/licenses/MIT)
|
|
6
|
+
[](#)
|
|
7
|
+
[](#supported-runtimes)
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## The Problem: The JSON Re-serialization Pitfall
|
|
12
|
+
|
|
13
|
+
Developers building webhooks in modern frameworks (Next.js, Hono, Remix, Cloudflare Workers) constantly run into signature verification failures.
|
|
14
|
+
|
|
15
|
+
Why? Because parsing incoming requests as JSON strips original whitespace, normalizes numbers, and reorders object keys. When you try to re-stringify that JSON, **the resulting string does not match the sender's original payload bytes**, immediately breaking cryptographic HMAC verification:
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
// The pitfall: Parsing JSON changes bytes and invalidates HMAC
|
|
19
|
+
export async function POST(req: Request) {
|
|
20
|
+
const json = await req.json(); // strips whitespace & reorders keys
|
|
21
|
+
const isValid = verify(JSON.stringify(json), sig, secret); // Fails verification
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// With webhook-kit: Direct Web Standard verification & constant-time check
|
|
25
|
+
import { verifyWebhookSignature } from "@amirabaris/webhook-kit";
|
|
26
|
+
|
|
27
|
+
export async function POST(req: Request) {
|
|
28
|
+
const result = await verifyWebhookSignature({
|
|
29
|
+
body: req, // Pass Web Standard Request directly
|
|
30
|
+
signature: req.headers.get("x-signature")!,
|
|
31
|
+
secret: process.env.WEBHOOK_SECRET!,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
if (!result.valid) {
|
|
35
|
+
return new Response("Unauthorized", { status: 401 });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Safely use typed payload parsed AFTER valid signature verification
|
|
39
|
+
const payload = result.payload;
|
|
40
|
+
return Response.json({ received: true });
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## Why `webhook-kit`?
|
|
47
|
+
|
|
48
|
+
- **Zero Dependencies**: 0 runtime dependencies for a tiny bundle footprint.
|
|
49
|
+
- **Universal Edge Support**: Built entirely on the Web Cryptography API (`crypto.subtle`) and Web Standards (`Request`, `Headers`, `Uint8Array`). Works on Node.js, Bun, Deno, Cloudflare Workers, Next.js Edge, and Fastly Compute.
|
|
50
|
+
- **Automatic Stream-Cloning**: Internally calls `req.clone().text()`, leaving the incoming body stream unconsumed (`req.bodyUsed === false`) so downstream middleware, proxies, and logging never throw `Body already consumed`.
|
|
51
|
+
- **Constant-Time Comparison**: Built-in timing-safe equality prevents timing side-channel attacks.
|
|
52
|
+
- **Replay Protection & Store**: Built-in timestamp tolerance verification plus pluggable memory / Redis / Cloudflare KV idempotency stores (`WebhookReplayStore`).
|
|
53
|
+
- **Universal 3-Line Route Handler**: `createWebhookHandler` drops directly into Next.js App Router and Hono with automatic error mapping (`401`, `400`, `409`) and typed dispatch.
|
|
54
|
+
- **Extensible Architecture**: Modular provider plugin system (Stripe, GitHub, Shopify, and custom providers).
|
|
55
|
+
- **Agent-First DX**: Includes specialized rules and CLI tools to ensure AI assistants (Cursor, Claude, Copilot) write safe webhook code.
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## Supported Runtimes
|
|
60
|
+
|
|
61
|
+
| Runtime | Support | Notes |
|
|
62
|
+
| ------------------------- | ----------- | -------------------------------------- |
|
|
63
|
+
| **Node.js** | `>= 22.0.0` | Uses native `globalThis.crypto.subtle` |
|
|
64
|
+
| **Bun** | `>= 1.0.0` | Native Web Cryptography API |
|
|
65
|
+
| **Deno** | `>= 1.30.0` | Native Web Standards |
|
|
66
|
+
| **Cloudflare Workers** | Supported | Zero Node polyfills needed |
|
|
67
|
+
| **Next.js (Edge & Node)** | Supported | Works with App Router `Request` |
|
|
68
|
+
| **Fastly Compute** | Supported | Web Standards compliant |
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## Installation
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
# pnpm
|
|
76
|
+
pnpm add @amirabaris/webhook-kit
|
|
77
|
+
|
|
78
|
+
# npm
|
|
79
|
+
npm install @amirabaris/webhook-kit
|
|
80
|
+
|
|
81
|
+
# bun
|
|
82
|
+
bun add @amirabaris/webhook-kit
|
|
83
|
+
|
|
84
|
+
# yarn
|
|
85
|
+
yarn add @amirabaris/webhook-kit
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## Quick Start
|
|
91
|
+
|
|
92
|
+
### 3-Line Route Handler (`createWebhookHandler`)
|
|
93
|
+
|
|
94
|
+
Drop `createWebhookHandler` directly into **Next.js App Router** (`Request`) or **Hono** (`Context`) routes. It automatically clones the raw request stream, verifies the cryptographic signature, and dispatches the strongly typed event payload:
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
// app/api/webhook/route.ts (Next.js App Router)
|
|
98
|
+
import { createWebhookHandler } from "@amirabaris/webhook-kit";
|
|
99
|
+
|
|
100
|
+
export const POST = createWebhookHandler({
|
|
101
|
+
provider: "stripe",
|
|
102
|
+
secret: process.env.STRIPE_WEBHOOK_SECRET!,
|
|
103
|
+
onEvent: async (event) => {
|
|
104
|
+
console.log("Verified Stripe event:", event.type, event.id);
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Unified Verification (`verifyWebhookSignature` & `assertWebhookSignature`)
|
|
110
|
+
|
|
111
|
+
For custom control flow, `webhook-kit` also provides `verifyWebhookSignature` and `assertWebhookSignature` across providers and runtimes:
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
import {
|
|
115
|
+
verifyWebhookSignature,
|
|
116
|
+
assertWebhookSignature,
|
|
117
|
+
} from "@amirabaris/webhook-kit";
|
|
118
|
+
|
|
119
|
+
// 1. Result-based verification (discriminated union)
|
|
120
|
+
const result = await verifyWebhookSignature({
|
|
121
|
+
body: req, // Pass Web Standard Request, Stream, Buffer, or string directly
|
|
122
|
+
secret: process.env.STRIPE_WEBHOOK_SECRET!,
|
|
123
|
+
provider: "stripe", // Built-in: 'stripe' | 'github' | 'shopify' (or generic HMAC)
|
|
124
|
+
toleranceSeconds: 300, // Built-in replay attack protection
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
if (!result.valid) {
|
|
128
|
+
// result.error is strongly typed WebhookKitError
|
|
129
|
+
console.error("Verification failed:", result.error.message);
|
|
130
|
+
} else {
|
|
131
|
+
// result.payload is automatically typed (e.g. StripeWebhookEvent)
|
|
132
|
+
console.log("Verified payload:", result.payload);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 2. Or assert mode (returns typed payload or throws typed WebhookKitError)
|
|
136
|
+
const payload = await assertWebhookSignature({
|
|
137
|
+
body: req,
|
|
138
|
+
secret: process.env.GITHUB_WEBHOOK_SECRET!,
|
|
139
|
+
provider: "github",
|
|
140
|
+
});
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## Framework Recipes & Automatic Stream-Cloning
|
|
146
|
+
|
|
147
|
+
In modern Web Standards runtimes (Next.js App Router, Hono, Cloudflare Workers), the incoming `Request` body is a single-use `ReadableStream`. In conventional implementations, consuming the stream via `await req.text()` drains it immediately (`req.bodyUsed === true`), which breaks downstream middleware, logging proxies, or subsequent `await req.json()` handlers with:
|
|
148
|
+
|
|
149
|
+
```text
|
|
150
|
+
TypeError: Body has already been consumed
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
`webhook-kit` solves this transparently with **automatic stream-cloning**:
|
|
154
|
+
|
|
155
|
+
- When you pass a `Request` to `verifyWebhookSignature` or `createWebhookHandler`, the library internally calls `request.clone().text()`.
|
|
156
|
+
- The original incoming `Request` remains pristine and unconsumed (`req.bodyUsed === false`).
|
|
157
|
+
- You can safely inspect typed payloads, access raw event bytes, forward the untouched `Request` to upstream services or queues, or invoke `await req.json()` downstream without stream errors.
|
|
158
|
+
|
|
159
|
+
### Next.js App Router (`app/api/webhook/route.ts`)
|
|
160
|
+
|
|
161
|
+
#### Option A: Declarative 3-Line Route Handler (`createWebhookHandler`)
|
|
162
|
+
|
|
163
|
+
Export `createWebhookHandler` directly as your Next.js `POST` route handler. It automatically unwraps the `Request`, clones the stream, verifies HMAC and replay timestamp tolerances, and dispatches the typed event:
|
|
164
|
+
|
|
165
|
+
```ts
|
|
166
|
+
// app/api/webhook/route.ts — 3-line declarative route handler
|
|
167
|
+
import { createWebhookHandler } from "@amirabaris/webhook-kit";
|
|
168
|
+
|
|
169
|
+
export const POST = createWebhookHandler({
|
|
170
|
+
provider: "stripe",
|
|
171
|
+
secret: process.env.STRIPE_WEBHOOK_SECRET!,
|
|
172
|
+
onEvent: async (event, { rawBody, request }) => {
|
|
173
|
+
// event is strongly typed StripeWebhookEvent
|
|
174
|
+
// request.bodyUsed remains false thanks to automatic stream-cloning!
|
|
175
|
+
switch (event.type) {
|
|
176
|
+
case "checkout.session.completed":
|
|
177
|
+
console.log("Fulfilling checkout session:", event.data.object.id);
|
|
178
|
+
break;
|
|
179
|
+
default:
|
|
180
|
+
console.log(`Unhandled event type: ${event.type}`);
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
#### Option B: Manual Verification with Automatic Stream-Cloning (`verifyWebhookSignature`)
|
|
187
|
+
|
|
188
|
+
If your route requires custom branching, telemetry, or subsequent body access, pass `req` directly. Because `webhook-kit` clones the stream internally, `req.bodyUsed` remains `false`:
|
|
189
|
+
|
|
190
|
+
```ts
|
|
191
|
+
// app/api/webhook/route.ts
|
|
192
|
+
import { NextResponse } from "next/server";
|
|
193
|
+
import { verifyWebhookSignature } from "@amirabaris/webhook-kit";
|
|
194
|
+
|
|
195
|
+
export async function POST(req: Request) {
|
|
196
|
+
// Pass Web Standard `Request` directly.
|
|
197
|
+
// webhook-kit clones the stream via req.clone(), extracting exact raw bytes
|
|
198
|
+
// without consuming the original request stream (req.bodyUsed stays false).
|
|
199
|
+
const result = await verifyWebhookSignature({
|
|
200
|
+
body: req,
|
|
201
|
+
secret: process.env.STRIPE_WEBHOOK_SECRET!,
|
|
202
|
+
provider: "stripe", // Automatically parses and verifies Stripe-Signature (v1 + timestamp)
|
|
203
|
+
toleranceSeconds: 300,
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
if (!result.valid) {
|
|
207
|
+
console.error("Stripe verification failed:", result.error.message);
|
|
208
|
+
return new NextResponse(`Webhook Error: ${result.error.message}`, {
|
|
209
|
+
status: 400,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Strongly typed payload parsed after valid HMAC verification
|
|
214
|
+
const event = result.payload;
|
|
215
|
+
console.log("Verified Stripe event:", event.type, event.id);
|
|
216
|
+
|
|
217
|
+
// Automatic stream-cloning guarantee:
|
|
218
|
+
// req.bodyUsed is STILL false! Downstream handlers, logging utilities,
|
|
219
|
+
// or req.json() can still execute safely without throwing "Body already read":
|
|
220
|
+
// const rawJson = await req.json();
|
|
221
|
+
|
|
222
|
+
return NextResponse.json({ received: true, id: event.id });
|
|
223
|
+
}
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
### Hono (`src/index.ts`)
|
|
227
|
+
|
|
228
|
+
`webhook-kit` natively supports Hono routes in edge and serverless environments.
|
|
229
|
+
|
|
230
|
+
#### Option A: Declarative Route Handler (`createWebhookHandler`)
|
|
231
|
+
|
|
232
|
+
`createWebhookHandler` automatically unwraps Hono's `Context` (`c.req.raw`), resolves environment secrets dynamically via `c.env`, clones the stream, and dispatches verified events:
|
|
233
|
+
|
|
234
|
+
```ts
|
|
235
|
+
// src/index.ts
|
|
236
|
+
import { Hono } from "hono";
|
|
237
|
+
import { createWebhookHandler } from "@amirabaris/webhook-kit";
|
|
238
|
+
|
|
239
|
+
type Bindings = {
|
|
240
|
+
GITHUB_WEBHOOK_SECRET: string;
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const app = new Hono<{ Bindings: Bindings }>();
|
|
244
|
+
|
|
245
|
+
// Natively unwraps Hono Context, clones c.req.raw, and resolves dynamic secrets
|
|
246
|
+
app.post(
|
|
247
|
+
"/api/webhook",
|
|
248
|
+
createWebhookHandler({
|
|
249
|
+
provider: "github",
|
|
250
|
+
secret: (c) => c.env.GITHUB_WEBHOOK_SECRET,
|
|
251
|
+
onEvent: async (event, { request }) => {
|
|
252
|
+
// event is strongly typed GithubWebhookEvent
|
|
253
|
+
// request.bodyUsed is false — Hono context is completely undisturbed
|
|
254
|
+
console.log("Verified GitHub webhook action:", event.action);
|
|
255
|
+
},
|
|
256
|
+
}),
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
export default app;
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
#### Option B: Manual Verification with Automatic Stream-Cloning (`verifyWebhookSignature`)
|
|
263
|
+
|
|
264
|
+
Pass `c.req.raw` directly to `verifyWebhookSignature`. Because `c.req.raw` is cloned under the hood, subsequent Hono middleware or route logic can still call `await c.req.json()` or `await c.req.text()` without triggering a stream exhaustion error:
|
|
265
|
+
|
|
266
|
+
```ts
|
|
267
|
+
// src/index.ts
|
|
268
|
+
import { Hono } from "hono";
|
|
269
|
+
import { verifyWebhookSignature } from "@amirabaris/webhook-kit";
|
|
270
|
+
|
|
271
|
+
type Bindings = {
|
|
272
|
+
GITHUB_WEBHOOK_SECRET: string;
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const app = new Hono<{ Bindings: Bindings }>();
|
|
276
|
+
|
|
277
|
+
app.post("/api/webhook", async (c) => {
|
|
278
|
+
// Pass c.req.raw directly — webhook-kit clones the stream internally:
|
|
279
|
+
const result = await verifyWebhookSignature({
|
|
280
|
+
body: c.req.raw,
|
|
281
|
+
secret: c.env.GITHUB_WEBHOOK_SECRET,
|
|
282
|
+
provider: "github",
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
if (!result.valid) {
|
|
286
|
+
return c.text(`Unauthorized: ${result.error.message}`, 401);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Safe: c.req.raw was not consumed: other Hono plugins or c.req.json() remain fully usable!
|
|
290
|
+
const event = result.payload;
|
|
291
|
+
return c.json({ received: true, action: event.action });
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
export default app;
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
### Cloudflare Workers (`src/worker.ts`)
|
|
298
|
+
|
|
299
|
+
Cloudflare Workers isolates enforce strict single-read constraints on incoming `ReadableStream` instances. `webhook-kit` runs with zero Node polyfills and uses standard `crypto.subtle`.
|
|
300
|
+
|
|
301
|
+
#### Automatic Stream-Cloning & Proxy Forwarding
|
|
302
|
+
|
|
303
|
+
When passing `request` to `verifyWebhookSignature`, `webhook-kit` invokes `request.clone().text()`. This keeps `request.bodyUsed === false`, allowing you to safely forward the pristine original `request` to an upstream backend, queue, or analytics pipeline:
|
|
304
|
+
|
|
305
|
+
```ts
|
|
306
|
+
// src/worker.ts
|
|
307
|
+
import { verifyWebhookSignature } from "@amirabaris/webhook-kit";
|
|
308
|
+
|
|
309
|
+
interface Env {
|
|
310
|
+
STRIPE_WEBHOOK_SECRET: string;
|
|
311
|
+
INTERNAL_API_URL: string;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export default {
|
|
315
|
+
async fetch(
|
|
316
|
+
request: Request,
|
|
317
|
+
env: Env,
|
|
318
|
+
ctx: ExecutionContext,
|
|
319
|
+
): Promise<Response> {
|
|
320
|
+
if (
|
|
321
|
+
request.method !== "POST" ||
|
|
322
|
+
new URL(request.url).pathname !== "/api/webhook"
|
|
323
|
+
) {
|
|
324
|
+
return new Response("Not Found", { status: 404 });
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Automatic Stream-Cloning:
|
|
328
|
+
// Webhook-kit automatically clones request via request.clone().
|
|
329
|
+
// Cryptographic HMAC verification succeeds WITHOUT locking or draining request.body!
|
|
330
|
+
const result = await verifyWebhookSignature({
|
|
331
|
+
body: request,
|
|
332
|
+
secret: env.STRIPE_WEBHOOK_SECRET,
|
|
333
|
+
provider: "stripe",
|
|
334
|
+
toleranceSeconds: 300,
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
if (!result.valid) {
|
|
338
|
+
return new Response(`Unauthorized: ${result.error.message}`, {
|
|
339
|
+
status: 401,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Safe: request.bodyUsed is still false!
|
|
344
|
+
// You can forward the original unconsumed request directly to your internal service:
|
|
345
|
+
const upstreamResponse = await fetch(env.INTERNAL_API_URL, request);
|
|
346
|
+
|
|
347
|
+
return new Response(upstreamResponse.body, {
|
|
348
|
+
status: upstreamResponse.status,
|
|
349
|
+
headers: upstreamResponse.headers,
|
|
350
|
+
});
|
|
351
|
+
},
|
|
352
|
+
};
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
### Express & Node.js HTTP Handler
|
|
356
|
+
|
|
357
|
+
In Express and Node.js HTTP servers, the most common pitfall is global JSON body parsers that mutate request streams before your webhook handler executes.
|
|
358
|
+
|
|
359
|
+
#### Express
|
|
360
|
+
|
|
361
|
+
Configure your webhook endpoint with `express.raw({ type: "application/json" })` **before** any application-wide `express.json()` middleware:
|
|
362
|
+
|
|
363
|
+
```ts
|
|
364
|
+
// server.ts
|
|
365
|
+
import express from "express";
|
|
366
|
+
import { verifyWebhookSignature } from "@amirabaris/webhook-kit";
|
|
367
|
+
|
|
368
|
+
const app = express();
|
|
369
|
+
|
|
370
|
+
// IMPORTANT: Webhook routes must receive raw payload bytes (Buffer)
|
|
371
|
+
// Register this handler BEFORE global express.json() middleware!
|
|
372
|
+
app.post(
|
|
373
|
+
"/api/webhook",
|
|
374
|
+
express.raw({ type: "application/json" }),
|
|
375
|
+
async (req, res) => {
|
|
376
|
+
// req.body is a raw Buffer (Uint8Array), preserving original bytes
|
|
377
|
+
const result = await verifyWebhookSignature({
|
|
378
|
+
body: req.body,
|
|
379
|
+
headers: req.headers, // webhook-kit inspects headers for signature and timestamp
|
|
380
|
+
secret: process.env.STRIPE_WEBHOOK_SECRET!,
|
|
381
|
+
provider: "stripe",
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
if (!result.valid) {
|
|
385
|
+
console.error("Verification failed:", result.error.message);
|
|
386
|
+
return res.status(400).send(`Webhook Error: ${result.error.message}`);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// result.payload is safely parsed and typed
|
|
390
|
+
const event = result.payload;
|
|
391
|
+
console.log("Verified event:", event.type);
|
|
392
|
+
|
|
393
|
+
return res.json({ received: true });
|
|
394
|
+
},
|
|
395
|
+
);
|
|
396
|
+
|
|
397
|
+
// Global JSON middleware for standard API endpoints
|
|
398
|
+
app.use(express.json());
|
|
399
|
+
|
|
400
|
+
app.listen(3000, () => console.log("Server listening on port 3000"));
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
#### Vanilla Node.js HTTP (`node:http`)
|
|
404
|
+
|
|
405
|
+
```ts
|
|
406
|
+
// server.mjs
|
|
407
|
+
import http from "node:http";
|
|
408
|
+
import { verifyWebhookSignature } from "@amirabaris/webhook-kit";
|
|
409
|
+
|
|
410
|
+
const server = http.createServer(async (req, res) => {
|
|
411
|
+
if (req.method === "POST" && req.url === "/api/webhook") {
|
|
412
|
+
// Buffer raw incoming stream chunks to preserve exact byte sequence
|
|
413
|
+
const chunks: Buffer[] = [];
|
|
414
|
+
for await (const chunk of req) {
|
|
415
|
+
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
416
|
+
}
|
|
417
|
+
const rawBody = Buffer.concat(chunks);
|
|
418
|
+
|
|
419
|
+
const result = await verifyWebhookSignature({
|
|
420
|
+
body: rawBody,
|
|
421
|
+
headers: req.headers,
|
|
422
|
+
secret: process.env.WEBHOOK_SECRET!,
|
|
423
|
+
provider: "shopify", // Automatically verifies X-Shopify-Hmac-Sha256
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
if (!result.valid) {
|
|
427
|
+
res.writeHead(400, { "Content-Type": "text/plain" });
|
|
428
|
+
return res.end(`Verification failed: ${result.error.message}`);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
432
|
+
return res.end(JSON.stringify({ received: true }));
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
res.writeHead(404).end();
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
server.listen(3000);
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
---
|
|
442
|
+
|
|
443
|
+
## Error Handling Best Practices
|
|
444
|
+
|
|
445
|
+
`webhook-kit` provides two error handling styles and a strongly typed error class hierarchy:
|
|
446
|
+
|
|
447
|
+
### 1. Functional Pattern (Discriminated Union)
|
|
448
|
+
|
|
449
|
+
`verifyWebhookSignature` returns a discriminated union `WebhookVerificationResult<T>`. Checking `result.valid` cleanly narrows the types with full TypeScript compiler safety:
|
|
450
|
+
|
|
451
|
+
```ts
|
|
452
|
+
const result = await verifyWebhookSignature({
|
|
453
|
+
body: req,
|
|
454
|
+
secret: process.env.WEBHOOK_SECRET!,
|
|
455
|
+
provider: "stripe",
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
if (!result.valid) {
|
|
459
|
+
// TypeScript narrows result to WebhookVerificationFailure
|
|
460
|
+
// result.error is guaranteed to be a WebhookKitError
|
|
461
|
+
console.error("Error Code:", result.error.code);
|
|
462
|
+
console.error("Error Message:", result.error.message);
|
|
463
|
+
return new Response("Unauthorized", { status: 401 });
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// TypeScript narrows result to WebhookVerificationSuccess<StripeWebhookEvent>
|
|
467
|
+
// result.payload is guaranteed non-null and typed
|
|
468
|
+
const event = result.payload;
|
|
469
|
+
console.log("Processed event:", event.id);
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
### 2. Exception Pattern (`assertWebhookSignature`)
|
|
473
|
+
|
|
474
|
+
If your application prefers `try / catch` blocks or centralized error middleware, use `assertWebhookSignature` (or pass `{ throwOnError: true }`). It returns the typed payload directly on success and throws a `WebhookKitError` on failure:
|
|
475
|
+
|
|
476
|
+
```ts
|
|
477
|
+
import {
|
|
478
|
+
assertWebhookSignature,
|
|
479
|
+
SignatureVerificationError,
|
|
480
|
+
TimestampToleranceError,
|
|
481
|
+
InvalidSignatureHeaderError,
|
|
482
|
+
PayloadExtractionError,
|
|
483
|
+
} from "@amirabaris/webhook-kit";
|
|
484
|
+
|
|
485
|
+
try {
|
|
486
|
+
const event = await assertWebhookSignature({
|
|
487
|
+
body: req,
|
|
488
|
+
secret: process.env.STRIPE_WEBHOOK_SECRET!,
|
|
489
|
+
provider: "stripe",
|
|
490
|
+
toleranceSeconds: 300,
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
// Direct access to typed payload
|
|
494
|
+
return Response.json({ received: true });
|
|
495
|
+
} catch (err) {
|
|
496
|
+
if (err instanceof TimestampToleranceError) {
|
|
497
|
+
// Webhook timestamp outside tolerance window: possible replay attack or clock drift
|
|
498
|
+
console.warn(
|
|
499
|
+
`Replay protection: event timestamp ${err.timestamp} exceeded tolerance of ${err.toleranceSeconds}s`,
|
|
500
|
+
);
|
|
501
|
+
return new Response("Webhook timestamp expired", { status: 400 });
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
if (err instanceof SignatureVerificationError) {
|
|
505
|
+
// Signature mismatch: tampered payload or incorrect signing secret
|
|
506
|
+
console.error("Signature verification failed:", err.message);
|
|
507
|
+
return new Response("Invalid signature", { status: 401 });
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
if (err instanceof InvalidSignatureHeaderError) {
|
|
511
|
+
// Missing or malformed header (e.g. missing sha256= prefix or missing stripe-signature)
|
|
512
|
+
return new Response("Malformed signature header", { status: 400 });
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
if (err instanceof PayloadExtractionError) {
|
|
516
|
+
// Request stream could not be read
|
|
517
|
+
return new Response("Invalid request payload", { status: 400 });
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
return new Response("Internal Server Error", { status: 500 });
|
|
521
|
+
}
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
### Error Classes & Error Codes
|
|
525
|
+
|
|
526
|
+
All errors inherit from `WebhookKitError` and include machine-readable `code` properties:
|
|
527
|
+
|
|
528
|
+
| Error Class | `code` | Root Cause & Mitigation |
|
|
529
|
+
| ----------------------------- | ------------------------------- | -------------------------------------------------------------------------------------------------- |
|
|
530
|
+
| `SignatureVerificationError` | `SIGNATURE_VERIFICATION_FAILED` | HMAC digest mismatch or missing signing secret. Check that your webhook signing secret is correct. |
|
|
531
|
+
| `TimestampToleranceError` | `TIMESTAMP_TOLERANCE_EXCEEDED` | Payload timestamp exceeds `toleranceSeconds` window. Protects against replay attacks. |
|
|
532
|
+
| `ReplayAttackError` | `WEBHOOK_REPLAY_DETECTED` | Event ID already exists in `store`. Prevents duplicate webhook processing within the TTL window. |
|
|
533
|
+
| `InvalidSignatureHeaderError` | `INVALID_SIGNATURE_HEADER` | Header is missing, empty, or lacks required prefix (`sha256=`, `t=...,v1=...`). |
|
|
534
|
+
| `PayloadExtractionError` | `PAYLOAD_EXTRACTION_FAILED` | Body is an unsupported format or input stream was prematurely closed. |
|
|
535
|
+
| `WebhookKitError` | `WEBHOOK_KIT_ERROR` | Base class for all webhook-kit errors. |
|
|
536
|
+
|
|
537
|
+
### Replay Attack Mitigation & Idempotency Store (`WebhookReplayStore`)
|
|
538
|
+
|
|
539
|
+
Replay attacks occur when an attacker (or a retry storm) replays a valid webhook request to your server to trigger duplicate side effects (such as fulfilling an order twice).
|
|
540
|
+
|
|
541
|
+
`webhook-kit` combines two layers of replay protection:
|
|
542
|
+
|
|
543
|
+
1. **Timestamp Tolerance**:
|
|
544
|
+
- **Stripe**: Parses `t=...` from `Stripe-Signature` and validates against `toleranceSeconds` (default: 300s).
|
|
545
|
+
- **Generic**: Automatically inspects `x-webhook-timestamp` (or explicit `timestamp` option) and validates against `toleranceSeconds`.
|
|
546
|
+
2. **Idempotency / Replay Store (`store: { has(eventId), set(eventId, ttl) }`)**:
|
|
547
|
+
- Pass any synchronous or asynchronous store implementing `WebhookReplayStore` (`has(eventId)` and `set(eventId, ttlSeconds)`).
|
|
548
|
+
- `webhook-kit` automatically extracts the event ID from `options.eventId`, standard delivery headers (`X-GitHub-Delivery`, `X-Shopify-Webhook-Id`, `X-Webhook-Id`), or `payload.id` **after** cryptographic HMAC verification succeeds, and rejects duplicates with `ReplayAttackError` (`409 Conflict` in `createWebhookHandler`).
|
|
549
|
+
|
|
550
|
+
#### Default In-Memory TTL Store (`createInMemoryReplayStore`)
|
|
551
|
+
|
|
552
|
+
For single-instance Node, Bun, or Deno servers, use the built-in zero-dependency TTL store with automatic expiration and LRU capacity bounding:
|
|
553
|
+
|
|
554
|
+
```ts
|
|
555
|
+
import {
|
|
556
|
+
createWebhookHandler,
|
|
557
|
+
createInMemoryReplayStore,
|
|
558
|
+
} from "@amirabaris/webhook-kit";
|
|
559
|
+
|
|
560
|
+
const replayStore = createInMemoryReplayStore({
|
|
561
|
+
maxSize: 10_000,
|
|
562
|
+
defaultTtlSeconds: 300,
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
export const POST = createWebhookHandler({
|
|
566
|
+
provider: "stripe",
|
|
567
|
+
secret: process.env.STRIPE_WEBHOOK_SECRET!,
|
|
568
|
+
store: replayStore,
|
|
569
|
+
onEvent: async (event, { eventId }) => {
|
|
570
|
+
console.log("Processing unique event:", eventId, event.type);
|
|
571
|
+
},
|
|
572
|
+
});
|
|
573
|
+
```
|
|
574
|
+
|
|
575
|
+
#### Distributed Store Hooks (Redis / Upstash / Cloudflare KV)
|
|
576
|
+
|
|
577
|
+
For multi-instance or serverless edge deployments, wire a 6-line `WebhookReplayStore` adapter into Redis, Upstash, or Cloudflare Workers KV:
|
|
578
|
+
|
|
579
|
+
```ts
|
|
580
|
+
import type { WebhookReplayStore } from "@amirabaris/webhook-kit";
|
|
581
|
+
|
|
582
|
+
// 1. Node / Bun Redis (ioredis or node-redis)
|
|
583
|
+
const redisStore: WebhookReplayStore = {
|
|
584
|
+
has: async (id) => (await redis.exists(`wh:replay:${id}`)) === 1,
|
|
585
|
+
set: async (id, ttl) => await redis.set(`wh:replay:${id}`, "1", "EX", ttl),
|
|
586
|
+
};
|
|
587
|
+
|
|
588
|
+
// 2. Upstash Redis (@upstash/redis — Edge & Next.js)
|
|
589
|
+
const upstashStore: WebhookReplayStore = {
|
|
590
|
+
has: async (id) => (await upstash.exists(`wh:replay:${id}`)) === 1,
|
|
591
|
+
set: async (id, ttl) =>
|
|
592
|
+
await upstash.set(`wh:replay:${id}`, "1", { ex: ttl }),
|
|
593
|
+
};
|
|
594
|
+
|
|
595
|
+
// 3. Cloudflare Workers KV ( passed via Hono / Worker env )
|
|
596
|
+
const createCloudflareKvStore = (kv: KVNamespace): WebhookReplayStore => ({
|
|
597
|
+
has: async (id) => (await kv.get(`wh:replay:${id}`)) !== null,
|
|
598
|
+
set: async (id, ttl) =>
|
|
599
|
+
await kv.put(`wh:replay:${id}`, "1", {
|
|
600
|
+
expirationTtl: Math.max(60, ttl),
|
|
601
|
+
}),
|
|
602
|
+
});
|
|
603
|
+
```
|
|
604
|
+
|
|
605
|
+
---
|
|
606
|
+
|
|
607
|
+
## Architecture Overview
|
|
608
|
+
|
|
609
|
+
```mermaid
|
|
610
|
+
flowchart LR
|
|
611
|
+
A["Raw Input<br/>(Request | Stream | string | Uint8Array)"] --> B["extractRawBody()<br/>Byte-preserving extraction"]
|
|
612
|
+
B --> C["crypto.subtle Engine<br/>Web Crypto HMAC"]
|
|
613
|
+
C --> D["timingSafeEqual()<br/>Constant-time comparison"]
|
|
614
|
+
D --> E["Timestamp Tolerance<br/>Replay attack check"]
|
|
615
|
+
E --> F["Safe JSON Parse<br/>(Only after valid signature)"]
|
|
616
|
+
F --> G["Typed Result<br/>WebhookVerificationResult<T>"]
|
|
617
|
+
```
|
|
618
|
+
|
|
619
|
+
`webhook-kit` is organized into modular layers:
|
|
620
|
+
|
|
621
|
+
- **`crypto/`**: Web Cryptography API (`crypto.subtle`) primitives and constant-time buffer comparison.
|
|
622
|
+
- **`utils/`**: Stream and body extraction that preserves raw payload bytes without consuming or mutating input requests.
|
|
623
|
+
- **`providers/`**: Pluggable provider-specific format parsers (Stripe timestamped `t=...,v1=...`, GitHub `sha256=...`, Shopify Base64, etc.).
|
|
624
|
+
- **`errors/`**: Strongly typed error classes (`SignatureVerificationError`, `TimestampToleranceError`, `PayloadExtractionError`).
|
|
625
|
+
|
|
626
|
+
---
|
|
627
|
+
|
|
628
|
+
## Agent-First Developer Experience (DX)
|
|
629
|
+
|
|
630
|
+
`webhook-kit` provides first-class support for AI coding assistants (Claude Code, Cursor, Copilot, Gemini) to prevent them from accidentally generating broken JSON re-stringification patterns.
|
|
631
|
+
|
|
632
|
+
### Automatically Configure Your Project
|
|
633
|
+
|
|
634
|
+
Run the built-in zero-dependency CLI to inject webhook rules into your project's `AGENTS.md` or `CLAUDE.md`:
|
|
635
|
+
|
|
636
|
+
```bash
|
|
637
|
+
npx @amirabaris/webhook-kit init-agent
|
|
638
|
+
```
|
|
639
|
+
|
|
640
|
+
For full details, see the [Agent Rules Guide](file:///docs/agent-rules.md).
|
|
641
|
+
|
|
642
|
+
---
|
|
643
|
+
|
|
644
|
+
## Contributing & Commit Rules
|
|
645
|
+
|
|
646
|
+
We welcome contributions! Please read [`CONTRIBUTE.md`](file:///CONTRIBUTE.md) for full guidelines.
|
|
647
|
+
|
|
648
|
+
We strictly follow **Conventional Commits**:
|
|
649
|
+
|
|
650
|
+
```text
|
|
651
|
+
type(scope): brief description in imperative mood
|
|
652
|
+
```
|
|
653
|
+
|
|
654
|
+
Permitted types: `feat`, `fix`, `docs`, `test`, `refactor`, `chore`.
|
|
655
|
+
|
|
656
|
+
---
|
|
657
|
+
|
|
658
|
+
## License
|
|
659
|
+
|
|
660
|
+
[MIT](file:///LICENSE) © [Amir Abaris](https://github.com/AmirAbaris)
|