← back to Ventura Claw Leads
v0.2 #4: Stripe subscription billing — products, prices, webhook live
319e023c8851a36c2f61e5b628c9065b53285dba · 2026-05-06 16:42:18 -0700 · Steve Abrams
Same Stripe LIVE account as NPH + lawyer + 3 home-history sites; VCL filters
incoming webhook events by metadata.project so cross-project event leakage
is impossible.
WHAT LANDED
- scripts/stripe-bootstrap-v01.js: idempotent bootstrap. Creates 3 products
(Starter $49/mo, Standard $99/mo, Premier $199/mo) keyed by metadata.vcl_tier.
Creates webhook endpoint at https://leads.venturaclaw.com/webhooks/stripe with
8 subscription events (no payment_intent — VCL has no consumer payment surface).
- lib/stripe.js: VCL_TIERS + createSubscriptionCheckout + createPortalSession +
constructWebhookEvent + eventBelongsToVCL (the filter that drops events from
other projects sharing this Stripe account).
- routes/webhooks.js: /webhooks/stripe POST handler. Sig validation, eventBelongsToVCL
check, idempotency lock via subscription_events.stripe_event_id UNIQUE, on
customer.subscription.* updates businesses.tier + subscription_status +
stripe_subscription_id.
- db/migrations/003_subscription_events.sql: audit + idempotency table.
- server.js: mount /webhooks/stripe with raw-body parser BEFORE express.json so
signatures verify; CSP allows js.stripe.com + api.stripe.com + hooks.stripe.com.
- 4 secrets saved to /secrets master + routed VCL-only:
STRIPE_VCL_WEBHOOK_SECRET (whsec)
STRIPE_PRICE_VCL_STARTER_MONTH / STANDARD_MONTH / PREMIER_MONTH
- VCL added to STRIPE_SECRET_KEY + STRIPE_PUBLISHABLE_KEY destinations; live keys
now land on VCL Mac2 + Kamatera.
WHAT'S STILL MISSING (v0.2 #3 admin/claim)
- No UI to actually buy a subscription yet. createSubscriptionCheckout exists
but no business_users / claim flow / /admin route to invoke it. That's the
next-session task — once a business can sign in, /admin/billing/checkout can
call into this.
LIVE on prod 2026-05-06: webhook endpoint we_1TUF1Y63uNmiRsMbNr1kLu52 enabled
with 8 events; HEAD/GET=200, POST(unsigned)=400. isLive=true on both hosts.
priceIdFor() returns valid price IDs for all 3 tiers. Webhook secret loaded.
Files touched
A db/migrations/003_subscription_events.sqlA lib/stripe.jsM package-lock.jsonM package.jsonA routes/webhooks.jsA scripts/stripe-bootstrap-v01.jsM server.js
Diff
commit 319e023c8851a36c2f61e5b628c9065b53285dba
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed May 6 16:42:18 2026 -0700
v0.2 #4: Stripe subscription billing — products, prices, webhook live
Same Stripe LIVE account as NPH + lawyer + 3 home-history sites; VCL filters
incoming webhook events by metadata.project so cross-project event leakage
is impossible.
WHAT LANDED
- scripts/stripe-bootstrap-v01.js: idempotent bootstrap. Creates 3 products
(Starter $49/mo, Standard $99/mo, Premier $199/mo) keyed by metadata.vcl_tier.
Creates webhook endpoint at https://leads.venturaclaw.com/webhooks/stripe with
8 subscription events (no payment_intent — VCL has no consumer payment surface).
- lib/stripe.js: VCL_TIERS + createSubscriptionCheckout + createPortalSession +
constructWebhookEvent + eventBelongsToVCL (the filter that drops events from
other projects sharing this Stripe account).
- routes/webhooks.js: /webhooks/stripe POST handler. Sig validation, eventBelongsToVCL
check, idempotency lock via subscription_events.stripe_event_id UNIQUE, on
customer.subscription.* updates businesses.tier + subscription_status +
stripe_subscription_id.
- db/migrations/003_subscription_events.sql: audit + idempotency table.
- server.js: mount /webhooks/stripe with raw-body parser BEFORE express.json so
signatures verify; CSP allows js.stripe.com + api.stripe.com + hooks.stripe.com.
- 4 secrets saved to /secrets master + routed VCL-only:
STRIPE_VCL_WEBHOOK_SECRET (whsec)
STRIPE_PRICE_VCL_STARTER_MONTH / STANDARD_MONTH / PREMIER_MONTH
- VCL added to STRIPE_SECRET_KEY + STRIPE_PUBLISHABLE_KEY destinations; live keys
now land on VCL Mac2 + Kamatera.
WHAT'S STILL MISSING (v0.2 #3 admin/claim)
- No UI to actually buy a subscription yet. createSubscriptionCheckout exists
but no business_users / claim flow / /admin route to invoke it. That's the
next-session task — once a business can sign in, /admin/billing/checkout can
call into this.
LIVE on prod 2026-05-06: webhook endpoint we_1TUF1Y63uNmiRsMbNr1kLu52 enabled
with 8 events; HEAD/GET=200, POST(unsigned)=400. isLive=true on both hosts.
priceIdFor() returns valid price IDs for all 3 tiers. Webhook secret loaded.
---
db/migrations/003_subscription_events.sql | 12 +++
lib/stripe.js | 116 +++++++++++++++++++++
package-lock.json | 20 +++-
package.json | 3 +-
routes/webhooks.js | 88 ++++++++++++++++
scripts/stripe-bootstrap-v01.js | 162 ++++++++++++++++++++++++++++++
server.js | 11 +-
7 files changed, 407 insertions(+), 5 deletions(-)
diff --git a/db/migrations/003_subscription_events.sql b/db/migrations/003_subscription_events.sql
new file mode 100644
index 0000000..b79dd7c
--- /dev/null
+++ b/db/migrations/003_subscription_events.sql
@@ -0,0 +1,12 @@
+-- Stripe webhook event audit + idempotency lock. The UNIQUE on stripe_event_id
+-- prevents double-processing when Stripe retries the same event.
+
+CREATE TABLE IF NOT EXISTS subscription_events (
+ id BIGSERIAL PRIMARY KEY,
+ stripe_event_id TEXT UNIQUE NOT NULL,
+ event_type TEXT NOT NULL,
+ payload JSONB NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS subscription_events_type_idx ON subscription_events (event_type, created_at DESC);
diff --git a/lib/stripe.js b/lib/stripe.js
new file mode 100644
index 0000000..1b59953
--- /dev/null
+++ b/lib/stripe.js
@@ -0,0 +1,116 @@
+// Ventura Claw Leads — Stripe subscription helpers (subscription-only, no
+// Connect, no consumer payment). The same Stripe LIVE account powers NPH +
+// lawyer + 3 home-history sites, so each project filters incoming webhook
+// events by `metadata.project === 'ventura-claw-leads'`.
+
+const Stripe = require('stripe');
+
+const VCL_TIERS = {
+ starter: { name: 'Starter', envMonth: 'STRIPE_PRICE_VCL_STARTER_MONTH', cents: 4900, label: '$49 / month' },
+ standard: { name: 'Standard', envMonth: 'STRIPE_PRICE_VCL_STANDARD_MONTH', cents: 9900, label: '$99 / month' },
+ premier: { name: 'Premier', envMonth: 'STRIPE_PRICE_VCL_PREMIER_MONTH', cents: 19900, label: '$199 / month' }
+};
+
+function client() {
+ const key = process.env.STRIPE_SECRET_KEY;
+ if (!key || !key.startsWith('sk_')) return null;
+ return new Stripe(key, { apiVersion: '2024-10-28.acacia' });
+}
+
+function isLive() { return !!client(); }
+
+function priceIdFor(tier) {
+ const t = VCL_TIERS[tier];
+ if (!t) return null;
+ return process.env[t.envMonth] || null;
+}
+
+function tierFromPriceId(priceId) {
+ if (!priceId) return null;
+ for (const [tier, cfg] of Object.entries(VCL_TIERS)) {
+ if (process.env[cfg.envMonth] === priceId) return tier;
+ }
+ return null;
+}
+
+async function ensureCustomer(business) {
+ const c = client();
+ if (!c) return { mocked: true, id: 'cus_mock_' + business.id };
+ if (business.stripe_customer_id) return { id: business.stripe_customer_id };
+ const created = await c.customers.create({
+ email: business.email || undefined,
+ name: business.business_name,
+ metadata: { business_id: String(business.id), business_slug: business.slug, project: 'ventura-claw-leads' }
+ });
+ return { id: created.id };
+}
+
+async function createSubscriptionCheckout({ business, tier, successUrl, cancelUrl }) {
+ const c = client();
+ const priceId = priceIdFor(tier);
+ if (!c) {
+ return { mocked: true, url: `${successUrl}?mock=1&tier=${tier}`, tier };
+ }
+ if (!priceId) throw new Error('price_not_configured');
+ const cust = await ensureCustomer(business);
+ const session = await c.checkout.sessions.create({
+ mode: 'subscription',
+ customer: cust.id,
+ line_items: [{ price: priceId, quantity: 1 }],
+ success_url: successUrl,
+ cancel_url: cancelUrl,
+ metadata: { business_id: String(business.id), business_slug: business.slug, vcl_tier: tier, project: 'ventura-claw-leads' },
+ subscription_data: {
+ metadata: { business_id: String(business.id), business_slug: business.slug, vcl_tier: tier, project: 'ventura-claw-leads' }
+ },
+ allow_promotion_codes: true
+ });
+ return { url: session.url, id: session.id, customer_id: cust.id };
+}
+
+async function createPortalSession({ business, returnUrl }) {
+ const c = client();
+ if (!c) return { mocked: true, url: returnUrl };
+ if (!business.stripe_customer_id) throw new Error('no_customer');
+ const session = await c.billingPortal.sessions.create({
+ customer: business.stripe_customer_id,
+ return_url: returnUrl
+ });
+ return { url: session.url };
+}
+
+function constructWebhookEvent(rawBody, signature) {
+ const c = client();
+ const secret = process.env.STRIPE_VCL_WEBHOOK_SECRET;
+ if (!c || !secret) return null;
+ return c.webhooks.constructEvent(rawBody, signature, secret);
+}
+
+// Whether an event belongs to VCL — every webhook handler MUST call this
+// before acting because the same Stripe account fires events to all
+// subscribed endpoints (NPH, lawyer, home-history sites all share the SK).
+function eventBelongsToVCL(event) {
+ if (!event || !event.data || !event.data.object) return false;
+ const obj = event.data.object;
+ // Direct project tag on the object's metadata.
+ if (obj.metadata && obj.metadata.project === 'ventura-claw-leads') return true;
+ // Subscription events carry the price; check if any item references one of our prices.
+ if (obj.items && Array.isArray(obj.items.data)) {
+ for (const item of obj.items.data) {
+ if (item.price && tierFromPriceId(item.price.id)) return true;
+ }
+ }
+ // Invoice events carry lines.
+ if (obj.lines && Array.isArray(obj.lines.data)) {
+ for (const line of obj.lines.data) {
+ if (line.price && tierFromPriceId(line.price.id)) return true;
+ }
+ }
+ return false;
+}
+
+module.exports = {
+ VCL_TIERS, isLive, priceIdFor, tierFromPriceId,
+ ensureCustomer, createSubscriptionCheckout, createPortalSession,
+ constructWebhookEvent, eventBelongsToVCL
+};
diff --git a/package-lock.json b/package-lock.json
index e11ed68..33503f5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -19,7 +19,8 @@
"morgan": "^1.10.0",
"nodemailer": "^8.0.7",
"pg": "^8.13.1",
- "slugify": "^1.6.6"
+ "slugify": "^1.6.6",
+ "stripe": "^22.1.1"
}
},
"node_modules/accepts": {
@@ -1191,6 +1192,23 @@
"node": ">= 0.8"
}
},
+ "node_modules/stripe": {
+ "version": "22.1.1",
+ "resolved": "https://registry.npmjs.org/stripe/-/stripe-22.1.1.tgz",
+ "integrity": "sha512-cmodIYP27tBkJ8G7DuGgWw0PFuemlFZbuF3Wwr1TrjFjUa3T7NIgCe6TVwX8BO2ynu+xtTuDGfHafNDCPt9lXA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
diff --git a/package.json b/package.json
index 527cc60..6ef1765 100644
--- a/package.json
+++ b/package.json
@@ -22,6 +22,7 @@
"morgan": "^1.10.0",
"nodemailer": "^8.0.7",
"pg": "^8.13.1",
- "slugify": "^1.6.6"
+ "slugify": "^1.6.6",
+ "stripe": "^22.1.1"
}
}
diff --git a/routes/webhooks.js b/routes/webhooks.js
new file mode 100644
index 0000000..afb33bb
--- /dev/null
+++ b/routes/webhooks.js
@@ -0,0 +1,88 @@
+const express = require('express');
+const db = require('../lib/db');
+const stripe = require('../lib/stripe');
+const router = express.Router();
+
+const IS_PROD = process.env.NODE_ENV === 'production';
+
+router.get('/', (req, res) => res.status(200).type('text/plain').send('ok'));
+router.head('/', (req, res) => res.status(200).end());
+
+router.post('/', async (req, res) => {
+ const sig = req.headers['stripe-signature'];
+
+ let event;
+ try {
+ event = stripe.constructWebhookEvent(req.body, sig);
+ } catch (err) {
+ console.warn('[webhook] sig fail', err.message);
+ return res.status(400).send(`bad signature: ${err.message}`);
+ }
+
+ if (!event) {
+ console.warn('[webhook] not_configured', { isProd: IS_PROD });
+ return res.status(400).json({ error: 'webhook_not_configured' });
+ }
+
+ // Same Stripe account fires events to every subscribed endpoint (NPH + lawyer +
+ // home-history sites + VCL). Drop events that aren't VCL's.
+ if (!stripe.eventBelongsToVCL(event)) {
+ return res.json({ received: true, ignored: true, reason: 'not_vcl_event' });
+ }
+
+ const client = await db.pool.connect();
+ try {
+ await client.query('BEGIN');
+ const audit = await client.query(
+ `INSERT INTO subscription_events (stripe_event_id, event_type, payload)
+ VALUES ($1, $2, $3) ON CONFLICT (stripe_event_id) DO NOTHING
+ RETURNING id`,
+ [event.id, event.type, event]
+ );
+ if (audit.rowCount === 0) {
+ await client.query('COMMIT');
+ client.release();
+ return res.json({ received: true, idempotent: true });
+ }
+
+ const obj = event.data.object;
+ if (event.type.startsWith('customer.subscription.')) {
+ const businessId = obj.metadata && obj.metadata.business_id;
+ const tier = obj.metadata && obj.metadata.vcl_tier;
+ if (businessId) {
+ if (event.type === 'customer.subscription.deleted') {
+ await client.query(
+ `UPDATE businesses SET tier='free', subscription_status='canceled', stripe_subscription_id=NULL WHERE id=$1`,
+ [businessId]
+ );
+ } else {
+ let resolvedTier = tier;
+ if (!resolvedTier && obj.items && obj.items.data && obj.items.data[0]) {
+ resolvedTier = stripe.tierFromPriceId(obj.items.data[0].price.id);
+ }
+ await client.query(
+ `UPDATE businesses SET tier=COALESCE($2,tier), subscription_status=$3, stripe_subscription_id=$4, stripe_customer_id=COALESCE($5,stripe_customer_id) WHERE id=$1`,
+ [businessId, resolvedTier || null, obj.status || 'active', obj.id, obj.customer]
+ );
+ }
+ }
+ }
+ if (event.type === 'checkout.session.completed' && obj.metadata && obj.metadata.business_id) {
+ await client.query(
+ `UPDATE businesses SET stripe_customer_id=COALESCE(stripe_customer_id,$2) WHERE id=$1`,
+ [obj.metadata.business_id, obj.customer]
+ );
+ }
+
+ await client.query('COMMIT');
+ res.json({ received: true, processed: event.type });
+ } catch (err) {
+ await client.query('ROLLBACK').catch(() => {});
+ console.error('[webhook] error', event.type, err.message);
+ res.status(500).json({ error: 'internal' });
+ } finally {
+ client.release();
+ }
+});
+
+module.exports = router;
diff --git a/scripts/stripe-bootstrap-v01.js b/scripts/stripe-bootstrap-v01.js
new file mode 100644
index 0000000..f8bd3de
--- /dev/null
+++ b/scripts/stripe-bootstrap-v01.js
@@ -0,0 +1,162 @@
+#!/usr/bin/env node
+// VCL v0.2 #4 bootstrap — provisions 3 subscription products (VCL Starter,
+// VCL Standard, VCL Premier) + 1 webhook endpoint pointing at
+// https://leads.venturaclaw.com/webhooks/stripe in the same Stripe LIVE account
+// that already powers NPH + lawyer + 3 home-history sites. Idempotent: reuses
+// products/prices keyed by metadata.vcl_tier; reuses webhook keyed by URL.
+//
+// CRITICAL: every product, price, and webhook is tagged with
+// metadata.project='ventura-claw-leads' so the per-project webhook handler
+// can ignore events that don't belong to it (since multiple projects share
+// the same Stripe account, ALL endpoints receive ALL events for matching types).
+//
+// Output: writes summary JSON to /tmp/vcl-stripe-bootstrap.json with the IDs
+// to fan via /secrets. Webhook secret is written one-time to a chmod-600
+// file at /tmp/.vcl-stripe-webhook-secret; route via /secrets and rm.
+
+const fs = require('node:fs');
+const path = require('node:path');
+require('dotenv').config({ path: path.resolve(__dirname, '..', '.env') });
+
+const Stripe = require('stripe');
+const SK = process.env.STRIPE_SECRET_KEY;
+if (!SK || !SK.startsWith('sk_live_')) {
+ console.error('refusing to run: STRIPE_SECRET_KEY missing or not a live key');
+ process.exit(2);
+}
+const stripe = new Stripe(SK, { apiVersion: '2024-10-28.acacia' });
+
+const PUBLIC_URL = (process.env.VCL_PUBLIC_URL || 'https://leads.venturaclaw.com').replace(/\/+$/, '');
+const WEBHOOK_URL = `${PUBLIC_URL}/webhooks/stripe`;
+
+const PLAN = [
+ {
+ tier: 'starter',
+ product_name: 'Ventura Claw — Starter',
+ description: 'Custom listing on Ventura Claw — claimed badge, priority over free listings, monthly traffic report.',
+ prices: [
+ { env: 'STRIPE_PRICE_VCL_STARTER_MONTH', cents: 4900, interval: 'month' }
+ ]
+ },
+ {
+ tier: 'standard',
+ product_name: 'Ventura Claw — Standard',
+ description: 'Featured-tier listing on Ventura Claw — appears above free listings, sidebar feature in category, lead-form fields per profile, sub-60s lead delivery.',
+ prices: [
+ { env: 'STRIPE_PRICE_VCL_STANDARD_MONTH', cents: 9900, interval: 'month' }
+ ]
+ },
+ {
+ tier: 'premier',
+ product_name: 'Ventura Claw — Premier',
+ description: 'Top-tier listing on Ventura Claw — home-page featured rotation, top placement in category search, priority lead-form, quarterly co-marketing.',
+ prices: [
+ { env: 'STRIPE_PRICE_VCL_PREMIER_MONTH', cents: 19900, interval: 'month' }
+ ]
+ }
+];
+
+// Subscription-only events. NO payment_intent / checkout.session events here —
+// those belong to NPH (booking deposits) not VCL (subscription only).
+const WEBHOOK_EVENTS = [
+ 'customer.subscription.created',
+ 'customer.subscription.updated',
+ 'customer.subscription.deleted',
+ 'invoice.payment_succeeded',
+ 'invoice.payment_failed',
+ 'customer.created',
+ 'customer.updated',
+ 'customer.deleted'
+];
+
+(async () => {
+ const out = { products: [], prices: {}, webhook: null };
+
+ // 1. Find or create products by metadata.vcl_tier
+ const existing = await stripe.products.list({ active: true, limit: 100 });
+ const byTier = {};
+ for (const p of existing.data) {
+ if (p.metadata && p.metadata.vcl_tier) byTier[p.metadata.vcl_tier] = p;
+ }
+ for (const planTier of PLAN) {
+ let product = byTier[planTier.tier];
+ if (product) {
+ console.log(`[products] reusing ${product.id} for vcl_tier=${planTier.tier}`);
+ } else {
+ product = await stripe.products.create({
+ name: planTier.product_name,
+ description: planTier.description,
+ metadata: { vcl_tier: planTier.tier, project: 'ventura-claw-leads' }
+ });
+ console.log(`[products] created ${product.id} for vcl_tier=${planTier.tier}`);
+ }
+ out.products.push({ tier: planTier.tier, id: product.id, name: product.name });
+
+ // 2. Find or create prices
+ const existingPrices = await stripe.prices.list({ product: product.id, active: true, limit: 100 });
+ for (const priceSpec of planTier.prices) {
+ const match = existingPrices.data.find(pr =>
+ pr.unit_amount === priceSpec.cents &&
+ pr.recurring?.interval === priceSpec.interval &&
+ pr.currency === 'usd'
+ );
+ let price;
+ if (match) {
+ price = match;
+ console.log(` [prices] reusing ${price.id} (${priceSpec.cents}¢/${priceSpec.interval}) for ${priceSpec.env}`);
+ } else {
+ price = await stripe.prices.create({
+ product: product.id,
+ currency: 'usd',
+ unit_amount: priceSpec.cents,
+ recurring: { interval: priceSpec.interval },
+ metadata: { vcl_tier: planTier.tier, env_var: priceSpec.env, project: 'ventura-claw-leads' }
+ });
+ console.log(` [prices] created ${price.id} (${priceSpec.cents}¢/${priceSpec.interval}) for ${priceSpec.env}`);
+ }
+ out.prices[priceSpec.env] = price.id;
+ }
+ }
+
+ // 3. Find or create webhook endpoint
+ const endpoints = await stripe.webhookEndpoints.list({ limit: 100 });
+ const existingWh = endpoints.data.find(e => e.url === WEBHOOK_URL);
+ let endpoint, secret;
+ if (existingWh) {
+ endpoint = existingWh;
+ console.log(`[webhook] reusing endpoint ${endpoint.id} → ${WEBHOOK_URL}`);
+ const missing = WEBHOOK_EVENTS.filter(e => !endpoint.enabled_events.includes(e));
+ if (missing.length) {
+ const merged = Array.from(new Set([...endpoint.enabled_events, ...WEBHOOK_EVENTS]));
+ endpoint = await stripe.webhookEndpoints.update(endpoint.id, { enabled_events: merged });
+ console.log(` [webhook] added ${missing.length} missing events`);
+ }
+ secret = null;
+ } else {
+ endpoint = await stripe.webhookEndpoints.create({
+ url: WEBHOOK_URL,
+ enabled_events: WEBHOOK_EVENTS,
+ metadata: { project: 'ventura-claw-leads' }
+ });
+ secret = endpoint.secret; // ONE-TIME
+ console.log(`[webhook] created endpoint ${endpoint.id} → ${WEBHOOK_URL}`);
+ }
+ out.webhook = {
+ id: endpoint.id, url: endpoint.url,
+ events: endpoint.enabled_events.length,
+ secret_revealed: !!secret
+ };
+
+ const summary = path.resolve('/tmp/vcl-stripe-bootstrap.json');
+ fs.writeFileSync(summary, JSON.stringify(out, null, 2));
+ console.log(`\n[summary] wrote ${summary}`);
+
+ if (secret) {
+ const secretPath = '/tmp/.vcl-stripe-webhook-secret';
+ fs.writeFileSync(secretPath, secret, { mode: 0o600 });
+ fs.chmodSync(secretPath, 0o600);
+ console.log(`[summary] webhook secret → ${secretPath} (chmod 600) — route via /secrets and rm`);
+ } else {
+ console.log('[summary] webhook secret NOT revealed (endpoint already existed). If lost, rotate via stripe.webhookEndpoints.update.');
+ }
+})().catch(err => { console.error('FATAL:', err.message); process.exit(1); });
diff --git a/server.js b/server.js
index bcc3a06..ae2029b 100644
--- a/server.js
+++ b/server.js
@@ -10,6 +10,7 @@ const rateLimit = require('express-rate-limit');
const db = require('./lib/db');
const publicRoutes = require('./routes/public');
+const webhookRoutes = require('./routes/webhooks');
const app = express();
const PORT = parseInt(process.env.PORT || '9789', 10);
@@ -25,10 +26,10 @@ app.use(helmet({
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
fontSrc: ["'self'", 'https://fonts.gstatic.com', 'data:'],
- scriptSrc: ["'self'", "'unsafe-inline'"],
+ scriptSrc: ["'self'", "'unsafe-inline'", 'https://js.stripe.com'],
imgSrc: ["'self'", 'data:', 'https:'],
- connectSrc: ["'self'"],
- frameSrc: ["'self'"],
+ connectSrc: ["'self'", 'https://api.stripe.com'],
+ frameSrc: ["'self'", 'https://js.stripe.com', 'https://hooks.stripe.com'],
frameAncestors: ["'none'"],
formAction: ["'self'"],
baseUri: ["'self'"],
@@ -53,6 +54,10 @@ app.use(session({
}
}));
+// Stripe webhook needs the raw body BEFORE express.json() runs so the
+// signature verifier can hash it. Mount it first.
+app.use('/webhooks/stripe', express.raw({ type: 'application/json', limit: '512kb' }), webhookRoutes);
+
app.use(express.urlencoded({ extended: false, limit: '64kb' }));
app.use(express.json({ limit: '64kb' }));
app.use('/static', express.static(path.join(__dirname, 'public'), { maxAge: '7d' }));
← a6b4e19 v0.2 #1+#2: lead-delivery cron + /map Leaflet view
·
back to Ventura Claw Leads
·
v0.2 #3: admin/claim flow + lead inbox + profile editor + bi cc3cfef →