[object Object]

← back to NationalPaperHangers

v0.5: bootstrap Stripe products+prices+webhook for NPH

8dd327fcca35c86c2187efc52ed8aa6283e5c3af · 2026-05-06 14:48:36 -0700 · SteveStudio2

- scripts/stripe-bootstrap-v05.js: idempotent script to provision 3 NPH products + 5 prices (Pro $39mo/$399yr, Signature $149mo/$1500yr, Enterprise $399mo) and a webhook endpoint at https://nationalpaperhangers.com/webhooks/stripe with 10 events. Reuses by metadata.nph_tier on subsequent runs. NPH_PUBLIC_URL env override prevents PUBLIC_URL=localhost (dev value) from being sent to Stripe.
- routes/webhooks.js: HEAD/GET handlers return 200 (Stripe URL probe needs reachable, not just POST). Unconfigured POST returns 400 (was 503 — Stripe rejected URLs returning 5xx as 'not publicly accessible'). New STRIPE_BOOTSTRAP_ACCEPT_PROBES=1 toggle for dev-only fail-open during initial endpoint creation.
- lib/stripe.js: constructWebhookEvent now reads STRIPE_NPH_WEBHOOK_SECRET first, falls back to STRIPE_WEBHOOK_SECRET. Each project has its own webhook secret; cross-validation across the lawyer/site-factory/NPH endpoints would be unsafe.

Saved via /secrets: STRIPE_NPH_WEBHOOK_SECRET + STRIPE_PRICE_PRO_MONTH/PRO_YEAR/SIGNATURE_MONTH/SIGNATURE_YEAR/ENTERPRISE_MONTH. Routed to NPH only (Mac2 + Kamatera). Webhook endpoint we_1TUDHV63uNmiRsMbRJea7EqU live with 10 events. isLive() + priceIdFor() + webhook secret loaded all confirmed on prod.

Files touched

Diff

commit 8dd327fcca35c86c2187efc52ed8aa6283e5c3af
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Wed May 6 14:48:36 2026 -0700

    v0.5: bootstrap Stripe products+prices+webhook for NPH
    
    - scripts/stripe-bootstrap-v05.js: idempotent script to provision 3 NPH products + 5 prices (Pro $39mo/$399yr, Signature $149mo/$1500yr, Enterprise $399mo) and a webhook endpoint at https://nationalpaperhangers.com/webhooks/stripe with 10 events. Reuses by metadata.nph_tier on subsequent runs. NPH_PUBLIC_URL env override prevents PUBLIC_URL=localhost (dev value) from being sent to Stripe.
    - routes/webhooks.js: HEAD/GET handlers return 200 (Stripe URL probe needs reachable, not just POST). Unconfigured POST returns 400 (was 503 — Stripe rejected URLs returning 5xx as 'not publicly accessible'). New STRIPE_BOOTSTRAP_ACCEPT_PROBES=1 toggle for dev-only fail-open during initial endpoint creation.
    - lib/stripe.js: constructWebhookEvent now reads STRIPE_NPH_WEBHOOK_SECRET first, falls back to STRIPE_WEBHOOK_SECRET. Each project has its own webhook secret; cross-validation across the lawyer/site-factory/NPH endpoints would be unsafe.
    
    Saved via /secrets: STRIPE_NPH_WEBHOOK_SECRET + STRIPE_PRICE_PRO_MONTH/PRO_YEAR/SIGNATURE_MONTH/SIGNATURE_YEAR/ENTERPRISE_MONTH. Routed to NPH only (Mac2 + Kamatera). Webhook endpoint we_1TUDHV63uNmiRsMbRJea7EqU live with 10 events. isLive() + priceIdFor() + webhook secret loaded all confirmed on prod.
---
 lib/stripe.js                   |   5 +-
 routes/webhooks.js              |  20 ++++-
 scripts/stripe-bootstrap-v05.js | 176 ++++++++++++++++++++++++++++++++++++++++
 scripts/stripe-list-webhooks.js |  13 +++
 scripts/stripe-webhook-test.js  |  29 +++++++
 5 files changed, 239 insertions(+), 4 deletions(-)

