Setting Stripe up

This package ships the payment provider. Nothing here needs a Stripe SDK, a build step or a redeploy — a portal that holds the keys sells, and a portal that does not says so.

1. The three keys

Key What it is Where it comes from
Commerce:Stripe:SecretKey the server-side API secret (sk_test_… / sk_live_…) Stripe dashboard → Developers → API keys
Commerce:Stripe:WebhookSecret the endpoint signing secret (whsec_…) Stripe dashboard → the endpoint you add in step 2
Commerce:BaseUrl this portal's public base URL the deployment

As environment variables they are Commerce__Stripe__SecretKey, Commerce__Stripe__WebhookSecret and Commerce__BaseUrl.

🚨 The key names are contract. They are read out of environment variables, Key Vault secret names and helm values on every portal that sells, so renaming one is a silent deletion: a renamed key reads as absent, and absent means "this portal does not sell".

🚨 A secret's VALUE never leaves configuration. Nothing logs it, renders it or stores it on a node. The only thing derived from the secret key is its prefix, which names test-vs-live mode.

2. The webhook endpoint

Add an endpoint in the Stripe dashboard pointing at:

{Commerce:BaseUrl}/api/hooks/Store/Payments

Subscribe it to exactly these events (the list PaymentPathAudit measures against — it is StripePaymentProvider.RequiredEvents, and a test holds it equal to the Store's runbook):

🚨 invoice.paid and customer.subscription.deleted are what keeps a subscription alive. An endpoint not subscribed to invoice.paid grants each plan exactly one month while Stripe keeps charging the card; without customer.subscription.deleted a cancelled subscriber keeps access forever.

Then allowlist the target on the portal: WebhookInbox:Targets:0 = Store/Payments. The inbox is fail-closed — without the allowlist the endpoint answers 404 and stores nothing, however correct the Stripe side is.

First route: the EnsurePaymentEndpoint maintenance task (Store Payments → Registering the endpoint). A global admin creates a Store/Maintenance node under Admin/Maintenance/… with task: EnsurePaymentEndpoint and requestedAction: Run (without the action a request is never picked up): that is the dry run, which shows the plan. A SECOND node (a dry run's node is done and does not run again) with the same task and requestedAction: Run plus execute: true and confirm set to the mode either CREATES this instance's endpoint with every required event (its signing secret is shown once in the node's PaymentEndpointSecret area and stored nowhere — paste it into the record page's Set Key Vault secrets dialog as Commerce:Stripe:WebhookSecret, then Restart) or ADDS the events an existing endpoint lacks. It acts only while the payment-path audit reads NotDelivering, and refuses — naming why — two endpoints for one URL, endpoints for other hosts in the account, and a disabled endpoint. The task is the portal acting on a person's explicit request with the key it already charges with; no agent calls the Stripe API with an account key.

Adding an event to an EXISTING endpoint keeps its signing secret — nothing changes on the portal. The test-mode endpoint of the public instance (we_…) predates invoice.payment_failed; the task above adds it. The fallbacks, in TEST mode:

3. The Customer Portal ("Manage billing")

Manage billing on the plan page opens Stripe's hosted Customer Portal for the subscriber (POST /v1/billing_portal/sessions with the customer and a return URL — StripeGateway.OpenBillingPortal). The session is created per click; what the portal offers is the account's portal CONFIGURATION, which lives in the dashboard, once per mode:

Nothing else is needed on the portal side: the secret key the checkout already uses opens the portal. A portal-side cancellation arrives twice: as customer.subscription.updated when it is REQUESTED (the plan then says "will not renew — active until …" and offers no second Cancel), and as customer.subscription.deleted when the paid period runs out, which ends the plan. A subscriber who changes their mind on the portal before then is reported by the same customer.subscription.updated, and the plan says "renews on …" again.

4. Test and live are separate worlds

Test-mode and live-mode endpoints are configured separately at Stripe, so a live-mode endpoint never fires for an sk_test_… checkout and vice versa. A key lists only the endpoints of its own mode — which is what lets Store/Maintenance → PaymentPathAudit tell "no endpoint registered" apart from "registered in the other mode".

5. Switching the public instance to live mode

Going live is a NEW key, a NEW endpoint and a NEW portal configuration — nothing from test mode carries over. The steps, in order; a person does the Stripe half, and no agent reads, writes or calls anything with a live key.

  1. Precondition — one payment, one period, on the RUNNING image. A plan's opening month is granted by checkout.session.completed alone, keyed by its checkout session; invoice.paid extends only for billing_reason: subscription_cycle, keyed by its invoice and landing on the invoice line's period end — so any number of deliveries of either event grant exactly one period (see Subscriptions → One payment, one grant). The fix (Plugins #2406 and #2410) ships in the Store and Stripe PACKAGES, which advance independently of the portal image, so a running image proves nothing about it. The precondition is #2405's own closing check, still in TEST mode: one plan checkout on the public instance ends Fulfilled with no error and exactly one new [active] line on Admin/Subscriptions/<buyer>. Do not replace the test secrets before that has passed — otherwise a live subscriber can be credited twice.
  2. In the Stripe dashboard, LIVE mode (the maintainer):
    • create the live secret key (Developers → API keys, sk_live_…);
    • the live webhook endpoint: FIRST ROUTE, once the live secret key is in place (step 3) and the portal restarted, a global admin runs the EnsurePaymentEndpoint task exactly as in §2 — a dry-run node, then a NEW node, each with task: EnsurePaymentEndpoint and requestedAction: Run, the second with execute: true and confirm: live — it creates the endpoint at the SAME URL with the SAME required events and shows its signing secret once, which then goes in through the same write-only dialog, followed by a Restart. FALLBACK: add a live webhook endpoint by hand at the SAME URL, https://portal.example.com/api/hooks/Store/Payments, subscribed to the SAME events as §2 — checkout.session.completed, checkout.session.expired, invoice.paid, customer.subscription.deleted, invoice.payment_failed and customer.subscription.updated — and copy its signing secret (whsec_…; a live endpoint has its own);
    • save the live Customer Portal configuration exactly as §3 describes (it is per mode, and until it is saved every Manage billing click is refused).
  3. Enter the two values through the write-only GUI, never by hand. On the control instance open Deployments/<deployment> → Set Key Vault secrets… and write <prefix>-Stripe-SecretKey (the live secret key) and memexcloud-Stripe-WebhookSecret (the live endpoint's signing secret). The GUI writes without showing the value back; nothing else — no az keyvault, no chat, no issue — ever carries it.
  4. Make the portal read them. The secrets are read only at pod start, so run the governed Reconcile and then Restart for the public instance on the control instance — Hosting/InstanceAction nodes the operator executes, never kubectl.
  5. Check the mode, then buy. Store/Maintenance → PaymentPathAudit must now report LIVE mode with the live endpoint registered and every required event present. Then work through the real-card checklist on #2440: one real purchase of the smallest plan, validUntil exactly one period ahead, cancel at period end, refund, and the evidence recorded on Payments.

What the module does, and deliberately does not

Saving a method without a payment

The module implements the platform's IPaymentMethodSetupProvider (core Doc/Architecture/PaymentProviderContract → "Saving a method without a payment"). A setup never sends an amount, a line item, an invoice or a subscription. Two handoffs, one Stripe object each:

Handoff Stripe object Setup id Its deliveries When it is offered
Hosted Checkout Session, mode=setup cs_… checkout.session.completed / .expired (already required) for an authorized renewal only
ClientRendered SetupIntent, confirmed by Stripe's own Payment / Express Checkout Elements seti_… setup_intent.succeeded / .setup_failed / .canceled / .requires_action when Commerce:Stripe:PublishableKey is set

The embedded form (PaymentSetupFormControl)

The portal's Blazor view (PaymentSetupFormView) draws Stripe's Express Checkout Element (Apple Pay and Google Pay where eligible) and Payment Element (card) for a client-rendered setup. The one-time client secret comes from RevealClientSecret(setupId, buyer). The view calls it server-side, in the buyer's own circuit, with that circuit's signed-in identity. Stripe answers only when the SetupIntent's stamped buyer is exactly that identity and the intent can still be confirmed; any other viewer is refused, and the refusal never repeats the secret. The secret exists only as one local value, passed to the script's mount, and the script drops its reference once Stripe's Elements hold it. It is never bound, written to the area or a node, logged, or stored in the browser. The React and React Native clients have no buyer-bound channel and render nothing for the control.

The view binds the setup and the provider. A provider change on the same setup counts as a new binding: when a provider the view does not draw becomes Stripe, the form is drawn, and when Stripe becomes another provider, the form is released. When the browser fails (the module import is rejected, Stripe.js cannot be loaded, or the script throws while mounting), the view shows the localized "unavailable" line instead of "loading" forever. The exception's text never reaches the log or the page. When the setup is cleared while Stripe.js is still loading, there is no registration yet to dispose. The view then calls the module's release(container), which withdraws the pending claim on the container, so the late mount creates no Elements or handlers. The view also ends the old binding's generation before it waits for the release. A mount that completes while the browser is still answering is therefore released immediately, and never kept as the live form. At teardown the container may already be gone from the page. In that case release does nothing, and the view still disposes the module even when the release fails. The lifecycle is tested on a real renderer with a recording JS runtime (PaymentSetupFormRegistrationTest). The module's own DOM behaviour is pinned at source level (PaymentSetupFormViewTest), because neither this repository nor core has a JS test harness for Blazor interop scripts.

The portal's Content-Security-Policy admits https://js.stripe.com, and only that host, in script-src of both the enforced and the report-only policy (ContentSecurityPolicy.StripeScriptOrigin). Stripe's frames and API calls were already inside frame-src https: and connect-src https:. The portal's Permissions-Policy allows payment for this origin and https://js.stripe.com only (payment=(self "https://js.stripe.com"), SecurityHeaders.PermissionsPolicy). That is what lets the Express Checkout Element use the browser's Payment Request capability for Apple Pay and Google Pay. Every other directive stays denied. The portal's Cross-Origin-Opener-Policy stays same-origin everywhere except the one page that hosts the form, the coupon journey's review page (/Store/CouponJourney/Review, matched exactly). That page sends same-origin-allow-popups, which keeps the opener relationship Google Pay's popup flow needs (SecurityHeaders.OpenerPolicyFor).

Owner acts before it can be used: set Commerce:Stripe:PublishableKey (test mode first) for the embedded form; register every portal domain that will show wallet buttons as a Stripe payment-method domain and enable Apple Pay and Google Pay on the account; then run EnsurePaymentEndpoint, which adds the four setup_intent.* events once the publishable key is set.

Reading further