Subscribing is mostly the client's job. The API opens a Stripe Checkout Session and later writes the local rows once it completes. Everything in between, the address, the card, bank challenges, and the confirm itself, runs in the client against Stripe's SDK using the session's client secret.
The session is created with ui_mode set to elements, so the client mounts Stripe's address and payment elements inside its own page. That's the choice over a hosted page or Stripe's embedded checkout, both of which render Stripe's UI. Here the client owns the styling and the steps, and Stripe still owns the card handling.
The API sees a signup three times. POST /subscription/session takes a plan slug and an interval and answers with the session id and its client secret. The client then confirms against Stripe directly. Once the session completes, POST /subscription/sync takes the session id and writes the local subscription, the card, and the plan.
The sync is the happy path rather than the only one. A checkout.session.completed webhook runs the exact same write for the cases where the client never comes back, a tab closed after a bank challenge for instance. There's more on that in Webhooks.
Stripe creates the subscription inside the confirm, after the payment settles. Opening the page creates a customer if there isn't one, plus the session, and that's all. An abandoned signup leaves a session that ages out on its own, and a declined card leaves nothing to tear down. The client just tries again on the same session.
It also means there's no incomplete subscription sitting around waiting to be reconciled, and the create call doesn't need to be safe to repeat. A refresh just gets a fresh session.
The check for an existing subscription runs when the session is created, since that's the only time the API sees the user before Stripe creates the subscription. A live subscription is refused with already_subscribed. Past due and unpaid get payment_required instead, so the client can send them to the card page rather than tell them they're already subscribed.
The check reads the local subscription, which trails Stripe by a moment. Two confirms from different devices seconds apart can both get through. It takes the same person doing it on purpose, so it's been left alone for now.
Creating a session expires every other open session on the customer. Stripe keeps a session usable for up to a day, so without the sweep a tab left open on another device could still be confirmed after the user subscribed somewhere else, and they'd end up with two subscriptions. The stale tab now fails at its confirm and the user starts again from a fresh session.
A customer with no card on file is asked for a billing address inside the session, and customer_update copies it and the name onto the Stripe customer at confirm. The API writes neither and nothing about the address is stored locally.
A customer who already has a card went through the payment method flow, which wrote the address directly, so the session skips the step. Asking again would put a step in front of someone with nothing to correct, and customer_update would overwrite a tax address with a form they never came to fill in.
Whether a card is on file is read from Stripe rather than a local flag, and filtered the same way the session filters its saved cards. A local flag drifts, and if the two ever disagreed a user would land on a confirm step for a session that was still asking for an address. The session also has to be told to show saved cards at all, which is what the saved_payment_method_options setting on the create is for. Without it every user opens on the address step.
Stripe's Elements SDK takes no locale of its own, so the session create is the only place the elements are told what language to render in. The user's locale is mapped onto Stripe's set, which has no region for English. Adding a locale to config/user.php means adding it to the map in CreateSessionService too, otherwise it falls through to auto and the session follows the browser instead of the user. A test fails if the two drift apart.
The sync is a set of updateOrCreate calls against ids Stripe has already settled, so whichever of the client call and the webhook lands second finds everything written and changes nothing.
The session id arrives from the client, so it's only trusted once it's shown to belong to the user's Stripe customer. Anything else answers nothing_to_sync without saying whether the session exists. A session that isn't complete yet answers the same way, since there's no subscription to read until it is.
The plan announcements don't happen here. They stay with the webhook listener, which sees the subscription go live whichever path wrote the rows.
Automatic tax, promotion codes, and trials aren't part of the core flow. Each is layered onto the same session, switched by its own setting where it has one, and the flow reads exactly as above with them off. See Trials, Automatic Tax, and Promotion Codes.