Rails Stripe Checkout: creating the session and reading the return
September 12, 2026
A Rails Stripe Checkout integration has two halves. The billing hub
opens on the half people get wrong, the webhook, and
Stripe webhooks in Rails works
through the signature and the idempotency that endpoint needs. The other half is the trip out and
back: creating the session, handing the buyer to Stripe, and deciding what to do with the browser
that returns.
The outbound leg is short. Almost all of the difficulty sits on the return, and it comes from one
fact: the return is a browser, and a browser is not evidence.
The single API call that creates a Stripe Checkout Session
Creating a Stripe Checkout Session is one call with four arguments and nothing else that has to be
there. Only mode is unconditionally required by Stripe; line_items and success_url are
required conditionally, and cancel_url is optional, adding the back button that returns a customer
who changes their mind:
Stripe::Checkout::Session.create(mode: mode,# "subscription" or "payment"line_items: [{price: price_id,quantity: 1}],success_url: success_url,cancel_url: cancel_url,**discount_options,**customer_options,**invoice_options,**metadata_options)
mode is "subscription" for a recurring plan and "payment" for a one-off. line_items names a
price that already exists in Stripe rather than an amount you calculated, which is what keeps the
price out of the request the browser can edit. The two URLs are where Stripe sends the browser
afterwards.
Everything else is optional, and each of the four splats above returns {} when it does not apply,
so the call keeps one shape whether the buyer is a guest paying once or a signed-in user starting a
subscription. The four optional groups are where the decisions live: which discount applies, whether
a Stripe customer is created, whether an invoice is produced, and what metadata travels with the
session.
The three things the checkout controller settles before calling Stripe
The checkout controller answers three questions before any Stripe call happens, and two of them end
the request.
unlessAppConfig.stripe_configured?redirect_tohelpers.pricing_destination,alert: t("billing.checkouts.flash.not_configured")returnendifparams[:price_id].blank?redirect_tohelpers.pricing_destination,alert: t("billing.checkouts.flash.no_plan")returnendplan=Pricing.plan_for(params[:price_id])# resolve mode + coupon server-side, not from the form
An unconfigured Stripe account is a fresh clone before anyone ran the setup wizard. Calling Stripe
with no API key there produces a 500 on a marketing page, so the controller redirects to pricing
with a flash instead. A blank price_id gets the same treatment.
The third line is the security one. The plan's mode and its coupon are looked up from the price
id server side and never read from the submitted form, so a forged mode or a forged coupon
parameter is ignored rather than honoured. The request spec asserts exactly that: it posts a
checkout with both fields tampered with and expects the plan's own values to reach Stripe.
One discount slot, and which coupon wins it
A Stripe Checkout Session accepts only one discount. Stripe's reference for the discounts
parameter states the limit plainly ("Currently, only up to one may be specified"), which turns what
looks like a list into a choice the application has to make before it calls Stripe.
Two coupons can plausibly apply to the same purchase here: the discount a referred visitor was
promised, and the plan's own promotional coupon. The referral wins when it is active, the plan's
coupon takes the slot otherwise, and when neither applies the discounts key is absent from the
create call entirely rather than present and empty.
Deciding this in Ruby is not a workaround. The parameter takes a list and accepts one entry, so the
choice between two applicable coupons exists whether or not the application writes it down.
customer_creation, and the guest customer a payment-mode session leaves behind
customer_creation is the Stripe Checkout parameter that decides whether a Customer object exists
after a one-off purchase. Stripe documents it as optional, settable only in payment and setup mode,
and taking either always or if_required, where if_required means the session "will only create
a Customer if it is required for Session confirmation".
The important part is the default. Stripe's changelog for API version 2022-08-01 records that in
payment mode "the default value of customer_creation is now if_required instead of always". A
plain payment-mode session therefore does not necessarily leave a Customer behind, and the sample
response on Stripe's own create-session page shows "customer_creation": "if_required".
What Stripe does instead is documented too: sessions that create no Customer are grouped as guest
customers, a read-only grouping for completed transactions. Stripe does not save payment methods for
guest customers, so you cannot initiate new payments on their behalf, and promotion codes restricted
to first-time customers come back invalid for those sessions.
So a guest paying once is sent customer_creation: "always", which states the requirement rather
than leaving it to depend on whatever else the session happens to enable. A guest subscribing is
sent nothing, because subscription mode needs a Customer anyway. A signed-in buyer is sent their
existing customer id plus client_reference_id, which is the user id travelling to Stripe and back.
What a blank stripe_customer_id costs every lookup afterwards
The stripe_customer_id column is the join between Stripe's records and this database, and every
later lookup goes through it. A checkout that left the column blank is not a cosmetic gap.
Billing::Webhooks::SubscriptionUpserted starts with User.find_by(stripe_customer_id:
subscription.customer) and returns early on a miss, so a subscription event about an unlinked buyer
is silently dropped. Billing::Webhooks::PaymentIntentSucceeded does the same and additionally
bails when the intent carries no customer at all, for a reason worth stealing:
find_by(stripe_customer_id: nil) matches the first customerless user in the table and would
attribute a stranger's payment to them.
There is a second cost, and the buyer sees this one. Billing::Customer#ensure! returns the stored
id when it is present and otherwise creates a brand new Stripe customer and writes it back. A buyer
whose purchase left no id therefore gets a fresh, empty customer the first time they open the
billing portal: no payment history, no invoices, nothing they recognise.
invoice_creation, because a one-off Stripe payment produces no invoice
invoice_creation is the flag that gives a one-time buyer something to download. A payment-mode
Checkout produces a PaymentIntent and a receipt, but no Stripe invoice by default, which means the
buyer has no PDF and the invoice history in the Billing Portal is empty for them.
Subscription mode is excluded on purpose, because a subscription invoices on every cycle by itself.
The asymmetry catches people out: the same product sold as a subscription arrives with an invoice
trail, and sold once arrives with none, and the difference is invisible until a customer asks for a
document their accountant needs.
The referral code rides on Stripe session metadata instead of a cookie
Session metadata is how a value survives the hop to Stripe and back. A referral code arrives on this
site in a cookie, and the buyer then spends several minutes on a domain that cannot read it, so the
code is copied onto the session at creation time:
Metadata is readable from both ends of the round trip, which is the point. The success page reads it
off the session it fetched, and the webhook reads it off the session in the event payload, so
attribution works for a buyer who lands back and for one who closes the tab. A cookie only works for
the first of those, and only while the browser keeps it.
Stripe substitutes {CHECKOUT_SESSION_ID} into success_url after payment
success_url carries a template variable that looks like a bug in a code review:
The braces are literal. Stripe's documentation for a custom success page describes exactly this
string: you send it as written, and Stripe substitutes the real session id when it redirects the
customer back after payment. Interpolating a Ruby value into that slot is the mistake, and it is a
tempting one because the session id exists by the time the redirect happens.
The session id exists too late to help, though. The URL is part of the create call, so it is
written before the session has an id, and the brace template is how Stripe closes that gap on its
side. What comes back is a session id in a query parameter, which is a lookup key and nothing
more.
redirect_to session.url needs allow_other_host in Rails 8
The redirect to Stripe is a cross-host redirect, and Rails refuses those by default:
Without allow_other_host: true, Action Pack raises UnsafeRedirectError, whose message reads
"Unsafe redirect to ..., pass allow_other_host: true to redirect anyway". The protection exists
because a redirect target taken from user input is an open redirect, so Rails makes you state that
this particular target is meant to leave the application. session.url came from Stripe's API
response rather than from a parameter, which is what makes saying so honest here.
status: :see_other is a 303, the status a POST handler returns when the follow-up request should
be a GET.
One thing happens before the redirect, and its placement is the whole reason it works:
Recording the Stripe session id here, in the visitor's browser, is what makes the sale attributable
later. The visit, its referrer and its UTM parameters are known at this moment and are gone by the
time a webhook arrives from Stripe's servers, so the session id is the thread that ties the two
together.
Fetching the Checkout Session back rather than trusting the session id
The session_id in the return URL is a lookup key and never a fact. Everything the success page
believes comes from fetching that session back from Stripe over the server-side API:
Four outcomes, one of which is a session. A blank id returns nil without a network call. A
made-up id makes Stripe answer with Stripe::InvalidRequestError, which is rescued to nil so a
typed URL is a miss rather than a 500. A real but unpaid session is rejected on payment_status,
which the Stripe library documents as one of paid, unpaid or no_payment_required with the
comment "You can use this value to decide when to fulfill your customer's order". Only a paid
session is handed back.
The keyword form retrieve(id:, expand: [...]) is supported rather than accidental: the gem's
APIResource.retrieve passes its argument through Util.normalize_id, which treats a Hash as an
"overloaded id", deletes :id from it and keeps the remaining keys as retrieve parameters.
expand: ["customer"] then returns the customer inline instead of as a bare id string, saving a
second round trip.
Why the Stripe Checkout success page cannot be the trigger for fulfilment
Stripe states the limitation on the same documentation page that describes the custom success page:
customers are not guaranteed to visit it. A successful payment followed by a lost connection before
the page loads is money taken and nothing granted, and no amount of care in the controller recovers
it, because the controller never runs.
A Stripe Checkout success page also runs for anyone who pastes a URL, which is the opposite failure
and the reason a server-side retrieval that rejects an unpaid session has to sit in front of it.
Stripe's answer to both is the same: a webhook event handler is the reliable confirmation of
payment, with automatic retries when delivery fails.
The line to hold onto is narrower than "the success page only reads". It is that the redirect is
never the source of truth: no value is taken from the URL, every value comes from a server fetch,
and every write is keyed so the webhook can do it again without consequence.
Why the success page writes anything at all
The success page in this codebase provisions the account, mirrors the subscription and records the
transaction, and the reason is written into the services themselves. In a pay-first flow the account
does not exist when the buyer pays, so a customer.subscription.created webhook arriving first
finds no user by customer id and skips, exactly as SubscriptionUpserted is written to do.
Mirroring on the return closes that gap, and the local development case is starker: the
payment_intent.succeeded event never fires at all without stripe listen running, so a checkout
on a laptop would grant nothing.
Note what the code above does not do. SyncCheckoutSubscription calls the webhook's own handler
rather than reimplementing the mirror, and SyncCheckoutPayment performs the same
find_or_create_by! on the payment intent id that PaymentIntentSucceeded performs. Two entry
points, one implementation, so the two paths cannot drift apart. The webhook remains the source of
truth for everything afterwards: renewals, cancellations and plan changes.
Keying every write so a repeated webhook changes nothing
Every write on the return path is keyed on a Stripe id, which is what lets the webhook repeat the
same work minutes later without doubling it:
The key is the Stripe payment intent id, guarded by mode == "payment" and a paid status, and the
spec proves the sequence: success page, then webhook, one Transaction and not two. The database
backs it rather than trusting the code, with index_transactions_on_stripe_id declared UNIQUE, and
users carries the same treatment on email_address and stripe_customer_id.
Be precise about what that buys. find_or_create_by! is a check followed by an act, so a genuinely
simultaneous success page and webhook can both find nothing and both insert. The unique index is
what keeps the duplicate row out of the table, and it does so by raising
ActiveRecord::RecordNotUnique, which nothing here rescues. The idempotency proven by the specs is
sequential idempotency, which is the case that actually happens.
SyncCheckoutPayment also accepts the payment intent in either shape Stripe hands over, a bare id
string on the webhook payload or an expanded object on the server-fetched session, through
value.respond_to?(:id) ? value.id : value. The same trick resolves the customer.
Provisioning an account from the session's customer_details
A guest who paid has no account, and the only identity available is the email Stripe collected.
Provisioning reads it from the fetched session and find-or-creates on it:
user=User.find_or_create_by!(email_address: email)do|new_user|new_user.password=User.random_password# placeholder until they claim the accountnew_user.confirmed_at=Time.current# they paid with this addressnew_user.claimed_at=nilendnewly_created=user.previously_new_record?user.update!(stripe_customer_id: customer_id)ifcustomer_id.present?&&user.stripe_customer_id.blank?
The email is read as session.customer_details&.email with session.customer_email as a fallback,
and the customer id accepts a bare string or an expanded object. An existing account is left alone
apart from filling in a blank stripe_customer_id.
Two details in there cost real debugging. previously_new_record? is captured before the
update!, because that UPDATE flips it to false and the referral on a brand new account would be
missed. And session metadata is read with metadata[:referral_code] rather than a getter, because
Stripe::StripeObject raises NoMethodError for a key the object does not hold, while [] returns
nil. A non-referred checkout carries an empty metadata object, so the getter form fails on the
common case rather than the rare one.
The claim is gated on the signed session, not on the session id
Setting the password on a pay-first account is the moment where a URL must not be enough, because
anybody holding a Stripe session id would otherwise be able to take over the account it created.
session[:pending_claim_user_id]=@user.id
The success page writes that key into Rails' signed session cookie, and the claim controller reads
it back, refusing when the record is missing or already claimed. Possession of the URL grants
nothing by itself.
The ordering on the success page matters as much as the gate. A guest who returns with a paid
session is provisioned, their subscription and payment are mirrored immediately, and only then is
the claim offered. An account that already exists and is already claimed is a returning customer, so
they are redirected to sign in instead, and the request spec pins that case: a second purchase on
the same address cannot reset the first buyer's password.
What this codebase does on the Stripe return
The Stripe return path here is one fetch and four writes, all of them replayable. RetrieveCheckout
turns the session_id parameter into either a paid session or nil, and a signed-in buyer is
mirrored and redirected into the app before anything renders, which is also why the Google Ads
conversion payload is stashed in the session rather than drawn on a page nobody sees. A guest is
provisioned, mirrored, and offered a claim gated on the signed session.
The webhook does all of it again, from checkout.session.completed, for the buyer who never came
back: client_reference_id binds a signed-in user to their customer id, its absence runs the same
ProvisionFromCheckout, and the same SyncCheckoutPayment records the same transaction under the
same key. Analytics is wrapped in Safely.safely at the end, so a tracking failure can never be the
reason a paid customer does not get their account.
A Rails Stripe Checkout integration has two halves. The billing hub opens on the half people get wrong, the webhook, and Stripe webhooks in Rails works through the signature and the idempotency that endpoint needs. The other half is the trip out and back: creating the session, handing the buyer to Stripe, and deciding what to do with the browser that returns.
The outbound leg is short. Almost all of the difficulty sits on the return, and it comes from one fact: the return is a browser, and a browser is not evidence.
The single API call that creates a Stripe Checkout Session
Creating a Stripe Checkout Session is one call with four arguments and nothing else that has to be there. Only
modeis unconditionally required by Stripe;line_itemsandsuccess_urlare required conditionally, andcancel_urlis optional, adding the back button that returns a customer who changes their mind:modeis"subscription"for a recurring plan and"payment"for a one-off.line_itemsnames a price that already exists in Stripe rather than an amount you calculated, which is what keeps the price out of the request the browser can edit. The two URLs are where Stripe sends the browser afterwards.Everything else is optional, and each of the four splats above returns
{}when it does not apply, so the call keeps one shape whether the buyer is a guest paying once or a signed-in user starting a subscription. The four optional groups are where the decisions live: which discount applies, whether a Stripe customer is created, whether an invoice is produced, and what metadata travels with the session.The three things the checkout controller settles before calling Stripe
The checkout controller answers three questions before any Stripe call happens, and two of them end the request.
An unconfigured Stripe account is a fresh clone before anyone ran the setup wizard. Calling Stripe with no API key there produces a 500 on a marketing page, so the controller redirects to pricing with a flash instead. A blank
price_idgets the same treatment.The third line is the security one. The plan's
modeand its coupon are looked up from the price id server side and never read from the submitted form, so a forgedmodeor a forgedcouponparameter is ignored rather than honoured. The request spec asserts exactly that: it posts a checkout with both fields tampered with and expects the plan's own values to reach Stripe.One discount slot, and which coupon wins it
A Stripe Checkout Session accepts only one discount. Stripe's reference for the
discountsparameter states the limit plainly ("Currently, only up to one may be specified"), which turns what looks like a list into a choice the application has to make before it calls Stripe.Two coupons can plausibly apply to the same purchase here: the discount a referred visitor was promised, and the plan's own promotional coupon. The referral wins when it is active, the plan's coupon takes the slot otherwise, and when neither applies the
discountskey is absent from the create call entirely rather than present and empty.Deciding this in Ruby is not a workaround. The parameter takes a list and accepts one entry, so the choice between two applicable coupons exists whether or not the application writes it down.
customer_creation, and the guest customer a payment-mode session leaves behind
customer_creationis the Stripe Checkout parameter that decides whether a Customer object exists after a one-off purchase. Stripe documents it as optional, settable only in payment and setup mode, and taking eitheralwaysorif_required, whereif_requiredmeans the session "will only create a Customer if it is required for Session confirmation".The important part is the default. Stripe's changelog for API version 2022-08-01 records that in payment mode "the default value of
customer_creationis nowif_requiredinstead ofalways". A plain payment-mode session therefore does not necessarily leave a Customer behind, and the sample response on Stripe's own create-session page shows"customer_creation": "if_required".What Stripe does instead is documented too: sessions that create no Customer are grouped as guest customers, a read-only grouping for completed transactions. Stripe does not save payment methods for guest customers, so you cannot initiate new payments on their behalf, and promotion codes restricted to first-time customers come back invalid for those sessions.
So a guest paying once is sent
customer_creation: "always", which states the requirement rather than leaving it to depend on whatever else the session happens to enable. A guest subscribing is sent nothing, because subscription mode needs a Customer anyway. A signed-in buyer is sent their existing customer id plusclient_reference_id, which is the user id travelling to Stripe and back.What a blank stripe_customer_id costs every lookup afterwards
The
stripe_customer_idcolumn is the join between Stripe's records and this database, and every later lookup goes through it. A checkout that left the column blank is not a cosmetic gap.Billing::Webhooks::SubscriptionUpsertedstarts withUser.find_by(stripe_customer_id: subscription.customer)and returns early on a miss, so a subscription event about an unlinked buyer is silently dropped.Billing::Webhooks::PaymentIntentSucceededdoes the same and additionally bails when the intent carries no customer at all, for a reason worth stealing:find_by(stripe_customer_id: nil)matches the first customerless user in the table and would attribute a stranger's payment to them.There is a second cost, and the buyer sees this one.
Billing::Customer#ensure!returns the stored id when it is present and otherwise creates a brand new Stripe customer and writes it back. A buyer whose purchase left no id therefore gets a fresh, empty customer the first time they open the billing portal: no payment history, no invoices, nothing they recognise.invoice_creation, because a one-off Stripe payment produces no invoice
invoice_creationis the flag that gives a one-time buyer something to download. A payment-mode Checkout produces a PaymentIntent and a receipt, but no Stripe invoice by default, which means the buyer has no PDF and the invoice history in the Billing Portal is empty for them.Subscription mode is excluded on purpose, because a subscription invoices on every cycle by itself. The asymmetry catches people out: the same product sold as a subscription arrives with an invoice trail, and sold once arrives with none, and the difference is invisible until a customer asks for a document their accountant needs.
The referral code rides on Stripe session metadata instead of a cookie
Session metadata is how a value survives the hop to Stripe and back. A referral code arrives on this site in a cookie, and the buyer then spends several minutes on a domain that cannot read it, so the code is copied onto the session at creation time:
Metadata is readable from both ends of the round trip, which is the point. The success page reads it off the session it fetched, and the webhook reads it off the session in the event payload, so attribution works for a buyer who lands back and for one who closes the tab. A cookie only works for the first of those, and only while the browser keeps it.
Stripe substitutes {CHECKOUT_SESSION_ID} into success_url after payment
success_urlcarries a template variable that looks like a bug in a code review:The braces are literal. Stripe's documentation for a custom success page describes exactly this string: you send it as written, and Stripe substitutes the real session id when it redirects the customer back after payment. Interpolating a Ruby value into that slot is the mistake, and it is a tempting one because the session id exists by the time the redirect happens.
The session id exists too late to help, though. The URL is part of the create call, so it is written before the session has an id, and the brace template is how Stripe closes that gap on its side. What comes back is a session id in a query parameter, which is a lookup key and nothing more.
redirect_to session.url needs allow_other_host in Rails 8
The redirect to Stripe is a cross-host redirect, and Rails refuses those by default:
Without
allow_other_host: true, Action Pack raisesUnsafeRedirectError, whose message reads "Unsafe redirect to ..., pass allow_other_host: true to redirect anyway". The protection exists because a redirect target taken from user input is an open redirect, so Rails makes you state that this particular target is meant to leave the application.session.urlcame from Stripe's API response rather than from a parameter, which is what makes saying so honest here.status: :see_otheris a 303, the status a POST handler returns when the follow-up request should be a GET.One thing happens before the redirect, and its placement is the whole reason it works:
Recording the Stripe session id here, in the visitor's browser, is what makes the sale attributable later. The visit, its referrer and its UTM parameters are known at this moment and are gone by the time a webhook arrives from Stripe's servers, so the session id is the thread that ties the two together.
Fetching the Checkout Session back rather than trusting the session id
The
session_idin the return URL is a lookup key and never a fact. Everything the success page believes comes from fetching that session back from Stripe over the server-side API:Four outcomes, one of which is a session. A blank id returns
nilwithout a network call. A made-up id makes Stripe answer withStripe::InvalidRequestError, which is rescued tonilso a typed URL is a miss rather than a 500. A real but unpaid session is rejected onpayment_status, which the Stripe library documents as one ofpaid,unpaidorno_payment_requiredwith the comment "You can use this value to decide when to fulfill your customer's order". Only a paid session is handed back.The keyword form
retrieve(id:, expand: [...])is supported rather than accidental: the gem'sAPIResource.retrievepasses its argument throughUtil.normalize_id, which treats a Hash as an "overloaded id", deletes:idfrom it and keeps the remaining keys as retrieve parameters.expand: ["customer"]then returns the customer inline instead of as a bare id string, saving a second round trip.Why the Stripe Checkout success page cannot be the trigger for fulfilment
Stripe states the limitation on the same documentation page that describes the custom success page: customers are not guaranteed to visit it. A successful payment followed by a lost connection before the page loads is money taken and nothing granted, and no amount of care in the controller recovers it, because the controller never runs.
A Stripe Checkout success page also runs for anyone who pastes a URL, which is the opposite failure and the reason a server-side retrieval that rejects an unpaid session has to sit in front of it. Stripe's answer to both is the same: a webhook event handler is the reliable confirmation of payment, with automatic retries when delivery fails.
The line to hold onto is narrower than "the success page only reads". It is that the redirect is never the source of truth: no value is taken from the URL, every value comes from a server fetch, and every write is keyed so the webhook can do it again without consequence.
Why the success page writes anything at all
The success page in this codebase provisions the account, mirrors the subscription and records the transaction, and the reason is written into the services themselves. In a pay-first flow the account does not exist when the buyer pays, so a
customer.subscription.createdwebhook arriving first finds no user by customer id and skips, exactly asSubscriptionUpsertedis written to do.Mirroring on the return closes that gap, and the local development case is starker: the
payment_intent.succeededevent never fires at all withoutstripe listenrunning, so a checkout on a laptop would grant nothing.Note what the code above does not do.
SyncCheckoutSubscriptioncalls the webhook's own handler rather than reimplementing the mirror, andSyncCheckoutPaymentperforms the samefind_or_create_by!on the payment intent id thatPaymentIntentSucceededperforms. Two entry points, one implementation, so the two paths cannot drift apart. The webhook remains the source of truth for everything afterwards: renewals, cancellations and plan changes.Keying every write so a repeated webhook changes nothing
Every write on the return path is keyed on a Stripe id, which is what lets the webhook repeat the same work minutes later without doubling it:
The key is the Stripe payment intent id, guarded by
mode == "payment"and a paid status, and the spec proves the sequence: success page, then webhook, oneTransactionand not two. The database backs it rather than trusting the code, withindex_transactions_on_stripe_iddeclared UNIQUE, anduserscarries the same treatment onemail_addressandstripe_customer_id.Be precise about what that buys.
find_or_create_by!is a check followed by an act, so a genuinely simultaneous success page and webhook can both find nothing and both insert. The unique index is what keeps the duplicate row out of the table, and it does so by raisingActiveRecord::RecordNotUnique, which nothing here rescues. The idempotency proven by the specs is sequential idempotency, which is the case that actually happens.SyncCheckoutPaymentalso accepts the payment intent in either shape Stripe hands over, a bare id string on the webhook payload or an expanded object on the server-fetched session, throughvalue.respond_to?(:id) ? value.id : value. The same trick resolves the customer.Provisioning an account from the session's customer_details
A guest who paid has no account, and the only identity available is the email Stripe collected. Provisioning reads it from the fetched session and find-or-creates on it:
The email is read as
session.customer_details&.emailwithsession.customer_emailas a fallback, and the customer id accepts a bare string or an expanded object. An existing account is left alone apart from filling in a blankstripe_customer_id.Two details in there cost real debugging.
previously_new_record?is captured before theupdate!, because that UPDATE flips it tofalseand the referral on a brand new account would be missed. And session metadata is read withmetadata[:referral_code]rather than a getter, becauseStripe::StripeObjectraisesNoMethodErrorfor a key the object does not hold, while[]returnsnil. A non-referred checkout carries an empty metadata object, so the getter form fails on the common case rather than the rare one.The claim is gated on the signed session, not on the session id
Setting the password on a pay-first account is the moment where a URL must not be enough, because anybody holding a Stripe session id would otherwise be able to take over the account it created.
The success page writes that key into Rails' signed session cookie, and the claim controller reads it back, refusing when the record is missing or already claimed. Possession of the URL grants nothing by itself.
The ordering on the success page matters as much as the gate. A guest who returns with a paid session is provisioned, their subscription and payment are mirrored immediately, and only then is the claim offered. An account that already exists and is already claimed is a returning customer, so they are redirected to sign in instead, and the request spec pins that case: a second purchase on the same address cannot reset the first buyer's password.
What this codebase does on the Stripe return
The Stripe return path here is one fetch and four writes, all of them replayable.
RetrieveCheckoutturns thesession_idparameter into either a paid session ornil, and a signed-in buyer is mirrored and redirected into the app before anything renders, which is also why the Google Ads conversion payload is stashed in the session rather than drawn on a page nobody sees. A guest is provisioned, mirrored, and offered a claim gated on the signed session.The webhook does all of it again, from
checkout.session.completed, for the buyer who never came back:client_reference_idbinds a signed-in user to their customer id, its absence runs the sameProvisionFromCheckout, and the sameSyncCheckoutPaymentrecords the same transaction under the same key. Analytics is wrapped inSafely.safelyat the end, so a tracking failure can never be the reason a paid customer does not get their account.