[object Object]

← 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

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 →