10-cross-cuttingwave: W6filled1 citations

Stripe Catalog as Code & the Webhook Dev-Bridge

Audiences: developer, internal

Stripe Catalog as Code & the Webhook Dev-Bridge

Two distinct dev-time Stripe tools sit beside the in-app subscription integration. ddx-stripe is a standalone CLI that owns the Stripe product/price catalog as code — it diffs a JSON manifest against Stripe and idempotently creates/updates/archives products and prices. The Stripe CLI dev-bridge, wired into ddx-manage.sh, forwards live Stripe webhook events into the local ddx-saas billing endpoint so the full signup-to-provision flow can run on a laptop. Both are upstream of the runtime SaaS Billing integration; neither runs in production request paths.

Business Purpose

The Dudoxx commercial layer needs a catalog of plans and prices (SaaS Starter/Professional, On-Prem tiers, add-ons — each in en/de/fr) that is identical and auditable across Stripe's two separate object spaces (test and live). A hand-edited Dashboard catalog drifts the moment there is more than one environment, locale, or developer. ddx-stripe gives the catalog the same review/merge/audit lifecycle as code: edit a manifest, PR it, sync to test, smoke-test, sync to live. A wrong amount caught in the manifest is a typo; the same amount clicked into the Dashboard is a support ticket.

The dev-bridge solves a different problem: Stripe webhooks (checkout.session.completed, subscription lifecycle) only reach a public URL. On a developer laptop there is no public URL, so the self-serve onboarding flow could never complete locally. The Stripe CLI's stripe listen opens an authenticated outbound tunnel from Stripe to the developer's machine and re-POSTs every event to localhost:6300/saas/billing/webhook, making the entire paid-onboarding state machine exercisable offline.

Business outcome: the price a customer is charged is reviewable in git and provably identical in test and live; and an engineer can drive a full "sign up → pay → provision tenant" flow end-to-end on a laptop without deploying anything.

Audiences

  • Investor: pricing is managed as code with a test-then-live promotion gate — the same control discipline applied to software changes, applied to revenue. This reduces the risk of pricing errors reaching customers.
  • Clinical buyer: not customer-facing. The pricing they see at Checkout is the same pricing reviewed and approved internally before any live change.
  • Developer/partner: ddx-stripe keys products on metadata.manifest_id and prices on lookup_key — re-running pnpm run sync on an unchanged manifest is a no-op. Resolve prices at runtime by lookup_key, never by hardcoded price_* IDs (the same key maps to different IDs in test vs live). The dev-bridge is ./ddx-manage.sh start stripe.
  • Internal (ops/support): live syncs move real money — sync:dry is mandatory before any live sync, and live keys live only in a gitignored .env.production. The dev-bridge refuses to start on a sk_live_* key, so it can never forward real webhooks to localhost.

Architecture

Two independent tools, one shared Stripe account:

Rendering diagram…

ddx-stripe (catalog-as-code CLI)manifests/dudoxx-catalog.json partials:

PartialContents
dudoxx/saas.json6 products
dudoxx/onprem.json6 products
dudoxx/addons.json6 products

src/sync.ts steps:

#Step
1fetch products by metadata.manifest_id
2fetch prices by lookup_key
3diff → create / update / rotate (↻)
4set default_price (★)

dev-bridge (stripe listen)ddx-saas StripeWebhookController: HMAC verify (whsec_…) → enqueue tenant.provision.

ddx-stripe identity rules (the heart of idempotency):

Stripe objectManifest fieldMatch mechanism
ProductidWritten as metadata.manifest_id on the Stripe product
Pricelookup_keyStripe's first-class stable identifier

Changing a price amount does not mutate the immutable Stripe Price — createPrice() issues a new Price with the same lookup_key and transfer_lookup_key: true, so Stripe atomically rotates the key onto the new price and deactivates the old one. archive-stale deactivates (never deletes) products absent from the manifest.

Dev-bridge registration: scripts/ddx-lib.sh registers a service named stripe with get_type = stripe-cli, get_dir = ddx-saas, and a process-match pattern of stripe listen --forward-to localhost:6300/saas/billing/webhook. scripts/ddx-services.sh holds the stripe-cli start branch: it skips (warn, exit 0) if the stripe CLI is not installed or if STRIPE_SECRET_KEY is missing/sk_test_placeholder, and hard-refuses (error, exit 1) on a sk_live_* key, then runs stripe listen --api-key <key> --forward-to localhost:6300/saas/billing/webhook --skip-verify.

