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):
checkout.session.completed— the purchase that payscheckout.session.expired— the buyer who walked awayinvoice.paid— the monthly RENEWAL of a subscriptioncustomer.subscription.deleted— the END of a subscriptioninvoice.payment_failed— a FAILED charge: the plan goes past-due and the subscriber is toldcustomer.subscription.updated— a cancellation REQUESTED for the period end, or withdrawn — including one made on Stripe's own Customer Portal (§3): the plan says "will not renew — active until …" from the moment it was asked, instead of "renews on …" for the rest of the period
🚨 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:
Dashboard: Developers → Webhooks (test mode:
https://dashboard.stripe.com/test/webhooks/we_1UJssh1RJOWModnTiCCfq7R5) → ⋯ → Update details → Select events → tickinvoice.payment_failed→ Update endpoint. The endpoint's other events stay ticked.Stripe CLI, logged in to the TEST account (
stripe login; the CLI uses a restricted test key):stripe webhook_endpoints update we_1UJssh1RJOWModnTiCCfq7R5 \ -d "enabled_events[]=checkout.session.completed" \ -d "enabled_events[]=checkout.session.expired" \ -d "enabled_events[]=invoice.paid" \ -d "enabled_events[]=customer.subscription.deleted" \ -d "enabled_events[]=invoice.payment_failed" \ -d "enabled_events[]=customer.subscription.updated"🚨
enabled_eventsis REPLACED, not appended: the call must list every event above, or the ones it leaves out stop arriving. Read the result'senabled_eventsback and check every one is there.
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:
- 🚨 It must be SAVED once in each mode before the first session can be created. Until then
Stripe refuses every portal session in that mode with a message saying so, and the plan page shows
that message. Open Settings → Billing → Customer portal (test mode:
https://dashboard.stripe.com/test/settings/billing/portal) and press Save; repeat in live mode before selling live. - Enable: Payment methods → update (the reason the button exists — a failed card is fixed
here), Invoice history, and Cancel subscriptions with "At the end of billing period". That
last setting is load-bearing: the plan here ends only when
customer.subscription.deletedarrives, which with this setting happens when the paid period runs out — the same rule as the plan page's own Cancel button. "Immediately" would end a paid month early. - Leave off: Switch plans (plans are sold with inline prices, not Stripe Products, so the portal has nothing to switch to) and Update quantities. Customer-information updates are harmless and optional.
- The default business information (name, privacy/terms links) is shown on the portal page; set it under Settings → Business if it is not already.
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.
- Precondition — one payment, one period, on the RUNNING image. A plan's opening month is
granted by
checkout.session.completedalone, keyed by its checkout session;invoice.paidextends only forbilling_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 endsFulfilledwith no error and exactly one new[active]line onAdmin/Subscriptions/<buyer>. Do not replace the test secrets before that has passed — otherwise a live subscriber can be credited twice. - 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
EnsurePaymentEndpointtask exactly as in §2 — a dry-run node, then a NEW node, each withtask: EnsurePaymentEndpointandrequestedAction: Run, the second withexecute: trueandconfirm: 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_failedandcustomer.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).
- create the live secret key (Developers → API keys,
- 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) andmemexcloud-Stripe-WebhookSecret(the live endpoint's signing secret). The GUI writes without showing the value back; nothing else — noaz keyvault, no chat, no issue — ever carries it. - 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/InstanceActionnodes the operator executes, neverkubectl. - Check the mode, then buy.
Store/Maintenance→PaymentPathAuditmust 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,validUntilexactly one period ahead, cancel at period end, refund, and the evidence recorded on Payments.
What the module does, and deliberately does not
- Does: create hosted Checkout sessions (one-off and recurring, with inline prices), verify
every delivery's
Stripe-SignatureHMAC inside a five-minute replay window, parse the events, stop a subscription at the END of the period already paid for, and open the hosted Customer Portal for a subscriber. - Does (QR Coupon Journeys, Plugins#2064): save a payment method without taking a payment,
as the platform's optional
IPaymentMethodSetupProvider— see Saving a method without a payment below. - Does not: decide who is entitled to what, price anything, write an order, or send anyone anywhere. Where a buyer returns to is passed in by the caller, so moving a checkout surface can never leave the payment provider redirecting at a dead URL.
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 |
- 🚨 Stripe Checkout cannot save a method for customer-present use only. Its
setup_intent_datacarries only a description, metadata and on-behalf-of, so the intent it creates is always for off-session use. A customer-present setup on the hosted handoff is therefore refused with a sentence rather than opened, because its result could never match the request. It needs the embedded form. - 🚨 A setup-mode session's completion is reported as a setup, never as a paid checkout. Before
this,
ReadDeliveryreported everycheckout.session.completedas a completed checkout. - One delivery shape per setup. The hosted session's metadata is not copied onto the intent
Checkout creates, so that intent's own events name no work and are ignored. A bare session
delivery names no saved method; the caller reconciles through
RetrieveSetup, which reads the session with its intent and method expanded. - Wallets.
DescribeSetupanswers per kind: a card is reusable for both uses; a saved Apple Pay token is not a reusable customer-present method (a later purchase opens a fresh wallet sheet) but is what Stripe charges for an authorized renewal; Google Pay saves a card. Whether a wallet shows on a device is Stripe's in-page controls' answer, never this table's. - Card only. Both handoffs ask Stripe for
payment_method_types=card, never the account's automatic set. Apple Pay and Google Pay are card methods, so they still show where eligible, but a bank debit (or anything else with its own mandate) the account enabled is never offered. - Cancelling reads first. Payment Checkout Sessions also carry
cs_ids, soCancelSetupretrieves and validates the setup before any write. A setup that already stopped is answered as read, with no second expire, and a write whose answer was lost is reconciled by reading again. - Required events follow the offer. With
Commerce:Stripe:PublishableKeyset, the foursetup_intent.*events join the path's required events, soEnsurePaymentEndpointsubscribes them andPaymentPathAuditmeasures the endpoint against them. - Safety. A request whose
ExpectedModediffers from the key's mode is refused, so a setup expectingtestnever opens on a live key. Every create carries the request's idempotency key. The client secret reaches only the buyer's page; it is never serialized or logged. - Not offered: a deferred (zero-due-today) subscription. It keeps the contract's not supported defaults until the campaign's renewal terms are approved.
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
- Payments — what the Store does with a verified delivery, and how to read a
non-empty
_Rejected. - Subscriptions — the recurring lane end to end.