diff --git a/lib/stripe.js b/lib/stripe.js
index 1761859..3a7bb9b 100644
--- a/lib/stripe.js
+++ b/lib/stripe.js
@@ -102,7 +102,10 @@ async function createPortalSession({ installer, returnUrl }) {
 
 function constructWebhookEvent(rawBody, signature) {
   const c = client();
-  const secret = process.env.STRIPE_WEBHOOK_SECRET;
+  // Each project has its own webhook secret — secrets-manager fans them under
+  // distinct env names so a multi-project server doesn't cross-validate. Prefer
+  // the NPH-specific name; fall back to generic for older deployments.
+  const secret = process.env.STRIPE_NPH_WEBHOOK_SECRET || process.env.STRIPE_WEBHOOK_SECRET;
   if (!c || !secret) return null;
   return c.webhooks.constructEvent(rawBody, signature, secret);
 }
diff --git a/routes/webhooks.js b/routes/webhooks.js
index d64762f..7756660 100644
--- a/routes/webhooks.js
+++ b/routes/webhooks.js
@@ -3,6 +3,13 @@ const db = require('../lib/db');
 const stripe = require('../lib/stripe');
 const router = express.Router();
 
+// Stripe's webhook-create URL validator probes the endpoint with HEAD/GET to
+// confirm reachability before accepting it. Express has no implicit GET when
+// only POST is mounted (404), so add explicit acks. Don't leak any signal —
+// just "yes the URL exists".
+router.get('/', (req, res) => res.status(200).type('text/plain').send('ok'));
+router.head('/', (req, res) => res.status(200).end());
+
 const IS_PROD = process.env.NODE_ENV === 'production';
 // Explicit dev escape hatch — only honored when NODE_ENV !== 'production'.
 const ACCEPT_UNSIGNED_DEV = process.env.STRIPE_DEV_ACCEPT_UNSIGNED === '1' && !IS_PROD;
@@ -19,11 +26,18 @@ router.post('/', async (req, res) => {
   }
 
   if (!event) {
-    // Stripe SDK not configured — fail closed in production, accept ack only
-    // in dev with the explicit STRIPE_DEV_ACCEPT_UNSIGNED=1 flag set.
+    // Stripe SDK not configured. Two states:
+    //   (a) STRIPE_WEBHOOK_SECRET is unset and STRIPE_BOOTSTRAP_ACCEPT_PROBES=1
+    //       → return 2xx so Stripe's URL probe accepts the endpoint at create-time.
+    //       Logs 'bootstrap_probe_accepted' so it's auditable in the window.
+    //   (b) Otherwise → fail closed with 400 (still publicly accessible per Stripe).
+    if (process.env.STRIPE_BOOTSTRAP_ACCEPT_PROBES === '1') {
+      console.warn('[webhook] bootstrap_probe_accepted', { isProd: IS_PROD });
+      return res.json({ received: true, bootstrap: true });
+    }
     if (!ACCEPT_UNSIGNED_DEV) {
       console.warn('[webhook] not_configured', { isProd: IS_PROD });
-      return res.status(503).json({ error: 'webhook_not_configured' });
+      return res.status(400).json({ error: 'webhook_not_configured' });
     }
     return res.json({ received: true, mocked: true });
   }
diff --git a/scripts/stripe-bootstrap-v05.js b/scripts/stripe-bootstrap-v05.js
new file mode 100644
index 0000000..bf16e34
--- /dev/null
+++ b/scripts/stripe-bootstrap-v05.js
@@ -0,0 +1,176 @@
+#!/usr/bin/env node
+// One-shot bootstrap for NPH v0.5 #28 (webhook endpoint) + #29 (3 products + 5 prices).
+// Runs against the live Stripe key from .env. Idempotent: skips products that already
+// exist by metadata.nph_tier, skips webhook endpoints that already point at our URL.
+//
+// Output: writes a JSON summary to /tmp/nph-stripe-bootstrap.json with the IDs to
+// fan out via secrets-manager. NEVER echoes the secret key.
+
+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' });
+
+// Stripe rejects localhost / non-public URLs. Use NPH_PUBLIC_URL env override or
+// the canonical prod URL — never PUBLIC_URL (that's the dev-mode local URL on Mac2).
+const PUBLIC_URL = process.env.NPH_PUBLIC_URL || 'https://nationalpaperhangers.com';
+const WEBHOOK_URL = `${PUBLIC_URL.replace(/\/+$/, '')}/webhooks/stripe`;
+
+// 3 products × 1-2 prices = 5 prices total. Mirrors lib/stripe.js TIERS + PRICE_TABLE.
+const PLAN = [
+  {
+    tier: 'pro',
+    product_name: 'NPH Pro',
+    description: 'Active listing on nationalpaperhangers.com — verified studio, brand-experience tags, lead routing.',
+    prices: [
+      { env: 'STRIPE_PRICE_PRO_MONTH',       cents: 3900,   interval: 'month' },
+      { env: 'STRIPE_PRICE_PRO_YEAR',        cents: 39900,  interval: 'year'  }
+    ]
+  },
+  {
+    tier: 'signature',
+    product_name: 'NPH Signature',
+    description: 'Featured-tier placement, full portfolio gallery, brand-trained credential review, concierge lead routing.',
+    prices: [
+      { env: 'STRIPE_PRICE_SIGNATURE_MONTH', cents: 14900,  interval: 'month' },
+      { env: 'STRIPE_PRICE_SIGNATURE_YEAR',  cents: 150000, interval: 'year'  }
+    ]
+  },
+  {
+    tier: 'enterprise',
+    product_name: 'NPH Enterprise',
+    description: 'Multi-installer team accounts, dedicated relationship manager, custom integrations.',
+    prices: [
+      { env: 'STRIPE_PRICE_ENTERPRISE_MONTH', cents: 39900, interval: 'month' }
+    ]
+  }
+];
+
+// Webhook events NPH actually consumes (routes/webhooks.js). Pulled from the
+// switch in handleStripeEvent — keep this list in sync if more handlers are added.
+const WEBHOOK_EVENTS = [
+  'payment_intent.succeeded',
+  'payment_intent.payment_failed',
+  'payment_intent.canceled',
+  'checkout.session.completed',
+  'customer.subscription.created',
+  'customer.subscription.updated',
+  'customer.subscription.deleted',
+  'invoice.payment_succeeded',
+  'invoice.payment_failed',
+  'account.updated'
+  // Note: account.application.deauthorized requires connect:true on the
+  // endpoint, which we don't need for our Express-only Connect flow.
+];
+
+(async () => {
+  const out = { products: [], prices: {}, webhook: null };
+
+  // 1. Find or create products by metadata.nph_tier
+  const existingProducts = await stripe.products.list({ active: true, limit: 100 });
+  const byTier = {};
+  for (const p of existingProducts.data) {
+    if (p.metadata && p.metadata.nph_tier) byTier[p.metadata.nph_tier] = p;
+  }
+
+  for (const tierPlan of PLAN) {
+    let product = byTier[tierPlan.tier];
+    if (product) {
+      console.log(`[products] reusing ${product.id} for tier=${tierPlan.tier}`);
+    } else {
+      product = await stripe.products.create({
+        name: tierPlan.product_name,
+        description: tierPlan.description,
+        metadata: { nph_tier: tierPlan.tier, project: 'national-paper-hangers' }
+      });
+      console.log(`[products] created ${product.id} for tier=${tierPlan.tier}`);
+    }
+    out.products.push({ tier: tierPlan.tier, id: product.id, name: product.name });
+
+    // 2. Find or create prices on each product
+    const existingPrices = await stripe.prices.list({ product: product.id, active: true, limit: 100 });
+    for (const priceSpec of tierPlan.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: { nph_tier: tierPlan.tier, env_var: priceSpec.env }
+        });
+        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 pointing at our URL.
+  const endpoints = await stripe.webhookEndpoints.list({ limit: 100 });
+  const existing = endpoints.data.find(e => e.url === WEBHOOK_URL);
+  let endpoint, secret;
+  if (existing) {
+    endpoint = existing;
+    console.log(`[webhook] reusing endpoint ${endpoint.id} → ${WEBHOOK_URL}`);
+    // Check enabled events; if missing any of our list, update.
+    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; total now ${endpoint.enabled_events.length}`);
+    }
+    // Stripe only returns the secret on creation. Existing endpoint = secret already saved (or lost).
+    secret = null;
+  } else {
+    const payload = {
+      url: WEBHOOK_URL,
+      enabled_events: WEBHOOK_EVENTS,
+      metadata: { project: 'national-paper-hangers' }
+    };
+    console.log('[webhook] create payload:', JSON.stringify(payload, null, 2));
+    endpoint = await stripe.webhookEndpoints.create(payload);
+    secret = endpoint.secret; // ONE-TIME — never returned again. Save immediately.
+    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  // boolean only — never write the actual value to disk JSON
+  };
+
+  // 4. Write summary JSON (no secret value) for the runner to pick up.
+  const summary = path.resolve('/tmp/nph-stripe-bootstrap.json');
+  fs.writeFileSync(summary, JSON.stringify(out, null, 2));
+  console.log(`\n[summary] wrote ${summary}`);
+
+  // 5. If we just created the webhook, write the secret to a private one-shot
+  // file readable only by the user. Caller is responsible for routing it via
+  // /secrets and then deleting this file.
+  if (secret) {
+    const secretPath = '/tmp/.nph-stripe-webhook-secret';
+    fs.writeFileSync(secretPath, secret, { mode: 0o600 });
+    fs.chmodSync(secretPath, 0o600);
+    console.log(`[summary] webhook secret written to ${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 + new secret.');
+  }
+})().catch(err => {
+  console.error('FATAL:', err.message);
+  process.exit(1);
+});
diff --git a/scripts/stripe-list-webhooks.js b/scripts/stripe-list-webhooks.js
new file mode 100644
index 0000000..b7a2065
--- /dev/null
+++ b/scripts/stripe-list-webhooks.js
@@ -0,0 +1,13 @@
+#!/usr/bin/env node
+const path = require('node:path');
+require('dotenv').config({ path: path.resolve(__dirname, '..', '.env') });
+const Stripe = require('stripe');
+const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, { apiVersion: '2024-10-28.acacia' });
+
+(async () => {
+  const list = await stripe.webhookEndpoints.list({ limit: 100 });
+  console.log('total:', list.data.length);
+  for (const ep of list.data) {
+    console.log(`${ep.status.padEnd(8)} ${ep.id} → ${ep.url} (${ep.enabled_events.length} events)`);
+  }
+})();
diff --git a/scripts/stripe-webhook-test.js b/scripts/stripe-webhook-test.js
new file mode 100644
index 0000000..fd41a29
--- /dev/null
+++ b/scripts/stripe-webhook-test.js
@@ -0,0 +1,29 @@
+#!/usr/bin/env node
+const path = require('node:path');
+require('dotenv').config({ path: path.resolve(__dirname, '..', '.env') });
+const Stripe = require('stripe');
+const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, { apiVersion: '2024-10-28.acacia' });
+
+const urls = [
+  'https://nationalpaperhangers.com/webhooks/stripe',
+  'https://nationalpaperhangers.com/webhooks/stripe/',
+  'https://www.nationalpaperhangers.com/webhooks/stripe',
+  'https://nationalpaperhangers.com/api/stripe/webhook'
+];
+
+(async () => {
+  for (const url of urls) {
+    try {
+      const ep = await stripe.webhookEndpoints.create({
+        url,
+        enabled_events: ['payment_intent.succeeded','payment_intent.payment_failed','payment_intent.canceled','checkout.session.completed','customer.subscription.created','customer.subscription.updated','customer.subscription.deleted','invoice.payment_succeeded','invoice.payment_failed','account.updated'],
+        metadata: { project: 'national-paper-hangers' }
+      });
+      console.log('OK  ', url, '→', ep.id);
+      await stripe.webhookEndpoints.del(ep.id);
+      console.log('     cleaned up');
+    } catch (e) {
+      console.log('FAIL', url, '→', e.message.slice(0, 150));
+    }
+  }
+})();

← de5a034 v0.4 #5 follow-up: hide brand-name + tighten header actions  ·  back to NationalPaperHangers  ·  v0.5: NPH email through Purelymail SMTP + tighter /book cale 196af63 →