Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Tern Framework Examples

All examples are standalone packages and depend on the local repo build via `"@hookflo/tern": "../../"`.

## Setup handler with Tern CLI (recommended)

```bash
npx @hookflo/tern-cli
```

Then choose framework + Stripe platform, set `STRIPE_WEBHOOK_SECRET`, and use generated file path for your framework.

## Quick starts

- **Hono**: `cd examples/hono && npm install && STRIPE_WEBHOOK_SECRET=whsec_xxx npm run dev`
- **Next.js**: `cd examples/nextjs && npm install && STRIPE_WEBHOOK_SECRET=whsec_xxx npm run dev`
- **Cloudflare Workers**: `cd examples/cloudflare-workers && npm install && STRIPE_WEBHOOK_SECRET=whsec_xxx npm run dev`
- **Express**: `cd examples/express && npm install && STRIPE_WEBHOOK_SECRET=whsec_xxx npm run dev`

## Stripe CLI testing

```bash
stripe listen --forward-to localhost:3000/webhooks/stripe
stripe trigger payment_intent.succeeded
```

Use the printed `whsec_...` secret as `STRIPE_WEBHOOK_SECRET`.

See per-example READMEs for deployment notes.
38 changes: 38 additions & 0 deletions examples/cloudflare-workers/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Tern + Cloudflare Workers Example

## Setup handler with Tern CLI

```bash
npx @hookflo/tern-cli
```

Choose:
- Framework: `Cloudflare Workers`
- Platform: `Stripe`
- Env var: `STRIPE_WEBHOOK_SECRET`
- Handler path: `examples/cloudflare-workers/src/index.ts`

## Manual setup

```bash
cd examples/cloudflare-workers
npm install
STRIPE_WEBHOOK_SECRET=whsec_xxx npm run dev
```

Endpoint: `POST http://localhost:8787/webhooks/stripe`

## Local test with Stripe CLI

```bash
stripe listen --forward-to localhost:8787/webhooks/stripe
stripe trigger payment_intent.succeeded
```

## Deploy

```bash
cd examples/cloudflare-workers
wrangler secret put STRIPE_WEBHOOK_SECRET
wrangler deploy
```
17 changes: 17 additions & 0 deletions examples/cloudflare-workers/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"name": "tern-example-cloudflare-workers",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy"
},
"dependencies": {
"@hookflo/tern": "../../"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250430.0",
"typescript": "^5.8.3",
"wrangler": "^4.13.2"
}
}
34 changes: 34 additions & 0 deletions examples/cloudflare-workers/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { WebhookVerificationService } from '@hookflo/tern';

interface Env {
STRIPE_WEBHOOK_SECRET: string;
}

export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);

if (url.pathname === '/' && request.method === 'GET') {
return Response.json({ status: 'ok', framework: 'cloudflare-workers' });
}

if (url.pathname === '/webhooks/stripe' && request.method === 'POST') {
// Workers use the standard Web API Request — tern works natively.
const result = await WebhookVerificationService.verifyWithPlatformConfig(
request,
'stripe',
env.STRIPE_WEBHOOK_SECRET,
);

if (!result.isValid) {
console.error('❌ Verification failed:', result.error);
return Response.json({ error: result.error }, { status: 400 });
}

console.log('✅ Verified. Event type:', result.payload?.type);
return Response.json({ received: true });
}

return Response.json({ error: 'Not found' }, { status: 404 });
},
};
12 changes: 12 additions & 0 deletions examples/cloudflare-workers/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2021",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2021"],
"types": ["@cloudflare/workers-types"],
"strict": true,
"noEmit": true
},
"include": ["src"]
}
7 changes: 7 additions & 0 deletions examples/cloudflare-workers/wrangler.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
name = "tern-example-workers"
main = "src/index.ts"
compatibility_date = "2024-01-01"

# Set your real secret with: wrangler secret put STRIPE_WEBHOOK_SECRET
[vars]
STRIPE_WEBHOOK_SECRET = ""
35 changes: 35 additions & 0 deletions examples/express/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Tern + Express Example

## Setup handler with Tern CLI

```bash
npx @hookflo/tern-cli
```

Choose:
- Framework: `Express`
- Platform: `Stripe`
- Env var: `STRIPE_WEBHOOK_SECRET`
- Handler path: `examples/express/src/index.ts`

## Manual setup

```bash
cd examples/express
npm install
STRIPE_WEBHOOK_SECRET=whsec_xxx npm run dev
```

Endpoint: `POST http://localhost:3002/webhooks/stripe`

## Local test with Stripe CLI

```bash
stripe listen --forward-to localhost:3002/webhooks/stripe
stripe trigger payment_intent.succeeded
```

## Deploy notes