Tech Stack & Choices

  • ddx-stripe: TypeScript run via tsx (no build step), strict mode with zero any, Stripe SDK pinned to apiVersion: '2025-01-27.acacia'. No DB, no cache — Stripe itself is the system of record; the manifest is the desired state. The root manifest merges partials in order and throws on duplicate product ids at load time.
  • Manifest over Dashboard: the Dashboard is fine for a solo founder but offers no PR review, no diff, and no cross-environment parity. Catalog-as-code gives all three.
  • lookup_key over price_* IDs: the same lookup_key resolves to different price_* IDs in test vs live. Hardcoding an ID hardcodes "test or live" — a bug that stays silent until a real customer hits it. ddx-api resolves prices by lookup_key at runtime.
  • Dev-bridge as a port-less service: stripe listen binds no port; it is an outbound tunnel. ddx-manage.sh tracks its lifecycle by PID file + process-match pattern only, the same model used for LiveKit worker agents.
  • Safety gating: the bridge self-disables on the sk_test_placeholder sentinel (the dev-bypass self-disabling pattern) and refuses live keys outright, so a misconfigured laptop simply runs without webhooks rather than leaking real payment events to localhost.

Data Flow

Catalog sync (ddx-stripe)

  1. loadConfig() reads STRIPE_SECRET_KEY from ddx-stripe/.env (must start with sk_) and resolves the manifest path (--manifest= flag > STRIPE_MANIFEST_PATH > default).
  2. loadManifest() merges the root file + partials, rejecting duplicate ids.
  3. runSync() fetches existing products by metadata.manifest_id and prices by lookup_key (chunked 10 per prices.list call).
  4. Per product: create if absent, update if name/description/tax_code/unit_label/statement_descriptor/images/metadata differ. Per price: create if absent, rotate (new price + transferred lookup_key) if amount/currency/recurring/nickname differ. Set default_price if a manifest price is default: true.
  5. sync:dry performs the same diff with GET-only calls and prints + created / ~ updated / ↻ rotated / ★ default — the contract to review before any write, especially against live.

Webhook dev-bridge

  1. ./ddx-manage.sh start stripe (or the full dev stack) launches stripe listen, which prints a whsec_… signing secret on first run.
  2. The developer pastes that secret into ddx-saas/.env as STRIPE_WEBHOOK_SECRET.
  3. A real (test-mode) Stripe Checkout completes → Stripe POSTs checkout.session.completed to the bridge → the bridge re-POSTs to localhost:6300/saas/billing/webhook → ddx-saas verifies the HMAC and enqueues provisioning.
  4. To forge an event without a browser: stripe trigger checkout.session.completed --override checkout_session:metadata.jobId=<id> --override checkout_session:metadata.slug=<slug> --override checkout_session:metadata.planTier=PRO --override checkout_session:metadata.orgEmail=<email>.

Implicated Code

  • ddx-stripe/src/client.tsloadConfig() (env + manifest path resolution, sk_ validation), loadManifest() (partial merge + duplicate-id guard), formatAmount().
  • ddx-stripe/src/sync.tsrunSync(); fetchProductsByManifestId(), fetchPricesByLookupKey(), detectProductChanges(), diffPrice(), createPrice() (with transfer_lookup_key), syncDefaultPrice().
  • ddx-stripe/src/list.ts — read-only inventory (pnpm run list [--include-archived]).
  • ddx-stripe/src/archive.tsfindStale() + archive-stale [--apply] (deactivate, never delete).
  • ddx-stripe/src/types.tsManifestProduct, ManifestPrice (discriminated on type), MANIFEST_ID_KEY = 'manifest_id'.
  • ddx-stripe/manifests/dudoxx-catalog.json (+ dudoxx/*.json) — the desired-state catalog: 18 products / 24 prices today.
  • scripts/ddx-lib.sh:126stripe service process-match pattern; get_type stripe-cli; get_dir ddx-saas.
  • scripts/ddx-services.sh (~67–97) — stripe-cli start branch: install check, placeholder/missing-key skip, sk_live_* refusal, stripe listen --forward-to localhost:6300/saas/billing/webhook.

Operational Notes

  • sync:dry before sync is mandatory, especially against live — the dry-run output is the contract; if it shows a surprise, stop.
  • Two object spaces, two syncs: promoting test → live is not a copy; it is a second sync run against the same manifest with a sk_live_* key sourced from a gitignored .env.production. Never paste a live key into chat, a PR, or a commit; rotate immediately if leaked.
  • Prices are immutable — an amount change creates a new Price and rotates the lookup_key; existing subscriptions keep billing the old price until separately migrated (intentional, not a bug).
  • lookup_keys are forever — once ddx-api references saas_starter_monthly_en, that key must keep resolving; "renaming" means create-new + repoint + archive-old.
  • Dev-bridge needs the Stripe CLI installed (brew install stripe/stripe-cli/stripe); without it ddx-manage.sh start stripe warns and skips (webhooks simply do not fire).
  • The whsec_… is per-listen-session-ish — if the bridge is restarted and emits a new secret, update ddx-saas/.env and restart ddx-saas, or webhook HMAC verification returns {received: false}.
  • Distinct from billing-saas — that topic covers the runtime ddx-api/ddx-saas Stripe subscription integration that consumes this catalog and receives these forwarded webhooks. Catalog authoring and the listen tunnel are dev-time concerns and live in this topic.
    Stripe Catalog as Code & the Webhook Dev-Bridge — Dudoxx Docs | Dudoxx