Stripe Catalog as Code & the Webhook Dev-Bridge
Audiences: developer, internal
Two distinct dev-time Stripe tools sit beside the in-app subscription integration.
ddx-stripeis 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 intoddx-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-stripekeys products onmetadata.manifest_idand prices onlookup_key— re-runningpnpm run syncon an unchanged manifest is a no-op. Resolve prices at runtime bylookup_key, never by hardcodedprice_*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:dryis mandatory before any livesync, and live keys live only in a gitignored.env.production. The dev-bridge refuses to start on ask_live_*key, so it can never forward real webhooks to localhost.
Architecture
Two independent tools, one shared Stripe account:
ddx-stripe (catalog-as-code CLI) dev-bridge (stripe listen)
──────────────────────────────── ─────────────────────────────
manifests/dudoxx-catalog.json Stripe (test mode events)
├─ dudoxx/saas.json (6 products) │ checkout.session.completed
├─ dudoxx/onprem.json (6 products) │ customer.subscription.*
└─ dudoxx/addons.json (6 products) ▼
│ stripe listen --forward-to
│ load + merge partials localhost:6300/saas/billing/webhook
▼ │ (outbound tunnel, port-less)
src/sync.ts ▼
fetch products by metadata.manifest_id ddx-saas StripeWebhookController
fetch prices by lookup_key HMAC verify (whsec_…) → enqueue
diff → create / update / rotate (↻) tenant.provision
set default_price (★)
│
▼
Stripe (test OR live object space)
ddx-stripe identity rules (the heart of idempotency):
| Stripe object | Manifest field | Match mechanism |
|---|---|---|
| Product | id | Written as metadata.manifest_id on the Stripe product |
| Price | lookup_key | Stripe'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 viatsx(no build step), strict mode with zeroany, Stripe SDK pinned toapiVersion: '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 productids 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_keyoverprice_*IDs: the samelookup_keyresolves to differentprice_*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 bylookup_keyat runtime.- Dev-bridge as a port-less service:
stripe listenbinds no port; it is an outbound tunnel.ddx-manage.shtracks 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_placeholdersentinel (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)
loadConfig()readsSTRIPE_SECRET_KEYfromddx-stripe/.env(must start withsk_) and resolves the manifest path (--manifest=flag >STRIPE_MANIFEST_PATH> default).loadManifest()merges the root file + partials, rejecting duplicateids.runSync()fetches existing products bymetadata.manifest_idand prices bylookup_key(chunked 10 perprices.listcall).- Per product: create if absent, update if
name/description/tax_code/unit_label/statement_descriptor/images/metadatadiffer. Per price: create if absent, rotate (new price + transferredlookup_key) if amount/currency/recurring/nickname differ. Setdefault_priceif a manifest price isdefault: true. sync:dryperforms 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
./ddx-manage.sh start stripe(or the full dev stack) launchesstripe listen, which prints awhsec_…signing secret on first run.- The developer pastes that secret into
ddx-saas/.envasSTRIPE_WEBHOOK_SECRET. - A real (test-mode) Stripe Checkout completes → Stripe POSTs
checkout.session.completedto the bridge → the bridge re-POSTs tolocalhost:6300/saas/billing/webhook→ ddx-saas verifies the HMAC and enqueues provisioning. - 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.ts—loadConfig()(env + manifest path resolution,sk_validation),loadManifest()(partial merge + duplicate-id guard),formatAmount().ddx-stripe/src/sync.ts—runSync();fetchProductsByManifestId(),fetchPricesByLookupKey(),detectProductChanges(),diffPrice(),createPrice()(withtransfer_lookup_key),syncDefaultPrice().ddx-stripe/src/list.ts— read-only inventory (pnpm run list [--include-archived]).ddx-stripe/src/archive.ts—findStale()+archive-stale [--apply](deactivate, never delete).ddx-stripe/src/types.ts—ManifestProduct,ManifestPrice(discriminated ontype),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:126—stripeservice process-match pattern;get_typestripe-cli;get_dirddx-saas.scripts/ddx-services.sh(~67–97) —stripe-clistart branch: install check, placeholder/missing-key skip,sk_live_*refusal,stripe listen --forward-to localhost:6300/saas/billing/webhook.
Operational Notes
sync:drybeforesyncis 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
syncrun against the same manifest with ask_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 referencessaas_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 itddx-manage.sh start stripewarns and skips (webhooks simply do not fire). - The
whsec_…is per-listen-session-ish — if the bridge is restarted and emits a new secret, updateddx-saas/.envand 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.
Related Topics
- SaaS Billing — Stripe Subscription Management — the runtime integration that resolves prices by
lookup_keyand processes the webhooks the dev-bridge forwards. - SaaS Self-Serve Onboarding — the end-to-end flow that becomes locally testable because of the dev-bridge; uses
stripe triggerto forgecheckout.session.completed. - Tenant Provisioner — the downstream provisioning a forwarded
checkout.session.completedultimately triggers.