- Set `STRIPE_WEBHOOK_SECRET` in your host env vars.
- Keep webhook route registration before `app.use(express.json())`.
19 changes: 19 additions & 0 deletions examples/express/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"name": "tern-example-express",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {
"@hookflo/tern": "../../",
"express": "^4.21.2"
},
"devDependencies": {
"@types/express": "^5.0.1",
"tsx": "^4.19.3",
"typescript": "^5.8.3"
}
}
66 changes: 66 additions & 0 deletions examples/express/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import express from 'express';
import { createWebhookMiddleware } from '@hookflo/tern/express';

const app = express();

// ─── CRITICAL ORDER ───────────────────────────────────────────────────────────
// Register the webhook route BEFORE app.use(express.json()).
// If express.json() runs first it consumes the raw body and signature
// verification will always fail — tern cannot recover the original bytes.
// ─────────────────────────────────────────────────────────────────────────────

app.get('/', (_req, res) => {
res.json({ status: 'ok', framework: 'express' });
});

// Recommended: use the tern Express adapter.
// It applies express.raw() semantics internally by reading the raw stream/body
// and attaches the verified payload to req.webhook before calling next().
app.post(
'/webhooks/stripe',
createWebhookMiddleware({
platform: 'stripe',
secret: process.env.STRIPE_WEBHOOK_SECRET ?? '',
}),
Comment on lines +21 to +24

Check failure

Code scanning / CodeQL

Missing rate limiting High

This route handler performs
authorization
, but is not rate-limited.

Copilot Autofix

AI 7 days ago

To fix the problem, introduce an Express rate‑limiting middleware and apply it to the webhook route (and optionally to other routes) so that expensive verification work in createWebhookMiddleware cannot be abused by unbounded request rates. The fix should not alter the existing behavior of the route for legitimate traffic, just bound the number of requests per time window.

The best way to do this in this context is to use the widely‑used express-rate-limit package. In examples/express/src/index.ts, we will:

  1. Import rateLimit from express-rate-limit.
  2. Define a stripeWebhookLimiter configured with a reasonable windowMs and max requests.
  3. Insert stripeWebhookLimiter as middleware in the app.post('/webhooks/stripe', ...) call, before createWebhookMiddleware(...), ensuring that abusive traffic is rejected before the verification work runs.
  4. Leave the rest of the app structure unchanged, respecting the existing comment about the critical middleware order (webhook route before express.json()).

Concretely:

  • Add a new import line after the existing express import.
  • Add a small configuration block for the limiter after const app = express();.
  • Modify the app.post('/webhooks/stripe', ...) definition to include the limiter as an additional argument between the route path and createWebhookMiddleware({ ... }).
Suggested changeset 2
examples/express/src/index.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/examples/express/src/index.ts b/examples/express/src/index.ts
--- a/examples/express/src/index.ts
+++ b/examples/express/src/index.ts
@@ -1,8 +1,14 @@
 import express from 'express';
 import { createWebhookMiddleware } from '@hookflo/tern/express';
+import rateLimit from 'express-rate-limit';
 
 const app = express();
 
+const stripeWebhookLimiter = rateLimit({
+  windowMs: 15 * 60 * 1000, // 15 minutes
+  max: 100, // limit each IP to 100 Stripe webhook requests per windowMs
+});
+
 // ─── CRITICAL ORDER ───────────────────────────────────────────────────────────
 // Register the webhook route BEFORE app.use(express.json()).
 // If express.json() runs first it consumes the raw body and signature
@@ -18,6 +21,7 @@
 // and attaches the verified payload to req.webhook before calling next().
 app.post(
   '/webhooks/stripe',
+  stripeWebhookLimiter,
   createWebhookMiddleware({
     platform: 'stripe',
     secret: process.env.STRIPE_WEBHOOK_SECRET ?? '',
EOF
@@ -1,8 +1,14 @@
import express from 'express';
import { createWebhookMiddleware } from '@hookflo/tern/express';
import rateLimit from 'express-rate-limit';

const app = express();

const stripeWebhookLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // limit each IP to 100 Stripe webhook requests per windowMs
});

// ─── CRITICAL ORDER ───────────────────────────────────────────────────────────
// Register the webhook route BEFORE app.use(express.json()).
// If express.json() runs first it consumes the raw body and signature
@@ -18,6 +21,7 @@
// and attaches the verified payload to req.webhook before calling next().
app.post(
'/webhooks/stripe',
stripeWebhookLimiter,
createWebhookMiddleware({
platform: 'stripe',
secret: process.env.STRIPE_WEBHOOK_SECRET ?? '',
examples/express/package.json
Outside changed files

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/examples/express/package.json b/examples/express/package.json
--- a/examples/express/package.json
+++ b/examples/express/package.json
@@ -9,7 +9,8 @@
   },
   "dependencies": {
     "@hookflo/tern": "../../",
-    "express": "^4.21.2"
+    "express": "^4.21.2",
+    "express-rate-limit": "^8.3.1"
   },
   "devDependencies": {
     "@types/express": "^5.0.1",
EOF
@@ -9,7 +9,8 @@
},
"dependencies": {
"@hookflo/tern": "../../",
"express": "^4.21.2"
"express": "^4.21.2",
"express-rate-limit": "^8.3.1"
},
"devDependencies": {
"@types/express": "^5.0.1",
This fix introduces these dependencies
Package Version Security advisories
express-rate-limit (npm) 8.3.1 None
Copilot is powered by AI and may make mistakes. Always verify output.
(req, res) => {
const event = (req as any).webhook?.payload;
console.log('✅ Verified. Event type:', event?.type);
res.json({ received: true });
},
);

// ── Alternative (no adapter) ──────────────────────────────────────────────────
// If you want to use WebhookVerificationService directly, reconstruct a
// Web API Request from the raw buffer that express.raw() gives you:
//
// import { WebhookVerificationService } from '@hookflo/tern'
//
// app.post(
// '/webhooks/stripe/raw',
// express.raw({ type: '*/*' }), // <-- must come before express.json()
// async (req, res) => {
// const webRequest = new Request('https://example.com/webhooks/stripe', {
// method: 'POST',
// headers: req.headers as Record<string, string>,
// body: req.body, // Buffer from express.raw()
// })
// const result = await WebhookVerificationService.verifyWithPlatformConfig(
// webRequest,
// 'stripe',
// process.env.STRIPE_WEBHOOK_SECRET ?? ''
// )
// if (!result.isValid) return res.status(400).json({ error: result.error })
// res.json({ received: true })
// }
// )
// ─────────────────────────────────────────────────────────────────────────────

// Global JSON parser — AFTER the webhook route
app.use(express.json());

app.get('/health', (_req, res) => res.json({ ok: true }));

const port = Number(process.env.PORT ?? 3002);
app.listen(port, () =>
console.log(`🚀 Express running at http://localhost:${port}`),
);
11 changes: 11 additions & 0 deletions examples/express/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}
35 changes: 35 additions & 0 deletions examples/hono/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Tern + Hono Example

## Setup handler with Tern CLI

```bash
npx @hookflo/tern-cli
```

Choose:
- Framework: `Hono`
- Platform: `Stripe`
- Env var: `STRIPE_WEBHOOK_SECRET`
- Handler path: `examples/hono/src/index.ts`

## Manual setup

```bash
cd examples/hono
npm install
STRIPE_WEBHOOK_SECRET=whsec_xxx npm run dev
```

Endpoint: `POST http://localhost:3000/webhooks/stripe`

## Local test with Stripe CLI

```bash
stripe listen --forward-to localhost:3000/webhooks/stripe
stripe trigger payment_intent.succeeded
```

## Deploy notes

- This example runs on Node via `@hono/node-server`.
- Deploy to any Node host (Railway, Render, Fly.io, etc.) and set `STRIPE_WEBHOOK_SECRET`.
19 changes: 19 additions & 0 deletions examples/hono/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"name": "tern-example-hono",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {
"@hookflo/tern": "../../",
"@hono/node-server": "^1.13.7",
"hono": "^4.7.10"
},
"devDependencies": {
"tsx": "^4.19.3",
"typescript": "^5.8.3"
}
}
30 changes: 30 additions & 0 deletions examples/hono/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { serve } from '@hono/node-server';
import { Hono } from 'hono';
import { WebhookVerificationService } from '@hookflo/tern';

const app = new Hono();

app.get('/', (c) => c.json({ status: 'ok', framework: 'hono' }));

app.post('/webhooks/stripe', async (c) => {
// IMPORTANT: pass c.req.raw — the native Web API Request object.
// Never call c.req.json() or c.req.text() before this; it consumes the stream.
const result = await WebhookVerificationService.verifyWithPlatformConfig(
c.req.raw,
'stripe',
process.env.STRIPE_WEBHOOK_SECRET ?? '',
);

if (!result.isValid) {
console.error('❌ Verification failed:', result.error);
return c.json({ error: result.error }, 400);
}

console.log('✅ Verified. Event type:', result.payload?.type);
return c.json({ received: true });
});

const port = Number(process.env.PORT ?? 3000);
serve({ fetch: app.fetch, port }, () =>
console.log(`🚀 Hono running at http://localhost:${port}`),
);
11 changes: 11 additions & 0 deletions examples/hono/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}
Loading
Loading