LaunchKit

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.

unless AppConfig.stripe_configured?
  redirect_to helpers.pricing_destination, alert: t("billing.checkouts.flash.not_configured")
  return
end

if params[:price_id].blank?
  redirect_to helpers.pricing_destination, alert: t("billing.checkouts.flash.no_plan")
  return
end

plan = 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.

def checkout_coupon(plan)
  if referee_discount_cents.positive?
    Billing::ReferralCoupon.new(amount_cents: referee_discount_cents, currency: plan&.currency || "usd").id
  elsif plan&.coupon&.active?
    plan.coupon.id
  end
end

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.

def customer_options
  return (mode == "payment" ? { customer_creation: "always" } : {}) unless user

  { customer: Billing::Customer.new(user).ensure!, client_reference_id: user.id }
end

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.

return if payment_intent.customer.blank?

user = User.find_by(stripe_customer_id: payment_intent.customer)
return unless user

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.

def invoice_options
  mode == "payment" ? { invoice_creation: { enabled: true } } : {}
end

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.

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:

def metadata_options
  referral_code.present? ? { metadata: { referral_code: referral_code } } : {}
end

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:

success_url: "#{checkout_success_url}?session_id={CHECKOUT_SESSION_ID}"

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:

redirect_to session.url, allow_other_host: true, status: :see_other

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:

ahoy.track "checkout_started", stripe_session_id: session.id, plan: plan&.key

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:

class RetrieveCheckout
  PAID_STATUSES = %w[paid no_payment_required].freeze

  def call
    return if @session_id.blank?

    session = Stripe::Checkout::Session.retrieve(id: @session_id, expand: [ "customer" ])
    return unless PAID_STATUSES.include?(session.payment_status)

    session
  rescue Stripe::InvalidRequestError
    nil
  end
end

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.

def call
  return if subscription_id.blank?

  subscription = Stripe::Subscription.retrieve(subscription_id)
  Billing::Webhooks::SubscriptionUpserted.new(subscription).call
end

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:

buyer.transactions.find_or_create_by!(stripe_id: payment_intent_id) do |transaction|
  transaction.amount = session.amount_total
  transaction.currency = session.currency
  transaction.status = "succeeded"
end

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 account
  new_user.confirmed_at = Time.current       # they paid with this address
  new_user.claimed_at = nil
end
newly_created = user.previously_new_record?
user.update!(stripe_customer_id: customer_id) if customer_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.

More on Stripe billing in Rails

← All Stripe billing in Rails articles